Stricter and Safer Type Guards in TypeScript
In typescript, type guards are extremely unsafe. I will try to explain why and what’s the solution. To start let’s take a look at this simple program:
function isNumber(x: any): x is number {
return typeof x === "number";
}
function main(x: unknown) {
if (isNumber(x)) {
console.log(x.toFixed(10));
} else {
console.log("0.0");
}
}
main("");
There is nothing crazy about it. it works as expected and all good. but did you know that you can change definition of isNumber to this:
function isNumber(x: any): x is number {
return typeof x === "string";
}
and it will still compile. But, of course it not work as expected any more. You will get an exception as string has no method toFixed.
Type Guards are basically conditional type assertions which also are not safe as you are bypassing type-system and you can make errors (or the invariants you had in mind when using type assertion/type guard might change in some other place without you or anyone knowing it).
Of course, this isNumber is a simple example, in real live you will have something more complicated which will be even more error prone as time passes and apis/types change.
OK, now we are on the same page here, we know that Type Guards are not safe. But, what can we do instead, to get the same user experience that Type Guards are offering and still stay safe?
Here it is:
export function is<Input, Output extends Input, Args extends unknown[]>(
f: (value: Input, ...args: Args) => Output | undefined
) {
return (value: Input, ...args: Args): value is Output => {
if (f(value, ...args) !== undefined) {
return true;
}
{
return false;
}
};
}
and this is how it’s used
const isNumber = is((x: unknown) => (typeof x === "number" ? x : undefined));
// isNumber : (value: unknown) => value is number
basically this is function is a high order function that accepts a function from unknown to some other type or undefined and returns a Type Guard function that will return true only if the original input function returns non undefined value, plus the input function can also accept other arguments.
now if you try and make a mistake here:
const isNumber = is((x: unknown) => (typeof x === "string" ? x : undefined));
// isNumber: (value: unknown) => value is string
proper type will be inferred… Yes this is another benefit, you wouldn’t have to add type annotations that you need for type guards, if you don’t want to.
My goal here was to explain that:
- Type Guards are unsafe,
- there is a solution out there.
Cheers and stay type-safe 🎉
p.s.
Of course you can modify this so that it suits your needs. For example this version will not allow undefined to be subset of the Input. If you need that, you would have to make little adjustments to this function. Or, If you are using fp-ts you can do this instead:
import * as O from "fp-ts/Option";
const isNumber = O.getRefinement((x: unknown) =>
typeof x === "number" ? O.some(x) : O.none
);
p.p.s.
Related TypeScript issue about this topic: https://github.com/microsoft/TypeScript/issues/29980
Update:
The shortcomings of this implementations is that you can do this
const isNumber = is<string | number, number>((x) =>
typeof x === "number" ? 9 : undefined
);
and it will compile just fine.
The issue is that the extends
constrain is not enough to guaranty that input is returned in output untouched and only type narrowing was done to it. We basically need some sort of linear types in TS to have that level of safety. Which TS doesn’t have, but we can simulate it:
export function is<Input, Output extends Input, Args extends unknown[] = []>(
f: <T>(value: Input & T, ...args: Args) => (Output & T) | undefined
) {
return (value: Input, ...args: Args): value is Output => {
return f(value as Input, ...args) !== undefined;
};
}
const isNumber1 = is<string | number, number>((x) =>
typeof x === "number" ? x : undefined
);
const isNumber2 = is<string | number, number>((x) =>
typeof x === "number" ? 9 : undefined
);