Home > Enterprise >  User defined type guard function and type narrowing
User defined type guard function and type narrowing

Time:12-08

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 attempting new As, which would cause a runtime error because of the “lie” discussed above. It does not prevent us from trying class X extends As, that will compile, and would also cause a runtime error. Don’t do that. (You could declare class X extends As if you wanted to extend the lie, though.)
  • Tag extends keyof never makes As 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’s private, so no one can access it (which is good because this is another lie), and using As.$as$, which is defined as a unique 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” whatever Tag is passed into the class’s generic parameters. Making it a Record allows multiple brands to co-exist on the same object.
  • Related