Recent articles About

Compiling enterprise

Ivan Koshelev blog on software development

Pragmatic uses of Typescript type system 04 Domain Modeling with Typescript [2021 November 22] Typescript, types, domain modeling, literal types, tagged union types, template literal types

This article gives an overlook of Typescript features that let us provide safety guarantees for our code unlike any other mainstream language. Typescript is a somewhat paradoxical language. It is a superset of Javascript and Javascript has one of the simplest, least strict typing systems among mainstream languages, where even distinction between int and float or char and string are deemed excessive. Javascript is also dynamically typed, a trait that may seem down-right harmful to developers coming from Java or C# background. And yet, Typescript, sitting on top of JS type system, allows us to model types far more strictly than any other mainstream language.

Article is quite big, owning to almost 4 years of complex Domain Modeling experience in Typescript that I'm attempting to sum-up and considerable amount of ways in which Typescript language features can be applied to Domain Modeling. First part of this article explains Typescript features that we will use and is most useful to developers coming from other languages. If you are up to speed with TS, you can jump to the second part, which covers their application.

🔗 TS language features overview

🔗 Literal Types

The superpowers of Typescript start with Literal Types. In simple terms, Literal Type means taking a single value out of a subset of all values possible in regular primitive type and treating it as its own type.


let a: number;  // normal type
let b: 5;       // Literal Type. 
                // Notice, that '5' takes the place of type in this definition

b = 5; // fine
b = 6; // error! 
TS playground

From the point of view of Javascript, b variable will still contain a number, but Typescript compiler now knows, that it can only ever have a specific number 5 stored in it. We have narrowed the set of possible values for our type from all numbers representable in Javascript to just the number 5.


type t = {
  b?: 5;
}

const subject: t = {};

subject.b = 5;
subject.b = 6; // error
TS playground

Compiler will never let us assign any other number into this variable, and when reasoning about correctness of ours program, it will only have to factor the value 5 into its reasoning. An example of difference in how compiler treats type number and type 5 can be seen below.


type t = {
  a: number,
  b: 5;
}

const subject: t = {
  a: 5,
  b: 5
};

// no error. '6' is a possible value of a.
if(subject.a === 6) {}

// error: "This condition will always return 'false' 
// since the types '5' and '6' have no overlap." 
if(subject.b === 6) {}

// but at runtime...
typeof subject.a; // "number"
typeof subject.b; // "number"
TS playground

In Typescript, we can use values from 3 primitive types number, string and boolean as literal types. There are also two curious cases of undefined and null, which are technically types, but with just 1 possible value each, and thus behave a lot like literals.


let a: 5;       // number
let b: "abc";   // string
let c: true;    // boolean
let d: undefined;
let e: null;

🔗 Literal Union Types

On their own, each Literal Type is a bit too restrictive to be useful, after all, if just 1 value is possible, why not use a constant? The true power comes from combining Literal Types and Union Types. An instance of a Union Type in Typescript can have a value of one of it's member types at any given time. So, if type A = B | C, instances of type A will have a value of type B or value of type C at any given time. In Type Theory this is called a Sum Type. "Sum" in this case refers to the fact, that the count of possible distinct values for a Union Type equals the sum of counts of possible distinct values of each of its members.


// from the readability point of view, we can read '|' as 'or';
// i.e. "type t1 can have value of 1 or 2 or 3 or 7";
type t1 = 1 | 2 | 3 | 7; // this type will have 4 possible values in it
let a: t1;
a = 2;  // fine
a = 7;  // fine
a = 11; // error: "Type '11' is not assignable to type 't1'."

// lets extend t1 with a few more values
type t2 = t1 | 4 | 5 | 11; // resulting type will have 7 possible values in it
let b: t2;
b = 2;  // fine
b = 11; // fine
b = 13; // error: "Type '13' is not assignable to type 't1'."
TS playground

Here is an example of string being used for Literal Union Type.


type HttpVerb = "GET" | "HEAD" |"POST" | "PUT" | "DELETE" | 
        "CONNECT" | "OPTIONS" | "TRACE" | "PATCH";

🔗 Template Literal Types

String Literal Types starting with Typescript 4 have another feature: String Template Literal types. They look like a string interpolation template but used as a type: type T = `abc${placeholder1}def${placeholder2}...`. Only primitive types (except symbol) and their Literal Types are allowed as placeholders: string, number, bigint, boolean, null, undefined. From technical viewpoint Template Literal Types fall into two categories. The first category are Template Literals where every placeholder has a reasonably small set of possible values (i.e. has type of boolean | null | undefined | Literal Union of primitive types), and it crates a Cartesian Product AKA Product Type (will be discussed later) . The result is a union of string literals of every possible combination of placeholders. The benefit here, besides having less manual work, is maintaining the connection to original sources of values: if we update a given Literal Union - every Template Literal using it gets the update automatically.


type Entity = 'customer-account' | 'shipping-order' | 'payment-order';  
type Operation = 'view' | 'list' | 'create' | 'edit' | 'archive';
type Permission = `${Entity}.${Operation}`;

