Chapter 5. Conditional Types

In this chapter, we will take a good look at a feature that is probably quite unique to TypeScript: Conditional types. Conditional types allow us to select types based on sub-type checks, allowing us to move around in the type space and get even more flexibility in how we want to design interfaces and function signatures.

Conditional types are a powerful tool that allows you to make up types on the fly. It makes TypeScript’s type system turing complete, as shown in this GitHub issue, which is both outstanding, but also a bit frightening. With so much power in your hands, it’s easy to lose focus of which types you actually need, letting you run into dead-ends or crafting types that are too hard to read. Throughout this book, we will discuss the usage of conditional types thoroughly, always reassessing that what we do actually leads to our desired goal.

Note that this chapter is much shorter than others. This is not because there’s not a lot to say about conditional types, quite on the contrary. It’s more because we will see good use of conditional types in the subsequent chapters. Here, we want to focus on the fundamentals and establish terminology that you can use and refer to whenever you are in the need of some type magic.

5.1 Managing Complex Function Signatures

Problem

You are creating a function with varying parameters and return types. Managing all variations using function overloads gets increasingly complex.

Solution

Use conditional types to define a set of rules for parameter and return types.

Discussion

You create software that presents certain attributes as labels based on user-defined input. You distinguish between StringLabel and NumberLabel to allow for different kinds of filter operations and searches.

type StringLabel = {
  name: string;
};

type NumberLabel = {
  id: number;
};

User input is either a string or a number. The createLabel function takes the input as a primitive type and produces either a StringLabel or NumberLabel object.

function createLabel(input: number | string): NumberLabel | StringLabel {
  if (typeof input === "number") {
    return { id: input };
  } else {
    return { name: input };
  }
}

With the basic functionality done, you see that your types are way too broad. If you enter a number, the return type of createLabel is still NumberLabel | StringLabel, when it can only be NumberLabel. The solution? Adding function overloads to explicitly define type relationships, like we learned in Recipe 2.6.

function createLabel(input: number): NumberLabel;
function createLabel(input: string): StringLabel;
function createLabel(input: number | string): NumberLabel | StringLabel {
  if (typeof input === "number") {
    return { id: input };
  } else {
    return { name: input };
  }
}

The way function overloads work is that the overloads themselves define types for usage, whereas the last function declaration defines the types for the implementation of the function body. With createLabel, we are able to pass in a string and get a StringLabel, or pass in a number and get a NumberLabel, as those are the types available to the outside.

This is problematic in cases where we couldn’t narrow down the input type beforehand. We lack a function type to the outside that allows us to pass in input that is of either number or string.

function inputToLabel(input: string | number) {
  return createLabel(input);
  //                    ^
  // No overload matches this call. (2769)
}

To circumvent this, we add another overload that mirrors the implementation function signature for very broad input types.

function createLabel(input: number): NumberLabel;
function createLabel(input: string): StringLabel;
function createLabel(input: number | string): NumberLabel | StringLabel;
function createLabel(input: number | string): NumberLabel | StringLabel {
  if (typeof input === "number") {
    return { id: input };
  } else {
    return { name: input };
  }
}

What we see here is that we already need three overloads, and four function signature declarations in total to describe the most basic behavior for this functionality. And from there on, it gets only worse.

We want to extend our function to also be able to copy existing StringLabel and NumberLabel objects. This ultimately means more overloads.

function createLabel(input: number): NumberLabel;
function createLabel(input: string): StringLabel;
function createLabel(input: StringLabel): StringLabel;
function createLabel(input: NumberLabel): NumberLabel;
function createLabel(input: string | StringLabel): StringLabel;
function createLabel(input: number | NumberLabel): NumberLabel;
function createLabel(
  input: number | string | StringLabel | NumberLabel
): NumberLabel | StringLabel;
function createLabel(
  input: number | string | StringLabel | NumberLabel
): NumberLabel | StringLabel {
  if (typeof input === "number") {
    return { id: input };
  } else if (typeof input === "string") {
    return { name: input };
  } else if ("id" in input) {
    return { id: input.id };
  } else {
    return { name: input.name };
  }
}

