typescriptinterfaceunion-typesintersection-types

Typescript union and intersection give unintuitive and wrong error messages


This exported type validates everything perfectly, but gives wrong validation error:

export type Animal = RequireSwimOrFly & RequireColorOrSizeOrVolume & (CantHaveBothColorOrSize | CanHaveNeitherColorOrSize)

const myAnimal: Animal = {
swim: "greatly",
color: "yellow",
size: "large",
}

Gives the following error:

Property 'fly' is missing in type '{ swim: string; color: string; size: string; }' but required in type 'Required<Pick<IAnimal, "fly">>'.(2322)

But that's not the correct validation! It's acceptable to NOT have fly, because it has swim. The true error is having color and size at the same time. Any way to give more intuitive errors for future developers?

Here's a working version on TS Playground

I am using multiple types to check multiple conditions:

type RequireSwimOrFly = RequireAtLeastOne<IAnimal, 'swim' | 'fly'>
type RequireColorOrSizeOrVolume = RequireAtLeastOne<IAnimal, 'color' | 'size' | 'volume'>
type CantHaveBothColorOrSize = RequireOnlyOne<IAnimal, 'color' | 'size'>
type CanHaveNeitherColorOrSize = Omit<IAnimal, 'color' | 'size'> & { color?: never, size?: never }

With the requires defined as follow:

type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
    Pick<T, Exclude<keyof T, Keys>>
    & {
        [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>
    }[Keys]

type RequireOnlyOne<T, Keys extends keyof T = keyof T> =
    Pick<T, Exclude<keyof T, Keys>>
    & {
        [K in Keys]-?: Required<Pick<T, K>> & Partial<Record<Exclude<Keys, K>, undefined>>
    }[Keys]

And the interface:

interface IAnimal {
    swim?: string;
    fly?: string;
    color?: string;
    size?: string;
    volume?: string;
}

Solution

  • Ultimately your Animal type is a large union type of object types; the intersections tend to be distributed over the unions and an intersection of object types can generally be collapsed together. You can even write a helper "identity" utility type to make that happen explicitly when you view it with IntelliSense:

    type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
    
    export type Animal = Id<RequireSwimOrFly & RequireColorOrSizeOrVolume & (
        CantHaveBothColorOrSize | CanHaveNeitherColorOrSize
    )>
    

    which gives you this:

    /* type Animal = {
        color: string; size?: undefined; volume?: string | undefined;
        swim: string; fly?: string | undefined;
    } | {
        color?: undefined; size: string; volume?: string | undefined;
        swim: string; fly?: string | undefined;
    } | {
        color: string; size?: undefined; volume: string;
        swim: string; fly?: string | undefined;
    } | {
        color?: undefined; size: string; volume: 
        string; swim: string; fly?: string | undefined;
    } | {
        color?: undefined; size?: undefined; volume: string;
        swim: string; fly?: string | undefined;
    } | {
        color: string; size?: undefined; volume?: string | undefined;
        fly: string; swim?: string | undefined;
    } | {
        color?: undefined; size: string; volume?: string | undefined;
        fly: string; swim?: string | undefined;
    } | {
        color: string; size?: undefined; volume: string;
        fly: string; swim?: string | undefined;
    } | {
        color?: undefined; size: string; volume: string;
        fly: string; swim?: string | undefined;
    } | {
        color?: undefined; size?: undefined; volume: string;
        fly: string; swim?: string | undefined;
    } */
    

    If you give the compiler a value which is not of type Animal, that means it is not assignable to any of the members of the union. It fails each and every one. A full reporting of why that value is inappropriate would be very, very long, as the compiler mentions each member of the union and how your value fails to match it. Something like:

    const myAnimal: Animal = { swim: "greatly", color: "yellow", size: "large", } // error!
    /* Type { swim: string; color: string; size: string; } is not assignable to Animal.
     - It can't be a { ✂ 1st union member ✂ } because its 'size' property is 'string' and not 'undefined'
     - It can't be a { ✂ 2nd union member ✂ } because its 'color' property is 'string' and not 'undefined'
     - It can't be a { ✂ 3rd union member ✂ } because its 'size' property is 'string' and not 'undefined'
     - It can't be a { ✂ 4th union member ✂ } because its 'color' property is 'string' and not 'undefined'
     - It can't be a { ✂ 5th union member ✂ } because its 'size' property is 'string' and not 'undefined'
     - It can't be a { ✂ 6th union member ✂ } because its 'fly' property is 'undefined' and not 'string'
     - It can't be a { ✂ 7th union member ✂ } because its 'fly' property is 'undefined' and not 'string'
     - It can't be a { ✂ 8th union member ✂ } because its 'fly' property is 'undefined' and not 'string'
     - It can't be a { ✂ 9th union member ✂ } because its 'fly' property is 'undefined' and not 'string'
     - It can't be a { ✂ 10th union member ✂ } because its 'fly' property is 'undefined' and not 'string'
    */
    

    Some unions have thousands of members, so it is not realistic for the compiler to give a full accounting of all the ways in which a value fails to be assignable to it. It has to do something else.

    Now, it could try to find "the closest" member of the union to the value according to some metric, and then report only an error there. Or maybe it could try to find "the most common" reason for failure and report that. Or maybe it could do some other more human-friendly heuristic. But it doesn't do any of those things; it picks the last member in the union, and reports that one:

    const myAnimal: Animal = { swim: "greatly", color: "yellow", size: "large", } // error!
    /* Type '{ swim: string; color: string; size: string; }' is not assignable to type 'Animal'.
      It is missing the following properties from type '{ ✂ 10th union member ✂ }' : volume, fly */
    

    This message is not wrong. It's just not particularly illuminating or intuitive.


    There was a GitHub issue filed at microsoft/TypeScript#4451 asking for something better, but it was closed as "Won't Fix", with the main explanatory comment being that it wouldn't be obvious how to write a good algorithm here.

    So that's, unfortunately, how it is. You get some error message that correctly explains part of the reason why your value is bad, but not necessarily "the" reason a clever human being might give.

    Playground link to code