Home > Blockchain >  How can I make a type predicate that only narrows if it returns true?
How can I make a type predicate that only narrows if it returns true?

Time:02-21

For example, an isLongString function that returns true if and only if the argument is a string with more than 10 characters couldn't be (naively) implemented as a type predicate because it might confuse the compiler into narrowing incorrectly.

function isLongString(v: unknown): v is string {
  return typeof v === "string" && v.length > 10;
}

const shortString = "short" as string | number;

if (isLongString(shortString)) {
  const tst: string = shortString;

  // @ts-expect-error
  const tst2: number = shortString;
} else {
  // @ts-expect-error
  const tst: string = shortString;

  // @ts-expect-error
  const tst2: number = shortString;
}

(Playground)

CodePudding user response:

A close approximation to this would be to use a unique type.

function isLongString(v: unknown): v is string & {__limitedPredicate: "isLongString"} {
  return typeof v === "string" && v.length > 10;
}

(Playground)

If the predicate returns true, the unique type can be used nearly identically to a string. If the predicate returns false, TypeScript can't narrow the type at all because all it knows is v is not the unique type.

CodePudding user response:

Typescript's type system isn't able to express types like "a string of length at least 11", although if you wanted "a string of length exactly 11" then you could write that like string & {length: 11}. If you have a type like this, which is an intersection of two types, then narrowing will (typically) be one-way in the sense that no narrowing is done in the else branch, because it isn't known which of the two intersected types was not satisfied. (Narrowing would still occur in a union like (string & {length: 11}) | number, of course.)

In the specific case of strings, you may be able to solve your problem by writing a suitable template literal type which your type guard checks for. For example:

function isStringStartingWithFoo(s: unknown): s is `foo${string}` {
    return typeof s === 'string' && s.startsWith('foo');
}

Unfortunately there is no (sane) way to use a template literal type to describe "a string of length at least 11", but if your function is testing something more concrete than that, then a template literal type may be the way forward.


In the general case, the solution is to write a return type like v is string & ... with an intersection type. The ... part could be something fictitious, as in @johncs's answer. Using fictitious properties to emulate nominal types is quite standard in Typescript, and shouldn't lead to incorrect code because nobody will have any reason to try to access the non-existent property. However, you could still instead write something true which is related to the fact that the string has a length of at least 11.

For example, you could say that the type guard checks that it's a string whose match method doesn't return null when called with the regex .{11,}:

type LongString = string & {match(regex: '.{11,}'): RegExpMatchArray}

function isLongString(v: unknown): v is LongString {
  // ...
}

(Note that this doesn't prevent calling match with other arguments, because intersection types of functions or methods are like overloads.) This is a bit clumsy in this example because you are unlikely to ever want to call .match('.{11,}') exactly on a string anyway, but one can imagine examples where it is actually useful, perhaps a type guard that checks v is MyDataStructure & {isEmpty(): false} or something similar.

  • Related