TypeScript

Aliases
  • typescript
  • ts
  • TS
Image of Author
October 20, 2022 (last updated September 18, 2024)

This is for specifically TypeScript related notes. For more general JavaScript notes see JavaScript. For Nodejs notes, see Nodejs.

Decorators

https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/#decorators

Also JavaScript#Decorators

tsconfig.json notes

tsconfig.json

A namespace and an interface too

I was not aware you could share a namespace with an interface. Note how user.address is of type User.Address. This makes grouping types much more natural. This can be recursively applied as well, so User.Address can itself be a namespace with exported things.

interface User {
  name: string;
  address: User.Address
}

namespace User {
  export interface Address {
    street: string
    zipcode: number
  }
}

const a: User.Address = ...
const u: User = ...

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.

Conditional types and the infer keyword

Conditional types are one of the most flexible features of the TypeScript type system. In particular the things you can do with conditional types combined with the infer keyword are pretty wild. Here's a few examples

A recursive partial type

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 syntactically valid, e.g., (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.

An intersection of many types

type With<Main, Rest extends any[]> = Main &
  (Rest extends [infer First, ...infer Others]
    ? First & With<Main, Others>
    : unknown);

The conditional wrapped in parentheses recursively intersects all the types in the array, ending finally with the type equivalent of a no-op. The end result is effectively Main & First & Second & ..., and an example type signature that would create it is With<Main, [First, Second, ...]>.

Ignoring errors

You can ignore a line by commenting // @ts-ignore above it. You can ignore a whole file by commenting // @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];