Advanced TypeScript

Javier RĂ­os

TypeScript is a humongous improvement over JavaScript. Without even using complex types, developers can make use of its type-safety, removing virtually all TypeErrors.

Even though primitive types such as number, string or the newer symbol combined with unions | and intersections &, and assertions with as and satisfies can be more than enough for a project, it can be challenging to satisfy more specific constraints with these basic types. This is where more advanced types come into play.

Keyof type operator

The keyof type operator takes an object type and produces a string or numeric literal union of its keys. It is fairly simple to understand.

const myArray = [1, 2, 3];
const myObject = { hello: "hi", world: "Mars" };

let myArrayTest: keyof myArray; // number[];
let myObjectTest: keyof myObject; // "hello" | "world";

Typeof type operator

Even though JavaScript already has its typeof operator, TypeScript's one is different. It is important to discern between types and values, and typeof just returns the type of a value. Alone it could be seen as a trivial type, but when combined it allows for more complex types.

const myObject = { length: 5, values: [1, 2, 3, 4, 5] };

type MyObjectType = typeof myObject; // { length: number, values: number[] };

Indexed Access Types

Indexed Access Types allow us to access a subtype from within the parent. For instance, this function creates RequestInit provided to fetch. The parameters of the function use the Indexed Access Types to limit the arguments to those matching the RequestInit type. We are also using the satisfies keyword to tell TypeScript that Object will be treated as RequestInit and that it matches its type, without changing the type using as.

const makeReqInit = (
  method: RequestInit["method"] = "GET",
  body: RequestInit["body"] = null,
) =>
  ({
    method,
    headers: {
      "Accept": "application/json",
      "Authorization": `Bot ${Deno.env.get("API_KEY")}`,
      "Content-Type": "application/json",
      "User-Agent": "Deno/1.29.1",
    },
    body,
  }) satisfies RequestInit;

const req = await fetch("https://example.com/", makeReqInit());
console.log(req.ok ? await req.text() : req);

Generics

Simple generic type

Generics are fundamental when it comes to writing reusable code. It avoids the hassle of having to create a type for every subtype and enables the creation of more complex types by combining types. A really simple usage of generics would be the following. We accept an array of elements of type T, (basically a placeholder for any type), and return an element of either type T or null (if it doesn't exist).

const getFirst = <T>([element]: T[]): T | null => element ?? null;
const item = getFirst([1, 2, 3]); // 1
const none = getFirst([]); // null

Generic type narrowing

Another thing that generics can do is narrow down the type that is passed through T to meet specific criteria. For instance, let's create a function that takes an Object with two keys, items and multiplier. The function will multiply each item by the multiplier and return the modified multiplier array.

type Element = { items: number[]; multiplier: number };

const getTotal = <T extends Element>(
  { items, multiplier }: T,
): T["items"] =>
  items.reduce<T["items"]>(
    (acc, val) => [...acc, val * multiplier],
    [],
  );

const double = getTotal({ items: [1, 2, 3], multiplier: 2 }); // [2, 4, 6]
const half = getTotal({ items: [2, 4, 6], multiplier: .5 }); // [1, 2, 3]

It seems as if a lot was going on but in reality, it is quite simple. First of all, the getTotal function takes a generic T type that must satisfy the constraint of matching the type Element. After this, we can reference subtypes by using bracket notation. We use this in the return type and in the reduce function.

Then we use <Array>.reduce to iterate over the array, multiply the current value and save it to the new array that we will return. To tell TypeScript the type of the accumulator, we need to use reduce<type>(...), so that it can infer it.

Compound generic type

Another interesting use of TypeScript's generic types is to make an auxiliary type to get types from types. For instance, lets create a type that takes an Object and a key, and returns they type of that key.

type ObjectKey<T extends Record<string, unknown>, U extends keyof T> = T[U];

const myObject = {
  hello: "world",
  boop: () => 9,
};

let myFn: ObjectKey<typeof myObject, "boop">; // () => number

Here we are creating a type ObjectKey that accepts two generic types: T and U. The former uses the extend keyword to only accept Objects with string keys and unknown values. The latter only accepts keys of the former.

In this example, instead of the boop key we could've used the hello key, which would've returned string.

Conditional types

Conditional types in TypeScript follow the style of a ternary. On the first branch, we will get the type if the extends clause is true, otherwise we will get the type in the second branch. For instance, we can take a generic argument and decide the type based on that type.

type ToLiteralKey<T extends number | string> = T extends number ? "number"
  : "string";

const myObject: Record<"number" | "string", 1 | "1"> = {
  number: 1,
  string: "1",
};

const numKey: ToLiteralKey<1> = "number";
const strKey: ToLiteralKey<"1"> = "string";

const newObj = {
  numKey: myObject[numKey], // 1
  strKey: myObject[strKey], // "1"
};

Infer keyword

The keyword infer is powerful since it allows us to get a subtype from within another type. For instance, let's create a type that returns the type of the first parameter of a function.

type FirstParam<T> = T extends (...t: infer p) => unknown ? p[0] : never;

const getOlder = (param: { people: { ages: number[] } }) =>
  Math.max.apply(this, param.people.ages);

type ParamType = FirstParam<typeof getOlder>; // { people: { ages: number[] } };

First of all, we are checking whether the generic type T is a function. Then we are using the spread operator to get all parameters and inferring them to the type p. If T satisfies the constraints we are returning the first type of the array of types enclosed in p, otherwise, we return never.

Another interesting type we can do is the ReturnType type. It is implemented in TypeScript itself, but understanding it will help to understand the infer keyword.

type ReturnType<T extends (...args: any) => any> = T extends
  (...args: any) => infer R ? R : any;

let output: ReturnType<typeof setTimeout>; // number

First of all, we are checking whether the generic type T is a function. Then we are using conditional types to isolate the return type in the R type, to return it.

Built-in types

TypeScript has a myriad of built-in types, which come in handy. For instance, Required<T> type marks all optional properties as required, Record<K, V> creates the type for an object with keys of type K and values of type V, and so on. Even Parameters<T> returns a tuple with the argument types of the function passed in T, such as we did before!

import { cyan } from "https://deno.land/std@0.127.0/fmt/colors.ts";

const myObject: Record<string, string> = {
  telephone: "555-5555",
  id: crypto.randomUUID(),
  get [Symbol.toStringTag]() {
    return cyan("myObject"); // color the output
  },
};

console.log(myObject.toString()); // [object myObject]

Wrapping up

TypeScript adds the safety that JavaScript lacks for bigger and more complex projects. Due to this, it is increasingly getting more popular amongst developers. Understanding more complex types is a must since it allows the programmer to have an even better developer experience, and to catch bugs even before transpiling the code into JavaScript.