TypeScript Tips and Tricks

Image of Author
October 20, 2022 (last updated August 21, 2023)

bind this

From MDN bind docs > Creating a bound function,

A common mistake for new JavaScript programmers is to extract a method from an object, then to later call that function and expect it to use the original object as its this (e.g., by using the method in callback-based code).

I'm not a new programmer and make this mistake every time I return to class-heavy code. Here's an example.

class A {
  b;

  constructor() {
    this.b = "b";
  }

  getB() {
    return this.b;
  }
}

const a = new A();
const bound = a.getB.bind(a);
const bound2 = () => a.getB();
const unbound = a.getB;

console.log(a.getB()); // 'b'
console.log(bound()); // 'b'
console.log(bound2()); // 'b'

console.log(unbound());
// TypeError: Cannot read properties of undefined (reading 'b')

Why does JS allow this behaviour? The answer is, first, that classes are syntactic sugar on top of the way javascript really handles instances, methods, and inheritance. Second, the way JavaScript really handles this is via the prototype chain, which means you can inherit "methods" from any object in your prototype chain. Under this particular paradigm, it makes sense for this to default to the object calling the function, and not the object defining the function (which could be anywhere along the prototype chain). This means that "unbound" functions such as a.getB expect to be called outside of the object that it was created in, with a dynamic this value. Unbound functions are more flexible in the prototypal-inheritance paradigm than in more 'normal' class-encapsulation paradigm.

The upside of all of that is flexibility. I.e., you can capture unbound function, making certain types of code reuse possible. The downside is you have to pay attention to when you bind/unbind functions.

Generic function type

There's more than one way to write this, but this works in many situations.

type Fn = (...args: any) => any

Optional chaining and undefined

Consider the following TypeScript code:

const x: { [key: string]: string } = {}
const y = x.a // string
const z = x?.a // string 

My intuition says that the type of z should actually be string | undefined. So, what's going on here? (This is a summary of my journey which began with this stackoverflow question and answer.)

Basically, the x has an interesting kind of type signature: The [key: string]: string part is known as an index signature. This capability of the type system is useful when working with certain objects, but it is imprecise, which means there will be edge cases. When you use index signatures in general, you are telling TS what type you want returned for arbitrary key access. You are saying, "give me a string type". You want the type behaviour of y. If you were then given a string | undefined, you would be disappointed.

But, in the context of optional chaining, you are expecting to work with T | undefined in general, and so not getting the string | undefined return type for z is disappointing. Hence, a dilemma.

There are a few potential solutions.

One solution is to make the return type explicitly undefined.

const x: { [key: string]: string | undefined }
const y = x.a // string | undefined
const z = x?.a // string | undefined

Another solution is to use the tsconfig setting noUncheckedIndexAccess. But, this setting has some unintuitive consequences you should be aware of.

The unknown type

https://mariusschulz.com/blog/the-unknown-type-in-typescript