let requiredPermission: Permission;
requiredPermission = 'customer-account.list'; // fine   
requiredPermission = 'work-order.list';  // error
// Type '"work-order.list"' is not assignable to type '"customer-account.view" | //
// "customer-account.list" | "customer-account.create" | "customer-account.edit" | 
// "customer-account.archive" | "shipping-order.view" | "shipping-order.list" | ... 7 more ... 
// | "payment-order.archive"'.

The second category are String Template Literals that have placeholders of non-Literal Type with a very big set of possible values: string, number, bigint. In this case, compiler factors resulting type into assignability checks, but otherwise treats it as a string.


let a: `grade:${number}` = 'grade:5';

a = 'grade:10';   //fine
a = 'grade:foobar';  //error;

// no error, despite neither condition expression
// being possible according to our type system 
if(a === 'foobar') {
  //...
} else if (a === 'grade:foobar') {
  //...
}
TS playground

Literal Types are a feature quite rare among programming languages. To the best of my knowledge, no other mainstream language has it at the moment. Python has a proposal PEP 586 -- Literal Types, and some members of Scala community claim having them. But otherwise, we would have to venture into the territory of Haskell to find something similar (once again, according to the community). And String Template Literals are something completely unique to Typescript.

🔗 Non-homogenous Union Types and Type Guarding

Up till now, Literal Union Types we've used were homogenous - all of their values came from the same runtime type. Typescript compiler takes this into account ans seamlessly widens such Literal Unions back to the source type when checking assignability.


type HttpVerb = "GET" | "HEAD" |"POST" | "PUT" ...;
let a: HttpVerb = "PUT";
let b: string = a; // fine 

But we are not limited to homogenous Literal Unions. We can even use non-Literal Types and non-primitive types!


// one of the most often needed cases, allowing 'undefined' values in a type
let a: 0 | 1 | undefined;   // 3 possible values
let b: number | undefined;  // possible values are all numbers or undefined

// imagine we have a field in our NoSQL DB, 
// which contains a license expiry date for a user,
// but overtime some special values were added to that field
type LicenseExpiration = Date | "perpetual-customer" | "perpetual-test-user";
type SaasUser = {
  name: string,
  licenseExpiration: LicenseExpiration
  //...
}

let user1: SaasUser = {
  name: 'Jane Smith',
  licenseExpiration: new Date(2030, 1, 1) // fine
};

let user2: SaasUser = {
  name: 'Test Bot 1',
  licenseExpiration: "perpetual-test-user" // fine
}

let user3: SaasUser = {
  name: 'placeholder',
  licenseExpiration: "placeholder" // error, not a valid value!
}
TS playground

Now that we're dealing with non-homogenous Union Types, Typescript will keep us safe by no-longer allowing us to assign them back into one of the types which are the sources for their values.


type LicenseExpiration = Date | "perpetual-customer" | "perpetual-test-user";
// declare means 'there will be a given variable in scope at runtime, 
// even though current piece of code does not create it'
declare let a: LicenseExpiration; 

let b: string = a;  
// Error: Type 'LicenseExpiration' is not assignable to type 'string'.
//    Type 'Date' is not assignable to type 'string'.
// The compiler is telling us "I can't just assign a to b. 
// a can SOMETIMES contain a string, 
// but sometimes it will contain a Date, which is not assignable"

let c: Date = a;  
// Error: Type 'LicenseExpiration' is not assignable to type 'Date'.
//    Type 'string' is not assignable to type 'Date'
// Notice, that now compiler complains in reverse

a.getUTCDate();   
// Property 'getUTCDate' does not exist on type 'LicenseExpiration'.
//    Property 'getUTCDate' does not exist on type '"perpetual-customer"'.

// huh? no error? this is because a method named 'toLocaleString' 
// exists on both Date and string type, 
// i.e. exists on all possible runtime values
a.toLocaleString();
TS playground

Ok, so we can only perform operations on Literal Union Types that would be allowed on any possible value? That would be a bit restrictive, so, no. We just need to perform a runtime check to narrow all possible values to a subset that can handle the operation which we want.


// notice, we added 'undefined' to the values mix
type LicenseExpiration = undefined | Date | "perpetual-customer" | "perpetual-test-user";

function isLicenseValid(a: LicenseExpiration) {
  // because of undefined, we can't even call 'toLocaleString' anymore
  a.toLocaleString();

  // Error: due to possible 'undefined' value we can't assign a
  let typeCheck1:  Date | "perpetual-customer" | "perpetual-test-user" = a;

  // first check - make sure value is not undefined
  if(!a) {
    return false;
  }

  // fine, since our code would never reach here if a was undefined.
  // So after above 'if' compiler knows to narrow type of 'a' by
  // removing 'undefined'.
  typeCheck1 = a;

  // now we have to deal wit possible string or date values

  if (a instanceof Date) {
    return new Date() < a;
  }

  // at this point, 'a' can only be one of 2 possible string values

  // Imagine a situation, where we used to have another type of license,
  // 'perpetual-dev-user' which now has to be removed from the system. 
  // But here we forgot. Well, compiler gives us a helping hand 
  // by pointing out, that the check, despite being fine from technical 
  // point of view, no longer makes sense within our type system.
  // 
  // error: This condition will always return 'false' since the types 
  // '"perpetual-customer" | "perpetual-test-user"' 
  // and '"perpetual-dev-user"' have no overlap.
  if(a === 'perpetual-dev-user') {
    return true;
  } else if (a === 'perpetual-customer') {
    return true;
  } else if (a === 'perpetual-test-user') {
    return true;
  } else {
    throw new Error("Unexpected value");
  }
}
TS playground

