typescripttypescript-typingsreact-typescript

Typescript Unions based on property inside nested object


I'm trying to create a Union Type based on a nested property in my object. See the example below:

type Foo = {
    abilities: {
        canManage: boolean
    }
}

type Bar = {
    abilities: {
        canManage: boolean
    }
    extraProp: number
}

type Condition1 = {
    abilities: {
        canManage: true
    }
} & Bar

type Condition2 = {
    abilities: {
        canManage: false
    }
} & Foo

type TotalData = Condition1 | Condition2

const data: TotalData = {
    abilities: {
        canManage: false, // if canManage is false, TS should complain when I add the `extraProp` key
    },
    extraProp: 5
}

The issue I'm having is that typescript ignores the conditions I set. I'm interested in only allowing certain properties if the canMange value is true. This doesn't seem to work when it's nested. But if I had simply something like this without nesting, it would be fine:

type Foo = {
     canManage: boolean
}

type Bar = {
    canManage: boolean
    extraProp: number
}

type Condition1 = {
    canManage: true
} & Bar

type Condition2 = {
    canManage: false
} & Foo
]
type TotalData = Condition1 | Condition2

const data: TotalData = {

canManage: false,
extraProp: 5 // now typescript complains that this property shouldn't be here because canManage is false
}

How can I go about solving this issue when trying to set a Union based on the property inside a nested object?


Solution

  • The compiler doesn't understand the concept of "nested discriminated unions". A type is a discriminated union if the members of the union share a common "discriminant" property. A discriminant property is generally a singleton/literal type like true or "hello" or 123 or even null or undefined. You can't use another discriminated union as a discriminant itself, though. It would be nice if you could, because then discriminated unions could propagate up from nested properties the way you're doing. There's a suggestion at microsoft/TypeScript#18758 to allow this, but I don't see any movement there.

    As it stands, the type TotalData isn't a discriminated union. It's just a union. That means the compiler will not try to treat a value of type TotalData as exclusively either Condition1 or Condition2. So you will probably run into issues if you write code that tests data.abilities.canManage and expects the compiler to understand the implications:

    function hmm(x: TotalData) {
        if (x.abilities.canManage) {
            x.extraProp.toFixed(); // error!
        //  ~~~~~~~~~~~ <--- possibly undefined?!
        } 
    }
    

    If you want to do this you might find yourself needing to write user-defined type guard functions instead:

    function isCondition1(x: TotalData): x is Condition1 {
        return x.abilities.canManage;
    }
    
    function hmm(x: TotalData) {
        if (isCondition1(x)) {
            x.extraProp.toFixed(); // okay!
        } 
    }
    

    The specific issue you're running into here, where data is seen as a valid TotalData has to do with how excess property checking is performed. Object types in TypeScript are "open"/"extendable", not "closed"/"exact". You are allowed to add extra properties unmentioned in a type's definition without violating the type. So the compiler can't prohibit excess properties completely; instead, it uses heuristics to try to figure out when such properties are a mistake and when they are intentional. The rule used is mostly: if you are creating a brand new object literal and any of its properties are not mentioned in the type of where it's being used, there will be an error. Otherwise there won't be.

    If TotalData were a discriminated union, you'd get the error you expect on data because data.abilities.canManage would cause the compiler to narrow data from TotalData to Condition2 which doesn't mention extraProp. But it's not, and so data remains TotalData which does mention extraProp.

    It's been proposed, in microsoft/TypeScript#20863, that excess property checking be stricter for non-discriminated unions. I pretty much agree; mixing and matching properties from different union members doesn't seem to be a common use case, so a warning would probably be helpful. But again, this is a longstanding issue and I don't see any movement there.


    One thing you can do for this is to be more explicit about excess properties you'd like to guard against. A value of type {a: string} can have a b property of type string, but a value of type {a: string, b?: never} cannot. So this latter type will prevent properties of type b without relying on the compiler's heuristics for excess property checking.

    In your case:

    type Foo = {
        abilities: {
            canManage: boolean
        };
        extraProp?: never
    }
    

    will behave very similarly to your original Foo definition, but now you get this error:

    const data: TotalData = { // error!
    // -> ~~~~
    // Type '{ abilities: { canManage: false; }; extraProp: number; }'
    // is not assignable to type 'TotalData'.
        abilities: {
            canManage: false,
        },
        extraProp: 5
    }
    

    The compiler can no longer reconcile data with either Condition1 or Condition2, so it complains.


    Playground link to code