12

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

  1. 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-in rr.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.
  2. 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.
  3. 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!

13
  • I think your Shape type is the issue, not the type guard... maybe you want type Shape<T extends (Circle | Square)["kind"] = (Circle | Square)["kind"]> = Extract<Circle | Square, { kind: T }>;... or maybe you should just have type Shape = Circle | Square and something like type ShapeKind<T extends Shape["kind"]> = Extract<Shape, {kind: T}> and not try to use a single type name for both things. ` Commented Sep 2, 2019 at 0:12
  • Also, discriminated unions only work if they have a discriminant property where each member of the union can be distinguished at compile time. The type {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 to never (in the "false" case), as your code shows. Commented Sep 2, 2019 at 0:20
  • 1
    🤔 User-defined type guards don't work the same as == in the false case (or as != in the true case). If the guard (x: T) => x is U returns false, the type of x will be narrowed to Exclude<T, U>, whereas when x == y returns false, no narrowing of x or y happens at all. If it is not appropriate to narrow on failure, don't use a user-defined type guard (or bad things happen). If T and U are the same type (e.g., "square" | "circle"), such narrowing results in never, which is bad. Commented Sep 23, 2019 at 18:48
  • 1
    Or, without one-sided user defined type guards, I can't come up with something that acts the way you want. And I don't know if we'll ever get those. Commented Sep 23, 2019 at 18:51
  • 1
    @jcalz So you did, my bad. I was surprised to see such an expert in TS say that, should have triple-checked I was reading correctly. Commented Sep 23, 2019 at 23:49

2 Answers 2

3

About narrowing conditional types

So fundamentally, your problem here is that narrowing a value does not narrow its type for the sake of mapped or conditional types. See this issue on the GitHub bug tracker, and specifically this comment explaining why this does not work:

If I've read correctly, I think this is working as intended; in the general case, the type of foobar itself doesn't necessarily reflect that FooBar (the type variable) will describe identical types of a given instantiation. For example:

function compare<T>(x: T, y: T) {
  if (typeof x === "string") {
    y.toLowerCase() // appropriately errors; 'y' isn't suddenly also a 'string'
  }
  // ...
}

// why not?
compare<string | number>("hello", 100);

Using type-guards can get you part of the way there:

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 s: string;
declare let shape: Shape;

declare function isShapeOfKind<Kind extends string>(
    shape: Shape,
    kind: Kind,
): shape is Shape<Kind>;

if (s === 'circle' && isShapeOfKind(shape, s)) {
    shape.radius;
}
else if (s === 'square' && isShapeOfKind(shape, s)) {
    shape.size;
}
else {
    shape.kind;
}

But you have to check the type of s before you can use isShapeOfKind and expect it to work. That’s because prior to checking s === 'circle' or s === 'square', the type of s is string, so the inference you get isShapeOfKind<string>(shape, s) and that only tells us that shape is Shape<string> which we already knew (and the false case is never because shape is defined to be a Shape, that is, a Shape<string>—it will never not be one). What you’d like to happen (but what Typescript doesn’t do) is for it to instead be something like Shape<typeof s> and then as more information about s is determined, knowledge about shape is determined. Typescript doesn’t track types of separate variables that may be related to one another.

The other way you could do this is to make things not a separate variable, if you really had to. That is, define a couple of interfaces like

interface ShapeMatchingKind<Kind extends string> {
    shape: Shape<Kind>;
    kind: Kind;
}

interface ShapeMismatchesKind<ShapeKind extends string, Kind extends string> {
    shape: Shape<ShapeKind>;
    kind: Kind;
}

type ShapeAndKind = ShapeMatchingKind<string> | ShapeMismatchesKind<string, string>;

declare function isShapeOfKind(
    shapeAndKind: ShapeAndKind,
): shapeAndKind is ShapeMatchingKind<string>;

const shapeAndKind = { shape, kind: s };
if (isShapeOfKind(shapeAndKind)) {
    const pretend = shapeAndKind as ShapeMatchingKind<'circle'> | ShapeMatchingKind<'square'>;
    switch (pretend.kind) {
        case 'circle':
            pretend.shape.radius;
            break;
        case 'square':
            pretend.shape.size;
            break;
        default:
            shapeAndKind.shape.kind;
            break;
    }
}

Even here, though, you have to use the pretend trick—a version of the variable cast to a narrower type, and then when pretend is never, you know the original variable in fact wasn’t part of that narrower type. Further, the narrower type has to be ShapeMatchesKind<A> | ShapeMatchesKind<B> | ShapeMatchesKind<C> rather than ShapeMatchesKind<A | B | C> because a ShapeMatchesKind<A | B | C> could have shape: Shape<A> and kind: C. (If you have a union A | B | C, you can achieve the distributed version you need using a conditional type, though.)

In our code we combine pretend often with otherwise:

function otherwise<R>(_pretend: never, value: R): R {
    return value;
}

The advantage of otherwise is that you can write your default case like this:

default:
    otherwise(pretend, shapeAndKind.shape.kind);
    break;

Now otherwise will demand that pretend is never—making sure your switch statement covered all the possibilities in pretend’s narrowed type. This is useful if you ever add a new shape that you want to handle specifically.

You don’t have to use switch here, obviously; a chain of if/else if/else will work just the same way.

About imperfect typeguards

In your final iteration, your problem is that isTriangle returns false to typeof triangle & typeof kind when really what is false is that the value of triangle and the value of kind do not match. So you get a situation where Typescript sees both 'equilateral' and 'isosceles' as ruled out, because typeof kind was 'equilateral' | 'isosceles' but kind’s actual value was only one of those two things.

You can get around this with fake nominal types, so you can do something like

class MatchesKind { private 'matches some kind variable': true; }

declare function isTriangle<T, K>(triangle: T, kind: K): triangle is T & K & MatchesKind;

declare const triangle: 'equilateral' | 'isosceles' | 'scalene';
declare const kind: 'equilateral' | 'isosceles';

if (!isTriangle(triangle, kind)) {
    switch (triangle) {
        case 'equilateral': 'OK';
    }
}
else {
    if (triangle === 'scalene') {
//      ^^^^^^^^^^^^^^^^^^^^^^
//      This condition will always return 'false' since the types
//      '("equilateral" & MatchesKind) | ("isosceles" & MatchesKind)'
//      and '"scalene"' have no overlap.
        'error';
    }
}

Note that I used if here—switch doesn’t seem to work for some reason, it allows case 'scalene' in the second block with no complaints even though the type of triangle at that point should make that impossible.

However, this seems like a really, really bad design. It might just be the hypothetical illustration scenario, but I am really struggling to determine why you would want to design things this way. It’s not at all clear why you would want to check triangle against the value of kind and have the result appear in the type domain, but without narrowing kind to the point that you can actually know its type (and thus triangle’s). It would be better to narrow kind first, and then use it to narrow triangle—in that situation, you have no problems. You seem to be reversing some logic somewhere, and Typescript is—reasonably, I think—uncomfortable with that. I certainly am.

Sign up to request clarification or add additional context in comments.

Comments

2

I'm going to address your "update 2" code, but the suggestion should apply to the general issue. I think the main situation here is that isShape(s, k) should only act as a type guard on s if s isn't already a narrower type than k is. Otherwise you don't want isShape(s, k) to do anything to the type of s, since in neither the true or the false case is anything of relevance implied (or at least nothing that can be represented in the type system).

Therefore my suggestion is to overload the function so that it is only a user-defined type guard in the "right" situations, like this:

type Kind = "circle" | "square";

// isShape(s, k) should only act as a type guard if s is not of a narrower type than k
function isShape<K extends Kind, S extends [S] extends [K] ? never : string>(
  s: S,
  kind: K
): s is S & K;
// otherwise, isShape(s, k) is not a type guard but just a boolean test
function isShape(s: string, kind: Kind): boolean;
function isShape(s: string, kind: Kind): boolean {
  return s === kind;
}

That first overload signature works because S is constrained to the conditional type [S] extends [K] ? : never : string. If S is inferred by the value of s to be same or narrower type as that of kind, then the constraint becomes S extends never, which will generally fail, and the compiler will try the next overload signature (which will succeed). Otherwise, if S is inferred by the value of s to be a wider or different type, the constraint becomes S extends string and the inference will succeed (assuming S is assignable to string) and the function will act as a type guard.

Now let's see how it behaves:

declare const s: string;
declare const kind: Kind;
declare let shape: Kind;

// Use of type guard on string against Kind literal:
if (isShape(s, "circle")) {
  const x: "circle" = s; // s is "circle"
} else {
  const x: typeof s = "someString"; // s is string
}

// Use of type guard on Kind against Kind literal:
if (isShape(shape, "circle")) {
  const x: "circle" = shape; // shape is "circle"
} else {
  const x: "square" = shape; // shape is "square"
}

// Use of type guard on string against Kind:
if (isShape(s, kind)) {
  const x: Kind = s; // s is Kind
} else {
  const x: typeof s = "someString"; // s is string
}

// Use of type guard on Kind against Kind:
if (isShape(shape, kind)) {
  const x: Kind = shape; // shape is Kind (no narrowing has taken place)
} else {
  const x: Kind = shape; // shape is Kind (no narrowing has taken place)
}

I think that covers all your use cases. Does that work?

It would be simpler, though, if you just don't use isShape(s, k) when you already know that s is of a narrower type than k. When you use user-defined type guards for a test where there are likely false negatives (where the false return does not imply anything new about the type of the guarded parameter), you are shooting yourself in the foot. The above overload definition tries to make isShape() disarm itself when you point it at your foot, but it's just easier for all involved not to require such things. You could use isShape(s, k) when s is wider than k, and otherwise just use s === k or some other non-type-guard test.

But in any case, I hope this helps. Good luck!

Link to code


UPDATE

You've expanded Kind to three literals, and I see now that my thoughts about which situations are the "right" ones to narrow were not completely correct. Now my plan of attack is for isTriangle(t, k) is that it should be a regular type guard only when k is a single string literal type and not a union at all. This is detectable by the type system, but it's not pretty:

type _NotAUnion<T, U> = T extends any
  ? [U] extends [T] ? unknown : never
  : never;

type IsSingleStringLiteral<
  T extends string,
  Y = T,
  N = never
> = string extends T ? N : unknown extends _NotAUnion<T, T> ? Y : N;

If k is a union of types, then you should only narrow in the true case and not in the false case. This is a one-sided user-defined type guard, which doesn't officially exist in TypeScript. However, @KRyan notes that you can emulate a one-sided type guard by making the guarded type narrow to a nominal or nominal-like type. I'll use branding, like type BrandedFoo = Foo & {__brand: "Foo"}... where I don't expect the __brand property to actually exist at runtime, but the compiler thinks it's there, and can use it to distinguish Foo from BrandedFoo. If the type guard narrows from Foo to BrandedFoo on the true case, then in the false case it will remain Foo because Exclude<Foo, BrandedFoo> is just Foo.

I'm still using overloads to determine which type of type guard we want, based on the type of kind:

type TriangleKind = "equilateral" | "isosceles" | "scalene";

function isTriangle<K extends IsSingleStringLiteral<K, TriangleKind, never>>(
  triangle: string,
  kind: K
): triangle is K;
function isTriangle<K extends TriangleKind>(
  triangle: string,
  kind: K
): triangle is K & { __brand: K };
function isTriangle(triangle: string, kind: TriangleKind): boolean {
  return triangle == kind;
}

And let's take it through its paces:

declare const triangle: "equilateral" | "isosceles" | "scalene";
declare const twoKind: "equilateral" | "isosceles";
declare const allKind: "equilateral" | "isosceles" | "scalene";
declare const s: string;

// Use of type guard on string against TriangleKind literal:
if (isTriangle(s, "equilateral")) {
  const x: "equilateral" = s; // s is "equilateral"
} else {
  const x: typeof s = "someString"; // s is string
}

// Use of type guard on string against union of two TriangleKind types:
if (isTriangle(s, twoKind)) {
  const x: "equilateral" | "isosceles" = s; // s is "equilateral" | "isosceles"
} else {
  const x: typeof s = "someString"; // s is still string, no narrowing
}

// Use of type guard on string against TriangleKind:
if (isTriangle(s, allKind)) {
  const x: TriangleKind = s; // s is TriangleKind
} else {
  const x: typeof s = "someString"; // s is still string, no narrowing
}

// Use of type guard on TriangleKind against TriangleKind literal:
if (isTriangle(triangle, "equilateral")) {
  const x: "equilateral" = triangle; // triangle is "equilateral"
} else {
  const x: "isosceles" | "scalene" = triangle; // triangle is "isosceles" | "scalene"
}

// Use of type guard on TriangleKind against union of two TriangleKind types:
if (isTriangle(triangle, twoKind)) {
  const x: "equilateral" | "isosceles" = triangle; // triangle is "equilateral" | "isosceles"
} else {
  const x: typeof triangle = allKind; // triangle is still TriangleKind, no narrowing
}

// Use of type guard on TriangleKind against TriangleKind:
if (isTriangle(triangle, allKind)) {
  const x: TriangleKind = triangle; // triangle is TriangleKind
} else {
  const x: typeof triangle = allKind; // triangle is still TriangleKind, no narrowing
}

This all looks mostly right. Note that in several of the true branches the type of the narrowed thing is branded, so you get ("isosceles" & {__brand: "isosceles"}) | ("scalene" & {__brand: "scalene"}) instead of "isosceles" | "scalene". You can mostly ignore those brands, but they are kind of ugly.

So there you go. Complicated and messy, best I've got.

Link to code

Good luck again!

1 Comment

I wonder if habitually using isShape(s, k) && isShape(k, s) gets the right results consistently without the overload. Obnoxious redundancy and runtime overhead, so not a great solution, but it might be less error-prone.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.