In the code above we've dealt with Type Guard clauses. They are special statements that return true or false based on a type of data stored in a given variable at runtime. When they are used as a flow control condition, compiler considers the type-check they perform inside its branches.


let a: string | number = 5;

if(typeof a === 'string') {
  let b: string = a; // fine, a is string
} else {
  let b: number = a; // fine, since string has been excluded, only number is left
}

// more examples of Type Guards
class User {
  public name: string = "";
  public surname: string = "";
}

class Machine {
  public name: string = "";
  public coresCount: 1 | 2 | 4 | 8 | 16 | 32 = 1;
}

let c: User | Machine = new User() as any;

if (c instanceof User) {
  let b: User = c;
} else {
  let b: Machine = c;
}

if ('name' in c) {
  // error. Since 'name' exists in both types, 
  // compiler can't use its presence to differentiate
  let b: User = c;
}

if('surname' in c) {
  // success. 'surname' only exists in 'User', so it's presence
  // indicates that we are dealing with User class;
  let b: User = c;
} else {
  let b: Machine = c;
}
TS playground

Typescript does a good job picking up existing Javascript Type Guards. It also uses its own type information to reason about type-safety. The most idiomatic way to achieve this is to use Discriminated Unions AKA Tagged Types.


type Truck = {
  kind: 'truck', // discriminator value AKA tag
  model: string,
  wheelsCount: number
}

type Ship = {
  kind: 'ship',
  model: number,
  tonnage: number;
}

let a: Truck | Ship = {} as any;

// the value of 'kind' property will uniquely identify type Truck at runtime
if (a.kind === 'truck') {
  let b: Truck = a; // success
} else {
  let b: Ship = a;
}

if (typeof a.model === 'string') {
  // error. We COULD reason, that only Truck type has type of string from 'model', 
  // but Typescript does not currently use non-Literal Types for guards
  let b: Truck = a;
} else {
  let b: Ship = a;
}
TS playground

In the last part of above example we saw that Typescript stops at certain point in it's reasoning about Type Guards. There is another case which is of interest to us, namely, how do we deal with absolute unknowns?


type Truck = {
  kind: 'truck', // discriminator value
  model: string,
  wheelsCount: number
}

type Ship = {
  kind: 'ship',
  model: number,
  tonnage: number;
}

// some JSON that we got over the network
let a = {
  kind: 'truck',
  model: 'CX-500',
  wheelsCount: 18
};

if (a.kind === 'truck') {
  let b: Truck = a; // error
  // this would have worked if a had type Truck | Ship.
  // But a is just an object.
} 
TS playground

Notice, that we mentioned network in above piece of code. Typescript is very good at reasoning about what is going on within the runtime. As long as we don't use escape hatches like 'any' type, Javascript code or invalid typings (.d.ts files) - Typescript is very good at making sure that nothing unexpected happens in our program. But it has no way to reason about data coming in from the boundaries like network io. This is where we, developers, have to provide Typescript with some of our knowledge about what is happening in the greater system that is our software. This is where we will use custom Type Guards. In their simplest form they look as follows.


type Truck = {
  kind: 'truck', // discriminator value
  model: string,
  wheelsCount: number
}

// some JSON that we got over the network
let a = {
  kind: 'truck',
  model: 'CX-500',
  wheelsCount: 18
};

function isTruck(val: { kind: string }): val is Truck {
  return val.kind === 'truck';
}

if (isTruck(a)) {
  let b: Truck = a; // success
} 
TS playground

Additionally if a failed Type Guard check means that something went very wrong in our runtime and the only course of action is to throw an error, we can use an Assertion Function in place of 'if'.


type Truck = {
  kind: 'truck', // discriminator value
  model: string,
  wheelsCount: number
}

// some JSON that we got over the network
let a = {
  kind: 'truck',
  model: 'CX-500',
  wheelsCount: 18
};

// the name 'assert' is already used by Node, so best call it something else
function typeAssert(condition: boolean, customMessage?: string): asserts condition {
  if (!condition) {
  throw new Error(customMessage ?? 'Critical type assertion failed');
  }
}

function isTruck(val: { kind: string }): val is Truck {
  return val.kind === 'truck';
}

typeAssert(isTruck(a));

// if previous line did not throw an Error - a has passed the 'Truck' check

let b: Truck = a; // success
TS playground

It is important to note, that Type Guards can be combined with generics (and other advanced types).


function everyValueOf<T>(
  dict: { [key: string]: any }, 
  check: (val: any) => val is T): dict is { [key: string]: T } {

  return Object.values(dict).every(check);
}

let dict: { [key: string]: any } = {
  ['a']: 1,
  ['b']: 2,
  ['c']: 3
};

const isNumber = (v: any) : v is number => typeof v === "number";

if(everyValueOf(dict, isNumber)){
  let b:  { [key: string]: number } = dict; // success
}
TS playground

🔗 Domain Modeling application of TS

🔗 Implicit typing and type extraction

