Advanced TypeScript Type Tricks

Posted on Monday, December 21, 2020

A journey

After spending some time in TypeScript you may begin to crave type safety everywhere in your life. JavaScript is an inherently unsafe language and there are dragons to be found everywhere and in almost every framework. Most frameworks are sufficiently covered, but what if you design a beautiful API internally that needs to be incredibly feature rich, and incredibly safe, but still allow for the best JavaScript idioms. Well at that point you may have to dive into writing some pretty gnarly types.

In general there are a number of TypeScript TYPE libraries out there, ts-toolbelt, utility-types, type-fest to name a few. These libraries are great, and contain a plethora of information, but they are generally sparse in describing HOW they created their utility types.

There are a lot of great type libraries, but no great documentation on how they’re built

The goal of this post is to not only expose some great utility types, but also some tips and tricks that I realized along the way. Unfortunately this post requires a relatively strong understanding of TypeScript types and tricks. Advanced Types is a great place to start before diving in!

Esoteric solutions

A lot of these solutions (and problems) may feel esoteric, and that’s because frankly they are. They are not for the faint of heart, they do not need to be known for daily TypeScript use. They are the nitty-gritty realities of writing type libraries for incredibly complex or il-defined JavaScript problems.

Most of these types come from a few TypeScript libraries im working on, namely safe-schema, and mongo-safe, both of which I intend to do lengthy blog posts on in the future.

I have tried to organize these with an example of the usage, and the reason why the problem or solution is novel or interesting. They are in no particular order.

I didnt say they couldnt I said you shouldnt


Setting A Variable (extends infer)

Example

// Bad
type Shoes<T> = {item: Fancy<Shmancy<Deeply<Nested<T>>>>} | 
                Fancy<Shmancy<Deeply<Nested<T>>>> | 
                [Fancy<Shmancy<Deeply<Nested<T>>>>, Fancy<Shmancy<Deeply<Nested<T>>>>] | 
                [Fancy<Shmancy<Deeply<Nested<T>>>>];
// Good
type Shoes<T> = Fancy<Shmancy<Deeply<Nested<T>>>> extends infer TItem ? 
                {item: TItem} | TItem | [TItem, TItem] | [TItem]:
                Impossible;

type Impossible = never;

Reason

Occasionally you will need to use the same type over and over in your type definition. Every time TypeScript encounters your type it will have to evaluate it again. Using the infer trick you can store a variable in your type that you can use throughout the rest of it.

You will see this extends infer trick pop up in a few places. infer has some interesting properties that make it useful when defining complex types.

Note the use of Impossible here. We can never reach the Impossible branch, so typically we would specify never here, but I have found just putting never there will be confusing in the future when looking at these types again thinking that never branch could possibly be hit. The use of explicit type alias’s that resolve to basic types is a great way to be explicit in your definitions.

Note: This will not work if the result of your type is never. If T or Fancy<Shmancy<Deeply<Nested<T>>>> is never, the whole expression will result in never.


Type is never

Example

// Bad
type Shoes<T> = T extends never ? 1 : 0;

type Test1 = Shoes<'hi'>; // 0
type Test2 = Shoes<never>; // never
// Good
type Shoes<T> = [T] extends [never] ? 1 : 0;
//              ^ ^         ^     ^
type Test1 = Shoes<'hi'>; // 0
type Test2 = Shoes<never>; // 1

Reason

It is not easy to test for never. Once one of the types in your expression is never it will poison the rest of the expression to evaluate to never. Typically, this is a useful feature, but when you actually need to check for never you can wrap your expression in a tuple. This will trick the TypeScript compiler to compare apples to apples instead of expanding the never.

It works by comparing the tuple T to the tuple never. We use this tuple trick in a number of places. I am not certain as to why it works, other than the fact that tuples make TypeScript slightly less greedy in its evaluation.


What never? No never.

Compare Multiple Types At Once

Example

// Bad
type Shoes<T1, T2, T3> = T1 extends {} ? 
                              (T2 extends number ? 
                                  (T3 extends string ? 
                                      1 : 
                                      0): 
                                  0): 
                              0;
// Good
type Shoes<T1, T2, T3> = 
                        [
                            T1 extends {} ? 1 : 0, 
                            T2 extends number ? 1 : 0, 
                            T3 extends Something ? 1 : 0
                        ] extends 
                        [1, 1, 1] ? 
                        1:
                        0;

Reason

You will often need to deeply check a few things, and only execute some logic if all are a certain value. This can result in an annoying deeply nested structure.

One clean solution to this is to check all of them upfront in a tuple and compare that to the expected results of each. Now your tree is only one level deep and your intent is much clearer!


Don’t Distribute My Union

Example

// Bad
type StringToObject<T> = T extends string ? {[key in T]: boolean} : never;

type UnionToObject<T> = StringToObject<T>;
type Nested<T> = {[key in keyof T]: UnionToObject<T[key]>};

