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?
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.