A very practical feature of Typescript is: we don't need to type things upfront. Oftentimes, we don't need to type them explicitly at-all, Typescript will infer resulting types from our code. At the same type, it gives us tools to pull the inferred types out and build on top of them.


let a = 5;    // inferred type: number
const b = 5;  // inferred type: 5
let c = {
  name: "John",
  age: 53
} // { name: string; age: number; }

function hasName(val: any) {
  return !!val && typeof val.name === 'string';
} // function hasName(val: any): boolean

// notice this line
// 'typeof', when used as a keyword in a type definition, 
// allows us to get the inferred type of any given value holder.
// 'ReturnType' is a generic type from Typescript BCL that takes
// a function type and gives us its return type
let hasNameResult: ReturnType<typeof hasName>; // boolean
TS playground

For a deeper dive into implicit type and type information extraction check-out this article: My type of type

typeof is indispensible when dealing with dependency libraries where some of the types involved were not properly exported. And at the prototyping stage - it saves a LOT of time. More importantly for Domain Modeling, let hasNameResult: ReturnType<typeof hasName>; maintains explicit connection to the source of this value through the 'typeof' statement. Such a connection is a double-edged sword, since it couples two pieces of code, oftentimes, however, that is exactly what we want within our Domain Model.

🔗Force-narrowing types to literals

When dealing with value literals, Typescripts default approach is to assign them the wider type, unless they are being assigned to a const variable. Oftentimes though, we want to force compiler to preserve the Literal Type. This is done via an as const cast. The most immediately useful case of this is to return descriptive string literals from our functions instead of ambiguous boolean values or enums that are only needed in one place. Consider an application that needs to keep working with network disruption (i.e. a truck maintenance app for on-the-road technicians).


// in this variant we return a boolean to indicate, if the operation was successful 
async function saveTruckState(val: Truck){
  if(!network.active) {
    const success: boolean = await localCache.update('truck', val);
    return success;
  } 

  const success: boolean = await httpClient.post('truck', val);
  return success;
}

// above function hides information about its result behind a boolean value.
// The higher-level logic using it will not be able to discern, what actually happened,
// and won't be able to react to individual success / failure cases.
// Compare it to the following function

async function saveTruckState(val: Truck){
  if(!network.active) {
    const result: boolean = await localCache.update('truck', val);
    return result 
      ? 'success-save-to-cache' as const
      : 'failure-cache-conflict' as const;
  } 

  const result: boolean = await httpClient.post('truck', val);
  return result
    ? 'success-save-to-server' as const
    : 'failure-save-to-server' as const;
  
  // as you can imagine, in a real world example both localCache.update
  // and httpClient.post would already return comprehensive statuses 
  // instead of booleans and we would just return their result. 
}

// with above signature, we can be sure that any user of the function 
// will be forced to react to every possible outcome 
// and will have no doubts what they are reacting to:

const saveResult = await saveTruckState(truck);
switch(saveResult) {
  case 'success-save-to-server':
    userMessages.push(
      'Truck status saved to main server.'
    );
    break;
  case 'success-save-to-cache':
    userMessages.push(
      'Network not available. Truck status saved !LOCALLY!.'
    );
    break;
  case 'failure-save-to-server':
    userMessages.push(
      'Server rejected Tuck status update. Please contact main office.'
    );
    break;
  case 'failure-cache-conflict':
    userMessages.push(
      'Network not available and cache has conflicting Truck state update.'
        + 'Please reload Truck state and re-apply operations.'
    );
    break;
  default:
    throw new Error(`Unexpected result ${saveResult}`);
}

You may be thinking 'but surely we will have enums for possible operation results, especially if our system has a requirement to fall-back to cache?'. Having used the approach of 'return descriptive string literal instead of booleans and enums' for a few years, I found a few advantages in it. Assuming we are talking about number-based enums in Typescript (as opposed to string based ones) - they don't exist at runtime, their values turn into Numbers. The debug experience becomes a lot worse when we are looking at magic numbers instead of descriptive strings. Furthermore, number based enums don't merge. In properly separated code our cache.update function would return members of one enum and httpClient.post would return members of another, but both would reuse the same numbers starting with 1, and if we had to return one or the other from our function - we would have to combine them into a new entity and add mapping code. Such mappings quickly become cumbersome to maintain. On the other hand, string Literal Union values preserve semantic meaning, as such, collisions are very rare and when they do happen - they might even be acceptable. Using Literal Unions also saves us from having dozens of enums used for 1 function each. "entities should not be multiplied beyond necessity" ~Occam's Razor. Later in this article we will also look at Tagged Tuples as a way to add semantic tokens to the returned values.

as const in Typescript can also be applied to object and array literals, in which case it recursively applies to all literals found inside them and makes properties readonly.


const a = {
  b: 1,
  c: 'abc',
  d: true
} as const;
// resulting type is 
// {
//   readonly b: 1;
//   readonly c: "abc";
//   readonly d: true;
// }


const httpVerbs = ["GET", "HEAD", "POST", "PUT" ] as const;
// resulting type is 
// readonly ["GET", "HEAD", "POST", "PUT"]

type HttpVerb = (typeof httpVerbs)[number];
// resulting type is 
// "GET" | "HEAD" | "POST" | "PUT"
TS playground

