Skip to content

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:

  1. Brief (collect trip info)
  2. Launch (sell, marketing)
  3. Operate (manage guests, departures, arrivals, etc.)
  4. 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.