The unknown type is supposed to be annoying to work with because it's "safe". If you don't want safety, the more flexible any type can be used instead. You need to use type narrowing (see the section #Type Predicates and Type Narrowing) or some method of type inference (such as typeof) to work safely with the unknown type.

const x = { a: 1 } as unknown;
// const y = x.a; // Error: 'x' is of type 'unknown';
if (x && typeof x === 'object' && 'a' in x) {
  const y = x.a;
}

A weakness in the type system

Here is an edge case

const builder1 = (f: () => string): (() => string) => f;
const builder2 = (f: () => string): typeof f => f;
const f = (): string => demo.b;

const demo = {
  // a: builder1(() => demo.b), // demo: any
  // a: builder2(() => demo.b), // demo: any
  a: f, // demo: { a: () => string, b: string }
  b: 'b',
};

Why don't the first two a's work? Because Typescript cannot be certain that you have not evaluated the parameter.

As an aside, the following is an error:

// bad! type error!
const demo: {
  a: demo.b,
  b: 'b'
}

You cannot refer to the object before you've defined it. But if a is a function, it's okay, because the definition of a as a function means demo.b does not need to be evaluated until the function is called.

const demo: {
  a: () => demo.b,
  b: 'b'
}

This is why a: f works in the first definition. But, Typescript does not know what is happening inside the builder function. You can tell the Typescript type system that the return "is the unevaluated parameter", but you aren't actually telling it that, you are simply saying the return "is a function that returns a string". Only you know that the function you are returning is the same function that was passed in as a parameter.

Why can't Typescript infer this? Because the Typescript type inference algorithm is "weak" or "liberal". It is duck-typing.

Any situation where Typescript is uncertain of evaluation, it will act like this. For example, default parameters are the same way:

const pre = {
  // a: (x: string = pre.b) => x ?? pre.b, // pre: any
  // a: (x: () => string = () => pre.b) => x(), // pre: any
  a: (x: string = 'b') => x ?? pre.b, // pre: { ... }
  b: 'b',
};

The first a above is perhaps intuitive because the default parameter would need to be immediately evaluated. But, it doesn't error out with "define before using", it instead just anys your object. The second a is similar to our builder problem: it's not "reading" your code, just inferring types, so it can't be certain you are delaying evaluating pre.b. The final a allows for normal type inference.

Nested object with comments

The goal is a namespace/object containing "discoverable" (aka auto-completable) nested functions and constants, spread across multiple files, well tested and commentated. For example, A.b.c(), where the global namespace/object is A, b.js has b-related functions, c is such a function defined in b.js with comments and is tested in b.test.js.

You have to do this just right to keep hold of the comments. There's a million different ways to compose a large nested object of functions, but this one is my favourite because you can keep the comments local, the tests can import just the function under test, and the full object is easily discoverable and importable.

// one.ts

/** uno comment */
export function uno() {
  return 1;
}

/** ein comment */
export function ein() {
  return 1;
}

// two.ts

/** dos comment */
export function dos() {
  return 2;
}

/** zwei comment */
export function zwei() {
  return 2;
}

// index.ts
import * as one from './one';
import * as two from './two';

export const All = {
  one: {
    ...one,
  },
  two: {
    ...two,
  },
};

Now you can just import All in a file and autocomplete to All.two.zwei() and get jsdocs describing it. And zwei can be independently tested (e.g., in two.test.js).

A recursive partial type, and using the infer keyword

https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#inferring-within-conditional-types

I first discovered this keyword searching for a "recursive partial" type. A stack overflow question asking for this had [this answer using the infer keyword]:

type RecursivePartial<T> = {
  [P in keyof T]?: T[P] extends (infer U)[]
    ? RecursivePartial<U>[]
    : T[P] extends object | undefined
    ? RecursivePartial<T[P]>
    : T[P];
};

The type works by iterating through different data types, first arrays, then objects, then the primitives. The most confusing bit was the first true branch where it is "partialising" arrays. Let's break that part down. First of all, you might be familiar with array type like number[]. Parentheses are legal, (number)[] and can be used for more complex types, (number | string)[]. So, (infer U)[] is describing an array of an arbitrary type, and, within the conditional type branches, refer to that arbitrary type as U. It is that U type that you want to "partialise" while still having it maintain its array structure, hence, you want an array of partials, which is exactly what RecursivePartial<U>[] gets you. For example, you could have an array of objects that become an array of partial objects.

type A = {
  b: { c: number }[];
};

const recursivePartialA: RecursivePartial<A> = {
  b: [{}, { c: 1 }, {}, /* { c: 'two' } would be an error */],
};

I find this technique useful when writing mocks of large data structures. It allows me to stub only the parts I care about, completely ignore the parts I don't care about, and still have type safety.

Ignoring errors

You can ignore a line by commenting // @ts-ignore above it. You can ignore a whole file by comenting // @ts-nocheck at the top. The documentation on this is hard to find. This is the closest I got within the typescript handbook.

Type Predicates and Type Narrowing

aka, The Problem of Sticky Union Types

Let's say you have a list of (string | number)[] and want to filter out the numbers to be left with string[]. The following intuitive sequence won't work.

const a = [1, "two", 3, "four"];

const isString = (x): boolean => typeof x === "string";
const b = a.filter(isString);
// b: (string | number)[] = ['two', 'four']

The reason this does not work is because the filter function only goes from same-type to same-type. It will not introspect your type and infer that it can safely augment it. Instead it will keep your union type as is.

What you likely want is to leverage a type predicate for the purpose of type narrowing. The only change in what follows is the return type of isString was changed from boolean to the type predicate x is string.

const a = [1, "two", 3, "four"];

const isString = (x): x is string => typeof x === "string";
const b = a.filter(isString);
// b: string[] = ['two', 'four']

Another example is a (T | null)[] where you want to remove the nulls.

const isNotNull = <T>(x: T | null): x is T => !!x;
const a = [t1, null, t2].filter(isNotNull);
// a: T[] = [t1, t2]

Accessing deeply nested types

You can access deeply nested implicit types. For example, the type of a nested array:

interface A {
  list: { a: string; b: number }[];
}

function fun(item: A["list"][number]) {
  item.a;
  item.b;
}

// or

type B = A["list"][number];

Also, you don't even need a type

const A = {
  list: [{ a: "wow", b: 4 }],
};

function fun(item: (typeof A)["list"][number]) {
  item.a;
  item.b;
}

type B = (typeof A)["list"][number];