typescripttypescript-typingsdiscriminated-union

Why does TypeScript's Structural Typing (i.e. "Duck Typing") necessitate non-strict Type Unions?


Assume that the following types are defined:

type A = {
  a: string,
}

type B = {
  a: number,
  b: number,
}

These two assignments produce an error:

// Not assignable to A because 'b' doesn't exist on A
const x: A = {
  a: 'hello',
  b: 10
}

// Not assignable to B because 'a' expects type 'string'
const x: B = {
  a: 'hello',
  b: 10
}

TypeScript's documentation defines a Union Type as:

A union type is a type formed from two or more other types, representing values that may be any one of those types.

Based on this, I would have expected the following declaration to produce an error:

const x: A | B = {
  a: 'hello',
  b: 10
}

But it doesn't. The object literally is not assignable to type A and it's not assignable to type B, yet it is assignable to A | B.

How can I think of Type Unions in a way that makes this result make sense?

Editing: Of course, I search for half the day and can't find information that documents this, but as soon as I post, I find a number of other posts here talking about the same thing.

From what I found, an object literal can have excess properties when assigned as a Type Union, as long as one of the members has that property.

If I understand correctly, my object literal satisfies type A, with the property b being an excess property, which is allowed because another member of the union (B) includes the property.

Why does TypeScript's Structural Typing (i.e. "Duck Typing") necessitate non-strict Type Unions? From the number of SO posts I'm seeing relating to this, it seems like most people expect a Type Union to be strict. I'm sure that there's a compelling reason for non-strict unions to be the default, can someone provide some intuition for why that's the case?


Solution

  • From a type theory perspective, TypeScript uses structural typing ("if it acts like a duck, it's a duck") and allows additional properties. As a result:

    const x = {
      a: 'hello',
      b: 10
    }
    function doSomethingWithA(a: A) {}
    // Works: x has shape type A = { a: string }
    doSomethingWithA(x);
    // Also works: Same reason
    const y: A = x;
    

    Your A | B assignment works for similar reasons:

    // Works: has shape { a: string } | { a: number, b: number }
    const x: A | B = {
      a: 'hello',
      b: 10
    }
    

    TypeScript has additional handling for the specific case of object literals with an explicit type defined: since that's a common potential source of errors, it raises errors for that specific case. See Microsoft/TypeScript/#3755, which offers this more in-depth explanation from Anders Hejlsberg for the rationale:

    An interesting fact about object literals (and array literals) is that an object reference produced by a literal is known to be the only reference to that object. Thus, when an object literal is assigned to a variable or passed for a parameter of a type with fewer properties than the object literal, we know that information is irretrievably lost. This is a strong indication that something is wrong--probably stronger than the benefits afforded by allowing it.

    There's a popular feature request for exact types, which could help enforce all of this more strictly, but it has not been implemented.

    Now, if I understand correctly, TypeScript could extend its "not assignable" special case from Microsoft/TypeScript/#3755 to union literals: it presumably hasn't due to some combination of lack of demand, implementation complexity, or compiler performance hit. (I haven't searched GitHub issues to see if this has been requested.)