Recent articles About

Compiling enterprise

Ivan Koshelev blog on software development

Pragmatic uses of Typescript type system 03 Tag hierarchies via Template Literal Types [2021 May 09] Typescript, types, tagged union types, template literal types, hierarchy, polymorphism Features described in the articles require Typescript 4.3, which is in beta at the time of writing.

Tagged union types are a great part of Typescript, indispensible when working with groups of related data types, the kind we are likely to receive in JSON form from some API. They allow us to generalize groups of those types and strong-type functions that can operate on any member of the group.


const catTag = `cat` as const;
const dogTag = `dog` as const;

type CatRecord = {
    type: typeof catTag,
    name: string,
    huntsMice: boolean,
}

type DogRecord = {
    type: typeof dogTag,
    name: string,
    lovesFetch: boolean
}

type AnimalRecord = CatRecord | DogRecord;

declare const cat: CatRecord
let a: AnimalRecord = cat; // all good :-)
TS playground

This approach works fine in many situations, but its limits quickly become visible when we try modeling hierarchies of data.


const vanTag = `van` as const;
const ambulanceTag = `ambulance` as const;
type FuelType = 'gasoline' | 'diesel' | 'electric';

type VanRecord = {
    type: typeof vanTag,
    fuelType: FuelType
}

type AmbulanceRecord = Omit<VanRecord, 'type'> & {
    type: typeof ambulanceTag,
    medicalEquipment: string[]
}

type FuelStation = {
    availableFuels: FuelType[]
}

function canRefuel(station: FuelStation, van: VanRecord) {
    return station.availableFuels.some(x => x === van.fuelType);
}

declare const station: FuelStation;
declare const van: VanRecord;
declare const ambulance: AmbulanceRecord;

canRefuel(station, van); // fine
canRefuel(station, ambulance); // error!
TS playground

canRefuel function should have no problem working with AmbulanceRecord types, and it would work just fine in the world of "classical" inheritance and polymorphism (the kind found in Java or C#), but it does not work with Typescript tagged types. You might think, "we should just duck-type the function, to not rely on 'type', only relevant properties". And this may work, but it has it's own drawbacks. We now have to use more complex types like 'Omit<VanRecord, 'type'>' in our function signature, which is more clunky. But more importantly, it separates our function from a given domain type, as such, it broadens the scope of application of our function and can now easily lead to function being used in the wrong domain.


const vanTag = `van` as const;
const ambulanceTag = `ambulance` as const;
type FuelType = 'gasoline' | 'diesel' | 'electric';

type VanRecord = {
    type: typeof vanTag,
    fuelType: FuelType
}

type FuelStation = {
    availableFuels: FuelType[]
}

type FuelTester = {
    fuelType: FuelType
}

function canRefuel(station: FuelStation, van: Omit<VanRecord, 'type'>) {
    return station.availableFuels.some(x => x === van.fuelType);
}

declare const station: FuelStation;
declare const tester: FuelTester;

// we know this is wrong, but the compiler does not warn us,
// and a reasonable developer can easily conclude
// that fuel testers need refueling and a given station is suitable
canRefuel(station, tester); 
TS playground

If this example seems a bit contrived, here is a more typical one, that can easily come up with a NoSQL DB in a big organization:


const employeeTag = `employee` as const;
const managerTag = `manager` as const;

type Employee = {
    type: typeof employeeTag,
    employeeId: number,
    name: string,
    socialSecurityNumer?: string,
    workStartDate?: Date
    //....
}

type Manager = Omit<Employee, 'type'> & {
    type: typeof managerTag,
    managedEmployeeIds: number[]
    //.....
}

type YearlyBonusInformation = {
    employeeId: number,
    name: string,
    amount: number,
    //......
}

declare const dbClient: any;
// in our dbClient, a generated strongly typed method
function updateEmployeeCollection(employeeRecord: Omit<Employee, 'type'>) {
    dbClient.updateInDocument(
        'employee', 'employeeId', employeeRecord.employeeId, employeeRecord);   
}

declare const bonusInfo: YearlyBonusInformation;
// oops! our function should only work with 'Employee' domain type,
// but since we had to duck-type it to 
// the most basic employee type without tag,
// and most properties on that type are optional, the compiler has no problem 
// letting us pass a completely different domain type into it
updateEmployeeCollection(bonusInfo);
TS playground

This is the limit of duck-typing. Sometimes we want our function to work with something that walks like a duck and quacks like a duck. But sometimes we have to be absolutely sure it is an actual duck and not a duck-imitating robot, since we are deciding between a veterinarian and a mechanic. We have to remember that our code is built on more than just 'shape' assumptions about the types it works with, but also logical domain assumptions as to what those types represent and model.

By sticking to tagged types, we keep our functions responsible for a very specific chunk of domain, and that is good, since it follows Single Responsibility Principle of SOLID. So, how can we both leverage tagged types and model hierarchial data? Template Literal Types to the rescue! Added in Typescript 4.1, they allow us to have string literal types with placeholders, including wildcard placeholders of arbitrary string. With Typescript 4.3 they also benefit from improved assignability checks, so the following is now possible:


const vanTag: `van${string}` = `van`;
const ambulanceTag: `van;ambulance${string}` = `van;ambulance`;
type FuelType = 'gasoline' | 'diesel' | 'electric';

let a: typeof vanTag = ambulanceTag; // works!
let b: typeof ambulanceTag = vanTag; // error, as expected

type VanRecord = {
    type: typeof vanTag,
    fuelType: FuelType
}

type AmbulanceRecord = VanRecord & { //notice, we don't have to use Omit
    type: typeof ambulanceTag,       //and typeof ambulanceTag now organically  
                                     //narrows acceptable literal type
    medicalEquipment: string[]
}

type FuelStation = {
    availableFuels: FuelType[]
}

function canRefuel(station: FuelStation, van: VanRecord) {
    return station.availableFuels.some(x => x === van.fuelType);
}

declare const station: FuelStation;
declare const van: VanRecord;
declare const ambulance: AmbulanceRecord;

canRefuel(station, van); // fine
canRefuel(station, ambulance); // fine!
TS playground

With this approach, we can now fully leverage polymorphism in a way that works with duck-typing and JSON serialization. This is especially usefully with libraries for application state management that are based around a tree of immutable POJO objects (i.e. Redux). Using a hierarchy of tags as shown above, you can make sure Typescript will have your back when it comes to data-modeling complexity of an enterprise app, and by adding multimethods a-la Clojure, your app can leverage the best of both Redux (like rewind-time debugging and saving snapshots of your app state or rendering them on the server and then hydrating on the client) and polymorphic methods (where base types don't depend on derived types with constant switch statements to choose correct business logic path). If you are interested to see more of this approach, checkout a sample Rematch2 app on Github, it implements a set of realistic enterprise requirements for an automobile trading app, where we have a base deal, and a deal in foreign currency, which should behave the same, but slightly different ;-). A DEMO is worth a 1000 words.


Ivan Koshelev photo

I'm passionate for anything that can be programed and automated to make life better for all of us.

Archives

  1. November 2021 (1)
  2. May 2021 (1)
  3. March 2020 (1)
  4. August 2019 (1)
  5. July 2019 (1)
  6. May 2019 (1)
  7. February 2019 (1)
  8. October 2017 (1)
  9. August 2017 (3)
  10. July 2017 (3)
  11. May 2017 (3)

Elsewhere

  1. GitHub@IKoshelev
  2. NuGet@IKoshelev
  3. LinkedIn