Above example demonstrates an important technique. Since Typescript type information is fully erased at runtime, without any metadata left-over, it often makes sense to start with const arrays or dictionaries of literal values and extract types out of them for the compiler.

While we are on the subject of force narrowing via "as" keyword, it is worth mentioning that it can also be used for force-widening types. This technique is more useful for prototyping as opposed to proper Domain Modeling, but is worth having in your arsenal.


export const createBlankCarPurchaseDeal = () => ({

  businessParams: {
    downpayment: 0,
    insurancePlansSelected: [] as InsurancePlan[], 
    // notice, that we explicitly widen the type of undefined, 
    // because our function, for the moment, serves as source
    // of both blank deal objects and CarPurchaseDeal type
    carModelSelected: undefined as CarModel | undefined,
  },

  insurancePlansAvailable: [] as InsurancePlan[],
  carModelsAvailable: [] as CarModel[],
  messages: [] as string[],
});

export type CarPurchaseDeal = ReturnType<typeof createBlankCarPurchaseDeal>

🔗Type splitting

Lets say we have a back-end function which returns a result of loan application. Result may be 'ApprovalUnconditional', 'ApprovalConditional' with condition or 'Rejection' with reason. In a language like Java or C# we may have to model it like this:


//C# code
public enum LoanApplicationResultStatus
{
  ApprovalUnconditional, ApprovalConditional, Rejection
}

// this type will be returned from our API 
class LoanApplicationResult
{
  public int ApplicationId { get; set; }
  public LoanApplicationResultStatus Status { get; set; }
  public string? Condition { get; set; } 
  public string? Reason { get; set; }
  // '?' means 'nullable' string, a feature starting with C# 8 where 
  // reference types can be made non-nullable by default
  // https://docs.microsoft.com/en-us/dotnet/csharp/nullable-references
}

Looking at this type in our API controllers, it's not exactly clear, when will we get 'Condition' or 'Reason' properties. Well, ok, this particular case is artificially small, so it is quite clear, but imagine names were not as convenient and properties a lot more numerous. In a properly typed Typescript type, we would instead create the following definition:


type LoanApplicationResult = {
  ApplicationId: number
} & ({
    Status: 'ApprovalUnconditional'
  } 
  | {
    Status: 'ApprovalConditional',
    Condition: string
  } 
  | {
    Status: 'Rejection',
    Reason: string
  });
TS playground

& operator in this case produces an Intersection Type. In simple words, it combines two types, so type A = B & C means type A will have all properties of both B & C. In Type Theory this is called a Product Type. "Product" in this case refers to the fact, that the count of possible distinct values for an Intersection Type equals the product of counts of possible distinct values of each of its members.

So, we can read the type above as 'LoanApplicationResult will always have an ApplicationId and
(
(Status ApprovalUnconditional with no other properties)
OR (Status ApprovalConditional with Condition property)
OR (Status Rejection with Reason property)'
)

Side-note. The naming of '|' Union Types and '&' Intersection Types may seem a bit misleading. It may seems that result of '|' should be called an intersection, since we can only freely use properties which are identical for all member types, that is, the intersection of properties. And it may seem that result of '&' should be called a union, since we get a type unifying properties of all its members. Alas, please observe idiomatic terminology, but do make sure others understand it too when discussing things.

As you can imagine, the compiler will now guard us:


type LoanApplicationResult = {
  ApplicationId: number
} & ({
    Status: 'ApprovalUnconditional'
  } 
  | {
    Status: 'ApprovalConditional',
    Condition: string
  } 
  | {
    Status: 'Rejection',
    Reason: string
  });

declare function getLoanApplicationResult(): LoanApplicationResult;

const result = getLoanApplicationResult();

result.ApplicationId; // fine
result.Status; // fine, since all 3 cases have it
result.Condition; // Error! not all cases have it
result.Reason;  // Error! not all cases have it

// to access properties safely we must use Type Guards

if (result.Status === 'ApprovalConditional') {
  result.Condition; // fine
} else if  (result.Status === 'Rejection') {
  result.Reason; // fine
} else {
  result.Status; // at this point has type of only "ApprovalUnconditional"
}
TS playground

Side-note. Sometimes we are dealing with more advanced types constructed via use of several '|', '&', possibly some generic types, and we would like to see (or export) their simplified final version with just property signatures and a minimum of operators. For that we can make a utility type which I usually call 'Expand'.


type CustomError<T> = {
  UserMessage: string,
  Error: T
}

type LoanApplicationResult = {
  ApplicationId: number
} & ({
    Status: 'ApprovalUnconditional'
  } 
  | ({
    Status: 'Rejection'
  } & CustomError<{ RejectionAuditId: number }>));

type Expand<T> = T extends infer U 
          ? { [K in keyof U]: U[K] } 
          : never;

type EasierToRead = Expand<LoanApplicationResult>;
//type EasierToRead = {
//  ApplicationId: number;
//  Status: 'ApprovalUnconditional';
//} | {
//  ApplicationId: number;
//  Status: 'Rejection';
//  UserMessage: string;
//  Error: {
//    RejectionAuditId: number;
//  };
//}
TS playground