Truth be told, depending on how expressive we want our type hints to be, we can write fewer, but also a lot more function overloads. The problem is still apparent: More variety results in more complex function signatures.

There is one tool in TypeScript’s toolbelt that can help with situations like that: Conditional types. Conditional types allow us to select a type based on certain sub-type checks. We ask if a generic type parameter is of a certain sub-type and if so, return the type from the true branch, otherwise return the type from the false branch.

For example, the following type returns the input parameter if T is a sub-type of string (which means all strings or very specific ones). Otherwise, it returns never.

type IsString<T> = T extends string ? T : never;

type A = IsString<string>; // string
type B = IsString<"hello" | "world">; // string
type C = IsString<1000>; // never

TypeScript borrows this syntax from JavaScript’s ternary operator. And just like JavaScript’s ternary operator, it checks if certain conditions are valid. But instead of having the typical set of conditions you know from a programming language, TypeScript’s type system only checks if the values of the input type are included in the set of values we check against.

With that tool, we are able to write a conditional type called GetLabel<T>. We check if the input is either of string or StringLabel. If so, we return StringLabel, else we know that it must be a NumberLabel.

type GetLabel<T> = T extends string | StringLabel ? StringLabel : NumberLabel;

This type only checks for the inputs string, StringLabel. number, and NumberLabel are in the else branch. If we want to be more on the safe side, we would also include a check against possible inputs that produce a NumberLabel by nesting conditional types.

type GetLabel<T> = T extends string | StringLabel
  ? StringLabel
  : T extends number | NumberLabel
  ? NumberLabel
  : never;

Now it’s time to wire up our generics. We add a new generic type parameter T to createLabel that is constrained to all possible input types. This T parameter serves as input for GetLabel<T>, where it will produce the respective return type.

function createLabel<T extends number | string | StringLabel | NumberLabel>(
  input: T
): GetLabel<T> {
  if (typeof input === "number") {
    return { id: input } as GetLabel<T>;
  } else if (typeof input === "string") {
    return { name: input } as GetLabel<T>;
  } else if ("id" in input) {
    return { id: input.id } as GetLabel<T>;
  } else {
    return { name: input.name } as GetLabel<T>;
  }
}

Fantastic, now we are suited for all possible type combinations, and will still get the correct return type from getLabel, all in just one line of code.

If you look closely you will see that we needed to work around type checks for the return type. Unfortunately, TypeScript is not able to do proper control flow analysis when working with generics and conditional types. A little type assertion helps to ensure TypeScript that we are dealing with the right return type.

Another workaround would be to think of the function signature with conditional types as an overload to the original function which is broadly typed.

function createLabel<T extends number | string | StringLabel | NumberLabel>(
  input: T
): GetLabel<T>;
function createLabel(
  input: number | string | StringLabel | NumberLabel
): NumberLabel | StringLabel {
  if (typeof input === "number") {
    return { id: input };
  } else if (typeof input === "string") {
    return { name: input };
  } else if ("id" in input) {
    return { id: input.id };
  } else {
    return { name: input.name };
  }
}

This way, we have a flexible type for the outside world that tells exactly what output we get based on our input. And for your implementation, you have the full flexibility you know from a broad set of types.

Does this mean that you should prefer conditional types over function overloads in all scenarios? Not necessarily. In Recipe 12.7 we look at situations where function overloads are the better choice.

5.2 Filtering with never

Problem

You have a union of various types, but you just want to have all sub-types of string.

Solution

Use a distributive conditional type to filter for the right type.

Discussion

Let’s say you have some legacy code in your application where you tried to re-create frameworks like jQuery. You have your own kind of ElementList that has helper functions to add and remove class names to objects of type HTMLElement, or to bind event listeners to events.

Additionally, you can access each element of your list through index access. A type for such an ElementList can be described using an index access type for number index access, together with regular string property keys.

type ElementList = {
  addClass: (className: string) => ElementList;
  removeClass: (className: string) => ElementList;
  on: (event: string, callback: (ev: Event) => void) => ElementList;
  length: number;
  [x: number]: HTMLElement;
};

This data structure has been designed to have a fluent interface. Meaning that if you call methods like addClass or removeClass, you get the same object back so you can chain your method calls.

A sample implementation of said methods could look like this.

