The display of types

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.

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:

interface Color { r: number; g: number; b: number; a: number };

declare function pickChannels<Chan extends keyof Color>(
c: Color, chan: Chan
): Pick<Color, Chan>;

const c: Color = { r: 255, g: 128, b: 0, a: 0.5};
const red = pickChannels(c, 'r');
// ^? const red: Pick<Color, "r">

Here's the magic incantation:

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

(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:

declare function pickChannels<Chan extends keyof Color>(
c: Color, chan: Chan
): Resolve<Pick<Color, Chan>>;

const red = pickChannels(c, 'r');
// ^? const red: { r: number; }

(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:

interface Select<
TableT,
WhereCols extends keyof TableT,
SetCols extends keyof TableT
> {
(
where:
Pick<TableT, WhereCols> &
{ [K in SetCols]: Set<TableT[K & keyof TableT]> }
): Promise<TableT>;
}

declare let selectComments: Select<Comment, 'author_id' | 'doc_id', 'id'>;
selectComments()
// ^? let selectComments: Select
// (where: Pick<Comment, "author_id" | "doc_id"> & {
// id: Set<string>;
// }) => Promise<Comment>

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:


interface SelectResolved<
TableT,
WhereCols extends keyof TableT,
SetCols extends keyof TableT
> {
(
where: Resolve< // <-- Resolve added here
Pick<TableT, WhereCols> &
{ [K in SetCols]: Set<TableT[K & keyof TableT]> }
>
): Promise<TableT>;
}

declare let selectCommentsResolved: SelectResolved<Comment, 'author_id' | 'doc_id', 'id'>;
selectCommentsResolved()
// ^? let selectCommentsResolved: SelectResolved
// (where: {
// author_id: string;
// doc_id: string;
// id: Set<string>;
// }) => Promise<Comment>

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:

interface SelectOne<TableT, Cols extends keyof TableT = keyof TableT> {
(where: {id: string}): Pick<TableT, Cols>;
}

declare let getCommentById: SelectOne<Comment>;

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:

const comment = getCommentById({id: '123'});
// ^? const comment: Pick<Comment, keyof Comment>

This is cryptic and quite implementation-y. And the Resolve trick doesn't quite do what we want here:

interface SelectOne<TableT, Cols extends keyof TableT = keyof TableT> {
(where: {id: string}): Resolve<Pick<TableT, Cols>>; // <-- Resolve
}

declare let getCommentById: SelectOne<Comment>;

const comment = getCommentById({id: '123'});
// ^? const comment: {id: string; doc_id: string; author_id: string, ...}

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:

interface SelectOne<TableT, Cols extends keyof TableT = keyof TableT> {
(where: { id: string }):
keyof TableT extends Cols // <-- conditional type
? TableT // <-- special case
: Resolve<Pick<TableT, Cols>>; // <-- default case
}
declare let getCommentById: SelectOne<Comment>;

const comment = getCommentById({id: '123'});
// ^? const comment: Comment

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:

interface Color { r: number; g: number; b: number; a: number };
type Chan = keyof Color;
// ^? type Chan = keyof Color
type ChanInline = Exclude<keyof Color, never>;
// ^? type ChanInline = "r" | "g" | "b" | "a";

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.

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. Now in its second edition, the book's 83 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »