Handling Similar Types in TypeScript with Union

January 11th 2019 TypeScript

The TypeScript's type system can provide strong typing for many inconvenient situations we might encounter with JavaScript development. For example, union types are great for working with types that are very similar to each other.

I recently encountered that when using a web service API. Two different web services used the same type, but each one in its own version. Imagine the following:

// V1
export interface Person {
    id: number;
    firstName: string;
    lastName: string;
    birthday: Date;
}

// V2
export interface Person {
    id: number;
    firstName: string;
    lastName: string;
    birthdate: Date;
}

A function written for Person V1 won't work with the Person V2:

export function getFullNameV1(person: V1.Person) {
    return `${person.firstName} ${person.lastName}`;
}

Although it doesn't use any of the fields not available in Person V2, the compiler will complain if I pass a Person V2 to it:

let person: V2.Person = {
    id: 1,
    firstName: 'John',
    lastName: 'Doe',
    birthdate: new Date(2000, 1, 1)
};

// doesn't build
let fullName = getFullNameV1(person);

It will emit the following potentially confusing error message:

Property 'birthday' is missing in type 'Person' but required in type 'Person'.

Knowing the types, you can infer that the two Person types mentioned in the error message are V1 and V2. The problem is that Person V2 doesn't have the field birthday. Since I'm not using that field, I can define my own type without it and write a common function using that type instead:

// Common
export interface Person {
    id: number;
    firstName: string;
    lastName: string;
}

export function getFullNameCommon(person: Common.Person) {
    return `${person.firstName} ${person.lastName}`;
}

Because of TypeScript's [structural typing],(https://www.typescriptlang.org/docs/handbook/type-compatibility.html) this function will work with both Person V1 and Person V2. There's no need for a type to extend my common Person. It's enough that it has the same fields.

Still, this solution can become difficult to maintain if the individual types change because the common fields are explicitly listed. To avoid that I can just use a union type instead of creating my own:

export function getFullNameUnion(person: V1.Person | V2.Person) {
    return `${person.firstName} ${person.lastName}`;
}

If I don't want to write out the full union type everywhere (i.e. V1.Person | V2.Person), I can define a type alias and use that:

// TypeDef
export type Person = V1.Person | V2.Person;

export function getFullNameTypeDef(person: TypeDef.Person) {
    return `${person.firstName} ${person.lastName}`;
}

This already works great if I don't need to return any instances of my type from the functions. But if I do, there will be no (strongly-typed) way to access the version-specific fields not included in the union type:

export function reverseName(person: TypeDef.Person): TypeDef.Person {
    let reversePerson = Object.assign({}, person);
    reversePerson.firstName = person.firstName.split('').reverse().join('');
    reversePerson.lastName = person.lastName.split('').reverse().join('');
    return reversePerson;
}

let person: V2.Person = {
    id: 1,
    firstName: 'John',
    lastName: 'Doe',
    birthdate: new Date(2000, 1, 1)
};

let reversed = reverseNameNonGeneric(person);

// doesn't build
let birthdate = reversed.birthdate;

At the last line, the compiler will emit the following error:

Property 'birthdate' does not exist on type 'Person | Person'.

The issue can be resolved by using generics to specify that the function return value type will match its parameter type (which must still extend my union type):

export function reverseNameGeneric<T extends TypeDef.Person>(person: T): T {
    let reversePerson = Object.assign({}, person);
    reversePerson.firstName = person.firstName.split('').reverse().join('');
    reversePerson.lastName = person.lastName.split('').reverse().join('');
    return reversePerson;
}

Adding union types, type aliases and generics in the right places was enough to keep my existing code working with minimum changes when the second version of Person was introduced, although it was originally written for just a single version of the type.

Copyright
Creative Commons License