// begin excerpt
  addClass: function (className: string): ElementList {
    for (let i = 0; i < this.length; i++) {
      this[i].classList.add(className);
    }
    return this;
  },
  removeClass: function (className: string): ElementList {
    for (let i = 0; i < this.length; i++) {
      this[i].classList.remove(className);
    }
    return this;
  },
  on: function (event: string, callback: (ev: Event) => void): ElementList {
    for (let i = 0; i < this.length; i++) {
      this[i].addEventListener(event, callback);
    }
    return this;
  },
// end excerpt

As an extension of a built-in collection like Array or NodeList, changing things on a set of HTMLElement objects becomes really convenient.

declare const myCollection: ElementList;

myCollection
  .addClass("toggle-off")
  .removeClass("toggle-on")
  .on("click", (e) => {});

Let’s say you need to maintain your little jQuery substitute and figure out that direct element access has proven to be somewhat unsafe. When parts of your application can change things directly, it becomes harder for you to figure out where changes come from, if not from your carefully designed ElementList data structure.

myCollection[1].classList.toggle("toggle-on");

Since you can’t change the original library code (too many departments are dependent on it), you decide to wrap the original ElementList in a Proxy.

Proxy objects take an original target object and a handler object that defines how to handle access. The implementation below shows a Proxy that only allows read access, and only if the property key is of type string, and not a string that is a string representation of a number.

Note

Handler objects in Proxy objects only receive string or symbol properties. If you do index access with a number, e.g. 0, JavaScript converts this to the string "0".

const safeAccessCollection = new Proxy(myCollection, {
  get(target, property) {
    if (
      typeof property === "string" &&
      property in target &&
      "" + parseInt(property) !== property
    ) {
      return target[property as keyof typeof target];
    }
    return undefined;
  },
});

This works great in JavaScript, but our types don’t match anymore. The return type of the Proxy constructor is ElementList again, which means that the number index access is still intact.

// Works in TypeScript throws in JavaScript
safeAccessCollection[0].classList.toggle("toggle-on");

We need to tell TypeScript that we are now dealing with an object with no number index access by defining a new type.

Let’s look at the keys of ElementList. If we use the keyof operator, we get a union type of all possible access methods for objects of type ElementList.

// resolves to "addClass" | "removeClass" | "on" | "length" | number
type ElementListKeys = keyof ElementList;

It contains four strings as well as all possible numbers. Now that we have this union, we can create a conditional type that gets rid of everything that isn’t a string.

type JustStrings<T> = T extends string ? T : never;

JustStrings<T> is what we call a distributive conditional type. Since T is on its own in the condition — that means without being wrapped in an object or array — TypeScript will treat a conditional type of a union as a union of conditional types. Effectively, TypeScript does the same conditional check for every member of the union T.

In our case, it goes through all members of keyof ElementList.

type JustElementListStrings =
  | "addClass" extends string ? "addClass" : never
  | "removeClass" extends string ? "removeClass" : never
  | "on" extends string ? "on" : never
  | "length" extends string ? "length" : never
  | number extends string ? number : never;

The only condition that hops into the false branch is the last one, where we check if number is a sub-type of string, which it isn’t. If we resolve every condition, we end up with a new union type.

type JustElementListStrings =
  | "addClass"
  | "removeClass"
  | "on"
  | "length"
  | never;

A union with never effectively drops never: If you have a set with no possible value and you join it with a set of values, the values remain.

type JustElementListStrings =
  | "addClass"
  | "removeClass"
  | "on"
  | "length";

And this is exactly the list of keys we consider safe to access! By using the Pick helper type, we can create a type that is effectively a super-type of ElementList by picking all keys that are of type string.

type SafeAccess = Pick<ElementList, JustStrings<keyof ElementList>>;

If we hover over it, we see that the resulting type is exactly what we looked for.

type SafeAccess = {
  addClass: (className: string) => ElementList;
  removeClass: (className: string) => ElementList;
  on: (event: string, callback: (ev: Event) => void) => ElementList;
  length: number;
};

Let’s add the type as an annotation to safeAccessCollection. Since it’s possible to assign to a super-type, TypeScript will treat safeAccessCollection as a type with no number index access from that moment on.

