home
Photo by Lucas Benjamin on Unsplash
Typescript - a language for types
How to achieve the flexibility with typescript on the type level that we can achieve with javascript on the logic level.
Sep 30, 2020
#typescript

I like to think of typescript as a mean to do calculations on types. Just as we use language features or composition of any sort to do calculation on data, we do calculations on types.

Javascript is a weakly typed and a dynamic language. That means it does type checking after running (dynamic) and also not very strict with type checking when it does that (weak).

Typescript came to the rescue as a potential candidate to solve these problems. Typescript is more strict (not as weak) and does things at compile time (static).

Typescript

Typescript is a fundamentally unsound and structural type system.

Unsound

What does that mean? That means that it cannot guarantee that the types at compile time will be the same type at runtime (just think about the any type). Why we want such a type system?

A very good question. That is the main reason I was more than reluctant to get into Typescript. I mean have a look at the languages of OCaml / ReasonML or Rust. They are the Italy of programming languages. Elegance above all.

Their type system is a strict father, but a fair one. Whereas typescript, well, not so much. Why did I sort of changed my mind?

I still adore ReasonML. I like Rust more and more. But Typescript just nailed something. I think that is incremental adaptability. Because it is unsound because it is structural you can go from js to ts one chunk at a time.

If you know the library immer.js, I think there is a similarity between immer.js and TS. Immer.js is great because achieves immutability without introducing unnecessary API. It is easily digestible to every one of us. TS is something along that line. Easily digestible by JS developers or C#/Java peeps.

It is not perfect but good enough. I think that is a great one-liner for life in general.

Never strike for perfection neither on the individual nor on the collective level. Just do good enough work.

So TS is unsound, but still we can improve our codebase. We can achieve things we can do with stricter type system with a little workaround.

In javascript, we have the notion of structural versus reference equality.

copy
js
const a = {
x: 3,
y: 5,
};
const b = a;
Object.is(a, { x: 3, y: 5 }); // false
Object.is(a, b); // true

The first comparison is false despite the two objects being structurally equal. Typescript does not work like this. In Typescript if something is structurally equal, they are equal. Most of the time.

copy
ts
type Person = {
fistName: string;
lastName: string;
}
function changeName(person: Person, name: string): Person {}
const joe = { fistName: "Joe", lastName: "Doe" }; // its type is not Person, but { fistName: string, lastName: string }
changeName(joe); // we can pass joe here, since joe's type structurally equal to Person

After talking about some basic characteristics of TS let's dive into how to recreate some cool things. One of the best feature of ReasonML for me was pattern matching, so when I started out Typescript I looked for something like that. TS does not allow that kind of sophistication out of the box, but we can achieve something very similar with the help of some TS abilities.

This pattern is widely used in reducers. Let's see what it is with purely JS. But as we see, it can be applied to other domains.

copy
js
function reducer(prevState, action) {
switch (action.type) {
case "SET_ERROR":
return { ...prevState, error: action.payload.error };
case "SET_VALUE":
return { ...prevState, value: action.payload.value };
default:
return prevState;
}
}

This is a classic reducer implementation. We have a state and two different kind of actions. Both do different things. That's why they differ structurally as well. One has value in their payload one has error. How we can make them typed?

We can utilise typescript's control flow analysis, literal types and union types, discriminative unions to be more precise. With those we can achieve a sort of exhaustive pattern matching.

Typescript analysis our code and and tries to figure out what is the strictest type it can assign in each branch.

copy
ts
type NumberOrString = number | string;
function fn(arg: NumberOrString) {
if (typeof arg === "number") {
} else {
// here arg must be string
}
}

In this example TS automatically narrows down from the union number | string to string in the else branch and we have full autocompletion magic.

In TS we have primitive types, custom structures with interfaces or types. We can do more with TS. We can also use primitive values as types. Like strings, numbers, booleans

copy
ts
type One = 1;
type Red = "red";
type True = true;