const value: Nested<{thing: 'a' | 'b' | 'c'}> = {thing: {a: true, c: true}}; // NO ERROR
// Good
type StringToObject<T> = [T] extends [string] ? {[key in T]: boolean} : never;
//                       ^ ^         ^      ^
type UnionToObject<T> = StringToObject<T>;
type Nested<T> = {[key in keyof T]: UnionToObject<T[key]>};

const value: Nested<{thing: 'a' | 'b' | 'c'}> = {thing: {a: true, c: true}}; // ERROR Missing 'b'

Reason

Sometimes you will pass a union so deeply that when it reaches its final destination the union has been distributed. This can result in unintended behavior as described above. Since the union has been distributed, value in the first example will result in the type

{thing:
    {[key in 'a']:boolean} | 
    {[key in 'b']:boolean} | 
    {[key in 'c']:boolean}
}

when your goal was simply {thing: {[key in ('a'|'b'|'c')]:boolean} }.

The solution to this is to wrap T in a tuple to force TypeScript to not distribute your union in subsequent expressions.


Exclude never

Example

// Bad
type Bad = {shoes:never; other: boolean}
const value: Bad = {shoes:1, other:true};  // throws an error on shoes but intellisense and type is misleading
// Good

export type ExcludeNever<T> = {
    [key in keyof T as T[key] extends never ? never : key]: T[key];
};


const value: ExcludeNever<Bad> = {shoes: 1, other: true} // throws a full error and no intellisense

Reason

Occasionally you will process some type in a Record and set a value to never. This is perfectly valid and will throw errors when the user attempts to put a value there.

The problem is, intellisense does not exclude this key from your result list. Sure the type is never, but since the key still exists it will allow you to put a value there.

The solution is to use the new TypeScript as syntax to set the key to never when appropriate, that way it will not be available to the user.

Note: the end result is the same, you cannot put a value for shoes, but the DX is significantly improved in the second case.


Potential Solution To type instantiation is excessively deep and possibly infinite

Example

// Bad
type GoDeep<T> = /*...*/ GoDeep<T>; 
// Good
type GoDeep<T> = T extends infer R ? /*...*/ GoDeep<R> : Impossible;

Reason

Now this one is a bit hard to explain, but easily one of the most powerful tricks in this post. It has saved my bacon too many times to count.

The example above is intentionally sparse since it’s hard to find real world examples. When you need it, you’ll know.

Along your journey to making a complex library completely typesafe you will inevitably create a type that is potentially infinitely deeply nested.

A little background: TypeScript has a rule that it will generally not dive deeper than about 50 levels of type checking before it throws up its hands and resolves the dreaded “type instantiation is excessively deep and possibly infinite”. This means your type is too complex and it would take too long to evaluate, so it wont.

I will say that 90% of the time this is due to an issue in your code that is solvable without use of trickery. Typically, you screwed up somewhere. However, on the rare occasion where you did everything right, your type is properly optimized, but your library requirements actually do push the envelope, you can use the above trick.

How it works is still a bit of a mystery to me. This is a trick I learned from the incredible library ts-toolbelt. The best I can tell is it defers the evaluation of the type T until it is actually requested. What this means is just because your type can be nested infinitely, this outcome will be tested based on usage rather than a blanket statement.

That means if you pass a T that does exhaust the 50 cap then TypeScript will be sure to let you know, but until then all of the T’s that play nice will continue to work.

I dont know if the explanation above is correct and would love to be corrected! In my experience and research this is how it works.

Deep


Potential Solution To excessive union

Example

type Depths = 1 | 2 | 3 | 4 | 5;

type NextDepth<TDepth extends Depths> = TDepth extends 1
  ? 2
  : TDepth extends 2
  ? 3
  : TDepth extends 3
  ? 4
  : TDepth extends 4
  ? 5
  : never;

export type SafeTypes = number | string | boolean | Date

export type DeepKeys<T extends {}, TDepth extends Depths = 1> = TDepth extends 5
  ? //                                                           ^^ here is the check to break out
    ''
  : {
      [key in keyof T]: key extends string
        ? T[key] extends SafeTypes
          ? `${key}`
          : T[key] extends {}
          ? `${key}` | `${key}.${DeepKeys<T[key], NextDepth<TDepth>>}`
          : //                                     ^^ this is the magic
            never
        : never;
    };

Reason

This is a real world example to a problem that may not be common but I ran into more than a few times. Using the new TypeScript 4.1 string template syntax (which is incredible) you can now generate a union of strings that represent the deeply nested object.

The problem with this is that a union in TypeScript can only have so many members so depending on how complex and deeply nested your object is you can potentially blow this amount out.

The solution posed above is far from ideal, and will break eventually as my usage increases, but it is more food for thought on how to approach building your type systems.

The compromise I chose here was to simply limit my nesting. I decided that I will only be safe for up to 5 levels, after that the developer is on their own. This worked for my one use case on my one project, but your millage may vary!


Potential Solution To partial generic type inference

Example

/* Setup */
type SomeType = /**/;
function doThing<TTable, TLookupKey>(params: {tableName:string, key: TLookupKey}): TTable;


