A practical introduction to conditional types


Conditional types were the headline feature in TypeScript 2.8 but their use cases are not immediately obvious. The documentation describes a conditional type as:

... the ability to express non-uniform type mappings. A conditional type selects one of two possible types based
on a condition expressed as a type relationship test:

T extends U ? X : Y

The type above means when T is assignable to U the type is X, otherwise the type is Y.

For mere mortals, it is an if statement for types. It’s certainly an interesting feature, so let’s take a look at how we can use conditional types to write a HTTP request validator.

A basic request HTTP request validator

Starting with a simple case we can define a request:

interface Request {
  name: string,
  age: number,
  favouriteColour?: "red" | "blue" | "green",
  petsName?: string;
}

In this request the name and age must be set but the favouriteColour and petsName fields are optional. Our request handler will process a request from the HTTP framework (e.g. express/koa):

function handler(request: Request) {
  const [firstName, lastName] = request.name.split(" ");
}

The issue with this is that we don’t know whether our handler function is going to receive a complete request or not. If we were to be more accurate the handler would be defined as:

function handler(request: Partial<Request>) {
  const [firstName, lastName] = request.name.split(" ");
}

This results in a compilation error because the type of request.name is now string | undefined, as Partial<Request> is:

{
  name?: string,
  age?: number,
  favouriteColour?: "red" | "blue" | "green",
  petsName?: string;  
}

Our request validator can assert that the Partial<Request> we receive is actually a Request by checking that every mandatory property is not undefined:

function isValid<T>(mandatory: string[], request: Partial<T>): request is T {
  return mandatory.every(field => request[field] !== undefined);
}

Now when our handler accesses the name property it knows it will be a string and not undefined:

function handler(request: Partial<Request>) {
  if (!isValid(["name", "age"], request)) {
    throw new BadRequest(400, "Missing field");    
  }

  const [firstName, lastName] = request.name.split(" ");
}

Unfortunately, we have to explicitly pass the fields we want our validator to check as TypeScript doesn’t maintain any type information at runtime (because it’s just JavaScript!). However, we can add a little bit of type safety to our validator using conditional types to assert that the fields we receive are the non optional keys of Request.

type NotUndefined<T> = Exclude<T, undefined>;
type MandatoryPropertiesNames<T> = { [K in keyof T]: T[K] extends NotUndefined<T[K]> ? K : never }[keyof T];

function isValid<T>(mandatory: MandatoryPropertiesNames<T>[], request: Partial<T>): request is T {
  return mandatory.every(field => request[field] !== undefined);
}

The NotUndefined<T> type will exclude undefined from a list of types, so if we pass it string | number | undefined then the result will be string | number.

Using conditional types we can construct a list of mandatory properties of an object by asserting that the type of the key is the same as the type of the key without undefined:

T[K] extends NotUndefined<T[K]>

If this statement holds, then the type of K is set to K, otherwise it is set to never:

T[K] extends NotUndefined<T[K]> ? K : never

A type of never is omitted from the final object, so the result only contains the properties that cannot be set to undefined.

Now if we passed ["name", "petsName"] as mandatory fields we’d get compilation error because "petsName" is not a mandatory field.

Default properties

We may also want to populate the optional properties in our request with a default value:

function withDefaults<T>(defaults: object, request: T): T {
  return Object.assign({}, defaults, request);
}

The type information here is not entirely accurate meaning our handler wouldn’t know that the optional parameters have been set:

function handler(request: Partial<Request>): Response {
  if (!isValid(["name", "age"], request)) {
    throw new BadRequest(400, "Missing field");    
  }

  const defaults = { favouriteColour: "red", petsName: "John" };
  const requestWithDefaults = withDefaults(defaults, request);
  const colour = requestWithDefaults.favouriteColour.toUpperCase();
}

The type of requestWithDefaults.favouriteColour is still "red" | "blue" | "green" | undefined so the call to toUupperCase() would generate an error.

We can tighten up the type information by being more specific about the default parameters and return type:

type OptionalPropertiesNames<T> = { [K in keyof T]: T[K] extends NotUndefined<T[K]> ? never : K }[keyof T];
type OptionalProperties<T> = Pick<T, OptionalPropertiesNames<T>>;
type Complete<T extends object> = { [K in keyof T]-?: T[K]; };

function withDefaults<T>(defaults: Complete<OptionalProperties<T>>, request: T): Complete<T> {
  return Object.assign({}, defaults, request);
}

The Complete<T> type returns a version of T where all optional properties have been set to required. The return type of our function becomes Complete<Request>:

{
  name: string,
  age: number,
  favouriteColour: "red" | "blue" | "green" ,
  petsName: string;
}

This means favouriteColour and petsName can now be used without having to check they’ve been set.

The OptionalPropertiesNames<T> type is the inverse of MandatoryPropertiesNames<T> - all the properties that can be set to undefined. We can use the in built Pick type to extract those properties from another type:

type OptionalProperties<Request> = {
  favouriteColour?: "red" | "blue" | "green",
  petsName?: string;
}

Wrapping OptionalProperties<Request> with Complete means that the defaults value passed in to the withDefaults function must have every optional parameter set.

For instance passing { favouriteColour: "red" } to withDefaults results in a compilation error because the default value for petsName was not given.

The holes waiting to be filled

While this is all interesting, it’s not bullet-proof.

First, our definition of favouriteColour states that the value must be "red" | "blue" | "green" but the value passed from the client might be anything, likewise with age, it should be a number but we’re not checking that.

Second, MandatoryPropertiesNames<T>[] ensures that every value in the mandatory array is a mandatory key of T, but it does not ensure that the array contains every mandatory property. It’s possible to only pass ["age"] and the compiler won’t complain.

Without runtime type information these issues are hard, if not impossible, to fix. It might be possible to in future to write a compiler macro or template that puts the type information into runtime JavaScript, but there’s been no indication of that yet.


If you enjoyed this post, let me know.



Copyright © 2018, Linus Norton.