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;
}
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;
}
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.