The Problem
TypeScript is absolutely helpful for avoiding the most common problems in JS projects, including undefined is not a function
and cannot read property <foo> of undefined
.
Most of the time, those happen because you have typoed something in your code, or you are using a library and don’t know it’s API correctly.
Typings will help you in spotting these problems and more.
You can model your data using interfaces or classes and make give type annotations to functions, so that the compiler will warn you if the return value of a function does not match the annotation.
An example where TypeScript compiler will help you:
interface User {
id: number;
username: string;
}
const getUserName = (user: User): string => user.id;
console.log(getUsername({ id: 1234, username: '[email protected]' }));
In this case, you would see the following error:
[ts] Type 'number' is not assignable to type 'string'. [2322]
(parameter) user: User
And hopefully your editor will also highlight the problem to you immediately.
However, there are situations where TypeScript is not able to help you.
Those can be also on compile time but more critically they happen on runtime.
On runtime, your code is JavaScript and basically everything is of type any
.
As an example, what would happen if your api endpoint for returning the user information would return:
{ id: "1234", name: false }
Which would not match your User interface
in TypeScript.
However, your code would try to run until at some point it might crash.
This is frustrating.
A Solution
I had heard many people recommend using io-ts for tackling this problem. Last Friday I tried it for the first time and my mind was blown.
With io-ts
, you are able to validate types during runtime.
I’m not an expert on this topic yet, but here is an example of how we currently use it in our project.
models/District.ts
:
import * as t from 'io-ts';
export type District = t.TypeOf<typeof TDistrict>;
export const TDistrict = t.type({
id: t.number,
name: t.string,
});
/*
Which would be equivalent to the following TS interface:
export interface District {
id: number,
name: string
};
*/
The type District
is an alias so that we don’t have to pass around the type:
const District: t.TypeC<{
id: t.NumberC;
name: t.StringC;
}>;
Instead we now have:
type District = {
id: number;
name: string;
};
And then when fetching District
s from the backend, we are able to validate those against the schema:
// res is the response we got from the backend
const validDistricts = res
.map((q) => TDistrict.decode(q))
.filter((v) => {
if (v.isLeft()) {
console.warn('District validation failed', PathReporter.report(v));
return false;
}
return v.isRight();
})
.map((q) => q.value as District);
console.log('Valid districts from the backend are ', validDistricts);
Where PathReporter
is a utility provided by the library for nicely outputting the failed validation.
TDistrict.decode
returns a Validation<TDistrict>
which looks like this:
export declare type Validation<A> = Either<Errors, A>;
This means it can either hold an error in Left or the successfully validated value in Right. This is cool.
Option Types in TypeScript
Then, yesterday I was writing some code and implemented a function that would return a District
by its id.
The id could of course be invalid and not found in the list of District
s.
So, this means the return value of my function getDistrictById
is Option<District>
, which means it can return a District
or nothing in case it’s not found.
Sadly, I thought, we don’t have Option
in TS.
However I just typed out the type annotation there and my editor suggested to import { Option } from 'fp-ts/lib/Option'
.
Oh wow!
My mind was blown again.
So, my function definition looks like this:
const getDistrictByID = (districtId: number): Option<District>
And when calling it, I would check the return value like this:
import { Option, none, some } from 'fp-ts/lib/Option';
const districtOption: Option<District> = districtStore.getDistrictByID(
districId
);
if (districtOption.isSome()) {
// districtOption now has a value
} else {
// districtOption is none
}
Missing Pieces
So, now the only missing piece is pattern matching. I mean, it would be nice to have something like this in TS:
districtOption match {
case some(district: District) => console.log('Do something with the district')
case _ => console.warn(`District with id ${districtId} was not found`)
}
Or maybe there is a solution for that in io-ts
or in some other library that I just haven’t discovered yet?
Please let me know.
Update: Pattern Matching Using io-ts
As the author of io-ts helpfully pointed out, there is a way to do pattern matching with io-ts.
It can be done using fold
or foldL
, which is the lazy version of fold.
So, my example using foldL
looks like this, then:
districtOption.foldL(
() => console.warn(`District with id ${districtId} was not found`),
(district) => console.log(`Do something with the district ${district.name}`)
);
Amazing!