An often overlooked part is that we can (and should) also split on boolean values 'true' and 'false'. Here is a real-life example of React SelectDropdownComponent properties, which are split based on property 'hasEmptyOption' (can the user select 'nothing'). In this case, having 'hasEmptyOption={true}' will force the user of the SelectDropdownComponent to provide 'emptyPlaceholder' property and will change type signature of expected 'onSelect' function as well as make selected value 'undefinedable'.


type SelectDropdownProps<T> =
  {
    selectAttributes?: React.HTMLAttributes<HTMLElement>,  
    getKeyValue?: (item: T) => string,
    getDescription?: (item: T) => string,
    disabled?: boolean,
    availableItems: T[],
  }
  & ({
      hasEmptyOption: false, // we split on this property
      modelState: T,
      onSelect: (item: T) => void
    } 
    | {
      hasEmptyOption: true,
      emptyPlaceholder: string,
      modelState: T | undefined,
      onSelect: (item: T | undefined) => void
    });

🔗Tagged Tuples

In previous section we looked at splitting with the help of Tagged Types, now lets look at Tagged Tuples. This feature is often generally called Tagged Unions (although for Typescript that wiki article gives the Tagged Types examples). Here is an example, imagine that a manager logged into reporting api UI client, sent a request to prepare report and now the client is polling for the report.


type PollStatus = [status: 'idle']
                | [status: 'requested',         id: string, nextPoll: Date]
                | [status: 'poll-in-progress',  id: string]
                | [status: 'error',             id: string, message: string]
                | [status: 'ready',             id: string];

let reportPollingState: PollStatus;

declare const userNotificationsSystem: any;
declare const apiClient: {
  // backend api is from 2005 and not very good,
  // it returns true if report is ready, false if not 
  // and string if there was an error preparing it
  pollReportStatus(id: string): Promise<boolean | string>
};

let pollingIntervalHandle = setInterval(async () => {
  if (reportPollingState[0] === 'idle'
    || reportPollingState[0] === 'poll-in-progress') {
    return;
  }

  if (reportPollingState[0] === 'ready'
    || reportPollingState[0] === 'error') {
    userNotificationsSystem.blinkMessagesIcon();
    return;
  }

  if (reportPollingState[0] === 'requested'
    && reportPollingState[2] <= new Date()) {

    reportPollingState = ['poll-in-progress', reportPollingState[1]]; 
    reportPollingState = await pollReportStatus(reportPollingState[1]);

    return;
  }

  // there is a bug here ;-)
  throw new Error(`Unknown reportPollState ${reportPollingState[0]}`);

}, 10 * 1000);

export async function pollReportStatus(reportId: string): Promise<PollStatus> {
  try {
    const result =
      await apiClient.pollReportStatus(reportId);

    if (typeof result !== 'boolean') {
      return reportPollingState = [
        'error',
        reportId,
        result
      ];
    } else if (result === true) {
      return reportPollingState = [
        'ready',
        reportId
      ];
    } else {
      const nextPollDate = new Date();
      nextPollDate.setSeconds(nextPollDate.getSeconds() + 10);
      return reportPollingState = [
        'requested',
        reportId,
        nextPollDate
      ];
    }

  } catch (er) {
    return reportPollingState = [
      'error',
      reportId,
      'apiClient threw error'
    ];
  }
}
TS playground

Above code actually contains a bug, which Typescript will help us spot. If we look at `Unknown reportPollState ${reportPollingState[0]}`, we would expect reportPollingState[0] to be 'never', since all possible options have been exhausted by the compiler. But it shows type as 'requested'. Huh? Remember, that out actual check is 'if (reportPollingState[0] === 'requested' && reportPollingState[2] <= new Date())'. The second part of condition means there can be cases when we will not go in for 'requested'. There are two ways to fix this problem. First is to move date check inside the branch for 'requested'.

   
if (reportPollingState[0] === 'requested') {
  
  if(reportPollingState[2] > new Date()){
    return;
  }

  reportPollingState = ['poll-in-progress', reportPollingState[1]]; 
  reportPollingState = await pollReportStatus(reportPollingState[1]);

  return;
}

// no more bug
throw new Error(`Unknown reportPollState ${reportPollingState[0]}`);

Second way is to use 'switch' instead of 'if'. I generally avoid switch, since there are typically shorter ways to code a given piece of logic, but in situations like this it proves both more reliable and shorter. This snippet also showcases a helper function that will leverage compiler type-checking to guard us against forgetting to handle possible cases.

   
let pollingIntervalHandle = setInterval(async () => {

  switch (reportPollingState[0]) {
    case 'idle':
    case 'poll-in-progress':
      return;

    case 'ready':
    case 'error':
      userNotificationsSystem.blinkMessagesIcon();
      return;

    case 'requested':
      if (reportPollingState[2] <= new Date()) {
        reportPollingState = ['poll-in-progress', reportPollingState[1]];
        reportPollingState = await pollReportStatus(reportPollingState[1]);
      }
      return;

    default:
      // handling all possible cases will reduce type 
      // of `reportPollingState` to `never`.
      // If we were to add new cases in the future and 
      // forget to handle them here - type of `reportPollingState` 
      // will no longer resolve to `never` and compiler would give us an error, 
      // since only values of type `never` can be passed to assertValueExhaustion
      assertValueExhaustion(reportPollingState);
      throw new Error(`Unknown reportPollState ${reportPollingState[0]}`);
}

}, 10 * 1000);

