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.
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.