TypeScript Thursday - S01E03 - Conditional types

TypeScript Thursday - S01E03 - Conditional types

Welcome to TypeScript Thursday, a series of articles covering TypeScript's type system. Today's episode is about conditional types.

Jun 8, 2023ยท

15 min read

In TypeScript Thursday - S01E01 we learned about generic types and saw how we can make the output of a function preserve the type(s) of its input(s). For instance, we can write a <Dropdown> React component that automatically infers the type of its onChange callback based on the list of options:

type DropdownProps<T> = {
  value: T;
  options: T[];
  onChange: (value: T) => void;
}

<Dropdown options={[1, 2, 3]} value={2}
  onChange={(value) => {}} />
//             ^? number

Now let's try to add an optional multi prop that will let the user select more than 1 value from the list of options:

type DropdownProps<T> = {
  value: T | T[];
  options: T[];
  onChange: (value: T | T[]) => void;
  multi?: boolean;
}

<Dropdown 
  options={[1, 2, 3]}
  value={[2, 3]}
  multi
  onChange={(list) => {
    // Property 'includes' does not exist on type 'number'.(2339)
    //          VVV
    if (list.includes(42))
      console.log('You selected my favorite number!');
  }}
/>

We had to change the type of onChange to receive both a single value and a list, to account for both states of the multi prop. However, this causes an issue where we can't assume that we're going to get a list inside the callback when we pass the multi prop. The type says we could receive both, and it's up to us to do a check to narrow the type down.

Moreover, the value prop type is now also a union, but since there's no relationship between it and multi could pass the following incorrect values:

<Dropdown
  options={[1, 2, 3]}
  multi
  value={2} // should be an array, but there's no type error
  onChange={() => {}}
/>

Today's episode will cover conditional types which let us define generic types that have branching results depending on their parameter types. This will allow us to change the types of the onChange callback and value prop above depending on whether the multi prop is passed in or not.

โœ
Upcoming episodes will cover a couple of other ways to achieve the same effect. Stay tuned!

We'll also create the opposite of the Pick mapped type (covered in TypeScript Thursday - S01E02), create recursive types, extract types from 3rd party code, and more!

Conditional types

Let's look back at the example above, and write the code to implement it without worrying about the types just yet:

function Dropdown(props: any) {
  const onChange = (value: any) => {
    if (props.multi) {
      props.onChange([...props.value, value]);
    } else {
      props.onChange(value);
    }
  }

  return <select onChange={e => onChange(e.target.value)}>
    ...
  </select>;
}

The code is pretty straightforward: we start with an initial value for the selection, and we either replace it or append to it when the user selects a new option. We could simplify the if-else using a ternary expression:

props.onChange(props.multi
  ? [...props.value, value]
  : value
);

Conditional types look very similar: they have a condition, a truthy branch, and a falsy branch. Let's start with a simple example before tackling the onChange type:

type Foo<T> = T extends string ? number : boolean;

Foo<string>; // number
Foo<'a'>; // number
Foo<42>; // boolean

You can have more than one branch in a conditional type, so we can easily build a generic type that returns the name of a type as a string literal type:

type TypeName<T> = T extends string ? 'string' :
  T extends number ? 'number' :
  T extends boolean ? 'boolean' :
  T extends undefined ? 'undefined' :
  T extends null ? 'null' :
  T extends Function ? 'function' :
  T extends unknown[] ? 'array' :
  'object';

TypeName<'a'>; // 'string'
TypeName<[]>; // 'array'
TypeName<null>; // 'null'

When we don't have a useful type to return from the falsy branch, the convention is to return the built-in never type which can't be assigned to any other type (stay tuned for a future episode that will cover this type, and others, in more detail):

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

KeepStrings<string>; // string
KeepStrings<number>; // never

Now, let's try to improve our onChange and value types:

type DropdownProps<Option, Multi extends boolean = false> = {
  options: Option[];
  multi?: Multi;
  value: Multi extends true
    ? Option[]
    : Option;
  onChange: Multi extends true
    ? (values: Option[]) => void
    : (value: Option) => void;
}

We declare a type with 2 generic type parameters and use a conditional type to check whether the multi prop is passed in as true. If it is we'll set the onChange type to receive a list of values, otherwise it will receive a single one. Similarly, the value prop will choose between a list and a single value.