function assertValueExhaustion(val: never) {
  // intentionally blank
}
TS playground

Side-note. In above examples we explicitly typed async function pollReportStatus(reportId: string): Promise<PollStatus> Knowing that Typescript infers a given function return type from its actual code, and we don't have to specify it explicitly, when SHOULD we still specify it explicitly? It depends on what is driving the functions responsibility. In the case above, 'pollReportStatus' interface is driven by its user, the code inside polling interval. If we did not intend to unit-test 'pollReportStatus', we might have even moved it's definition inside the scope of our setInterval handler function. The key question here is: 'Do I want the code inside my function to be responsible for following its signature? Then return signature should be explicit. Do I want the code inside my function to be the driver and users of the function to adapt to all of it's possible results? Then return signature can be inferred'.

You may say 'But shouldn't we code against interfaces? As in, we create an interface with explicit types, then we create an object which implements the interface, then we create code which uses object implementing the interface'? You are absolutely right if we are talking Java or C#. You are also right if we are talking about Typescript, and the interface has to drive the implementation: either there has to be more than one implementation of that interface, or the interface is part of one package while the implementation will be in another. But in majority of cases neither of those assumptions is correct. Remember, in Typescript, with its type extraction mechanisms, there is no reason to have an interface which will only ever have one implementation in the same package. Check example below, to see how that case can be handled.


  // in the apiClient.ts
  
  const apiClient = {
    async pollReportStatus(id: string) {
      //http call code
    }
  }
  
  export type ApiClient = typeof apiClient;
  
  // in fooBar.ts
  // notice 'import type' - this tells Typescript to restrict import
  // to only symbols that are fully erased during compilation,
  // so the whole import will be erased in resulting JS code
  import type { ApiClient } from './apiClient';
  
  export class FooBar {
    constructor(private apiClient: ApiClient) {}
    //...
  }
  

One final thing to consider. Lack of explicit typing of a functions return signature does not mean lack of explicit typing of its returned values. Especially when dealing with literals, if you want to preserve semantic connection of a given string literal value to a Domain concept, to keep it reliably findable via 'find all references' functionality of your IDE - you need to explicitly cast it to a given type via "as" keyword. Check code below for an example.

When would we use Tagged Tuples over Tagged Types? Tagged Tuples typically represent one of two situations. Situation number 1 is where we really want to emphasize 'enum value + 1 or 2 additional small tokens of data', during debug especially, since enum value takes slot 0 of the array, one can't miss its importance. Still, even with Typescripts new feature of adding names metadata to tuple members, this situation might better be handled by Tagged Types with named properties. Situation number 2 is when we want to tack a piece of metadata onto a value. Imagine we want to get identification, like a passport number, for a person involved with our company, and sometimes wi also need to know, what kind of connection that person has.


// somewhere in out types, we have the following:
const companyToPhysicalPersonRelationships = [
  'employee',
  'contractor',
  'customer'
] as const;

type CompanyToPhysicalPersonRelationshipType = 
  (typeof companyToPhysicalPersonRelationships)[number];

// for sake of brevity we assume ever person has unique name :-)
async function findPersonIdentificationByName(name: string) {
  const [
    employeeIdentification,
    contractorIdentification,
    customerIdentification
  ] = await Promise.all(
    apiClient.getEmployeeIdentificationByName(name).
    apiClient.getContractorIdentificationByName(name),
    apiClient.getCustomerIdentificationByName(name)
  );

  if (employeeIdentification) {
      return [
        employeeIdentification,
        'employee' as CompanyToPhysicalPersonRelationshipType
      ] as const;
  } else if (contractorIdentification) {
      return [
        contractorIdentification,
        'contractor' as CompanyToPhysicalPersonRelationshipType
      ] as const;
  } else if (customerIdentification) {
      return [
        customerIdentification,
        'customer' as CompanyToPhysicalPersonRelationshipType
      ] as const;
  }

  return undefined;
}

🔗Domain meaning of Type Guards

Stepping back from technical details a bit, let us think what Type Guards mean for our Domain Modeling. Type Guards are functions that contain custom check logic who's goal is to verify type information at runtime. When they check technical aspects, like JS types - they are usually consistent in themselves. But oftentimes we use them to determine Domain meaning of incoming data. We instruct the compiler: "treat any object which passes this check as domain type T". The important thing to understand, is that we, developers, take a responsibility that the check provided will be enough to verify a given value as having a specific type, in any situation that our Javascript runtime will encounter.


type DomainObject = {
  id: string,
  kind: string,
}

type Truck = DomainObject & {
  // Notice use of Domain Namespaces AKA Domain Contexts AKA Domain Boundaries 
  // The structure of this namespaces is likely to correspond
  // to the physical business processes of our company.
  kind: 'OurCompany.Engineering.Inventory.Truck', // discriminator value
  model: string,
  wheelsCount: number
}

function isDomainObject(val: unknown): val is DomainObject {
  return typeof val === 'object'
      && val !== null   // huh? why did TS require this check?
                        // because typeof null === 'object', 
                        // so nulls would slip past first check

// this is the first point where we, developers, take an obligation onto ourselves.
// We tell the compiler, that any object encountered by our runtime in any situation,
// if it has properties 'kind' and 'id', will logically be a DomainObject
      && typeof (val as any).kind === 'string'
      && typeof (val as any).id === 'string';
}

