Search code examples
typescripttypescript-types

How to have TypeScript show computed/simplified type


I am a library author concerned with how types simplify for DX reasons. I generally can used Simplify and other utilities (the UnionExpanded trick below) to meet my needs, but I hit a case I can't seem to resolve. The question is how to solve Case 2 below.

File 1:

export type UnionExpanded<$Union> = $Union

export type HandleReturn2<$Data> = UnionExpanded<$Data | 2 | null>

File 2:

import type { HandleReturn2, UnionExpanded } from './utils.js'

//
// ----------------------------------------------------------------------------------------------
// Case 1 - OK
//
// In this test case, we achieve simplifying the type.

type HandleReturn1<$Data> = UnionExpanded<$Data | 2 | null>

const foo1 = (): HandleReturn1<1> => undefined as any
const _a = foo1()
//    |
//    type: 2 | 1 | null

//
// ----------------------------------------------------------------------------------------------
// Case 2 - FIXME
//
// In this test case, it is seemingly not possible to have the type simplify.
// The ONLY difference is that `HandleReturn` is defined in a different file.
// How can we get the type to simplify without re-defining `HandleReturn` like above?

const foo2 = (): HandleReturn2<1> => undefined as any
const _b = foo2()
//    |
//    type:  HandleReturn2<1>

I would like _b type to render like _a. However, unlike with _a_ it needs to use the HandleReturn type from import.


Solution

  • TypeScript uses various heuristic rules when deciding how to display a type. If a type is built from various named type aliases, TypeScript needs to decide whether to evaluate the type alias and display the result, or to defer evaluation and preserve the type aliases in the display. And of course when TypeScript does evaluate a type alias, the evaluated type might have other type aliases, so there are many possible ways TypeScript could display a single type. For example, if you have type Foo<T> = { foo: Foo<T> }, should TypeScript display Foo<Foo<string>> as Foo<Foo<string>> or as Foo<{ foo: Foo<string> }> or as { foo: Foo<Foo<string>> } or { foo: Foo<{foo: Foo<string> }> } or... etc?

    The details of the heuristic rules aren't well documented, nor are they guaranteed to remain stable across different versions of TypeScript. As long as the displayed type is equivalent to the actual type, TypeScript has done its job. So to some extent you can't really answer your question with a definitive solution that's sure to always work as you desired. At best you can come up with techniques which do what you want for specific test cases, and hope that these techniques remain effective.

    One rule of thumb is that TypeScript prefers not to leave members of intersections deferred, but to evaluate them. One common technique is therefore when you have a type X that's non-nullish (so X doesn't contain null or undefined), you can intersect it with the empty object type {} like X & {}, and TypeScript will tend to evaluate that if it can. For types which might be nullish, you can intersect with {} | null | undefined instead (which is more complicated, so if you know X is non-nullish, {} is preferred).

    Note that {} | null | undefined is essentially equivalent to unknown (see TypeScript 4.8's improved intersection reduction). You might therefore wonder why I didn't suggest writing & unknown instead of & ({} | null | undefined). That's because X & unknown is usually reduced immediately to X without evaluating X. If you intersect with type Unknown = {} | null | undefined, TypeScript will do some work computing that, so X & Unknown will tend to evaluate X, whereas X & unknown will leave X unaffected.

    In your example, this looks like:

    type Unknown = {} | null | undefined
    const foo2 = (): HandleReturn2<1> & Unknown  => undefined as any
    const _b = foo2()
    //    ^? const _b: 2 | 1 | null
    

    which gives you the results you wanted.

    Playground link to code