DEV Community

Cover image for Advanced Typescript Types, explanations and Cheatsheet
Peter Repukat
Peter Repukat

Posted on

Advanced Typescript Types, explanations and Cheatsheet

Disclaimer: This was originally posted on my own blog


This post might get updated from time to time.

TL;DR: GitHub-Gist
Skip all the explanations and just see types


I've grown pretty fond of Typescript in recent years.
In particular I very much like its Turing-complete type system.

Typescript provides a fair share of built-in Utility-Types, however, I always come back copy-pasta-ing from my old code when requiring more complex util-types.

So here's kind of a cheat-sheet, with examples and explanations, for some of the advanced types I've built and used within the last years.

Also, check out SimplyTyped or TypeFest. They contain some truly mind-boggling, but extremely useful stuff.

I try not to include it in every one of my projects, so there might be some duplicates.

As I find it pretty hard to explain more in-depth types, let me know where you would like to see clarification.
Also let me know if you know any other cool, usefull or mindbending stuff!

So, without further ado, Let's dive in

Await Type

This one is pretty straightforward.
We use the built-in PromiseLike type to check if the input really is a promise.
If it is, use the infer keyword to deduce its type-argument and recursively 'call' Await again.
If not, just return the input.

type Await<T> = T extends PromiseLike<infer U> ? Await<U> : T;

const asyncFn = async (): Promise<number> => new Promise((res) => res(1337));

type ResolvedReturnType = Await<ReturnType<typeof asyncFn>>; // = number
Enter fullscreen mode Exit fullscreen mode

Playground

Length Type

Another simple one is the Length type.

You can use this for example to infer the number of parameters to a given function or (readonly) array-lengths
There is, however, another clever use, to which we will come back later.

export type Length<T> = T extends { length: infer L } ? L : never;

type MyFnType = (a: number, b: string, c: number) => unknown;

type NumParams = Length<Parameters<MyFnType>>; // = 3
Enter fullscreen mode Exit fullscreen mode

Playground

KeysOfType

I find this one particularly useful from time to time.
It returns the keys of a given interface O, but only if they are of type T

export type KeysOfType<O, T> = {
    [K in keyof O]: O[K] extends T ? K : never;
}[keyof O];


// example 

interface MyInterface {
    keyA: number,
    keyB: string,
    keyC: Record<string, MyInterface>,
    keyD: 1337
};

type KeysOfTypeNumber = KeysOfType<MyInterface, number>; // 'keyA' | 'keyD'
Enter fullscreen mode Exit fullscreen mode

This one is not as easy to unserstand, so how does it work?
First, we create a new type that contains all of the keys of O,
then we get the valueType of O by its key O[K] and then check if it is of type T
If so, we just use the key K as our value, otherwise, we use never.
This would result in something like { keyA: 'keyA'; keyB: never }
The last step now is to look up this intermediate type, by all the keys of our interface O.
The never type gets discarded when looking up a type like this.
But because of this, if we wanted to filter our interface, we cannot just leave out the lookup.

Bonus: PickByType

// ConvertLiterals would convert literal types like `1337` to their base type like `number` if set to true 
export type PickByType<O, T, ConvertLiterals extends boolean = false> = {
    [K in KeysOfType<O, T>]: ConvertLiterals extends true ? T : O[K]
};

type PickedByNumber = PickByType<MyInterface, number>; // { keyA: number; keyD: 1337 }
Enter fullscreen mode Exit fullscreen mode

Playground

OneOf

Suppose you have an Interface (or intersecting Interfaces) and you want to create a (strict) Union type from it.
In the aforementioned SimplyTyped library, there exist types with which you can do the same thing.
I personally like this version better, though, as it's a single 'one-liner' (cough) type that one can even parameterize.

Let's start with just the type

export type OneOf<T, Strict extends boolean = true> = {
    [OuterKey in keyof T]: Strict extends false
        ? { [K in OuterKey]: T[K] }
        : { [InnerKey in OuterKey|keyof T]?: InnerKey extends OuterKey ? T[OuterKey] : never } & { [TheKey in OuterKey]: T[OuterKey] } 
}[keyof T];
Enter fullscreen mode Exit fullscreen mode

Now break this down somewhat.
First, we omit the second type parameter Strict and only use the then false-case and just look at a simpler Unionize type