const safeAccessCollection: Pick<
  ElementList,
  JustStrings<keyof ElementList>
> = new Proxy(myCollection, {
  get(target, property) {
    if (
      typeof property === "string" &&
      property in target &&
      "" + parseInt(property) !== property
    ) {
      return target[property as keyof typeof target];
    }
    return undefined;
  },
});

When we now try to access elements from safeAccessCollection, TypeScript will greet us with an error.

safeAccessCollection[1].classList.toggle("toggle-on");
// ^ Element implicitly has an 'any' type because expression of
// type '1' can't be used to index type
// 'Pick<ElementList, "addClass" | "removeClass" | "on" | "length">'.

And that’s exactly what we need. The power of distributive conditional types is that we change members of a union. We are going to see another example of that in Recipe 5.3, where we work with built-in helper types.

5.3 Grouping Elements by Kind

Problem

Your Group type from Recipe 4.5 works fine, but the type for each entry of the group is too broad.

Solution

Use the Extract helper type to pick the right member from a union type.

Discussion

For one more, time, let’s go back to the toy shop from Recipe 3.1 and Recipe 4.5. We started out with a thoughtfully crafted model, with discriminated union types allowing us to get exact information about every possible value.

type ToyBase = {
  name: string;
  description: string;
  minimumAge: number;
};

type BoardGame = ToyBase & {
  kind: "boardgame";
  players: number;
};

type Puzzle = ToyBase & {
  kind: "puzzle";
  pieces: number;
};

type Doll = ToyBase & {
  kind: "doll";
  material: "plush" | "plastic";
};

type Toy = Doll | Puzzle | BoardGame;

We then found a way to derive another type called GroupedToys from Toy, where we take the union type members of the kind property as property keys for a mapped typed, where each property is of type Toy[].

type GroupedToys = {
  [k in Toy["kind"]]?: Toy[];
};

Thanks to generics, we were able to define a helper type Group<Collection, Selector> to be able to reuse the same pattern for different scenarios.

type Group<
  Collection extends Record<string, any>,
  Selector extends keyof Collection
> = {
  [K in Collection[Selector]]: Collection[];
};

type GroupedToys = Partial<Group<Toy, "kind">>;

The helper type works great, but there’s one little caveat. If we hover over the generated type, we see that while Group<Collection, Selector> is able to pick the discriminant of the Toy union type correctly, all properties point to a very broad Toy[]:

type GroupedToys = {
  boardgame?: Toy[] | undefined;
  puzzle?: Toy[] | undefined;
  doll?: Toy[] | undefined;
};

But shouldn’t we know more? Why does e.g. boardgame point to a Toy[] when the only realistic type should be BoardGame[]. Same for puzzles and dolls, and all the subsequent toys we want to add to our collection. The type we are expecting should look more like this:

type GroupedToys = {
  boardgame?: BoardGame[] | undefined;
  puzzle?: Puzzle[] | undefined;
  doll?: Doll[] | undefined;
};

We can achieve this type by extracting the respective member from the Collection union type. Thankfully, there is a helper type for that: Extract<T, U>, where T is the collection, U is part of T.

Extract<T, U> is defined as follows:

type Extract<T, U> = T extends U ? T : never;

As T in the condition is a naked type, T is a distributive conditional type, which means TypeScript checks if each member of T is a sub-type of U, and if this is the case, it keeps this member in the union type. How would this work for picking the right group of toys from Toy?.

Let’s say we want to pick Doll from Toy. Doll has a couple of properties, but the kind property separates distinctly from the rest. So a type to only look for Doll would mean that we extract from Toy every type where { kind: "doll" }.

type ExtractedDoll = Extract<Toy, { kind: "doll" }>;

With distributive conditional types, a conditional type of a union is a union of conditional types, so each member of T is checked against U:

type ExtractedDoll =
  BoardGame extends { kind: "doll" } ? BoardGame : never |
  Puzzle extends { kind: "doll" } ? Puzzle : never |
  Doll extends { kind: "doll" } ? Doll : never;

Both BoardGame and Puzzle are no sub-types of { kind: "doll" }, so they resolve to never. But Doll is a sub-type of { kind: "doll" }, so it resolves to Doll.

