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

Variant Forms of Entities Using Type Families

image

Like most companies, we use datatypes to represent database entities. But we realized that there can be slightly different forms of an entity that require different types for the fields. Rather than create entirely new datatypes to handle each combination, each with their own fields, we used a parameterized data type, and type families to select the varying types for each field.

Example: CRUD

You might have the following datatype to represent a User. The Maybe fields correspond to nullable columns in the database table. For this example, suppose that the database provides the IDs when you create a record.

data User = User
{ userId :: Int
, userName :: Text
, phoneNumber :: Text
, email :: Maybe Text
, deletedAt :: Maybe UTCTime
}

Now suppose you want to have a function to create users. What would its type be? Perhaps:

createUser :: User -> IO User

But there’s a problem here - we don’t know what the ID is, so we can’t specify it on creation. So perhaps the userId field should have the type Maybe Int? Then we could supply a User with userId = Nothing, and get back a User with userId = Just some_id.

But then we’ve lost the guarantee in the type system that we get back an ID. And every function that accepts a User as an argument must now handle the case that the userId might be Nothing.

We could make a separate UserCreate datatype, but we’d have to duplicate most of the field definitions. And we couldn’t write code that could accept either a UserCreate or a User as an argument.

Also, when we’re creating a record, it wouldn’t make sense to set deletedAt.

We can use a data kind and a type family to solve this problem:

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeFamilies #-}
data Mode = CreateMode | ReadMode
type family UserIdType (mode :: Mode) where
UserIdType CreateMode = ()
UserIdType ReadMode = Int
type family DeletedAtType (mode :: Mode) where
DeletedAtType CreateMode = ()
DeletedAtType ReadMode = Maybe UTCTime
data User (mode :: Mode) = User
{ userId :: UserIdType mode
, userName :: Text
, phoneNumber :: Text
, email :: Maybe Text
, deletedAt :: DeletedAtType mode
}

The Mode datatype represents our two possible forms of the User datatype: CreateMode when we are creating a user, and ReadMode when we are reading a user record.

Now suppose we also want to be able to update these records. When we update them, we must specify userId. We can’t specify deletedAt. As for the other fields, we may or may not want to update each field. We can use a Maybe type to indicate whether it should be updated.

data Mode = ReadMode | CreateMode | UpdateMode
type family UserIdType (mode :: Mode) where
UserIdType CreateMode = ()
UserIdType ReadMode = Int
UserIdType UpdateMode = Int
type family DeletedAtType (mode :: Mode) where
DeletedAtType CreateMode = ()
DeletedAtType ReadMode = Maybe UTCTime
DeletedAtType UpdateMode = ()
type family Updatable (mode :: Mode) t where
Updatable CreateMode t = t
Updatable ReadMode t = t
Updatable UpdateMode t = Maybe t
data User (mode :: Mode) = User
{ userId :: UserIdType mode
, userName :: Updatable mode Text
, phoneNumber :: Updatable mode Text
, email :: Updatable mode Text
, deletedAt :: DeletedAtType mode
}

Example: Denormalization

Another way in which we can use type families is for normalized vs. denormalized versions of a datatype. Sometimes we want a record that corresponds directly to the database record; other times we want to attach a list of records from a child table.

For example, perhaps a user can have any number of addresses. So we have a table of addresses with a foreign key referencing the user table.

data Mode = CreateMode | ReadMode | UpdateMode
data ShouldIncludeAddresses = WithoutAddresses | WithAddresses
type family UserIdType (mode :: Mode) where
UserIdType ReadMode = Int
UserIdType CreateMode = ()
UserIdType UpdateMode = Int
type family DeletedAtType (mode :: Mode) where
DeletedAtType ReadMode = Maybe UTCTime
DeletedAtType CreateMode = ()
DeletedAtType UpdateMode = ()
type family Updatable (mode :: Mode) t where
Updatable ReadMode t = t
Updatable CreateMode t = t
Updatable UpdateMode t = Maybe t
type family AddressesType
(mode :: Mode)
(shouldIncludeAddresses :: ShouldIncludeAddresses) where
AddressesType CreateMode WithoutAddresses = ()
AddressesType CreateMode WithAddresses = [Address]
AddressesType ReadMode WithoutAddresses = ()
AddressesType ReadMode WithAddresses = [Address]
AddressesType UpdateMode WithoutAddresses = ()
AddressesType UpdateMode WithAddresses = Maybe [Address]
data User (mode :: Mode) (shouldIncludeAddresses :: ShouldIncludeAddresses) =
User
{ userId :: UserIdType mode
, userName :: Updatable mode Text
, phoneNumber :: Updatable mode Text
, email :: Updatable mode Text
, addresses :: AddressesType mode shouldIncludeAddresses
, deletedAt :: DeletedAtType mode
}