export type Unionize<T> = {
    [OuterKey in keyof T]: { [K in OuterKey]: T[K] }
}[keyof T];
Enter fullscreen mode Exit fullscreen mode

This creates a type with the keys of the input type T as key and a new dictionary type containing a single key-of T and the corresponding value type of T as value.
Afterward, we use the look-up trick we already used above to get a union type of the inner dictionary types (hence the unionize name)

interface MyIface {
    keyA: number;
    keyB: string;
}

type Demo = Unionize<MyIface>; // = { keyA: number } | { keyB: string }
Enter fullscreen mode Exit fullscreen mode

However...

const a: Unionize<MyIface> = { keyA: 1337 }; // ok
const b: Unionize<MyIface> = { keyA: 1337, keyB: 'foo' }; // also okay
Enter fullscreen mode Exit fullscreen mode

This is because union dictionary types are not exclusive.
In some cases, it can be okay, other times you'd want it to be strict.
So let's look at that

export type OneOf<T> = {
    [OuterKey in keyof T]: {
            [InnerKey in OuterKey|keyof T]?: InnerKey extends OuterKey ? T[OuterKey] : never
        } & {
            [TheKey in OuterKey]: T[TheKey] 
        } 
}[keyof T];
Enter fullscreen mode Exit fullscreen mode

Now, this is a bit more complex.
If we look closely, however, it is the same as the Unionize type shown above except for an intersecting type: { [InnerKey in OuterKey|keyof T]?: InnerKey extends OuterKey ? T[OuterKey] : never }.
Let's break this down some more.

OuterKey is a single key of our input-type T. Create a union with that and with all keys of T as keys for the inner dictionary ([InnerKey in OuterKey|keyof T]: ...).
For the value of the inner dictionary, we check if the current InnerKey is the OuterKey (InnerKey extends OuterKey ...).
If it is, we use the value-type of our original type T, otherwise never.
The last part is to make every key for this dictionary optional (the ? after the key declaration).

Now this gives us a type which, has all keys of the input-type, but only a single key is allowed to be assigned with a value-type other than undefined.
That means we can still use undefined, though.
We can still use undefined, though.
To mitigate this, we add an intersection to an inner dictionary, that being the one we've seen in the Unionize type, as it doesn't allow undefined.

The result is the something like this

export type OneOf<T, Strict extends boolean = true> = {
    [OuterKey in keyof T]: Strict extends false
    ? { [K in OuterKey]: T[K] }
    :   {
            [InnerKey in OuterKey|keyof T]?: InnerKey extends OuterKey ? T[OuterKey] : never
        } & {
            [TheKey in OuterKey]: T[TheKey] 
        } 
}[keyof T];


// example
interface MyInterface {
    keyA: number,
    keyB: string,
    keyC: Record<string, unknown>,
    keyD: 1337
};

type OnlyOne = OneOf<MyInterface>; // ^= more or less strict version of { keyA: number } | { keyB: string } | { keyC: Record<string, MyInterface> } | { keyD: 1337 }
type NonStrictVersion = OneOf<MyInterface, false>; // { keyA: number } | { keyB: string } | { keyC: Record<string, MyInterface> } | { keyD: 1337 }
Enter fullscreen mode Exit fullscreen mode

Playground

Tuple Manipulation

Tuples, combined with the ability to spread them and infer single or even rest-types, are an incredibly useful tool when dealing with more complex types.
So here are a few basic manipulation types starting with:

Push, PushFront, Pop, PopFront, Shift, ShiftRight

type Push<T extends unknown[], U> = T extends [...infer R] ? [...T, U] : never;
type PushFront<T extends unknown[], U> = T extends [...infer R] ? [U, ...T] : never;
type Pop<T extends unknown[]> = T extends [...infer R, infer U] ? U : never;
type PopFront<T extends unknown[]> = T extends [infer U, ...infer R] ? U : never;
type Shift<T extends unknown[]> = T extends [infer U, ...infer R] ? R : never;
type ShiftRight<T extends unknown[]> = T extends [...infer R, infer U] ? R : never;
Enter fullscreen mode Exit fullscreen mode

These should all be pretty straightforward.
Note: You could just as well use any instead of infer X where X isn't used.

Reverse

