Paul Cowan

Nomadic cattle rustler and inventor of the electric lasso

Narrowing a Union Type in Typescript and a Gotcha With Callbacks

Narrowing a union type

In typescript, a union type describes a value that can be one of several types separated by the vertical | bar, for example:

text.js
1
2
3
4
5
let text: string | string[];

text = 'text';

text = ['text1', 'text2'];

In the above code text can either be a string or an array of strings.

Problems arise in union types because you can only access members that are common to all types in the union.

I have recently been commiting code to afterjs to add better type safety to the existing code.

On one of my PRs I created the following union to model all the component types that afterjs might have to deal with when deciding whether or not the component is loaded via a dynamic import. The compponent might be an AyncComponent that knows how to load itself via a dynamic import or it might be a react-router aware component or just a plain old react component. I created the following union:

union.js
1
2
3
4
export type AsyncRouteableComponent<Props = any> =
  | AsyncRouteComponentType<RouteComponentProps<Props>>
  | React.ComponentType<RouteComponentProps<Props>>
  | React.ComponentType<Props>;

An AsyncRouteableComonent will have the following interface that gets added in a previous type.

AsyncRouteableComponent.js
1
2
3
4
interface AsyncComponent {
  getInitialProps: (props: DocumentProps) => any;
  load?: () => Promise<React.ReactNode>;
}

Type Guards

There is also in typescript the concept of Type Guards. A type guard is an expression that performs a runtime check that guarantees that you have the requested type. I came up with this type guard to ensure I am dealing with an AsyncRouteableComponent:

guard.js
1
2
export function isAsyncComponent(Component: AsyncRouteableComponent): Component is AsyncRouteComponentType<any> {
  return (<AsyncRouteComponentType<any>>Component).load !== undefined;

Any time isAsyncComponent is called, typescript will narrow the specific variable to that specific type:

narrow.js
1
2
3
if (isAsyncComponent(component)) {
    component.load().then(() => doStuff)
}

Gotcha

There is a gotcha though and I encountered it with this code:

problem.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  const match = routes.find((route: AsyncRouteProps) => {
    const matched = matchPath(pathname, route);

    if (matched && route.component && isAsyncComponent(route.component)) {
      promises.push(
        route.component.load
          ? route.component.load().then(() => route.component.getInitialProps({ matched, ...ctx }))
          : route.component.getInitialProps({ matched, ...ctx })
      );
    }

    return !!matched;
  });

  return {
    match,
    data: (await Promise.all(promises))[0]
  };

Typescript narrows the scope in all cases due to the isAsyncComponent type guard on line 4 apart from in the anonymous function resolve handler of the load promise on line 7:

resolve.js
1
load().then(() => route.component.getInitialProps({ matched, ...ctx }))

The route.component in the anonomus function that resolves after load gives the following compiler error:

Property ‘getInitialProps’ does not exist on type ‘AsyncRouteableComponent’. Property ‘getInitialProps’ does not exist on type ‘ComponentClass<RouteComponentProps<any, StaticContext>>’.

Typescript has not narrowed the scope because it cannot find a getInitialProps member on all types of the union.

It turns out narrowing for a mutable variable such as route.component does not apply inside callbacks such as the anonymous function resolve handler because typescript does not trust that the local variable will not be reassigned before the callback executes. There is a whole big thread about it here.

The workaround is to copy route.component to a local variable before the route guard is called.

Here is the working solution:

working.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { matchPath } from 'react-router-dom';
import { AsyncRouteProps, InitialProps } from './types';
import { isAsyncComponent } from './utils';

export async function loadInitialProps(routes: AsyncRouteProps[], pathname: string, ctx: any): Promise<InitialProps> {
  const promises: Promise<any>[] = [];

  const match = routes.find((route: AsyncRouteProps) => {
    const matched = matchPath(pathname, route);

    const component = route.component;

    if (matched && component && isAsyncComponent(component)) {
      promises.push(
        component.load
          ? component.load().then(() => component.getInitialProps({ matched, ...ctx }))
          : component.getInitialProps({ matched, ...ctx })
      );
    }

    return !!matched;
  });

  return {
    match,
    data: (await Promise.all(promises))[0]
  };
}

The copy happens on line 11 with const used to ensure the local variable cannot be reassigned, typescript is satisfied that it cannot be reassigned and the scope is narrowed for the union and the correct scope is applied.

Comments