Promise made and promise broken: TypeScript vs. real life data
Ján Jakub Naništa
Posted on June 7, 2020
tl;dr Why not increase the sturdiness of your TypeScript code using automatic type guards.
TypeScript has earned a stable spot in my JavaScript toolbox due to the way it enables me to talk and reason about code with fellow developers and to the improved code quality it offers. And if you've been using it, you must have plenty of your own reasons to share my enthusiasm.
But just as once a dark spectre of communism was haunting Europe there is a spectre of runtime haunting TypeScript now - the great compile-time safety net of typing is not present in the code actually running in the browser. Code that relies on external services is then either left to trust those services will communicate as they typed they would, or, rather more painfully, left to define custom type guards to protect them against corrupt data.
In other words, it is up to you to bring the compile time bliss into your runtime code, trying to match it as close as you possibly can. In some cases this is easy - like when you're trying to check whether something is a string:
// You can easily extend this example to check for
// number, boolean, bigint, Function and symbol types
const isString = (value: unknown): value is string => typeof value === 'string';
Things start to get more messy when it comes to interfaces, optional properties, unions, intersections and all the other non-primitive cases:
// In this made up scenario we are trying to make sure we didn't get
// a corrupted piece of data from a WebSocket
interface WebSocketMessage {
time: number;
text: string;
description?: string;
content: string[];
}
// You could also write this as one looong if statement if you prefer
const isWebSocketMessage = (value: unknown): value is WebSocketMessage => {
if (!value) return false;
if (typeof value.time !== 'number') return false;
if (typeof value.text !== 'string') return false;
if (typeof value.description !== 'string' && value.description !== undefined) return false;
if (!Array.isArray(value.content) || !value.content.every(content => typeof content === 'string')) return false;
return true;
}
Some of you might have already spotted that even though the code works you will get a couple of yellow and red squiggly lines from TypeScript here and there and unless you change the unknown
to the much discouraged any
, your code will not compile.
So not only is it messy, it also requires you to turn a blind eye to the errors and warnings you now need to suppress.
What about, and I am just thinking out loud here, what about taking that sweet TypeScript compiler API and generating these checks automatically? Actually sorry for that- I wasn't thinking out loud just then, silly me to think that would fool you. I was however thinking this out loud (at first with people who had no clue what TypeScript is, that caused a lot of awkward silences) and turned this idea into a bunch of code that you can now get on NPM!
The project is called ts-type-checked
and it integrates nicely with all the popular tooling out there - Webpack, Rollup, Jest, ts-node and ttypescript (there is an installation section with examples provided). If we would rewrite the examples above using ts-type-checked
we'd end up with something like:
import { isA, typeCheckFor } from 'ts-type-checked';
// Using the typeCheckFor type guard factory
const isString = typeCheckFor<string>();
const isWebSocketMessage = typeCheckFor<WebSocketMessage>();
// Or directly checking a value somewhere in the code
if (isA<string>(value)) return 'Hello String';
if (isA<WebSocketMessage>(value)) return 'Hello Web Socket!';
You can find lots more about what (crazy) types are now supported in the docs. ts-type-checked
is now nearing its 1.0.0 release and you are more than welcome to raise any issues or problems you run into when using it! So go ahead and yarn add -D ts-type-checked
!
This last section is for the ones that are interested in the nitty-gritty details of how this is built and maybe more importantly how I can be sure it works.
First, how does it work? Well the tool works as a TypeScript transformer, a function that is called in the process of generating JavaScript code from your TypeScript code. It ignores the vast majority of the code but whenever it meets an isA
or typeCheckFor
function call in the code it inspects the type argument you passed to either of those and tries to translate that type information into a type guard.
Due to the nature of JavaScript some of these type guards are very reliable - you can easily check whether a value is a string
or a number
. However it is impossible to determine your function's return type or your Promise's resolution value. This is due to the fact that once you lose the information about a function signature or a promise resolution value you cannot recover it just by examining the function or Promise. Detailed summary of what can be type checked can be found in the docs.
Second, how well does it work? Short answer: Tests. Lots of them.
At first I thought an extensive suite of unit tests would do but I quickly realised that I should be testing real life, end to end scenarios. That's why the test suite is based on another favourite tool of mine, property-based testing, more specifically a great implementation thereof called fast-check
. I use fast-check
to generate testing objects of certain qualities (like strings, arrays, objects with a certain shape etc.) and check that something like isA<string[]>(value)
will always return true
for arrays of strings and false
for everything else. Then finally to run these tests I plug in ts-type-checked
to jest (see how here), sit back and wait.
But wait, there's more! And by more I mean more versions of TypeScript. I cannot just assume you are using the same version as I was when writing the code (the transformer itself is written in TypeScript)! That's why the suite is run against a long-ish list of supported TypeScript versions, only then I am reasonably certain that the code works as it should.
The next step on my little roadmap is to go one step further and create a test suite creator, a contraption that receives three things: information about the type definition under test, a generator function for values that match that type and a generator function for values that don't match that type. Based on these it spits out a test file. And once this contraption works and I can generate test suites, I can not only randomly generate data using fast-check
, I can also randomly create types. Think property-based testing but on the type level.
Thank you for reading all the way down here! I will be more than grateful for any and all your feedback and even more grateful for issues submitted on the project github!
Posted on June 7, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 4, 2024