Advanced Typescript

We'll quickly put you back on track by covering "advanced" typescript concepts from the ground up.

Introduction and reminders

REMINDER: the unknown type encompases the same set as any (i.e. all types), except it is usage restrictive, meaning that with any typescript assumes you know what your type is. In contrast, with unknown typescript assumes you don't know the type, so it forces you to do some type checking before using it.

let myvar: unknown;
const d = myvar.some.deep.prop;
//        ^^^^^ -------->  Error, Object is of type 'unknown'

// ask permission to typescript step by step
const du = ((myvar as { some: unknown }).some as { deep: unknown; }).deep; // unknown
// ask permission to typescript in one go
const da = (myvar as { some: any }).some.deep; // any

const sb = myvar.substr();
//         ^^^^^ -------->  Error, Object is of type 'unknown'
if (typeof myvar === 'string') {
  // myvar is now assumed to be string
  const sb = myvar.substr(10); // string
}

Safe Type Guards "propName" in o

See the following example, where we have two types User and Admin which we consider to be a Person. The caviat is that these two types have a property they do not share (i.e. occupation - role). To create the Person type, we made sure to allow it to be one of the two types by making the unshared props (i.e. occupation and role) be an optional property in each other. Which means, when a Person is a User, it has the role property set to undefined and vice versa. This allows us to query p.role in the type guard personIsAdmin(p: Person): p is Admin. Hadn't we added the optional other prop, we would be getting an error: Property 'role' does not exist on type 'Person'. Property 'role' does not exist on type 'User'.

interface User {
    name: string;
    age: number;
    occupation: string;
}

interface Admin {
    name: string;
    age: number;
    role: string;
}
export type Person = (User | Admin) & ((Partial<User> & Admin) | (Partial<Admin> & User));

function personIsAdmin(p: Person): p is Admin {
    return p.role !== undefined; // Only possible when doing the Person "partial crossing" above.
    //       ^^^^                ---> Would throw if Person = User | Admin: Property 'role' does not exist on type 'Person'. Property 'role' does not exist on type 'User'.
}

export const persons: Person[] = [
    {
        name: 'Max Mustermann',
        age: 25,
        occupation: 'Chimney sweep'
    },
    {
        name: 'Jane Doe',
        age: 32,
        role: 'Administrator'
    },
    {
        name: 'Kate Müller',
        age: 23,
        occupation: 'Astronaut'
    },
    {
        name: 'Bruce Willis',
        age: 64,
        role: 'World saver'
    }
];

export function logPerson(person: Person) {
    let additionalInformation: string;
    if (personIsAdmin(person)) {
        additionalInformation = person.role;
    } else {
        additionalInformation = person.occupation;
    }
    console.log(` - ${person.name}, ${person.age}, ${additionalInformation}`);
}

persons.forEach(logPerson);

Instead of all these precautions, we should use a simple javascript operator: "prop" in obj. This operator is a safe check for a possibly inexistent property. When using that, like in:

export type Person = User | Admin;

// You can do a typegard
function personIsAdmin(p: Person): p is Admin {
    return "role" in p;
}

export function logPerson(person: Person) {
    let additionalInformation: string;
    // or even directly in the code
    if ("role" in person) {
        additionalInformation = person.role;
    } else {
        // Typescript knows person is User!
        additionalInformation = person.occupation;
    }
    console.log(` - ${person.name}, ${person.age}, ${additionalInformation}`);
}

Table Of Contents

Topics we want to understand:

  1. Generics
    1. When do I have to provide a value to the generic, and when can typescript detect from provided param ? (ans: only when using generic functions)
  2. Equality test: A extends B
  3. Conditional types
    1. What does our conditional type expression really return?

      type Subset<T, U> = {
        [K in keyof T]: K extends keyof U ? T[K] : never
      }
      
    2. What is the never keyword

  4. Mapped types
  5. TS: just basic set theory
  6. Combining Conditional and Mapped types to construct arbitrarily deep types

1. Generics

Generics are TypeScript's functional system. When you use generics, you can define general type behavior that becomes concrete once you provide a value to the generic placeholder.

You can use generic behavior in types:

type Diff<T, U> = {
  [K in keyof T]: K extends keyof U ? never : T[K]
}
// Usage:
type AB = { a: string, b: string };
const ab: AB = { a: "", b: "" }

type B = { b: string };
const b: B = { b: "" }

