What are type-level predicates?
In the context of programming, a predicate usually refers to a function that returns a boolean (yes/no) value.
For example, these are predicates that are commonly used:
function isPositive(n: number): boolean {
return n > 0;
}
function hasLength(s: string, l: string): boolean {
return s.length === l;
}
Both of the functions take one or more arguments and return yes or no.
Type-level predicates do the same thing at the type level. Here we define them as conditional types that take one or more arguments and return true
or false
type.
type IsPositive<N extends number> = /* snip */ ? true : false;
type HasLength<S extends string, L extends number> = /* snip */ ? true : false;
(The implementations of these predicates are a bit complex, so let's skip for now.)
Restricting function arguments using types
As you already know, function arguments can be restricted using types.
declare function myFunc(n: number): void;
myFunc(42); // OK
myFunc(0); // OK
myFunc(-42); // OK
myFunc("XXX"); // Error: "XXX" is not a number
We can also provide a subset of number
. This corresponds to defining a set by enumerating its members { 1, 2, 3 }
.
type MyNumber = 1 | 2 | 3;
declare function myFunc(n: MyNumber): void;
myFunc(1); // OK
myFunc(2); // OK
myFunc(3); // OK
myFunc(0); // Error: 0 is not a member of 1 | 2 | 3
But what if we want to constrain n
to be a positive number? We cannot provide the set by enumeration, because it has an infinite number of members.
type Positive = 1 | 2 | 3 | ...;
Can we define Positive
using the type-level predicate IsPositive<N>
, like a set comprehension { n | n is a number, n > 0}
?
// Can we define like this?
type Positive = (N | N extends number, IsPositive<N>);
But unfortunately, TypeScript does not provide such feature.
So simply using types to restrict arguments fails if there are an infinite number of (or too many) acceptable values, and we need another way for such case.
Restricting function arguments using type-level predicates
Here is the answer for the problem (playground):
type Positive<N extends number> = IsPositive<N> extends true ? N : never;
declare function myFunc<N extends number>(n: Positive<N>): void;
How does this work? Let's take a look.
When we call the function, for instance myFunc(42)
, the compiler first tries to infer the type parameter N
from the argument 42
. In this case, it knows that the conditional type Positive<N>
returns N | never = N
whatever N
is, and successfully infers N = 42
.
(Note that N
has an upper bound number
, so widening (inferring N
as N = number
) does not occur.)
Next, Positive<N>
is computed using the inferred N
. Clearly N = 42
is a positive number, so Positive<N> = N = 42
.
Finally, the compiler checks whether the argument 42
satisfies the type Positive<N>
. As seen above, Positive<N>
is 42
, and it passes the typecheck.
myFunc(42); // OK
When we call the function with a negative number, like myFunc(-42)
, the type parameter N
is inferred as N = -42
, and Positive<N> = never
.
The argument -42
is not a member of never
, of course, and it is rejected.
myFunc(-42); // Error
The downside of this technique is that the type error is not very helpful. In the above case, the error message is Argument of type 'number' is not assignable to parameter of type 'never'.
, which does not contain what constraint was not satisfied nor what to do to fix the error. Maybe throw types can be a solution for this?
Appendix
The type-level predicates IsPositive<N>
and HasLength<S, L>
can be implemented like these:
type IsPositive<N extends number> =
number extends N ? boolean
: N extends unknown ? (
N extends 0 ? false
: `${N}` extends `-${infer _}`? false
: true
)
: never;
type HasLength<S extends string, L extends number> = Length<S> extends L ? true : false;
type Length<S extends string> =
string extends S ? number
: S extends unknown ? _Length<S>
: never;
type _Length<S extends string, C extends unknown[] = []> =
S extends "" ? C["length"]
: S extends `${infer _}${infer R}` ? _Length<R, [...C, unknown]>
: never;
Top comments (0)