function isTruck(val: unknown): val is Truck {
  return isDomainObject(val)

// this is the second point where we, developers, take an obligation.
// We guarantee to the compiler, that any DomainObject with 
// ['kind'] === 'OurCompany.Engineering.Inventory.Truck'
// will have the rest of the properties compatible with type Truck
// (i.e. that a given object is assignable into type Truck).
// Equally important, we guarantee that data inside it will represent 
// type Truck as understood in our Domain and no other type in our Domain can 
// pass this check by accident.
// Namespaces greatly help us with that, since 'Truck' entity may well exist in
// 'OurCompany.Logistics.Shipping.Truck', 'OurCompany.Accounting.Vehicles.Truck' etc...
    && val.kind === 'OurCompany.Engineering.Inventory.Truck';
}

// some JSON that we got over the network
let a = {
  id: "....",
  kind: 'OurCompany.Engineering.Inventory.Truck',
  model: 'CX-500',
  wheelsCount: 18
};

if (isTruck(a)) {
  let b: Truck = a; // fine
} 
TS playground

🔗Type Guards and defensive programing

Looking at above code, you might be thinking, that it's hard to provide the guarantees described, both in terms of compatibility of properties (after all, who's to say our NoSQL DB does not have ancient instances lying around from the times when certain properties were not yet required?) and in terms of actually representing domain entities. Domain Namespaces really help with guaranteeing that a given object greatly does represent a specific type of domain entity. As for technical guarantees, say "Hello!" to ZOD library. It applies the principle of 'define code first and extract types later' to the problem of validation. Here is a basic case of it's usage.


import * as z from 'zod';

// define validation rules 
const CatSchema = z.object({
    type: z.literal('OurCompany.Employees.Cat'),
    // ZOD offers tons of validation rules, 
    // beyond what even Typescript type system can encode  
    name: z.string().min(3).max(32).nullable(),
    // and it lets us provide custom validators
    passportNumber: passportNumber(),
    huntsMice: z.boolean(),
    favoriteToy: z.string().optional()
  });

function passportNumber(){
  return z.custom<`${number}-${number}-${number}`>(
    (val): val is `${number}-${number}-${number}` =>
      /^\d+\-\d+\-\d+$/g.test(val as string)
  )
}

// Once we have the validator, we can pull a Typescript type out of it
type Cat = z.infer<typeof CatSchema>
// type Cat = {
//   favoriteToy?: string | undefined;
//   type: "OurCompany.Employees.Cat";
//   name: string | null;
//   passportNumber: `${number}-${number}-${number}`;
//   huntsMice: boolean;
// }

// and also use it to make sure our objects are 
// exactly as we expect them

// fine
let c1: Cat = CatSchema.parse({
  type: 'OurCompany.Employees.Cat',
  name: 'Skittles',
  passportNumber: '123-123-123',
  huntsMice: true
});

// error, this is not a cat
let c2: Cat = CatSchema.parse({
  type: 'OurCompany.Employees.Dog',
  name: 'Fido',
  passportNumber: '123-123-123',
  isHuntingBreed: true,
  knownCommands: []
});

ZOD library warrants a whole separate article, and probably more than one. I urge you to check it on your own, and limit myself to providing a more advanced example to give you a hint of it's capabilities.


import * as z from 'zod';

const DogCommandSchema =
    z.literal('sit')
  .or(z.literal('lie-down'))
  .or(z.literal('shake'));

const AnimalBaseSchema = z.object({
  type: z.string().nonempty({ message: "Can't be empty" }),
  name: z.string().min(3).max(32).nullable()
})

const DogSchema = z.intersection(
  AnimalBaseSchema,
  z.object({
    type: z.literal('dog'),
    isHuntingBreed: z.boolean(),
    knownCommands: z.array(DogCommandSchema)
  })
);

const CatSchema = z.intersection(
  AnimalBaseSchema,
  z.object({
    type: z.literal('cat'),
    huntsMice: z.boolean(),
    favoriteToy: z.string().optional()
  })
);

const AnimalSchema = z.union([DogSchema, CatSchema]);

🔗Further reading

If you found the concepts and techniques described in this article useful and/or curious, and would like to explore other advanced applications Typescripts type system, which will no-doubt make your code better, here are a few more articles for you.

Tag hierarchies via Template Literal Types - this article looks in more detail at Template Literal Types and how they can be useful in Domain Modeling.

Mapped Types - this article describes technical details of Mapped types, a special type of Generics unique to Typescript. They allow us to produce 'projections' of existing types where every property is transformed in an arbitrarily complex way. We can set rules to transform its name, its type signature, remove it or make it into several properties etc. This rule based transformations allow us to map our domain concepts to generic types and preserve the ephemeral abstract domain links which would be lost in other languages. This is how ZOD library can extract a validated type signature from a collection of validator functions.

io-ts - a very advanced library that seeks to provide even more complex type guarantee possibilities to Typescript, for true Type Theory geeks ;-).


Ivan Koshelev photo

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

Archives

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

Elsewhere

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