type A = Diff<AB, B> // { a: string; b: never; }
let a: A = { a: "", b: "" }
//                  ^^ ---> Error, Type 'string' is not assignable to type 'never'
let a: A = { a: "" } // Ok

You can use generic behavior in functions:

declare function hello<T>(arg: T): Promise<T>
hello<string>("Eleutheros"); // function hello<string>(arg: string): Promise<string>
hello<number>(1); // function hello<number>(arg: number): Promise<number>
hello<number>("Eleutheros");
//            ^^^^ ---> Error, Argument of type 'string' is not assignable to parameter of type 'number'

We are declaring a function with a generic parameter. When we passe the generic parameter final type (i.e. in the <string> or <number> part), typescript knows what the final function type is. Thus, it will complain if the javascript parameter does not conform to the concrete function type.

Could we skip the generic type and let Typescript decide the function type based on the javascript parameter type ? Absolutely! Specifiying the generic is optional in this case:

hello(eleutheros); // function hello<string>(arg: string): Promise<string>
hello(1); // function hello<number>(arg: number): Promise<number>

1.1 When are Generics not optional

We have seen in the last example that typescript is capable of determining the type of a Generic through the javascript usage of its parent type declare function hello<T>(arg: T): Promise<T>. What about a generic type that is not a funcion? Could we call the generic without specifying the generic parameter type IF we have some javascript code to help the compiler decide ?? Let's see an example:

type SimplestGeneric<T> = T
type Str = SimplestGeneric<string> // explicitely passed Generic param
// Let's attempt to use some Typescript magic by complementing generic type inference
// through javascript usage
// What happens if we don't explicitely pass the generic parameter type?
const what: SimplestGeneric = ""
//          ^^^^^^^^^^^^^^^ ----> Error, Generic type 'SimplestGeneric' requires 1 type argument(s).

In conclusion, it seems that the only way to avoid to explicitely passing the Generic parameter type, is when the generic is defined as a Generic Function in the other cases, you will have to explicitely pass the generic parameter type value.

The generic type parameter allows you to make a generic type concrete. The one exception to passing the generic parameter type explicitely is in function generic parameters AND when you provide an optional generic paramter.

type SimplestOptionalGenericType<T = string> = T
const str: SimplestOptionalGenericType = ""; // string
const num: SimplestOptionalGenericType = 1;
//    ^^^ ----------------------------------> Error, Type 'number' is not assignable to type 'string'.
const num: SimplestOptionalGenericType<number> = 1; // number, ok

2. Equality test

What is the A extends B used for?

2.1 To narrow the allowed generic parameters types

the generic type can only take values within the set of types on the right hand side of the extends keyword

// T can be a subset of string | number
type HelloSN<T extends string | number> = { who: T };

type HSN = HelloSN<"">
// type HSN = {
//   who: "";
// }
type HSNBad = HelloSN<null>
//                   ^^^^^^ ---> Error, Type 'null' does not satisfy the constraint 'string | number'.

type HelloO<T extends object> = { who: T };

type H = HelloO<{ a: "" }>
// type H = {
//   who: {
//       a: "";
//   };
// }
type Ha = HelloO<[""]>
// type Ha = {
//   who: [""];
// }
type HOBad = HelloO<string>
//                  ^^^^^^ -------> Error, Type 'string' does not satisfy the constraint 'object'.

2.2 Functions that require an object to have at least some props

Sometimes you want to be sure some props are present in a function argument, for example, let's say we want the first argument to be an object with a hey property:

const badFunc = function <T>(arg: T) {
  const h = arg.hey;
//              ^^^ ---> Error property hey does not exist on type T
  return { h };
}
// We didn't give typescript enough information about T

// Let's tell typescript we want property 'hey' to be present so we can use it in the body of the function
const myFunc = function (arg: { hey: any }) {
  const h = arg.hey;
  return { h };
}
const b = myFunc({ hey: 'str' }) // { h: any }
const badCall = myFunc({ hey: 'str', my: 'dear' });
//                                   ^^^^^^^^^^ -----> Error, Argument of type '{ hey: string; my: string; }' is not assignable to parameter of type '{ hey: any; }'. Object literal may only specify known properties, and 'my' does not exist in type '{ hey: any; }'