type ExtractedDoll = never | never | Doll;

In a union with never, never just disappears. So the resulting type is Doll.

type ExtractedDoll = Doll;

This is exactly what we are looking for. Let’s get that check into our Group helper type. Thankfully, we have all parts available to extract a specific type from a group’s collection:

  1. The Collection itself, a placeholder that eventually is substituted with Toy.

  2. The discriminant property in Selector, which eventually is substituted with "kind".

  3. The discriminant type we want to extract, which is a string type and coincidentally also the property key we map out in Group: K.

So the generic version of Extract<Toy, { kind: "doll" }> within Group<Collection, Selector> is this:

type Group<
  Collection extends Record<string, any>,
  Selector extends keyof Collection
> = {
  [K in Collection[Selector]]: Extract<Collection, { [P in Selector]: K }>[];
};

If we substitute Collection with Toy and Selector with "kind", the type reads as follows:

  1. [K in Collection[Selector]]: Take each member of Toy["kind"] — in that case "boardgame", "puzzle", and "doll" — as a property key for a new object type.

  2. Extract<Collection, ...>: Extract from the Collection, the union type Toy, each member that is a sub-type of…​

  3. { [P in Selector]: K }: Go through each member of Selector — in our case, it’s just "kind" — and create an object type that points to "boardgame" when the property key is “boardgame”, “puzzle” when the property key is “puzzle”, etc.

That’s how we pick for each property key the right member of Toy. The result is just as we expect it to be:

type GroupedToys = Partial<Group<Toy, "kind">>;
// resolves to:
type GroupedToys = {
  boardgame?: BoardGame[] | undefined;
  puzzle?: Puzzle[] | undefined;
  doll?: Doll[] | undefined;
};

Fantastic! The type is now a lot clearer and we can make sure that we don’t need to deal with puzzles when we selected board games. But some new problems have popped up.

Since the types of each property are much more refined and don’t point to the very broad Toy type, TypeScript struggles a bit with resolving each collection in our group correctly.

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {};
  for (let toy of toys) {
    groups[toy.kind] = groups[toy.kind] ?? [];
//  ^ Type 'BoardGame[] | Doll[] | Puzzle[]' is not assignable to
//    type '(BoardGame[] & Puzzle[] & Doll[]) | undefined'. (2322)
    groups[toy.kind]?.push(toy);
//                         ^
//  Argument of type 'Toy' is not assignable to
//  parameter of type 'never'.  (2345)
  }
  return groups;
}

The problem is that TypeScript still thinks of toy to be potentially all toys, whereas each property of group point to some very specific ones. There are three ways how we can solve this issue.

First, we could again check for each member individually. Since TypeScript thinks of toy to be of a very broad type, narrowing down makes the relationship clear again:

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {};
  for (let toy of toys) {
    switch (toy.kind) {
      case "boardgame":
        groups[toy.kind] = groups[toy.kind] ?? [];
        groups[toy.kind]?.push(toy);
        break;
      case "doll":
        groups[toy.kind] = groups[toy.kind] ?? [];
        groups[toy.kind]?.push(toy);
        break;
      case "puzzle":
        groups[toy.kind] = groups[toy.kind] ?? [];
        groups[toy.kind]?.push(toy);
        break;
    }
  }
  return groups;
}

That works, but there’s lots of duplication and repetition to it which we want to avoid.

Second, we can use a type assertion to widen the type of groups[toy.kind] so TypeScript can ensure that index access is ok.

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {};
  for (let toy of toys) {
    (groups[toy.kind] as Toy[]) = groups[toy.kind] ?? [];
    (groups[toy.kind] as Toy[])?.push(toy);
  }
  return groups;
}

This effectively works like before our change to GroupedToys, and the type assertion tells us that we intentionally changed the type here to get rid of type errors where we know better.

The third solution is to work with a little indirection. Instead of adding toy directly to a group, we use a little helper function assign where we work with generics.

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {};
  for (let toy of toys) {
    assign(groups, toy.kind, toy);
  }
  return groups;
}

function assign<T extends Record<string, K[]>, K>(
  groups: T,
  key: keyof T,
  value: K
) {
  // Initialize when not available
  groups[key] = groups[key] ?? [];
  groups[key]?.push(value);
}

