How do you write a generic type predicate in TypeScript?
In the following example, if (shape.kind == 'circle') doesn't narrow the type to Shape<'circle'>/Circle/{ kind: 'circle', radius: number }
interface Circle {
kind: 'circle';
radius: number;
}
interface Square {
kind: 'square';
size: number;
}
type Shape<T = string> = T extends 'circle' | 'square'
? Extract<Circle | Square, { kind: T }>
: { kind: T };
declare const shape: Shape;
if (shape.kind == 'circle') shape.radius;
// error TS2339: Property 'radius' does not exist on type '{ kind: string; }'.
I tried writing a generic type predicate to work around this, but the following doesn't work because the type parameter isn't available at runtime
function isShape1<T extends string>(shape: Shape): shape is Shape<T> {
return shape.kind extends T;
}
The following does work, but only if the type parameter T is a literal (has the same value at compile- and runtime)
function isShape2<T extends string>(shape: Shape, kind: T): shape is Shape<T> {
return shape.kind == kind;
}
if (isShape2(shape, 'circle')) shape.radius; // Works ✓
declare const kind: string;
if (!isShape2(shape, kind)) shape.kind;
// error TS2339: Property 'kind' does not exist on type 'never'.
Update 1
@jcalz The trouble is I need
declare const kind: string;
if (kind != 'circle' && kind != 'square') shape = { kind };
to work. I'd like to use a discriminated union, but can't, as you point out. If it were a discriminated union, could you write a generic type predicate?
type Shape<T = string> = Extract<Circle | Square, { kind: T }>;
The following still only works if the type parameter is a literal
function isShape3<T extends Shape['kind']>(shape: Shape, kind: T): shape is Shape<T> {
return shape.kind == kind;
}
if (isShape3(shape, 'circle')) shape.radius; // Works ✓
declare const kind: Shape['kind']; // 'circle' | 'square'
if (!isShape3(shape, kind)) shape.kind;
// error TS2339: Property 'kind' does not exist on type 'never'.
The only difference is in this case the compiler already provides a working type predicate
if (shape.kind != kind) shape.kind; // Works ✓
Update 2
@jcalz At runtime could it for example do the same thing as shape.kind == kind?
Here's a more concise demo
declare const s: string;
declare const kind: 'circle' | 'square';
declare let shape: 'circle' | 'square';
if (s == kind) shape = s; // Works ✓
if (shape != kind) shape.length; // Works ✓
function isShape1(s: string, kind: 'circle' | 'square') {
return s == kind;
}
if (isShape1(s, kind)) shape = s;
// error TS2322: Type 'string' is not assignable to type '"square" | "circle"'.
// https://github.com/microsoft/TypeScript/issues/16069
function isShape2(
s: string,
kind: 'circle' | 'square'
): s is 'circle' | 'square' {
return s == kind;
}
if (isShape2(s, kind)) shape = s; // Works ✓
if (!isShape2(shape, kind)) shape.length;
// error TS2339: Property 'length' does not exist on type 'never'.
Update 3
Thanks @jcalz and @KRyan for your thoughtful answers! @jcalz's solution is promising, especially if I disallow the non-narrowing case, vs. merely disarming it (via overload).
However it's still subject to the problem you point out (Number.isInteger(), bad things happen). Consider the following example
function isTriangle<
T,
K extends T extends K ? never : 'equilateral' | 'isosceles' | 'scalene'
>(triangle: T, kind: K): triangle is K & T {
return triangle == kind;
}
declare const triangle: 'equilateral' | 'isosceles' | 'scalene';
declare const kind: 'equilateral' | 'isosceles';
if (!isTriangle(triangle, kind)) {
switch (triangle) {
case 'equilateral':
// error TS2678: Type '"equilateral"' is not comparable to type '"scalene"'.
}
}
triangle will never be narrower than kind so !isTriangle(triangle, kind) will never be never, thanks to the conditional type (👍) however it remains narrower than it should be (unless K is a literal).
Update 4
Thanks again @jcalz and @KRyan for patiently explaining how this can in fact be accomplished, and the consequent weaknesses. I've chosen @KRyan's answer for contributing the fake-nominal idea, though your combined answers are extremely helpful!
My takeaway is that the type of s == kind (or triangle == kind or shape.kind == kind) is built in and not (yet) available to users, to assign to other things (like predicates).
I'm not sure that's exactly the same as one-sided type guards b/c the false branch of s == kind does narrow in (one) case
declare const triangle: 'equilateral' | 'isosceles' | 'scalene';
if (triangle != 'scalene')
const isosceles: 'equilateral' | 'isosceles' = triangle;
And to better motivate this question in the first place
- I have a type which is almost a discriminated union (DNS RRs) except I can't enumerate all of the discriminant's values (in general it's a
string | number, extensions are permitted). Consequently the built-inrr.rdtype == 'RRSIG'behavior doesn't apply. Unless I first narrow it to a true discriminated union with a user-defined type guard (isTypedRR(rr) && rr.rdtype == 'RRSIG'), which isn't a terrible option. - I can implement user-defined type guards for each RR type I can enumerate, but that's a lot of repetition (
function isRRSIG(rr): rr is RR<'RRSIG'>,function isDNSKEY(rr): rr is RR<'DNSKEY'>, etc.). Probably this is what I'll continue to do: It's repetitious but obvious. - The trouble with a trivial generic type guard is that non-literals aren't disallowed but don't make sense (unlike
s == kind/rr.rdtype == rdtype). e.g.function isRR<T>(rr, rdtype: T): rr is RR<T>. Hence this question.
This prevents me from say wrapping isTypedRR(rr) && rr.rdtype == rdtype in function isRR(rr, rdtype). Inside the predicate rr is narrowed rationally, but outside the only option is (currently) rr is RR<T> (or now a fake-nominal).
Maybe when type guards are inferred, it'll be trivial to rationally narrow the type outside the predicate as well? Or when types can be negated, it'll be possible to make a true discriminated union given a non-enumerable discriminant. I do wish the type of s == kind were (more conveniently :-P) available to users. Thanks again!
Shapetype is the issue, not the type guard... maybe you wanttype Shape<T extends (Circle | Square)["kind"] = (Circle | Square)["kind"]> = Extract<Circle | Square, { kind: T }>;... or maybe you should just havetype Shape = Circle | Squareand something liketype ShapeKind<T extends Shape["kind"]> = Extract<Shape, {kind: T}>and not try to use a single type name for both things. `{kind: string}doesn't count as a discriminated union, or even as a union, so it's not going to behave nicely. Checking{kind: string}against another{kind: string}with a type guard is only going to leave it unchanged (in the "true" case) or narrow it tonever(in the "false" case), as your code shows.==in the false case (or as!=in the true case). If the guard(x: T) => x is Ureturnsfalse, the type ofxwill be narrowed toExclude<T, U>, whereas whenx == yreturnsfalse, no narrowing ofxoryhappens at all. If it is not appropriate to narrow on failure, don't use a user-defined type guard (or bad things happen). IfTandUare the same type (e.g.,"square" | "circle"), such narrowing results innever, which is bad.