We have a few problems above. The first, is that we are using any as the hey type, that's because we don't want to restrict its possible values. The result is that b is of type any but we passed string. Another problem is the badCall error: myFunc typing expects to receive a parameter of the exact { hey: any } type, but it recieved an additional property my: 'dear'.

How can we allow other properties in first argument to myFunc as long as there is the minimal hey: any prop? Using generics.

const myFunc = function <T extends { hey: any }>(arg: T) {
  const h = arg.hey;
  return { h };
}
const b = myFunc({ hey: 'str' }) // { h: any }
const okCall = myFunc({ hey: 'str', my: 'dear' }); // { h: any }

All fixed! Notice, it's a bit sad that we cannot let typescript guess the exact type of property hey. Don't worry, we'll fix that later with the infer keyword.

2.3 Narrowing down the returned type

We can narrow down, by testing with conditions or by testing with conditions and inferring.

  • Use as condition in conditional types T extends string ? "its a string": "not a string"

  • Use the infer keyword to create variables, it's the only way to use infer: 'infer' declarations are only permitted in the 'extends' clause of a conditional type

    type ContructorOf<C> = C extends new(...args: any[]) => infer T ? T : never;
    class Hey {
      public hey: string;
      constructor() {
        this.hey = "ho";
      }
    }
    type Constructed = ContructorOf<typeof Hey>; // Hey
    

Conditional Types

Why: use conditional types to determine what type we are dealing with and to

Shape:

T extends U
  ? "cool"
  : "damn"

Example: Unpack the inner value of a promise.

type UnpackPromise<T> = T extends Promise<infer U> ? U : never;
const pr = new Promise((resolve: (p: string) => void, reject: (e: Error) => void) => {
  setTimeout(() => {
    resolve('foo');
  }, 300);
})
type MyPromiseRet = UnpackPromise<typeof pr>; // string

NOTE: in the previous example we must acknowledge that TypeScript infers the promise's return type based on the resolveCallback type in Promise((<resolveCallback>, <rejectCallback>) => <PromiseBody>). If we don't specify what the resolve callback expects as its first argument type (as in resolve: (p: string) => void), TypeScript is not able to tell the promise's return type. See below:

const pr = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('foo');
  }, 300);
})
type MyPromiseRet = UnpackPromise<typeof pr>; // unknown

Therefore, make sure to type your resolve parameter to let typescript know what the promise returns.

What does our conditional type return

So far we have seen the simplest forms of conditional types: A extends B ? TypeToReturnIfTrue : OtherTypeToReturnWhenFalse. Under this formulation it is clear what the expression returns based on the condition A extends B: when A is a subset of B, the expression returns TypeToReturnIfTrue (e.g. say type A = "hello", type B = string), and it returns OtherTypeToReturnWhenFalse when A does not extend B (e.g. say type A = string, type B = "hello").

Now there are other forumlations which are not that self evident. Let's see an example:

type Subset<T, U> = {
  [K in keyof T]: K extends keyof U ? T[K] : never
}
const a = { a: "asdf", b: "qwer", c: 1 };
type C = Subset<typeof a, { a: 1 }>
// type C = {
//     a: string;
//     b: never;
//     c: never;
// }

Subset returns the type of T excluding those keys of T also present in U. Here we are passing a U whose property a is a number, but since Subset picks up the types of the properties in T, the returned type is { a: string } and not { a: number }.

REMINDER: never is equivalent in set theory to the empty set. Any set united with empty set is that set, it's like the 0 in addition and the 1 in multiplication.

3 Mapped Types

What are Typescript mapped types? Sometimes you want to be able to extract dynamically all the properties of an object type and perform some transformation on them (map over them). The mapped type, is a type based on another type's properties over which we map, to transform and build our desired shape.

Let's see an example that takes all the properties of an input type and makes them nullable:

type AllowNull<T> = {
  [K in keyof T]: T[K] | null;
}

type Hello = { a: string; b: number; d: null; c: () => void; }
type HelloNullableProps = AllowNull<Hello>;
// type HelloNullableProps = {
//   a: string | null;
//   b: number | null;
//   d: null;
//   c: (() => void) | null;
// }

