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 // ArchivedGroceryListMore 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.