Here, we narrow down the right member of the Toy union by using TypeScript’s generic substitution:

  1. groups is T, a Record<string, K[]>. K[] can be potentially broad.

  2. key is in relation to T: a property key of T.

  3. value is of type K.

All three function parameters are in relation to each other, and the way we designed the type relations allows us to safely access groups[key], and push value to the array. Also, the types of each parameter when we call assign also fulfill the generic type constraints we just set. If you want to know more about this technique, check out Recipe 12.6.

5.4 Removing Specific Object Properties

Problem

You want to create a generic helper type for objects, where you select properties based on their type, rather than the property’s name.

Solution

Filter with conditional types and type assertions when mapping out property keys.

Discussion

TypeScript allows you to create types based on other types, so you can keep them up to date without maintaining every single of their derivates. We’ve seen examples in earlier items, like Recipe 4.5. In the following scenario, we want to adapt an existing object type based on the types of its properties. Let’s look at a type for Person.

type Person = {
  name: string;
  age: number;
  profession?: string;
};

It consists of two strings — profession and name — and a number: age. We want to create a type that only consists of string type properties.

type PersonStrings = {
  name: string;
  profession?: string;
};

TypeScript already has certain helper types to deal with filtering property names. For example, the mapped type Pick<T> takes a subset of an object’s keys to create a new object that only contains those keys.

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
}

// Only includes "name"
type PersonName = Pick<Person, "name">;

// Includes "name" and "profession"
type PersonStrings = Pick<Person, "name" | "profession">;

If we want to remove certain properties, we can use Omit<T>, which works just like Pick<T> with the small difference that we map through a slightly altered set of properties, one where we remove property names that we don’t want to include.

type Omit<T, K extends string | number | symbol> = {
  [P in Exclude<keyof T, K>]: T[P];
}

// Omits age, thus includes "name" and "profession"
type PersonWithoutAge = Omit<Person, "age">;

To select the right properties based on their type, rather than their name, we would need to create a similar helper type. One where we map over a dynamically generated set of property names that only point to the types that we are looking for. We know from Recipe 5.2 that when using conditional types over a union type, we can use never to filter elements from this union.

So a first idea could be that we map all property keys of Person, and check if Person[K] is a subset of our desired type. If so, we return the type, otherwise, we return never.

// Not there yet
type PersonStrings = {
  [K in keyof Person]: Person[K] extends string ? Person[K] : never;
};

The idea is good, but it comes with a caveat: The types we are checking are not in a union, but types from a mapped type. So instead of filtering property keys, we would get properties that point to type never, meaning that we would forbid certain properties to be set at all.

Another idea would be to set the type to undefined, treating the property as sort of optional, but as we learned in Recipe 3.11, missing properties and undefined values are not the same.

What we are actually looking for is dropping the property keys that point to a certain type. This can be achieved by putting the condition not on the right-hand side of the object, but on the left-hand side, where the properties are created.

Just like with the Omit type, we need to make sure that we map over a specific set of properties. When mapping keyof Person, it is possible to change the type of the property key with a type assertion. Just like with regular type assertions, there is a sort of fail-safe mechanism, meaning you just can’t assert it to be anything, it has to be within the boundaries of a property key.

The idea is to assert that K is in fact either K if Person[K] is of string, otherwise, it’s never. With never being on the left-hand side of the object, the property gets dropped.

type PersonStrings = {
  [K in keyof Person as Person[K] extends string ? K : never]: Person[K];
};

And with that, we only select property keys that point to string values. There is one little catch: Optional string properties have a broader type than regular strings, as undefined is also included as a possible value. Using a union type makes sure that optional properties are also kept.

type PersonStrings = {
  [K in keyof Person as Person[K] extends string | undefined
    ? K
    : never]: Person[K];
};

Next step, making this type generic. We create a type Select<O, T> by replacing Person with O and string with T.

type Select<O, T> = {
  [K in keyof O as O[K] extends T | undefined ? K : never]: O[K];
};

This new little helper type is versatile. We can use it to select properties of a certain type from our own object types.

type PersonStrings = Select<Person, string>;
type PersonNumbers = Select<Person, number>;