What if we pass a non object to this property mapping engine? It simply returns the type. We can also make Typescript complain about a non object type by enforcing it through: type AllowNull<T extends object> = {.

That's great but what if we want to only keep a subset of the props?

type SomeFalse = { a: string; b: false; d: null; c: () => void; }
type DiscardFalsey<T extends object> = {
  [K in keyof T]: T[K] extends false | null | undefined ? never : T[K]
}
type NoFalsey = DiscardFalsey<SomeFalse>;
// type NoFalsey = {
//   a: string;
//   b: never;
//   d: never;
//   c: () => void;
// }

Aint it great?! Now, instead of the full object, we could want to gather for example all the property value types in an object, that are not falsey:

type GetNonFalseyTypes<T extends object> = {
  [K in keyof T]: T[K] extends false | null | undefined ? never : T[K]
}[keyof T];
type NoFalseyTypes = GetNonFalseyTypes<SomeFalse>; // string | (() => void)

The last {...}[keyof T] is similar to T[K], both are Index Types; like the javascript index myObject["propName"]. It's just a way of expressing the value type of a property in an object. The slight difference here, is that we want to access all the properties' value types, therefore we use the keyof T which is a set instead of a specific variable.

Finally an example taken directly from Tim Suchanek's Prisma presentation:

type User = {
  id: string;
  email: string;
  name: string | null;
}

type UserGetSelectPayload<S extends UserSelect> = {
  [K in GetNonFalseyTypes<S>]: K extends keyof User
    ? User[k]
    : K extends 'posts'
      ? Array<PostsGetSelectPayload<S[P]>>
      : never
}

Usefull utility types

Construct an object type using generics

HasKey

Create a type that has a prop S and a value of type V

type HasKey<S extends string, V = any> = {
  [_ in S]: V;
}
// Notice the _ is just a convention to signify we won't use that variable

// Usage
type My = HasKey<'name', string>
// type My = {
//   name: string;
// }

// Nested Usage
type My = HasKey<'name', HasKey<'first', string>>
// type My = {
//   name: {
//     first: string;
//  }
// }

Source: Genious content from James McNamara!

What does he use this utility for? To create a function that is able to access an object's nested prop!

declare function get<K1 extends string, K2 extends string>(a: K1, b: K2): (
  <T extends HasKey<K1, HasKey<K2>>>(o: T) => T[K1][K2]
);

const pink = {
  hello: {
    isThere: 'anybody'
  }
};
get('hello', 'isThere')(pink); // 'string'
get('hello', '--------')(pink);
//                       ^^^^^  ----> Error Argument of type '{ hello: { isThere: string; }; }' is not assignable to parameter of type 'HasKey<"hello", HasKey<"darkness", any>>'. Types of property 'hello' are incompatible. Property 'darkness' is missing in type '{ isThere: number; }' but required in type 'HasKey<"darkness", any>'

We can create an equivalent function relying on the infer T we just learned

declare function get<K1 extends string, K2 extends string>(a: K1, b: K2): (
  <T extends HasKey<K1, HasKey<K2>>>(o: T) => T extends HasKey<K1, HasKey<K2, infer Z>> ? Z : never;
);

The above will tell typescript to infer Z when T extends HasKey<K1, HasKey<K2>>. infer Z and return Z is ay to reference the type of the nested property in T[K1][K2].

Since we are constrining our param's accepted input with <T extends HasKey<K1, HasKey<K2>>>(o: T), the never part of our inner function's conditional type, will never be reached.

Could we skip this param constraint and rely on the return conditional check T extends HasKey<K1, HasKey<K2, infer Z>> to return never when it does not conform ? We could, but see we happens:

declare function get<K1 extends string, K2 extends string>(a: K1, b: K2): (
  /* removed param type constraint*/(o: T) => T extends HasKey<K1, HasKey<K2, infer Z>> ? Z : never;
);
const pink = {
  hello: {
    isThere: 'anybody'
  }
};
get('hello', '--------')(pink); // never

Without constraining the input type, we lose Typescript's ability to warn us with errors.

Unpack

Given a wrapped type, like numbers in an array, or a string returned by a promise, or the values of an object's props etc., we want to infer the type.

type ObjectOf<T extends number | string | symbol, V> = { [k in T]: V; }
type Unpack<T> =
  T extends Array<infer V> ? V :
  T extends Set<infer V> ? V :
  T extends Map<any, infer V> ? V :
  T extends Promise<infer V> ? V :
  T extends ObjectOf<number, infer V> ? V :
  T extends ObjectOf<string, infer V> ? V :
  T extends ObjectOf<symbol, infer V> ? V :
  never;