TypeScript: instanceof typeguard fails if classes have similar structure

TypeScript Version:

1.8.x and nightly

Code

class C1 { item: string }
class C2 { item: string[] }
class C3 { item: string }

function Foo(x: C1 | C2 | C3): string {
    if (x instanceof C1)
        return x.item;
    else if (x instanceof C2)
        return x.item[0];
    else if (x instanceof C3)
        return x.item; // Error property item does not exist on C2
}

Expected behavior: Code should compile

Actual behavior: Code has an error as shown

More

The following works i.e. if C1 and C3 differ in structural compatibility:

class C1 { item: string }
class C2 { item: string[] }
class C3 { items: string }

function Foo(x: C1 | C2 | C3): string {
    if (x instanceof C1)
        return x.item;
    else if (x instanceof C2)
        return x.item[0];
    else if (x instanceof C3)
        return x.items; // Ok
}

🌹

About this issue

  • Original URL
  • State: closed
  • Created 8 years ago
  • Reactions: 1
  • Comments: 24 (16 by maintainers)

Commits related to this issue

Most upvoted comments

As noted in #8503, classes should be treated definitely for instanceof checks.

note this applies to instanceof type guards only, and not for user defined type guards; the later stays structural as we have no guarantees on how they will be implemented, where as instanceof is known to always be nominal.

Is this a problem with structural classes or with wrong if-else instanceof?

class Foo {
    foo: string;
}

class Bar {
    bar: string;
}

function test(x: Foo | Bar) {
    if (x instanceof Foo) {
        x.foo.toUpperCase()
    } else { // somewhy x is Bar here
        x.bar.toUpperCase();
    }
}
test({ foo: 'foo' });

I’ve also run into this problem, and wish that the compiler behaviour could be brought more into line with what happens at runtime. It’s not specific to instanceof or classes, it’s really just about the structural similarity of the types in the union. E.g., this works fine at runtime, but fails at compile time:

type UnaryFunction = (a: any) => any;
type BinaryFunction = (a: any, b: any) => any;
function isUnaryFunction(fn: Function) : fn is UnaryFunction {
    return fn.length === 1;
}
function isBinaryFunction(fn: Function) : fn is BinaryFunction {
    return fn.length === 2;
}
function foo(fn: UnaryFunction | BinaryFunction) {
    if (isBinaryFunction(fn)) {
        fn(1, 2);   // OK: fn is BinaryFunction here
    } else {
        fn(1);      // ERROR: Cannot invoke an expression whose type lacks a call signature
    }
}

I think the problem is similar to @basarat’s, because from a structural typing viewpoint, a UnaryFunction is just a subtype of a BinaryFunction, triggering different narrowing behaviour than if the types were structurally independent. However there is no subtype relationship according to the runtime checks in isUnaryFunction and isBinaryFunction.


Here is another case that brings up this type guard behaviour because the compiler sees the types as structurally related. Consider a function that takes either (a) an options object, where all options are optional, or (b) a function that returns an options object:

interface OptionsObject {
    option1?: string;
    option2?: number;
}
interface OptionsFunction {
    (): OptionsObject;
}
function isOptionsObject(opts: OptionsObject | OptionsFunction) : opts is OptionsObject {
    return opts && typeof opts === 'object'; // definitely not a function
}
function bar(opts: OptionsObject | OptionsFunction) {
    let option1: string;
    if (isOptionsObject(opts)) {
        option1 = opts.option1 || 'none';   // ERROR: no option1 on OptionsObject|OptionsFunction
    } else {
        option1 = opts().option1 || 'none'; // OK
    }
}

This also works at runtime but fails at compile time. The compiler sees OptionsFunction as just a special case of OptionsObject, because structurally it is. But it is not a subtype according to the runtime check in the type guard.


I have learned how to spot and work around these cases now. But that involves taking valid runtime code, and rearranging it just right so the compiler won’t complain. It’s a (rare) case where the tool is fighting me rather than helping me. Probably also quite unintuitive for beginners.