But also figure out e.g. which functions in the string prototype return a number.

type StringFnsReturningNumber = Select<String, (...args: any[]) => number>;

An inverse helper type Remove<O, T> where we want to remove property keys of a certain type is very similar to Select<O, T>. The only difference is to switch the condition and return never in the true branch.

type Remove<O, T> = {
  [K in keyof O as O[K] extends T | undefined ? never : K]: O[K];
};

type PersonWithoutStrings = Remove<Person, string>;

This is especially helpful if you want to create a serializable version of your object types.

type User = {
  name: string;
  age: number;
  profession?: string;
  posts(): string[];
  greeting(): string;
};

type SerializeableUser = Remove<User, Function>;

By knowing that you can do conditional types while mapping out keys, you suddenly have access to a wide range of potential helper types. We see a lot more of that in Chapter 8.

5.5 Inferring Types in Conditionals

Problem

You want to create a class for object serialization, which removes all un-serializable properties of an object like functions. If your object has a serialize function, the serializer takes the return value of the said function instead of serializing the object on its own. How can you type that?

Solution

Use a recursive conditional type to go modify the existing object type. For objects which implement serialize, use the infer keyword to pin the generic return type to a concrete type.

Discussion

Serialization is the process of converting data structures and objects into a format that can be stored or transferred. Think of taking a JavaScript object and storing its data on disk, just to pick it up later on by deserializing it again into JavaScript.

JavaScript objects can hold any type of data: primitive types like strings or numbers, as well as compound types like objects, and even functions. Functions are interesting as they don’t contain data, but behavior. Something that can’t be serialized well. One approach to serializing JavaScript objects is to get rid of functions entirely. And this is what we want to implement in this lesson.

We start with a simple object type Person, which contains the usual subjects of data we want to store: A person’s name and age. It also has a hello method, which produces a string.

type Person = {
  name: string;
  age: number;
  hello: () => string;
};

We want to serialize objects of this type. A Serializer class contains an empty constructor and a generic function serialize. Note that we add the generic type parameter to serialize and not to the class. That way, we can reuse serialize for different object types. The return type points to a generic type Serialize<T>, which will be the result of the serialization process.

class Serializer {
  constructor() {}
  serialize<T>(obj: T): Serialize<T> {
    // tbd...
  }
}

We take care of the implementation later. Let’s focus on the Serialize<T> type first. The first idea that comes into mind is to just drop properties that are functions. We already defined a Remove<O, T> type in Recipe 5.4 which comes in handy, as it does exactly that: removing properties that are of a certain type.

type Remove<O, T> = {
  [K in keyof O as O[K] extends T | undefined ? never : K]: O[K];
};

type Serialize<T> = Remove<T, Function>;

The first iteration is done, and it works for simple, one-level deep objects. Objects can be complex, however. For example, Person could nest other objects, which in turn could have functions as well.

type Person = {
  name: string;
  age: number;
  profession: {
    title: string;
    level: number;
    printProfession: () => void;
  };
  hello: () => string;
};

To solve this, we need to check each property if it is another object, and if so, use the Serialize<T> type again. A mapped type called NestSerialization checks in a conditional type if each property is of type object, and returns a serialized version of that type in the true branch, and the type itself in the false branch.

type NestSerialization<T> = {
  [K in keyof T]: T[K] extends object ? Serialize<T[K]> : T[K];
};

We redefine Serialize<T> by wrapping the original Remove<T, Function> type of Serialize<T> in NestSerialization, effectively creating a recursive type: Serialize<T> uses NestSerialization<T> uses Serialize<T>, and so on.

type Serialize<T> = NestSerialization<Remove<T, Function>>;

TypeScript can handle type recursion to a certain degree. In our case, it can see that there is literally a condition to break out of type recursion in NestSerialization.

And that’s our serialization type! Now for the implementation of the function, which is curiously a straight translation of our type declaration in JavaScript. We check for every property if it’s an object. If so, we call serialize again. If not, we only carry over the property if it isn’t a function.