Since there are no loop-functions for TypeScripts type-system, and we somehow need to iterate over the input tuple, we have to use recursion.
To check when the recursion needs to end, we can use the aforementioned Length-type. Afterward, just Pop from one tuple, and Push to another.

type Reverse<T extends unknown[], U extends unknown[] = []> = Length<T> extends 1 ? Push<U, Pop<T>> : Reverse<ShiftRight<T>, Push<U, Pop<T>>>;
Enter fullscreen mode Exit fullscreen mode

A little bit special here is the second type-parameter, which is default initialized to an empty tuple.
We basically use that as our return value, after recursively filling it from the input T.
If you wanted to restrict any other devs in your codebase to do nasty things with the second type parameter, you can just wrap the above implementation.

Filter, TupleIncludes

Next, let's look at a filter-type. This one's also pretty simple.
First, check if our tuple is empty. If it's not, infer the first Element F and the rest of the tuple as another type R.
Then we check if F is equal to, or extends, the type we want to filter out.
If it is, return Filter again, but this time on the rest R without F.
Otherwise, we return a new tuple, with F at the front and a spread (...) of the filtered-rest.

Combining the Filter type with our handy-dandy Length-type, we can also build an Includes-type by simply comparing the lengths of the filtered and unfiltered tuple!

type Filter<T extends unknown[], U> = T extends [] ? [] : T extends [infer F, ...infer R] ? F extends U ? Filter<R, U> : [F, ...Filter<R, U>] : never
type TupleIncludes<T extends unknown[], U> = Length<Filter<T, U>> extends Length<T> ? false : true
Enter fullscreen mode Exit fullscreen mode

Playground

You may have noticed that I didn't actually call the Includes-type like that, but rather TupleIncludes.
We'll come back to that just below...

Template Literal Types

My favorite "subtype" of Types.
Introduced in TypeScript 4.1, they really filled a gap for me.
With them, you can do so much fancy and mindblowing stuff
Someone even wrote a full-blown CSS-Parser 🤯!

Fancyness aside, I also want to share a few types I've found useful to me.

StringIncludes

The nice part about template literals is that you can infer literal types from them.
Even nicer: You can infer empty strings!
Which makes a type that can check if any string includes another pretty trivial.

type StringIncludes<S extends string, D extends string> = S extends `${infer T}${D}${infer U}` ? true : false;

type Demo1 = StringIncludes<'a.b', '.'>; // = true
type Demo2 = StringIncludes<'ab', '.'>; // = false
type Demo3 = StringIncludes<'a.', '.'>; // = true
type Demo4 = StringIncludes<'.', '.'>; // = true
Enter fullscreen mode Exit fullscreen mode

As we now also have a StringIncludes type, we can now combine that with the TupleIncludes from above, to get a completely generic Includes type

type Includes<T extends unknown[]|string, U> = T extends unknown[] ? TupleIncludes<T, U> : T extends string ? U extends string ? StringIncludes<T, U> : never : never;
Enter fullscreen mode Exit fullscreen mode

Template literal types are (or were, at the time of initially writing most of this), however, for some reason, more restricted than tuples, so we need to build a few types to help with that.

Split

type Split<S extends string, D extends string> =
    string extends S ? string[] :
        S extends '' ? [] :
            S extends `${infer T}${D}${infer U}` ?  [T, ...Split<U, D>] : [S];
Enter fullscreen mode Exit fullscreen mode

You may wonder why the first conditional is string extends S.
If you haven't guessed, it's the check if string itself is passed as a type argument, instead of any literal.
Any literal string extends string - but string doesn't extend any literal string

Here's some code to better explain:

type Demo<S extends string> = string extends S ? 'used string' : `used literal: ${S}`;

type D1 = Demo<'str'>; // = 'used literal: str'
type D2 = Demo<string>; // = 'used string'
Enter fullscreen mode Exit fullscreen mode

Join

type Join<T extends unknown[], D extends string> = string[] extends T ? string : T extends string[]
  ? PopFront<T> extends string ? Length<T> extends 1 ? `${PopFront<T>}` : `${PopFront<T>}${D}${Join<Shift<T>, D>}` : never
  : never;
Enter fullscreen mode Exit fullscreen mode

Using what we've learned so far and some of the tuple manipulation types above, we can now join any string tuple back to a string.