<Dropdown
  multi
  options={[1, 2, 3]}
  value={[2, 3]}
  onChange={(value) => {}} />
  //           ^? number[]

The default = false is needed so we can choose the single select branch in the conditional type when the multi prop is omitted.

<Dropdown
  options={[1, 2, 3]}
  value={2}
  onChange={(value) => {}} />
  //           ^? number

Besides the specialized onChange and value types, we now also get a type error if there's a mismatch between them:

<Dropdown
  options={[1, 2, 3]}
  // Type 'number[]' is not assignable to type 'number'.(2322)
  value={[2, 3]}
  onChange={(value) => {}} />
  //           ^? number

Assignability

Generic conditional types can take either branch depending on their input and you can't know which will be selected until you instantiate the generic type parameters. Once a branch is selected you can safely assign to the resulting type:

type Foo<T> = T extends string ? number : boolean;
//                                 ^         ^
//                                 |         |
const bar: Foo<string> = 42; // ---+         |
const baz: Foo<number> = true; // -----------+

When a branch hasn't been selected yet the generic conditional type is in a "deferred" state and you can assign to it only under very specific circumstances:

  1. The type on the right side of extends is not used in either the truthy branch or the falsy branch.

  2. The type on the right side of extends is not making use of infer.

  3. The type you're trying to assign to the conditional type can be assigned to both the truthy branch and the falsy branch.

๐Ÿ’ก
These restrictions were added in microsoft/TypeScript/#46429 and released as part of TypeScript version 4.5.
type B = string;
type C = number | boolean;
type D = boolean;

// B doesn't use `infer`.
type Foo<A> = A extends B ? C : D;

function foo<A>(data: A): Foo<A> {
  // `boolean` is assignable to both C and D.
  return false;
}

If the types C and D don't have any overlap then we can't assign anything to the conditional type, even if we use runtime checks to try and narrow down the branch that should be selected:

type Foo<T> = T extends string ? number : boolean;
//                         ^
//                         |
//                         +--------------+
function foo<T>(data: T): Foo<T> { //     |
  if (typeof data === 'string') { // -----+
    // This should narrow down the conditional type to
    // the truthy branch, but it doesn't:
    // Type '42' is not assignable to type 'Foo<T>'.(2322)
    return 42;
  } else {
    // Type 'true' is not assignable to type 'Foo<T>'.(2322)
    return true;
  }
}
๐Ÿ’ก
microsoft/TypeScript#33014 is exploring improvements in this area and is currently part of the 5.2 version roadmap.

In the meantime, we can:

  • Suppress the errors with @ts-ignore or @ts-expect-error.

  • Use type assertions to "cast" to the truthy or falsy branch types.

  • Use function overloads. This will be covered in a future TypeScript Thursday episode, so stay tuned!

If we take the <Dropdown> component implementation from the previous section and use type assertions to work around the errors we end up with the following:

function Dropdown<Option, Multi extends boolean>(
  props: DropdownProps<Option, Multi>
) {
  const onChange = (value: Option) => {
    if (props.multi) {
      const multiProps = props as DropdownProps<Option, true>;

      multiProps.onChange([...multiProps.value, value]);
    } else {
      const singleProps = props as DropdownProps<Option, false>;

      singleProps.onChange(value);
    }
  }

  return <select onChange={e => onChange(e.target.value)}>
    ...
  </select>;
}

When it comes to assigning "deferred" conditional types to other types, we can do that as long as the destination type is a union covering both the conditional's truthy and falsy branches:

function foo<T>(x: T): T extends boolean ? string : number { /* ... */ }

function bar<T>(data: T) {
  const a = foo(data);

  // This is OK.
  const b: string | number = a;
}

Constraints

In TypeScript Thursday - S01E01 we saw how we can constrain a generic type parameter to ensure it meets some requirements:

function sortById<T extends { id: number }>(values: T[]) {
  return values.slice().sort((a, b) => a.id - b.id);
}

The constraint carried over inside the body of the function to allow us to access the id property safely. For conditional types, the constraint carries over into the truthy branch:

type ExtractId<T> = T extends { id : unknown } ? T["id"] : never;

type Foo = { id: number };
type Bar = ExtractId<Foo>;
//    ^? number

In the example below we check if the input type is an array and then use an indexed access type (covered in TypeScript Thursday - S01E02) to get the value type. If the input type is not an array, we leave it unchanged.