I haven’t detailed the Address type here, but suppose that it’s imported from another module. It has an addressId and a userId as a foreign key.

Whether or not you allow creating/updating along with addresses is up to you. It can be tricky to handle the logic. If you do not want to allow those modes, then set the type for that particular configuration to ().

Example: Start/end times

Suppose you want to represent a trip for a rider. The rider waits for the vehicle to pick them up, gets picked up and driven to the dropoff point, where they will get dropped off. You might represent it like this:

data TripState =
TripNotStarted
| WaitingForPickup
| OnBoard
| TripCompleted
data LatLong = LatLong { lat :: Double, long :: Double }
data Trip = Trip
{ tripId :: Int
, userId :: Int
, pickupPoint :: LatLong
, dropoffPoint :: LatLong
, state :: TripState
, pickupTime :: Maybe UTCTime
, dropoffTime :: Maybe UTCTime
}

It can be useful to track the trip state. The problem here is that we are forced to leave pickupTime and dropoffTime as Maybe types, even though they we should know based on the state when they should be set. Sometimes we want to write functions that only need to handle trips that are in a certain state, for example, recordDropoff :: Trip -> UTCTime -> IO () would have to check the state to see whether it’s correct, and then it still can’t assume that dropoffTime is Nothing, so it has to check that, too.

You could build the times into the TripState datatype:

data TripState =
TripNotStarted
| WaitingForPickup
| OnBoard { pickupTime :: UTCTime }
| TripCompleted { pickupTime :: UTCTime, dropoffTime :: UTCTime }

However, this makes dropoffTime into a partial function. If it’s called on the wrong constructor, it will throw an exception. It doesn’t allow you to constrain a function to take only trips in a particular state. You also give up having a simple enumerated type. Using data kinds and type families, we can write it like this (retaining the original TripState definition):

type family PickupTimeType (state :: TripState) where
PickupTimeType TripNotStarted = ()
PickupTimeType WaitingForPickup = ()
PickupTimeType OnBoard = UTCTime
PickupTimeType TripCompleted = UTCTime
type family DropoffTimeType (state :: TripState) where
DropoffTimeType TripNotStarted = ()
DropoffTimeType WaitingForPickup = ()
DropoffTimeType OnBoard = ()
DropoffTimeType TripCompleted = UTCTime
data Trip (state :: TripState) = Trip
{ tripId :: Int
, userId :: Int
, pickupPoint :: LatLong
, dropoffPoint :: LatLong
, state :: TripState
, pickupTime :: PickupTimeType state
, dropoffTime :: DropoffTimeType state
}

Now we can type recordDropoff as Trip 'OnBoard -> UTCTime -> IO (), so that it can only be called if the trip is known to be in the right state.

But, there’s still too much wiggle room. The value in the state field doesn’t necessarily correspond to the type parameter. We can fix this if we make singletons for our TripState type:

import Data.Singletons.TH
$(singletons [d|
data TripState =
TripNotStarted
| WaitingForPickup
| OnBoard
| TripCompleted
|])
data Trip (state :: TripState) = Trip
{ tripId :: Int
, userId :: Int
, pickupPoint :: LatLong
, dropoffPoint :: LatLong
, state :: Sing TripState
, pickupTime :: PickupTimeType state
, dropoffTime :: DropoffTimeType state
}

Now TripState will be one of STripNotStarted, SWaitingForPickup, SOnBoard, or STripCompleted, and we can use fromSing to convert to the regular datatype values.

We can also make database constraints that mirror these type constraints, based on the state field:

CHECK (pickup_time IS NULL OR state IN ('OnBoard', 'TripCompleted'),
CHECK (dropoff_time IS NULL OR state IN ('TripCompleted'))

We haven’t gotten fancy enough to generate those from the Haskell types.

Conclusion

We have seen how to construct varying forms of a datatype by parameterizing it over data kinds, and using type families to define field types. This is useful for CRUD operations, denormalization, and controlling whether fields should be specified based upon a state field. Another way to use this technique is to version a datatype. The possibilities are unlimited! We find great utility in being able to maintain a flat record for a database entity, while also controlling which fields can be specified under what conditions.

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