Encoding entity cycles in the type system ​
A grocery list can be archived or active.
These cycles can – and should – be represented in the type system. For instance here, I will represent an ActiveGroceryList
and an ArchivedGroceryList
.
This will help me to constrain certain behaviors and prevent invalid operations like archiving an already archived list. Just using the type system. Convinced? Let’s go:
Encoding the GroceryList
cycles in the type system ​
ts
// src/domain/4-typing-entity-cycles/grocery-list.ts
export interface GroceryList {
id: string
// …
archiveDate: Date | undefined
}
export type ArchivedGroceryList = GroceryList & { archiveDate: Date }
export type ActiveGroceryList = GroceryList & { archiveDate: undefined }
// Let’s try it:
declare function archiveList(list: ActiveGroceryList): ArchivedGroceryList
declare const archivedList: ArchivedGroceryList
archiveList(archivedList) // fails: Type '"archived"' is not assignable to type '"active"'
declare const activeList: ActiveGroceryList
const result = archiveList(activeList) // passes
result // ArchivedGroceryList
More complex stuff – the cycles of a trip ​
Let’s say my company allows your users to create trips, which my company will sell and operate on their behalf. According to the business, here’s the flow:
- Brief (collect trip info)
- Launch (sell, marketing)
- Operate (manage guests, departures, arrivals, etc.)
- Done (collect feedback on how the trip went).
Step 1: Typing our entities according to their stages ​
In TypeScript, to enumerate different non-overlapping types, we use unions and discriminants. Here I will use the discriminant stage
to differentiate the different status we can have.
ts
// src/domain/4-typing-entity-cycles/trip.ts
import { TripEndDate, TripId, TripName, TripStartDate } from './trip-objects'
export interface TripBrief {
id: TripId
stage: 'brief'
// everything is optional, at this stage the trip is under construction
name: TripName | undefined
startDate: TripStartDate | undefined
endDate: TripEndDate | undefined
archiveDate: Date | undefined
}
export interface TripToLaunch {
id: TripId
stage: 'launch'
// These cannot be `undefined` anymore.
name: TripName
startDate: TripStartDate
endDate: TripEndDate
archiveDate: Date | undefined
}
export interface TripToOperate {
id: TripId
stage: 'operate'
name: TripName
startDate: TripStartDate
endDate: TripEndDate
archiveDate: undefined // The trip cannot be archived here.
}
// export interface TripDone { … }
export type Trip = TripBrief | TripToLaunch | TripToOperate
export type Archived<T extends Trip> = T & { archiveDate: Date }
export type Active<T extends Trip> = T & { archiveDate: undefined }
Step 2: Representing this flow using our staged entities ​
ts
// src/domain/4-typing-entity-cycles/trip-behavior.ts
import * as trip from './trip'
import type { Archived, Active } from './trip'
// a trip brief can be archived or submitted.
export declare function archiveTripBrief(
trip: Active<trip.TripBrief>,
): Archived<trip.TripBrief>
export declare function submitTripBrief(
trip: Active<trip.TripBrief>,
): Active<trip.TripToLaunch>
// from `launch` to `operate` stage:
/**
* After a certain deadline (60 days before the trip start date, usually),
* we remove the trip from the market.
* If it sold enough, it moves to `operate` stage
* If it has not sold enough, we archive it.
*/
export declare function removeFromMarket(
trip: Active<trip.TripToLaunch>,
): Archived<trip.TripToLaunch> | Active<trip.TripToOperate>
Step 3: Testing our type-encoded stages ​
ts
// src/domain/4-typing-entity-cycles/trip-tests.ts
import * as trip from './trip'
import type { Archived, Active } from './trip'
import { removeFromMarket, submitTripBrief } from './trip-behavior'
declare const archivedTrip: {
brief: Archived<trip.TripBrief>
toLaunch: Archived<trip.TripToLaunch>
toOperate: Archived<trip.TripToOperate>
}
declare const activeTrip: {
brief: Active<trip.TripBrief>
toLaunch: Active<trip.TripToLaunch>
toOperate: Active<trip.TripToOperate>
}
// testing `submitTripBrief`:
submitTripBrief(activeTrip.brief) // OK
submitTripBrief(activeTrip.toLaunch) // Fails, cannot submit a trip to launch.
submitTripBrief(activeTrip.toOperate) // Fails, cannot submit a trip to operate.
submitTripBrief(archivedTrip.brief) // Fails, cannot submit an archived trip.
// testing `removeFromMarket`:
removeFromMarket(activeTrip.toLaunch) // OK
removeFromMarket(archivedTrip.toLaunch) // Fails, cannot remove from market an archived trip.
removeFromMarket(activeTrip.brief) // Fails, cannot remove from market a trip brief.
removeFromMarket(activeTrip.toOperate) // Fails, cannot remove from market a trip to operate.
🎉 Type-safety at its maximum.