typescripttypescript2.2

Linking data fields with discriminated unions causing errors


I'm working on an Ionic (3.0.0) app, and frequently want to link the types of two fields in a data interface. For example, a NotificationData has verb: 'mention' | 'share' | ... and reference: ProfileReference | DocumentReference | ... fields, but really, a NotificationData is a union type:

{ verb: 'mention', reference: ProfileReference } | { verb: 'share', reference: DocumentReference }

So far, so good. There are other fields that don't change with verb, so I usually create a base interface, extend it in different ways, and then take the union, like so:

type X = 'a' | 'b' | 'c';
type Y = 'd' | 'e' | 'f';

interface I { x: X, other: Y };
interface A extends I { x: 'a', other: 'd' };
interface B extends I { x: 'b', other: 'e' };
interface C extends I { x: 'c', other: 'f' };
type J = A | B | C;

This is fine as long as I'm hard-coding the data

const j: J = { x: 'a', other: 'd' } // OK

or generating an entire from a switch

function f(x: X): J {
  switch (x) {
    case 'a': return { x, other: 'd' };
    case 'b': return { x, other: 'e' };
    case 'c': return { x, other: 'f' };
    default: ((y: never) => {})(x);
  }
}
// OK

But if I try to generate it another way, Typescript is complains:

function other(x: X): Y {
  switch (x) {
    case 'a': return 'd';
    case 'b': return 'e';
    case 'c': return 'f';
    default: ((y: never) => {})(x);
  }
}

function g(x: X): J {
  return { x, other: other(x) }
}
// Error:
// Type '{ x: X; other: number; }' is not assignable to type Z.
//   Type '{ x: X; other: number; }' is not assignable to type 'C'.
//     Types of property 'x' are incompatible.
//       Type 'X' is not assignable to type '"c"'.
//         Type '"a"' is not assignable to type '"c"'.

In fact, these errors come up even if there's no linking of data fields:

interface I2 { x: X, other: any };
interface A2 extends I2 { x: 'a' };
interface B2 extends I2 { x: 'b' };
interface C2 extends I2 { x: 'c' };
type J2 = A2 | B2 | C2;

function h(x: X): J2 { return { x, other: 0 }; }
// Error:
// Type '{ x: X; other: number; }' is not assignable to type J2.
//   Type '{ x: X; other: number; }' is not assignable to type 'C2'.
//     Types of property 'x' are incompatible.
//       Type 'X' is not assignable to type '"c"'.
//         Type '"a"' is not assignable to type '"c"'.

I could just use I in my type signatures

const xs: X[] = ['a', 'b', 'c'];
const is: I2[] = xs.map(x => ({ x, other: 0 })) // OK

but this loses the linking of fields that I wanted in the first place. I could also just always use a switch as in function f above, e.g.

const js: J[] = xs.map(f); // OK

but I'd like to be able to do this without creating a separate function, like

const j2s: J2[] = xs.map(x => ({ x, other: 0 }))
// Error: ... Type '"a"' is not assignable to type '"c"'.

and anyway, this feels like something that Typescript should be able to express/handle.

So my question is, is there a better way to express this linked-field type information in Typescript? Or another way to procedurally generate a J[] from an X[]?


Solution

  • This is a limitation of TypeScript's type system. I'll simplify the code in your question to make the problem easier to spot:

    function f(x: 'a' | 'b' | 'c'): { x: 'a' } | { x: 'b' } | { x: 'c' } {
        return { x: x };
    }
    
    // Type '{ x: "a" | "b" | "c"; }' is not assignable to type '{ x: "a"; } | { x: "b"; } | { x: "c"; }'.
    

    This error message is a little easier to understand. The type checker sees you using a value of type 'a' | 'b' | 'c' as the x entry in the object, and infers a type of { x: 'a' | 'b' | 'c' } for the expression. This is not the same type as { x: 'a' } | { x: 'b' } | { x: 'c' }! In particular, the type checker doesn't understand that every possible value of type 'a' | 'b' | 'c' does in fact produce a valid object of type { x: 'a' } | { x: 'b' } | { x: 'c' }.

    So the compiler needs some help. We can get our ducks in a row by performing a case analysis before constructing the object:

    function f(x: 'a' | 'b' | 'c'): { x: 'a' } | { x: 'b' } | { x: 'c' } {
        switch (x)
        {
            case 'a': return { x: x };
            case 'b': return { x: x };
            case 'c': return { x: x };
        }
    }
    

    In each branch of the case, x's type gets narrowed (to 'a', 'b', or 'c', respectively), so the returned expressions are of type { x: 'a' }, { x: 'b' } and { x: 'c' }, respectively. These clearly do type check at { x: 'a' } | { x: 'b' } | { x: 'c' }.

    If you really can't afford the extra keystrokes required for the switch statement, I think the simplest and most practical workaround is just to turn the type system off temporarily.

    function f(x: 'a' | 'b' | 'c'): { x: 'a' } | { x: 'b' } | { x: 'c' } {
        return <any>{ x: x };
    }
    

    You might reasonably object that TypeScript should be able to tell that { x: 'a' | 'b' | 'c' } is assignable to { x: 'a' } | { x: 'b' } | { x: 'c' }. Think about this like a compiler writer: what would an algorithm to check types like that look like?

    1. Break up the union type 'a' | 'b' | 'c' into its constituent literal types.
    2. Break up the result type { x: 'a' } | { x: 'b' } | { x: 'c' } into its constituent object types.
    3. For each pair of types ({x: x}, resultType), compare the types to see if they're assignable.

    In pseudocode:

    foreach (t: Type in 'a' | 'b' | 'c')
    {
        var checks = false;
        foreach (u: Type in { x: 'a' } | { x: 'b' } | { x: 'c' })
        {
            if ({ x: t } :<= u)
            {
                checks = true;
            }
        }
        if (!checks)
        {
            return false;
        }
    }
    return true;
    

    We've written an O(n^2) type checking algorithm! An alternative approach might be to put the types into a sum-of-products normal form - this would allow you to compare, say, { x: { y: 'a' | 'b' } | { y: 'c' | 'd' } } with { x: { y: 'a' } } | { x: { y: 'b' } } | { x: { y: 'c' } } | { x: { y: 'd' } } - but the normalisation algorithm blows up exponentially, and you still wouldn't be able to handle recursive type synonyms.

    The moral of the story is, in practice type systems aren't just set theory. Just because two types contain the same set of values doesn't mean you should expect them to be judged equal by a machine. In the most advanced type systems - dependent type systems - it's common for the type checker to require a computational proof of your program's soundness. (For example, you may know the length of your array to be n + m, but if the type checker was expecting an array of length m + n, you have to write extra code to convince the machine that you've fulfilled your obligations.)