TypeScript: Recursive Conditional Types

Typescript 2.8 brought with it some incredible new functionality - conditional types. These were a huge step forward in the expressivity of the type system, allowing us to create compile-time type-safety in a range of new situations.

One of the situations in which I have found these types helpful is within a service that accepts input in one format, but stores it in another. For example, imagine a "PeopleService", that accepts the following input:

interface PersonInput {
    id: string;
    name: string;
    car: {
        colour: string;
    }
}

But stores the name property with some metadata:

interface PersonStorage {
    id: string;
    name: {
        value: string;
        updatedAt: Date;
    };
    car: {
        colour: {
            value: string;
            updatedAt: Date;
        }
    }
}

We could use these interfaces "as-is", but there is repetition between them, and every chance that they will accidentally diverge over time. With some creative use of conditional types, we can define just one Person interface, and use it in both situations.

This is what we are aiming for (we'd also like this to work for arrays):

type InputProperty = string;
interface StoredProperty {
    value: string;
    updatedAt: Date;
}

type MetadataProperty = InputProperty | StoredProperty;

interface Person {
    id: string;
    name: MetadataProperty;
    car: {
        colour: MetadataProperty;
    }
}

// Equivalent to PersonInput above
type PersonInput = Cast<Person, MetadataProperty, InputProperty>;

// Equivalent to PersonStorage above
type PersonStorage = Cast<Person, MetadataProperty, StoredProperty>;

The Cast<T, U, V> type will take an object of type T, and replace all properties of type U, with type V. Because V is a narrowing of the intersection type U, we'll specify later that V must extends U.

Defining a Cast type

First, we'll define the types that need to be left alone (that Cast should ignore):

type AnyFunction = (...args: any[]) => any;

type TopLevelProperty =
    | number
    | string
    | boolean
    | symbol
    | undefined
    | null
    | void
    | AnyFunction
    | Date;

To ease in to conditional properties, we'll start with the simple Cast type, that delegates most of the heavy lifting elsewhere:

export type Cast<T, TComplex, TCastTo extends TComplex> = T extends object
    ? CastObject<T, TComplex, TCastTo>
    : T;

This reads as If T is an object, then the type should evaluate to that of CastObject<T, TComplex, TCastTo>. If T is not an object, then it should be left as its original type.

We can now look at the implementation of CastObject:

export type CastObject<T, TComplex, TCastTo extends TComplex> = {
    [K in keyof T]: T[K] extends TopLevelProperty
        ? T[K]
        : T[K] extends Array<infer U>
            ? Array<Cast<U, TComplex, TCastTo>>
            : T[K] extends TComplex 
                ? TCastTo 
                : Cast<T[K], TComplex, TCastTo>
};

This looks complicated, but can be broken down.

Firstly, we are looking at each property in T individually - [K in keyof T] will give us a K for each property key in the object, and we can then use T[K] to get the type of that property.

If this property is not-castable (i.e. it is one of the TopLevelProperty intersection types), then we leave it as T[K] - this will have no effect on the returned type.

Given that it is a castable property, we then check if it is an array. If it is, then we infer the inner type of the array as U, using Array<infer U>. We then declare this property as an array (it started as an array so it needs to remain one), but the type of element in this array is now cast to correct type.

If T[K] wasn't an array, then we now check if it is in our TComplex intersection type - the type we want to swap. If it is, then we replace it with TCastTo.

Finally, if we still haven't resolved our type we must be left with an object. In this case, to support deep properties we cast T[K] in the same way as its parent object - using Cast.

Real-world usage

While complicated to implement, the Cast type can add some real value to a codebase. I currently use this type to change:

  • Messaging interfaces between their serialised and de-serialised forms
  • Database models between populated and non-populated forms
  • Dates between user input and standardised forms