Let's say I have the following user defined type guard function that checks if a value is a number above 1000
:
function isBigNumber(something: unknown): something is number {
return typeof something === "number" && something > 1000;
}
Then I use it like this:
const strOrNum: string | number = "asdf";
if (isBigNumber(someVar)) {
console.log(someVar * 10); // works because of type-guard
} else {
// here type of strOrNum is "string" and no longer "string | number"
}
My problem is with the type of strOrNum
in the Else block.
It looks like that the type guard checks for the type and TS also uses the type guard to narrow down the type for the Else block which in this case is not what I want. Are type guards only meant to check the type and no additional information of values passed to them?
Is there a solution to this without changing the return type of isBigNumber
to boolean
and having to check the type of strOrNum
again for the If statement?
CodePudding user response:
There is no >1000
type. If you were dealing in a finite, known, set of numbers, you could do something like something is 1001 | 1002 | 1003 | 1004 | 1005
, and Typescript would keep track of that (and would understand that in a false
case something
could still be (some other) number
), but you’re not.
The solution here is “type branding,” which is effectively a way to “fake” nominal typing in Typescript’s structural type system. There are a number of approaches, and several libraries you could install, but for this I’m just going to use my own, which I just call As
. I’ve included As
at the bottom of this answer, along with some explanations, but it’s also fine to just use it as a black box.
The way As
works is that you can say something is, say, As<"big-number">
, and Typescript will respect that and keep track of this thing that is a big number. It exists purely in the type system, and completely disappears from the compiled Javascript. With it, you can write functions that only accept big numbers, you can write typeguards that confirm big numbers, and so on.
One of the really crucial things about these brands is that if you have a typeguard that says something is X & As<"whatever">
, Typescript will understand that something
might still be X
, because it might be the As<"whatever">
portion that something
is missing. This gets around your issue with your typeguard.
So, for your example:
type BigNumber = number & As<"big-number">;
function isBigNumber(something: unknown): something is BigNumber {
return typeof something === "number" && something > 1000;
}
function onBigNumber(value: BigNumber): void {
console.log(value - 1000); // works because BigNumber extends number
}
declare const strOrNum: string | number;
if (isBigNumber(strOrNum)) {
console.log(strOrNum * 10); // works because of type-guard
onBigNumber(strOrNum); // works because of type-guard
} else {
// here type of strOrNum is "string | number"
if (typeof strOrNum === "number") {
console.log(strOrNum * 100); // works because of type-guard
onBigNumber(strOrNum); // ERROR, because strOrNum is number but not As<"big-number">
}
}
Definition of As
declare abstract class As<Tag extends keyof never> {
private static readonly $as$: unique symbol;
private [As.$as$]: Record<Tag, true>;
}
Some key notes if you want to understand this instead of just treating it as a black box:
declare
means TS won’t generate code for this class—it tells TS that the runtime JS for this class already exists, we’re just informing TS of its existence. That’s a lie, in this case—this class doesn’t exist at all.abstract
prevents us from attemptingnew As
, which would cause a runtime error because of the “lie” discussed above. It does not prevent us from tryingclass X extends As
, that will compile, and would also cause a runtime error. Don’t do that. (You coulddeclare class X extends As
if you wanted to extend the lie, though.)Tag extends keyof never
makesAs
generic, so we can use it with many different brands.keyof never
is the weird best practice for determining the type union corresponding to legal JS object keys (string | number | symbol
).private [As.$as$]
tells Typescript that objects of this class have a private member, that is, that its structure is different and specific. That means that Typescript’s structural typing system will treat this as a different type. It’sprivate
, so no one can access it (which is good because this is another lie), and usingAs.$as$
, which is defined as aunique symbol
, guarantees we won’t have any name conflicts with whatever we apply this brand to.Record<Tag, true>
as the type of the private member “stores” whateverTag
is passed into the class’s generic parameters. Making it aRecord
allows multiple brands to co-exist on the same object.