//                                +--------+
//                                |        |
//                                V        V
type Reduce<T> = T extends unknown[] ? T[number] : T;

type Foo = Reduce<string[]>;
//    ^? string

type Bar = Reduce<number>;
//    ^? number

Continue reading to see how we can avoid the indexed access type, and instead infer the type of the array directly.

Inferring in conditional types

Let's take our earlier example from the constraints section and update it to infer the array type directly using the infer keyword:

type Reduce<T> = T extends Array<infer U> ? U : never;

Reduce<string[]>; // string
Reduce<number>; // never

infer works similarly to how TypeScript can infer generic type parameters from function calls (see TypeScript Thursday - S01E01 for more details), but it doesn't require defining those parameters upfront.

In the example below we infer both the argument type of a function, but also its return type:

//                                       V           V
type FuncTest<T> = T extends (...args: infer A) => infer R
  ? { input: A, expected: R }
  : never;

function createFuncTest<T extends (...args: any[]) => any>(
  func: T,
  tests: FuncTest<T>[]
) {
  tests.forEach(test => {
    expect(func(...test.input)).toEqual(test.expected);
  });
}

const sum = (a: number, b: number): number => a + b;

createFuncTest(sum, [
  { input: [2, 2], expected: 4 },
  { input: [-1, 1], expected: 0 },
]);

TypeScript comes with built-in types Parameters<T> and ReturnType<T>, so we can simplify FuncTest to the following:

type FuncTest<T extends (...args: any[]) => any> = {
  input: Parameters<T>;
  expected: ReturnType<T>;
}

We can use the conditional type in the function definition as we did above, but we can also use it when creating input data for the function:

const tests: FuncTest<typeof sum> = [
  { input: [2, 2], expected: 4 },
  { input: [-1, 1], expected: 0 },
];

The infer keyword can be followed by another optional extends to create a nested conditional without actually having to write all the branches:

type ReturnIfString<T> = T extends (
  (...args: any) => infer R extends string
) ? R : never;

ReturnIfString<() => 'a' | 'b'>; // 'a' | 'b'
ReturnIfString<() => number>; // never

infer can come in handy when you're dealing with 3rd party code that doesn't export a type that you need. Let's assume you're using a React component and would like to get the type of its props.

// This is not exported, but we need it.
type Props = { foo: number };

export const Component = (props: Props) => null;

type ExtractProps<T> = T extends (props: infer Props) => any
  ? Props : never;

type MyExtractedProps = ExtractProps<typeof Component>;
//         ^? { foo: number }

Distributive conditional types

If we use a conditional type on a union, the extends condition will be distributed over each member in the union, rather than being applied to the union itself:

type FilterPromises<T> = T extends Promise<any> ? T : never;

type Result = FilterPromises<Promise<string> | number>;
//    ^? Promise<string>

The extends Promise<any> condition was applied to Promise<string> and number, transforming the latter into never and keeping the former as-is. The results were merged into a new union, never was dropped, and the result is Promise<string>. Without distributivity, the result would've been never because the full union is not assignable to a Promise.

Flipping the branches allows us to remove Promises instead of keeping them:

type RemovePromises<T> = T extends Promise<any> ? never : T;

type Result = RemovePromises<Promise<string> | number>;
//     ^? number

We can add a second generic type parameter to use in the condition and create reusable Exclude and Extract types:

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

type Foo = Exclude<string | number, number>;
//    ^? string

type Bar = Extract<string | number, number>;
//    ^? number

Both of the above are built-in TypeScript types, and so is Omit which uses the mapped type Pick in combination with Exclude to remove a set of keys from an object type:

type Omit<T, K extends keyof T> = Pick<
  T,
  Exclude<keyof T, K>
>;

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

type Result = Omit<Person, 'age'>;
//     ^? { name: string }

