typescripttypescript-typings

Why are these two types AB and CD mutually assignable in TypeScript"?


Why are the two types established by the following interfaces the same?

interface AB {
    a?: string;
    b?: string;
    c?: string;
}

interface CD {
    c?: string;
    d?: string;
    
    a?: string;
}

(on example showing they are mutually assignable is here)

Of course they share the properties a and c but not b and d.


Solution

  • For better or worse, TypeScript's type system isn't fully type safe, or sound. Even though much of TypeScript's utility revolves around improving type safety, some common coding patterns violate this safety; there's a tradeoff between type safety and expressiveness; TypeScript will sometimes value expressiveness and developer convenience over safety. See TypeScript Design Non-Goal #5.

    One such soundness hole has to do with optional properties. See this comment on microsoft/TypeScript#47731 and this comment on microsoft/TypeScript#42479 for a demonstration.

    It is perfectly safe to assign an object type with an optional property to a type missing that property. That's just a normal widening of a type, and TypeScript allows this:

    let x: { a?: number, b: string } = { a: 1, b: "" };
    let y: { b: string };   
    y = x; // okay
    

    But TypeScript also allows you to assign an object type missing a property to a type with an optional property with the same key:

    x = y; // okay?!
    

    This is technically an unsafe narrowing. A value of type {b: string} might have any property at the a key (this is because TypeScript types aren't sealed or "exact", see extend and only specify known properties? for more information):

    const z = { a: "xyz", b: "" };
    y = z; // okay
    

    Which means it really isn't safe to assume that the a property is either undefined or string. This leads to possible runtime errors:

    x = y; // still okay, oops!
    x.a?.toFixed(); // RUNTIME ERROR!
    

    Still, this tends not to happen very often in practice, and the safe alternative would be very annoying, since the common case is that most unknown properties are actually missing. This is the sort of thing people write all the time:

    const w = { b: "" };
    x = w; // okay
    
    function acceptX(x: { a?: number, b: string }) { }
    acceptX(w); // okay
    

    All TypeScript knows about w is that the type is {b: string}. It doesn't remember that the initializing object literal actually lacked an a property. If TypeScript complained every time it didn't know whether an unknown property was assigned to an optional one, the above would have to error, and it would drive people crazy. So it's allowed.


    That brings us to

    interface AB {
        a?: string;
        c?: string;
    
        b?: string;
    }
    
    interface CD {
        a?: string;
        c?: string;
    
        d?: string;   
    }
    

    These types are mutually assignable because the only place they disagree is that one has an optional property the other one is missing. They both have optional a and c properties of type string. But AB has an optional b property missing in CD, and CD has an optional d property missing in AB. Neither of these discrepancies are considered a problem for assignability. And indeed, as long as you don't start doing things with {b: 123, d: 456} where the relevant properties are present-but-not-a-string, you won't see an issue with such cross-assignment.

    So it's behaving as intended.

    Playground link to code