This is part of an ongoing series on tips I learned for working with TypeScript generics from building the crosswalk library. Check out part 1 for more background.
- Part 0: The Golden Rule of Generics
- Part 1: Use Classes and Currying to create new inference sites
- Part 2: Intersect what you have with whatever TypeScript wants
- Part 3: Avoid Repeating Type Expressions
- Part 4: The display of types
We talk all the time about how to define and use types in TypeScript, but we rarely talk about how TypeScript chooses to display our types. There are often several possible ways to display the same type, and the choice can have a big impact on the usability of your library. TypeScript tries to make good decisions on its own about type display, but it also gives us a few levers by which we can control it ourselves.
Let's dive in to the strange world of type display!
Suppose you have a Comments
table in your database and you've defined a selectComments
function. As you prepare to call it, TypeScript shows you some type information:
This leaves something to be desired. One issue is that it feels a bit "implementation-y": why should I care that the parameter is constructed using Pick
and an intersection (&
)? It's also a bit opaque. Is author_id
nullable? What about metadata
in the return type? What's its type? And is it nullable?
It's hard to answer these questions without wading through some type declarations or experimenting.
Here's an alternative display of exactly the same types:
This is much better. All hints of the metaprogramming that went into deriving this type are gone, and you can see exactly what the type of each field is. author_id
is not nullable, but metadata
is. The type of metadata
is CommentMetadata | null
.
When you're writing code that works with types, you should consider safety and correctness first and foremost. But once you have those, you should also consider how your types display. The rest of this post walks through some of the techniques that you can use to change how TypeScript displays a type.
Resolving a type
This is the situation described above. TypeScript is showing a generic type expression (often involving Pick
) and you'd like it to do a little more work to resolve that type:
|
Here's the magic incantation:
|
(Resolve
is my choice of name. This type alias also goes by Simplify
or NOP
or NOOP
.)
This is an odd-looking type to be sure. Both cases of the conditional type are variations on the identity function. It doesn't look like it should do anything at all! But our goal isn't to change the type so much as to change how it's displayed, and, for whatever reason, this does the trick:
|
(The conditional type does seem to be necessary: type Resolve<T> = {[K in keyof T]: T[K]}
does not resolve this type in the same way.)
This trick is also helpful in resolving the intersection types like T[K & keyof T]
described in part 2 of this series: Intersect what you have with whatever TypeScript wants. For example, here's the code from the start of this post:
|
The Select
function takes a table type and two sets of keys: one containing the columns that have to be set to a specific value and one containing the columns that may be any value in a set. The resulting function call has a parameter with that tell-tale implementation-y look.
As you'd hope, Resolve
makes short work of this:
|
All vestiges of the implementation of this type are gone and we're left with a clean display. Hooray! (Full example on the playground.)
The Resolve
alias can sometimes resolve keyof
expressions. More on this below.
h/t to Tadhg McDonald-Jensen on Stack Overflow for introducing me to this helpful type alias!
Special-casing important types
Sometimes the display of a type is bad for a specific, important case of your generic. In these situations it can be worthwhile to handle those cases specially using a conditional type.
For example, say you have a function that can either select all the columns from a table or just a few specific columns:
|
It makes sense that the Cols
type parameter defaults to keyof TableT
since that corresponds to selecting all the columns and Pick<T, keyof T> = T
.
The type doesn't display very cleanly when we use the function, though:
|
This is cryptic and quite implementation-y. And the Resolve
trick doesn't quite do what we want here:
|
Resolve
has fully inlined this type. What it's displaying is exactly equivalent to Comment
, but that's hard to tell without a careful comparison. It would be much nicer if it just said Comment
!
You can improve the display here by explicitly checking for the default case:
|
Much better! Here's a full playground for this example.
Other techniques that don't work as well
There are a few other techniques I've run across for simplifying type display that don't work as well as Resolve
. They're included here for completeness. If you're using them, you may as well just use Resolve
instead.
Exclude<keyof T, never>
This can be used to inline the display of keyof
:
|
The Resolve
trick works just as well in this case, and can resolve many types that excluding never
cannot. So just use Resolve
.
Side note: why does TypeScript display keyof
types in this way? Prior to TypeScript 4.2, TypeScript always inlined keyof
display. This sometimes led to comical results:
Count 'em, that's a union of 260 string literal types! Newer versions of TypeScript simply display this as keyof HTMLButtonElement
, which seems like a win. But I think they threw the baby out with the bathwater here; for the common case of small unions (say less than 10 strings), it's clearer to just show the type union. This makes the behavior of keyof
much easier to understand. Perhaps someday this will improve and we won't need to Resolve
this type.
unknown & T
I learned about this one from Titian's classic answer about typing _.invert
on Stack Overflow. Sometimes you can replace TypeExpr
with {} & TypeExpr
or unknown & TypeExpr
to force TypeScript to resolve a type.
You can see an example of this technique working in this playground. I'll spare you the full details, but from hard-earned experience I've learned that this trick is much more finnicky than Resolve
. Just use Resolve
instead.
Conclusion
While it may seem that you're stuck with however TypeScript chooses to display your type, that's not actually the case! You do have a few levers at your disposal. The Resolve
alias is wonderful for removing implementation details from generic types. And where it doesn't work, you can sometimes add a special case to get the display that you want.
Remember that all representations are equally valid, and that the TypeScript team might choose to change the display of your type at any time (as happened with keyof
in TypeScript 4.2). So once you've got your types displaying the way you like, it's worth writing some tests to make sure they stay that way. Tools like dtslint and literate-ts, which make assertions about the textual display of types, are the way to go here.
Do you have other tricks for controlling type display? Let me know on Twitter or in the comments below.