That is powerful addition to the type system. Why would it be? Because we can narrow down types with the help of control flow analysis. Let's see a classic example.

copy
ts
type Circle = {
kind: "circle";
radius: number;
};
type Rectangle = {
kind: "rectangle";
x: number;
y: number;
};
type Shape = Rectangle | Circle;
function fn(arg: Shape) {
if (arg.kind === "circle") {
// here arg is Circle type
}
if (arg.kind === "rectangle") {
// here arg is Rectangle type
}
}

Shape is the union type of Rectangle and Circle. Rectangle and Circle are normal types except their kind property is a literal type. It is not string, but either of type circle or rectangle. These literal types make our union type Shape a discriminative union. Based on the kind field we can safely tell if our shape is a rectangle or circle.

We can move on to our reducer example. Reducers folds to some type S by processing through of type I.

After discussing all the features we use for the pattern matching let's go back to our original example. The reducer function. Let's make it typed.

copy
ts
type ReducerFunction<S, I> = (sum: S, item: I) => S;
// or to be specific in redux terms
type ReducerFunction<State, Action> = (prevState: State, action: Action) => State;

The reducer is a lot similar to our rectangle example. We would like to describe our state transitions with actions, where one action rules out (discriminates) others. That sort of can be described like this.

copy
ts
type SetError = {
type: "SET_ERROR",
payload: {
error: string
}
}
type SetValue<T> = {
type: "SET_VALUE",
payload: {
value: T
}
}
type Action<T> = SetValue<T> | SetError;

Again, the type variable is not string but a literal type. A concrete value. SetValue is also generic but that does not have to do anything with literal types. We just need the type of value to be dynamic. We also need to define the structure of our state.

copy
ts
type State<T> = {
value: T,
error?: string
}

After this we are all set to implement our reducer in a type safe manner.

copy
ts
function reducer<T>(prevState: State<T>, action: Action<T>): State<T> {
switch (action.type) {
case "SET_ERROR":
return { ...prevState, error: action.payload.error };
case "SET_VALUE":
return { ...prevState, value: action.payload.value };
default:
return prevState;
}
}

Our reducer is now type-safe thanks to the literal types. In the appropriate block we have the proper structure for our payload object. Thanks to unions, control flow analysis and literal types we have a good pattern matching experience.

But there is more. We can make it exhaustive. We can make the compiler moan when we forget to match against a member on the union type.

copy
ts
function reducer<T>(prevState: State<T>, action: Action<T>): State<T> {
switch (action.type) {
case "SET_ERROR":
return { ...prevState, error: action.payload.error };
case "SET_VALUE":
return { ...prevState, value: action.payload.value };
default:
const _shouldNotGetHere: never = action
return prevState;
}
}

The never type intends to implicate types and situations that should never happen. Unreachable code for example. So by logic, if we match against every case of Action, then the action type should become never.

copy
ts
type Action<T> = SetValue<T> | SetError | { type: "newAction" };
function reducer<T>(prevState: State<T>, action: Action<T>): State<T> {
switch (action.type) {
case "SET_ERROR":
return { ...prevState, error: action.payload.error };
case "SET_VALUE":
return { ...prevState, value: action.payload.value };
default:
const _shouldNotGetHere: never = action // Type '{ type: "newAction"; }' is not assignable to type 'never'.
return prevState;
}
}

So if the action type is never, we can safely assign it to another variable with the type never. But if we miss something it will complain that the missing type, in this case { type: "newAction" } is not the same as never.

We have a nice compile time warning that we missed one of the cases. Only by removing the _shouldNotGetHere variable or adding the appropriate case to our switch statement we can get rid of that error message.

We have seen the similarities between data and type manipulation. We have seen pattern matching on types, literal values being utilized, flow control and more. We cannot make our code 100% sound, but TS does a decent job at providing us with type safety, making our code more robust.

check out other topics