Mark Probst
Mark Probst

Reputation: 7367

Covariance with undefined in TypeScript

The following code is clearly incorrectly typed, yet I cannot make TypeScript error on it. I turned on strict, as well as strictNullChecks and strictFunctionTypes for good measure, but TS remains unwavering in its conviction that this code is fine and dandy.

abstract class A {
    // You can pass an undefined to any A
    public abstract foo(_x: number | undefined);
}

class B extends A {
    // B is an A, but it prohibits passing in an undefined.
    // Note that if we did `x: string`, TS would flag it as
    // an error.
    public foo(x: number) {
        if (x === undefined) {
            throw new Error("Type error!");
        }
    }
}

function makeAnA(): A {
    // This typechecks correct, so B is clearly an A, in
    // TS's opinion.
    return new B();
}

function test() {
    const b = makeAnA();
    // b is a B, so this should not be possible
    b.foo(undefined);
}

Is this expected behavior? Is there some option I can turn on that will flag this as an error? I've had this bite me more than once.

Upvotes: 5

Views: 141

Answers (1)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 250156

This is a design decision. All method parameters behave bivariantly. This means that as far as ts is concerned, for methods (_x: number) => void is a subtype of to (_x: number | number) => void (and vice-versa). This is obviously unsound.

Initially not only method parameters behaved bivariantly but all function signature parameters. To fix this, the strictFuctionTypes flag was added in typescript 2.6. From the PR:

With this PR we introduce a --strictFunctionTypes mode in which function type parameter positions are checked contravariantly instead of bivariantly. The stricter checking applies to all function types, except those originating in method or constructor declarations. Methods are excluded specifically to ensure generic classes and interfaces (such as Array) continue to mostly relate covariantly. The impact of strictly checking methods would be a much bigger breaking change as a large number of generic types would become invariant (even so, we may continue to explore this stricter mode).

(highlight added)

So here we get a glimpse into the decision to have methods parameters continue to relate bivariantly. It's for convenience. Without this unsoundness, most classes would be invariant. For example if Array were invariant, Array<Dog> would not be a subtype of Array<Animal>, creating all sort of pain points in pretty basic code.

While definitely not equivalent, if we use a function field instead of a method (with strictFunctionTypes turned on), we do get an error that Type '(x: number) => void' is not assignable to type '(_x: number | undefined) => void'

abstract class A {
    // You can pass an undefined to any A
    public foo!: (_x: number | undefined) => void;
}

class B extends A {
    // Error here
    public foo: (x: number) => void = x => {
        if (x === undefined) {
            throw new Error("Type error!");
        }
    }
}

function makeAnA(): A {
    //And here 
    return new B();
}

function test() {
    const b = makeAnA();
    // b is a B, so this should not be possible
    b.foo(undefined);
}

Playground Link

Note: The code above gives an error only with strictFunctionTypes as without it all function parameter continue to behave bivariantly.

Upvotes: 3

Related Questions