Advanced Typescript patterns

Dhruv Rajvanshi
5 min readMay 9, 2018

--

Well, I won’t technically show you “patterns” but I’ll show you some advanced type level things along with the explanations and hopefully, you’ll see the patterns yourself.

Here are some cool things that I discovered in Typescript that allow me to statically check pretty neat invariants and reduce type/interface declaration boilerplate using type inference.

Ensuring that a value has passed through a function

Consider this scenario. A client sends you dates, times, years as strings and you need to store all of them in the DB as Unix time-stamps. But you have to make sure that when client is putting things in, you parse the time according to the correct format and when you’re sending back entities to the client, you’re returning back strings that formatted correctly. To the client, all of them are strings but some are “YYYY”, others are “YYYY-MM-DD” and others are “YYYY-MM-DDTHH:mm”. Your database must store them as numbers for them to be sortable fields.

There’s a trick for differentiating between different formats of strings (or rather any values of the same type) using the never type.

Lets say our client needs an entity called BunchOfDates and when they call PUT /bunchofdates they send you this object.

interface PutBunchOfDatesObj {
date: string; // formatted as YYYY-MM-DD
time: string; // formatted as YYYY-MM-DDTHH:mm
year: string; // formatted as YYYY
}

And you want your DB object to contain a Unix timestamp. So, your DB row type will look something like this.

interface DBBunchOfDatesObj {
date: number;
time: number;
year: number;
}

Your putBunchOfDates function might look like this.

function putBunchOfDates(dates: DBBunchOfDatesObj): Promise<void>

You might accidentally try to parse time using YYYY format or what not. If you’re lucky, it will fail at run-time. If you’re not, it will fail silently and your DB will contain the wrong date.

Here’s the trick. First, update your DBBunchOfDatesObj interface to the following.

interface DBBunchOfDatesObj {
// all these My* types are actually just
// strings at runtime. Just their existence
// is a proof that they are correctly formatted.
date: MyDate;
time: MyTime;
year: MyYear;
}

Then, define interfaces for each type of time formatted string.

interface MyYear extends String {
// the name of this field doesn't really matter
// as long as it's unique to this interface.
// you can't really do anything with this field and this
// won't even be present in the object so, it's okay
// to pretend that this field isn't there. It only exists on
// the type level and has no runtime significance.
// This is why this technique is sometimes called a "Phantom type"
__my_year__: never;}

No value can ever be assigned to never type without type casting and no method can be called on never. This means that MyYear can be used instead where you’re expecting a string (you can call .split or whatever on a MyYear) but you can’t use a string where a MyYear is expected.

How do you create a MyYear?

Have a function parseMyYear.

function parseMyYear(str: string): MyYear | null {
if (/^\d{4}$/.test(str)) {
return str as MyYear;
} else {
// or throw an exception. Doesn't matter how you
// handle errors.
return null; }}

This function will check if the format is correct by whatever means necessary (here, by calling isYYYY) and if it is, it will cast it to a MyYear. If you turn on linter rules for disabling type casting, you can ensure that the only way to create a MyYear is by using the aforementioned function. You can add a tslint disable comment inside this function because you’ve proved that str is a MyYear by calling isYYYY.

Now, you can make a function for converting the MyYear into a UNIX time-stamp using the signature,

function myYearToUnix(y: MyYear): number

Inside this, you can treat y as a YYYY string because it’s guaranteed that y has been checked.

Type safe object validation

Your API might expect your clients to respect a certain schema for JSON payloads. It would be nice if you could write a declarative schema for the request and have that automatically converted to a type so that intellisense can work properly.

There’s a pretty good library for validating arbitrary objects according to a schema called Joi. It lets you write schemas like this. The syntax of the library isn’t important. You can write a similar declarative validator yourself. What matters is how we’re gonna convert this validator into an honest to god type that you can use intellisense on.

const myValidator = Joi.object().keys({
key1: Joi.string(),
key2: Joi.string().valid("x").valid("y").required(),
key2: Joi.number().required()}).required()

You can call .validate method on myValidator and pass in any value to it. It will check the type to make sure that it’s valid. It will only give you the value if the schema matches.

It should be pretty obvious what the type of the object that passes successfully thourgh this validator will be.

Now, lets write a type declaration for Joi object. This will get a bit dense so I’ll explain with comments. This is not for the faint hearted but these 100 lines of pure type level code covers a lot of ground on advanced typescript features.

You might wonder if this is worth it. I would argue yes because if you’re writing an HTTP api, you would have to write a bunch of routes. These 100 lines, would ensure that all of your dozen routes are using the payload correctly.

Here’s a sample route I wrote in using hapi.js. You can easily convert the route function to express/koa or whatever without any problems. Though for Hapi, the route function simply returns its argument. It’s present only for type inference. I won’t go into the details of the type of route function but you can ask if you wanna know how I did it.

Basically, the “framework” ensures that validator will be run on the request payload and the validated value will be set on the request object. If validation fails, a 400 response will be returned.

If you hover over item on line 19, you’ll see that it has its type inferred properly right down to the fact that .description is optional. I don’t know about you but I’m pretty darn pleased with this. Heck even intellisense works. Excuse my use of 90s patois but…RAD! The validator allows us to both check untrusted values at runtime, validated values at compile time and serve as a human readable documentation for your route/ You can probably auto generate a pretty HTML version of your api documentation using typescript compiler api if you were so inclined. BTW, Hapi already has plugins for generating type api docs using Joi validators so that’s pretty nice all things considered.

And there’s more

I’ve written previously about neat stuff you can do in Typescript.

Here’s how you emulate Haskell’s do notation.

And this is about emulating Rust’s error handling (sort of including the ? operator).

Also, my library ts-failable allows you to emulate Kotlin and Swift’s Elvis operator ?..

All of this is type safe. So that’s pretty cool. Once you get used to it, you’ll realize that type systems can do a lot more for you than simply ensuring you’re not assigning strings to ints.

Obviously, you also need to show a bit of restraint while using advanced types in production. You might not want your coworkers’ brain to explote when they try to read your 3 levels nested type mapped conditional types with string literal types and what not. You can always use the excuse that I use which is, just pretend the types aren’t there and you’ll be fine ;).

Till next time, farewell.

--

--