Reputation: 716
I have a data structure where one of the keys allows for a dynamic set of values. I know the potential type of these values but I am unable to express this in Typescript.
interface KnownDynamicType {
propA: boolean;
}
interface OtherKnownDynamicType {
propB: number;
}
// I want to allow dynamic to accept either KnownDynamicType, OtherKnownDynamicType or a string as a value
interface DataModel {
name: string;
dynamic: {[key: string]: KnownDynamicType | OtherKnownDynamicType | string};
}
const data: DataModel = { // Set up some values
name: 'My Data Model',
dynamic: {
someKnownType: {
propA: true
},
someOtherKnownType: {
propB: 1
},
someField1: 'foo',
someField2: 'bar'
}
}
data.dynamic.foo = 'bar'; // Fine
data.dynamic.someObject = { // <--- This should be invalid
propA: false,
propB: ''
}
Typescript seems to see data.dynamic.someObject
as KnownDynamicType
but unexpectedly allows me to assign properties from OtherKnownDynamicType
to it as well.
I've done my best to avoid complex typing but when a situation arrises, I rather avoid throwing in the towel and just setting dynamic: any
My question is, how can I properly express the above in Typescript?
Upvotes: 1
Views: 366
Reputation: 327964
It is true that a union is inclusive, so A | B
includes anything which is a valid A
, and anything which is a valid B
, including anything which is a valid A & B
. TypeScript doesn't have negated types, so you can't say something like (A | B) & !(A & B)
, which would explicitly rule out anything which matches both A
and B
. 🙁 Oh well.
TypeScript also lacks exact types, so adding a property to a valid A
will still result in a valid A
. So you can't say Exact<A> | Exact<B>
, which would also have the effect of ruling out anything which matches both A
and B
, as well as anything with any property other than the explicitly declared properties of either A
or B
. 🙁 Oh well.
TypeScript does have excess property checking, which provides some of the benefits of negated or exact types by disallowing extra properties on "fresh" object literals. Unfortunately, there is (as of TypeScript v2.8 v3.5) an issue where excess property checking on union types isn't as strict as most people would like, as you've discovered. It looks like the issue is considered a bug and should be addressed in TypeScript v3.0 (edit:) the future, but that doesn't solve your problem today. 🙁 Oh well.
So, maybe we can get fancy with our types to get something similar to the behavior you want:
type ProhibitKeys<K extends keyof any> = { [P in K]?: never }
type Xor<T, U> = (T & ProhibitKeys<Exclude<keyof U, keyof T>>) |
(U & ProhibitKeys<Exclude<keyof T, keyof U>>);
Let's examine those. ProhibitKeys<K>
returns a type with all optional properties whose keys are in K
and whose values are of type never
. So ProhibitKeys<"foo" | "bar">
is equivalent to {foo?: never, bar?: never}
. Since never
is an impossible type, the only way for a real object to match ProhibitKeys<K>
is for it not to have any properties whose keys are in K
. Hence the name.
Now let's look at Xor<T,U>
. The type Exclude<keyof A, keyof B>
is a union of all the declared keys in A
that do not also appear as keys in B
. Xor<T,U>
is a union of two types. The first one is T & ProhibitKeys<Exclude<keyof U, keyof T>>
, which means it is a valid T
with no properties from U
(unless those properties are also in T
). And the second is U & ProhibitKeys<Exclude<keyof T, keyof U>>
, which means a valid U
with no properties from T
. This is as close as I can get to expressing the idea of the exclusive union you want in TypeScript. Let's use it and see if it works.
First, change DataModel
:
interface DataModel {
name: string;
dynamic: { [key: string]: Xor<KnownDynamicType, OtherKnownDynamicType> | string };
}
And let's see what happens:
declare const data: DataModel;
data.dynamic.foo = 'bar'; // okay
data.dynamic.someObject = {
propA: false,
propB: 0
}; // error! propB is incompatible; number is not undefined.
data.dynamic.someObject = {
propA: false
}; // okay now
data.dynamic.someObject = {
propB: 0
}; // okay now
data.dynamic.someObject = {
propA: false,
propC: 0 // error! unknown property
}
That all works as expected. 🙂 Hope that helps. Good luck!
Upvotes: 6