Note: The version above is stricter than TypeScript's built-in Omit type which doesn't constrain the excluded keys to be part of the type (it's missing the extends keyof T constraint), so the compiler won't catch typos or mistakes:

type ProbablyATypo = Omit<{ foo: number }, 'fooooo'>;
๐Ÿ’ก
microsoft/TypeScript#30825 explains the reasoning behind keeping the type loose.

Omit can be useful when partially applying a function with object inputs:

type Filter = {
  start: Date;
  end: Date;
  label: string;
}

const getData = (filter: Filter) => { /* ... */ }

type FilterFromNow = Omit<Filter, 'start'>;

const getDataFromNow = (filter: FilterFromNow) => {
  return getData({ ...filter, start: new Date() });
}

By using generic type inference we can write a Redux-style connector function that partially applies a React component:

type ComponentType<Props> = (props: Props) => any;

function connect<Props>(
  Component: ComponentType<Props>
): <K extends keyof Props>(
  mapStateToProps: (state: any) => Pick<Props, K>
) => ComponentType<Omit<Props,K>> {
  /* ... */
}

type Props = { foo: number; bar: number };
const Foo = (props: Props) => null;

//                  removes `foo` from
//                   the Props type
//                   +------------+
//                   |            |
//                   V            V
const Bar = connect(Foo)(() => ({ foo: 42 }));
//     ^? ComponentType<{ bar: number }>

Distributivity helps us build the useful types above, but if you want to avoid it you can wrap both the type you're testing in the conditional and the one you're testing against in a tuple (or any kind of object for that matter, but a tuple is the simplest):

type Distributive<T> = T extends number ? T : never;
//                         wrap both sides
//                         V             V
type NonDistributive<T> = [T] extends [number] ? T : never;

type Foo = Distributive<number | string>;
//    ^? number

type Bar = NonDistributive<number | string>;
//    ^? never

Filtering keys in mapped types

The Omit type shown above lets us exclude certain keys from a type based on their names, but we can use conditional types to exclude keys based on the values associated with those keys.

TypeScript Thursday - S01E02 covered mapped types which can take the keys from one type and copy them over to a new type. We also saw how we can remap the keys using the as keyword:

type Analytics = Record<string, { title: string; views: number }>;

type ViewsByTitle<T extends Analytics> = {
  [K in keyof T as T[K]['title']]: T[K]['views']
}

If we remap a key to the special type never it will be dropped from the result:

type DropAllKeys<T> = {
  [K in keyof T as never]: T[K]
}

type Foo = DropAllKeys<{ foo: number }>;
//    ^? {}

If we add conditional types into the mix we can selectively drop certain keys if their values don't pass the condition:

type FilterMethods<T> = {
  [K in keyof T as (
    T[K] extends Function ? K : never
  )]: T[K]
}

type Foo = {
  bar: () => void;
  baz: number;
}

type Bar = FilterMethods<Foo>;
//    ^? { bar: () => void }

Because we're still using keyof to get the list of keys from the input type, the mapped type remains homomorphic, which means we can unwrap it back to the original type (see TypeScript Thursday - S01E02 for more details):

function unwrap<T>(data: FilterMethods<T>): T {}

const bar: Bar = { bar: () => {} };

let baz = unwrap(bar);
//  ^? Foo

Recursive types

We can write recursive conditional types and use the condition to know when to stop recursing. For instance, we can create a recursive conditional type that uses infer to flatten an array of any depth:

type Flatten<T> = T extends Array<infer U>
  ? Flatten<U> : T;

type Foo = Flatten<string[][][]>;
//    ^? string

We can also write a variant of Partial that deeply recurses into all the keys:

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

type Foo = { foo: { bar: number } };

type Bar = DeepPartial<Foo>;
//    ^? { foo?: { bar?: number } }

Summary

Thank you for reading and I hope you enjoyed the article. Below you can find the key takeaways and a shareable sketch note. Make sure to catch the next episode, which will cover function overloads and compare them to conditional types!

๐ŸคŽ
If you liked this article, maybe you'd also like some of my open-source projects at github.com/NiGhTTraX.

Takeaways:

  • conditional types are like if expressions for types

  • they look like ternary expressions and use the extends keyword for the condition

  • you can nest conditional types

  • the truthy branch will narrow down the type according to the condition, similar to generic constraints

  • conditional types distribute over unions, testing each member against the condition

  • you can avoid this behavior by wrapping both sides of the extends keyword with square brackets []

  • you can use the infer keyword to do some basic type pattern matching, like extracting the type inside a Promise, or extracting a type from 3rd party code

  • you can filter out keys in mapped types by remapping them to a conditional type that returns never

  • TypeScript ships with a few useful conditional types: Exclude, Extract, ReturnType, Parameters and Omit

TypeScript Thursday S01E03 Conditional Types Sketchnote

ย