/* Annoying */
doThing<SomeType, 'shoes'>({key: 'shoes', tableName: 'Shoe'});
//         ^         ^ Having to specify both :-(

/* Ideal */
doThing<SomeType>({key: 'shoes', tableName: 'Shoe'});
//         ^       Only specify one!



/* Magical Compromise */

type TableName<TTable> = string & {__table: TTable};
export function tableName<TTable extends {}>(tableName: string): TableName<TTable> {
  return tableName as TableName<TTable>;
}

function doThing<TTable, TLookupKey>(params: {tableName: TableName<TTable>, key: TLookupKey}): TTable;

doThing({key: 'shoes', tableName: tableName<SomeType>('Shoes') });
//     ^ specify neither!                      ^ Chefs kiss.

Reason

Alright, perhaps this one is a stretch, but personally I think this is the coolest trick here, and when I realized the potential my mind was boggling.

Basically there is an outstanding TypeScript issue around partial generic inference.

See, TypeScript will infer generic types based on usage, but only when ALL the types can be inferred. If one cannot be, you must provide ALL the types yourself. This can be a chore at best, and impossible at worse.

Our solution here is to use an opaque type that is really just a string, but use it to carry along some extra type data that we will then pass as our tableName param. This allows TypeScript to have just enough type information to infer TTable properly and not force us supply the types manually!

Chefs Kiss



Simple Useful Types

Along the way I have created or curated a handful of useful types that I don’t often see posted around, or at the very least explained why they’re useful.

Lookup

type Lookup<T,TKey> = TKey extends keyof T ? T[TKey] : never;

type Result = Lookup<{shoes:true},'shoes'>

While it may seem obvious and contrived, occasionally you will want to look up a key on a type that you are confident is there, even if TypeScript isn’t confident. The solution is to check if that key exists in your type, but this requires you to nest your expression one more time, which is at best annoying and at worst may blow out your 50 type depth. The solution to this is a simple Lookup type that returns never in the event its invalid.


Cast

type Cast<TOld, TNew> = TOld extends TNew ? TNew : never;
type Result<T> = Cast<{T,string}>

Same as above, when you have a type that you know extends another type, but you’re not in a place where TypeScript agrees without asserting, Cast will do the work for you without nesting your expression.


Discriminate

type DiscriminateUnion<T, TField extends keyof T, TValue extends T[TField]> = 
            T extends {[field in TField]: TValue}
            ? T
            : never;

type Query= { type: 'a', number: 1 } | { type: 'b', string: '1' };

type OnlyTypeA = DiscriminateUnion<Query, 'type', 'a'>

This is another typical you won’t need it until you do. Conventional wisdom says just store each part of the union as a separate type and go from there, but that is not always convenient or possible. This type will allow you to have a concrete version of just a piece of your union.


Tuple To LinkedList Or Reverse LinkedList

type LinkedList<T, Last = never> = T extends readonly [infer Head, ...infer Tail]
? {Item: Head; Next: LinkedList<Tail, Head>}
: never;

type LinkedListReverse<T extends readonly unknown[], Last = never> = T extends readonly [infer Head, ...infer Tail]
? LinkedListReverse<Tail, {Item: Head; Last: Last}>
: {Last: Last; Item: never};
type LinkedListResult=LinkedList<[
        {$match: {someId: 'ah'}},
        {$project: {someValue: '$someId'}},
        {$group: {_id: '$someValue'}},
        {$sort: {someValue: 1}}
    ]>;  
/*
{
    Item: {
        $match: {
            someId: 'ah';
        };
    };
    Next: {
        Item: {
            $project: {
                someValue: '$someId';
            };
        };
        Next: {
            Item: {
                $group: {
                    _id: '$someValue';
                };
            };
            Next: {
                Item: {
                    $sort: {
                        someValue: 1;
                    };
                };
                Next: never;
            };
        };
    };
}
*/

// Now you can traverse your linked list like this: 


type Process<T,TValue>=/*?*/

type TraverseLinkedList<T, LNLNext> = [LookupKey<LNLNext, 'Next'>] extends [never]
  ? Process<LookupKey<LNLNext, 'Item'>>
  : TraverseLinkedList<T, LookupKey<LNLNext, 'Next'>> extends infer J
    ? Process<J, LookupKey<LNLNext, 'Item'>>
    : Impossible;

type Result = TraverseLinkedList<{}, LinkedListResult;

Once upon a time I needed to turn a tuple into a LinkedList to make processing of the tuple array easier. I don’t expect this to be useful for anyone, but it was a nice exercise and can force you to think about building types in a slightly different way.

This has the issue of being limited to probably around 30 items in your tuple (the 50 limit minus the nesting in TraverseLinkedList). As a result I did not use it for my solution since I did not want to limit the user. Toaster



Conclusion

I hope these tips and tricks were helpful. I hope this provokes more Type Engineers to document the mind-numbing puzzle solving minutia of building complex TypeScript types. In future posts I hope to document some approaches to debugging complex types, as well as diving into how I made MongoDB aggregates type safe.

If you have any questions or comments, feel free to reach out to me on twitter!

See you next time!

Bye!