Playground

Example using all of this

A cool and really useful example, using most of the above util-types, is dealing with "Paths" and nested-types of dictionaries.

(Explanations for those are left as an excersice for the reader)

Let's start with valid Paths

ValidPaths, ValidPathTuples

At the time of initially writing this, the Tuple version was the only one working.In most recent TS versions both work, however, the string literal version has got some gotchas and so the tuple version is somewhat preferred.

const dictionary = {
  someProp: 123,
  nested: {
    moreProps: 333,
    deeper: {
      evenDeeper: {
        deepest: 'string'
      }
    },
    alsoDeeper: {
      randomProp: {
        anotherProp: 'wtf'
      }
    }
  }
} as const;

type MyDict = typeof dictionary;

// If you look at type "Debug" below, this surprisingly works.
// You may get a `Type instantiation is excessively deep and possibly infinite.`-error if you're using an older TS version, though
type ValidPaths<T> = keyof T extends never ? never : ({
     [K in keyof T]: T[K] extends never ? never : T[K] extends Record<string|number|symbol, unknown>
       ? K extends string ? `${K}.${ValidPaths<T[K]>}` | K : never
       : K
   })[keyof T] & string;

type Debug = ValidPaths<MyDict>;

// With Tuples.
type ValidPathTuples<T> = keyof T extends never ? never : ({
     [K in keyof T]: T[K] extends never ? never : T[K] extends Record<string|number|symbol, unknown>
       ? [K, ...ValidPathTuples<T[K]>] | [K]
       : [K]
   })[keyof T];

type DebugTuples = ValidPathTuples<MyDict>;

// on recent TS versions you can even join the tuples back to "PathStrings"
type DebugTuples1 = Join<ValidPathTuples<MyDict>, '.'>;

Enter fullscreen mode Exit fullscreen mode


typescript
Playground

NestedType, NestedTypeByTuple

We can also craft a type that gets us the nested-type of a dictionary for any valid path, or throw an error if an invalid path is passed.
And of course we can put it all together in a beautiful function where only valid Paths are allowed as parameter and the return type is automagically deduced for us.

// string version
type NestedType<T, P extends string> = (
  Includes<P, '.'> extends true
    ? PopFront<Split<P, '.'>> extends keyof T
      ? NestedType<T[PopFront<Split<P, '.'>>], Join<Shift<Split<P, '.'>>, '.'>>
      : never
    : P extends keyof T ? T[P] : never
);

type DemoNested1 = NestedType<MyDict, "nested.moreProps">; // 333
type DemoNested2 = NestedType<MyDict, "nested.deeper.evenDeeper">; // { readonly deepest: "string" }
type DemoNestedNever = NestedType<MyDict, "nested.randomProp">; // never

// tuple version
type NestedTypeByTuple<T, P extends string[]> = (
  Length<P> extends 1
    ? Pop<P> extends keyof T ? T[Pop<P>] : never
    : PopFront<P> extends keyof T ? Shift<P> extends string[] 
      ? NestedTypeByTuple<T[PopFront<P>], Shift<P>>
      : never : never
);

type DemoNestedTuple1 = NestedTypeByTuple<MyDict, ["nested" ,"moreProps"]>; // 333
type DemoNestedTuple2 = NestedTypeByTuple<MyDict, ["nested" , "deeper", "evenDeeper"]>; // { readonly deepest: "string" }
type DemoNestedTupleNever = NestedTypeByTuple<MyDict, ["nested", "sillyProp"]>; // never

// String version internally using tuples
// Bonus: Also errors now
type NestedTypeUsingTuplesAgain<T, P extends ValidPaths<T>> = NestedTypeByTuple<T, Split<P, '.'>>;

type Convoluted = NestedTypeUsingTuplesAgain<MyDict, 'nested.alsoDeeper'>;
type ConvolutedError = NestedTypeUsingTuplesAgain<MyDict, 'nested.nonExistant'>;

// And now we can finally give lodash's `get` function a run for it's money
function GetByPath<T, P extends ValidPaths<T>>(dict: T, path: P): NestedType<T, P> {
 // internal impl. must be different. only demoing types here!
 return (dict as any)[path];
}
Enter fullscreen mode Exit fullscreen mode

Playground

Top comments (0)