Contact
Categories
blog
Engineering
Share On
X
facebook icon
linkedin icon
instagram icon

Flexible, Correct Domain Models Using Type Families

image

At TripShot, we love Haskell. We especially love its powerful type system that allows you to model things exactly the way you intend. In this article, we’ll explore a common problem when modeling domain entities, look into some potential solutions and finally present the solution which we have implemented at TripShot.

Identifying the problem

At TripShot, we work with vehicles a lot, so let’s take a model of a Vehicle as an example.

-- Other types omitted for brevity

dataVehicle=Vehicle
  { vehicleId ::VehicleId
  , name ::VehicleName-- E.g. John's Van
  , licensePlate ::LicensePlate
  , make ::VehicleMake
  , model ::VehicleModel
  , vin ::VIN
  , year ::Year
  , weight ::Lbs
  }

Here we have a Vehicle with a bunch of vehicle-related fields. Nothing unlike most domain entities out there.

Now imagine someone made a typo in the vehicle name and we need to update it. We can’t do that now, so let’s write a function that updates a Vehicle. While we’re at it, we’ll also handle updates for more than the name field - mistakes happen, and we need to be able to correct them.

dataVehicleUpdate=VehicleUpdate
  { name ::VehicleName,
    licensePlate ::LicensePlate,
    make ::VehicleMake,
    model ::VehicleModel,
    vin ::VIN,
    year ::Year
  }

updateVehicle ::VehicleUpdate->Vehicle->IOVehicle
updateVehicle VehicleUpdate {..} vehicle =do
  weight <- getWeightFromExternalService make model year

return$
    vehicle
      { name = name,
        licensePlate = licensePlate,
        make = make,
        model = model,
        vin = vin,
        year = year,
        weight = weight
      }

Simple enough. You’ll notice two things:

  1. We don’t want to update the vehicleId field because that’s a unique identifier that we wish to preserve
  2. We don’t accept a weight field. Rather, we use the make, model and year to query an external service that will give us the weight.

While this works fine, we then need to duplicate every field besides the ones we want to be able to update in both VehicleUpdate and Vehicle. This can quickly get tedious when you introduce more operations such as creating a Vehicle.

dataVehicleCreate=VehicleCreate
  { name ::VehicleName,
    licensePlate ::LicensePlate,
    make ::VehicleMake,
    model ::VehicleModel,
    vin ::VIN,
    year ::Year
  }

dataVehicleUpdate=VehicleUpdate
  { name ::VehicleName,
    licensePlate ::LicensePlate,
    make ::VehicleMake,
    model ::VehicleModel,
    vin ::VIN,
    year ::Year
  }

dataVehicle=Vehicle
  { vehicleId ::VehicleId,
    name ::VehicleName,
    licensePlate ::LicensePlate,
    make ::VehicleMake,
    model ::VehicleModel,
    vin ::VIN,
    year ::Year,
    weight ::Lbs
  }

In the real world, you’re likely to have a lot more fields. This can result in thousands of lines of duplication across domain models. Fortunately, Haskell offers a few mechanisms that can help us drastically reduce that.

Approach 1: Use GADTs

One way to remedy this situation is to use the GADTs language extension. It would look something like this:

data VehicleData = VehicleData
  { name :: VehicleName
  , licensePlate :: LicensePlate
  , make :: VehicleMake
  , model :: VehicleModel
  , vin :: VIN
  , year :: Year
  , weight :: Lbs
  }

data Form = Create | Update | Model

data Vehicle (f :: Form) where
  CreateVehicle :: VehicleData -> Vehicle 'Create
  UpdateVehicle :: VehicleData -> Vehicle 'Update
  Vehicle :: VehicleId -> VehicleData -> Lbs -> Vehicle 'Model
 
updateVehicle :: Vehicle 'Update -> Vehicle 'Model -> IO (Vehicle 'Model)
updateVehicle = ...

We introduce a Form type that describes the shape of the model - for creating, for updating or for storing.

This has some benefits - we can use the Form type to state exactly the type of Vehicle we expect, but it isn’t very convenient to use due to the following reasons:

  1. We lost the built-in record fields and if we need those, we’ll need to write our own such as:
vehicleId ::Vehicle'Model->VehicleId
vehicleId (Vehicleid _ _ _ _) =id

2. We lost the ability to derive Generic. If you’re a user of generic-lens or otherwise like deriving instances such as FromJSON that may be a significant hindrance.

Again, luckily for us, Haskell’s type system can help us overcome those challenges as well!

Approach 2: Use TypeFamilies

Using the same Form type we introduced in the GADTs example, we can define type families that determine what the type of a field should be during compile time. You can think of those type families as type-level functions.

dataForm=Create|Update|Model

-- This is nothing but a simple function using
-- pattern matching, only it is at the type level
typefamilyId (f ::Form) where
-- We use the Unit type to signal "empty"
Id'Create= ()
Id'Update= ()
Id'Model=VehicleId

typefamilyWeight (f ::Form) where
Weight'Create= ()
Weight'Update= ()
Weight'Model=Lbs

dataVehicle (f ::Form) =Vehicle
  { id ::Id f,
    name ::VehicleName,
    licensePlate ::LicensePlate,
    make ::VehicleMake,
    model ::VehicleModel,
    vin ::VIN,
    year ::Year,
    weight ::Weight f
  }

updateVehicle ::Vehicle'Update->Vehicle'Model->IO (Vehicle'Model)
updateVehicle =...

createVehicle ::Vehicle'Create->IO (Vehicle'Model)
createVehicle =...

This is significantly less verbose than the GADTs example and with almost no drawbacks. We can easily derive Generic and use it for lenses or deriving various instances.

Deriving is a tiny bit more verbose than if we didn’t have the f parameter:

data Vehicle (f :: Form) = Vehicle
  { ...
  }
  deriving (Generic)

deriving instance (ToJSON (Id f), ToJSON (Weight f)) => ToJSON (Vehicle f)
deriving instance (FromJSON (Id f), FromJSON (Weight f)) => FromJSON (Vehicle f)
Or we can use generic-lens with OverloadedLabels and get proper type inference:
*> :t vehicle
vehicle :: Vehicle 'Model

*> :t vehicleCreate
vehicleCreate :: Vehicle 'Create

*> :t (vehicleCreate ^. #id)
(vehicleCreate ^. #id) :: ()

*> :t (vehicle ^. #id)
(vehicle ^. #id) :: Text

Conclusion

At TripShot, we’ve found TypeFamilies to be an incredibly effective tool, enabling us to design more precise, flexible and maintainable systems. If you haven’t yet, we strongly encourage you to explore this approach. This use of TypeFamilies is also a great showcase of the power and flexibility of Haskell’s type system. We hope this inspires you to dive even deeper and discover more ways to build robust, well-designed systems.

Related Posts

Solution & Products
image

TripShot Integrations: APCs with iris

Complement robust reporting with more ridership data

Read More
Engineering
image

Pattern Synonyms for Non-Empty Lists

Alleviate the pain of awkward code syntax

Engineering
image

Improving Build Time with Data

Engineering processes need data-driven optimization