class Serializer {
  constructor() {}
  serialize<T>(obj: T): Serialize<T> {
    const ret: Record<string, any> = {};

    for (let k in obj) {
      if (typeof obj[k] === "object") {
        ret[k] = this.serialize(obj[k]);
      } else if (typeof obj[k] !== "function") {
        ret[k] = obj[k];
      }
    }
    return ret as Serialize<T>;
  }
}

Note that since we are generating a new object within serialize, we start out with a very broad Record<string, any> which allows us to set any string property key to basically anything, and assert at the end that we created an object that fits our return type. This pattern is very common when you create new objects, but it ultimately requires you to be 100% sure that you did everything right. Please test this function extensively.

With the first implementation done, we can create a new object of type Person and pass it to our newly generated serializer.

const person: Person = {
  name: "Stefan",
  age: 40,
  profession: {
    title: "Software Developer",
    level: 5,
    printProfession() {
      console.log(`${this.title}, Level ${this.level}`);
    },
  },
  hello() {
    return `Hello ${this.name}`;
  },
};

const serializer = new Serializer();
const serializedPerson = serializer.serialize(person);
console.log(serializedPerson);

The result is as we expect it to be! The type of serializedPerson lacks all info on methods and functions. And if we log serializedPerson, we also see that all methods and functions are gone. The type matches the implementation result.

[LOG]: {
  "name": "Stefan",
  "age": 40,
  "profession": {
    "title": "Software Developer",
    "level": 5
  }
}

But we are not done, yet. The serializer has a special feature. Objects can implement a serialize method, and if they do, the serializer takes the output of this method instead of serializing the object on its own. Let’s extend the Person type to feature a serialize method.

type Person = {
  name: string;
  age: number;
  profession: {
    title: string;
    level: number;
    printProfession: () => void;
  };
  hello: () => string;
  serialize: () => string;
};

const person: Person = {
  name: "Stefan",
  age: 40,
  profession: {
    title: "Software Developer",
    level: 5,
    printProfession() {
      console.log(`${this.title}, Level ${this.level}`);
    },
  },
  hello() {
    return `Hello ${this.name}`;
  },
  serialize() {
    return `${this.name}: ${this.profession.title} L${this.profession.level}`;
  },
};

We need to adapt the Serialize<T> type. Before running NestSerialization, we check in a conditional type if the object implements a serialize method. We do so by asking if T is a sub-type of a type that contains a serialize method. If so, we need to get to the return type, because that’s the result of serialization.

This is where the infer keyword comes into play. It allows us to take a type from a condition and use it as a type parameter in the true branch. We tell TypeScript that if this condition is true, take the type that you found there and make it available to us.

type Serialize<T> = T extends { serialize(): infer R }
  ? R
  : NestSerialization<Remove<T, Function>>;

Think of R as being any at first. If we check Person against { serialize(): any } we hop into the true branch, as Person has a serialize function, making it a valid sub-type. But any is broad, and we are interested in the specific type that is at the position of any. The infer keyword can pick that exact type. So Serialize<T> now reads:

  1. If T contains a serialize method, get its return type and return it.

  2. Otherwise, start serialization by deeply removing all properties that are of type Function.

And we want to mirror that type’s behavior in our JavaScript implementation as well. We do a couple of type-checks (checking if serialize is available and if it’s a function), and ultimately call it. TypeScript requires us to be very explicit with type guards, to be absolutely sure that this function exists.

class Serializer {
  constructor() {}
  serialize<T>(obj: T): Serialize<T> {
    if (
      // is an object
      typeof obj === "object" &&
      // not null
      obj &&
      // serialize is available
      "serialize" in obj &&
      // and a function
      typeof obj.serialize === "function"
    ) {
      return obj.serialize();
    }

    const ret: Record<string, any> = {};

    for (let k in obj) {
      if (typeof obj[k] === "object") {
        ret[k] = this.serialize(obj[k]);
      } else if (typeof obj[k] !== "function") {
        ret[k] = obj[k];
      }
    }
    return ret as Serialize<T>;
  }
}

And with this little change, the type of serializedPerson is string, and the result is as we expect it.

[LOG]: "Stefan: Software Developer L5"

A powerful tool that greatly helps with object generation. And there’s beauty in the fact that we create a type using a declarative meta-language that is TypeScript’s type system, to ultimately see the same process imperatively written in JavaScript.