Software craftmanship

7 type tricks in 7 minutes

Typescript’s type system and a puzzle

--

Typescript has one of the most exciting type systems of any mainstream programming language. It can be challenging to learn, but there is so much strength in good types that it is well worth the effort. This post illuminates a lot of the essential properties of types in Typescript. I promised seven minutes so let’s jump straight into it.

Object types

We start with one of the simpler things: objects. We can define object types very similarly to how we define objects, by listing fields and their types.

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

Notice here that these two objects both have a field called b but the type of b differs.

Intersection and union types

Typescript’s type system starts showing when we introduce union and intersection types. Union means that something has either one or another type, so we can only rely on what they have in common. Intersection means it has both one and another type, so we can expect it to have everything from both. In Typescript, it looks like this:

type AorB = A | B;
// { b: number | string }
type AandB = A & B;
// { a: string; b: never; c: string[] }

Notice that in the AorB-case only the field b is in both, but since their types differ, Typescript pushes down the union operator. Another interesting detail is that in the intersection case b has type never. This signifies that there is no intersection between the number and string so this type can never be realized — which is true.

Constant types

Some else that Typescript brings to the table is constant types. We can tell the type checker not only which type an expression has, but which value. In this example, we have three different variables, two of which have a constant type:

let a1: string = "a";let a2: "a" = "a";let a3: 5 = 5;

Constant types may not seem very useful at first sight, but coupled with the union types above, we can use them to emulate sum types with primitive pattern matching:

type tree =
| { type: "Leaf", value: number }
| { type: "Node", left: tree, right: tree };
function sum_tree (t: tree): number {
if (t.type === "Leaf") {
return t.value;
} else {
return sum_tree(t.left) + sum_tree(t.right);
}
}

Looping

Another reason to combine constant and union types is to loop through them and use them to access fields in an object. The type looping keyword is in, and in the example, we use it to create a type similar to A but where we replace the fields with functions returning the values (called thunks):

type aorb = "a" | "b";type Athunks = {
[key in aorb]: () => A[key];
};
// { a: () => string; b: () => number }

Type operators

The for loop we just looked at only explicitly worked for the A type, so the natural question that jumps to a programmers mind is: Can we generalize it? We can, indeed. Typescript also comes with an operator to get a union of the fields in an object called keyof. Further, types are allowed to take arguments by using angles. Types that transform one type into another are called type operators. In this example, we first create a type operator for converting a type to a thunk, then we make a type operator to thunkify any object type:

type Akeys = keyof A;
// "a" | "b"
type Thunkify<T> = () => T;type ThunkifyObject<T> = {
[key in keyof T]: Thunkify<T[key]>;
};
type Athunks2 = ThunkifyObject<A>
// { a: () => string; b: () => number }

Pattern matching

We have already seen some crazy cool features of Typescript’s type system, but now we turn it up to 11. Not only does Typescript have for-each loops in the types, but it even has pattern matching. We can ask Typescript whether something fits a specific pattern and then return one thing if it does and something else if it does not. This type-operator checks whether its argument type is an object with an a field, of which it infers the type. If so, it returns the inferred type. Otherwise, it returns never.

type GetA<T> = T extends { a: infer R } ? R : never;type Aa = GetA<A>
// string
type Ba = GetA<B>
// never

Function types

The one kind (type of types) we haven’t looked at yet is function types. Function types seem relatively basic at first because they have some arguments and a return value. But that is until you learn that in Typescript, we can take advantage of variadic arguments to pattern match on functions even though we don’t know which, or even how many arguments they take. In this example, we have invented a type operator to remove the first argument from any function:

type foo = (name: string, age: number) => void;type RemoveFirstArg<T> = 
T extends (head: any, ...rest: infer F) => infer R
? (..._: F) => R
: never;
type f = RemoveFirstArg<foo>;
// f = (age: number) => void

Exercise

Let’s finish out with an exercise combining everything we have learned.

type puzzle = {
foo: {
bar: {
desc: string;
act: (name: string) => void;
};
baz: {
desc: string;
act: (name: string, props: { lock: boolean }) => string[];
};
};
};

We want a type operator that preserves the same structure, but removes everything except the act function in the innermost object. It should also remove the first argument from this. So for the object above, we should get:

type result = {
foo: {
bar: () => void;
baz: (props: { lock: boolean }) => string[];
};
};

Note that there is a bug in the type system which causes this error:

Error: Type cannot be used to index type

However, there is a way around it. Can you find it?

If you want more Typescript or more software craftmanship, you should check out my refactoring book:

--

--

I live by my mentor’s words: “The key to being consistently brilliant is: hard work, every day.”