'Enforce Typescript object has exactly one key from a set
Basic Question and Context
I'm trying to type an array of objects where each object has exactly one key from a set. For example:
const foo = [
{ a: 'foo' },
{ b: 'bar' },
{ c: 'baz' },
]
My first attempt was key in
a union:
type Foo = { [key in 'a' | 'b' | 'c']: string }[]
const foo: Foo = [
{ a: 'foo' },
{ b: 'bar' },
{ c: 'baz' },
]
This doesn't work as Typescript wants every object to have all the keys in the union:
type Foo = { [key in 'a' | 'b' | 'c']: string }[]
const foo: Foo = [
{ a: 'foo', b: 'bar', c: 'baz' },
{ a: 'foo', b: 'bar', c: 'baz' },
{ a: 'foo', b: 'bar', c: 'baz' },
]
My second attempt was:
type A = { a: string }
type B = { b: string }
type C = { c: string }
type Foo = (A | B | C)[]
const foo: Foo = [
{ a: 'foo' },
{ b: 'bar' },
{ c: 'baz' },
]
but, as jcalz points out, that still allows:
const foo: Foo = [{ a: 'foo', b: 'bar' }]
Is there a way to enforce that each object has exactly one key and that key is either a
or b
or c
?
Slightly more context
Our project is trying to read in this JSON to handle dynamic forms for address fields in different countries in React. When Typescript reads that JSON blob in, it gets most things wrong. Most importantly, it believes that the fields
key is not always an array and so doesn't let me .map
over it. So I decided to copy the JSON blob into our project and type it by hand. I'm trying to capture the fact that the fields
array is an array of objects that are either thoroughfare
, premise
, or locality
and that locality
is an array of objects that are either localityname
, etc.
Solution 1:[1]
If you want a type that expects exactly one key, you can (mostly) represent this as a union of object types where each member of the union has one key defined and all the rest of the keys as optional and of the never
type. (In practice this will also allow undefined
, see ms/TS#13195, unless you use the --exactOptionalPropertyTypes
compiler option which is not part of the --strict
suite.
So your Foo
should look something like:
type Foo = Array<
{ a: string; b?: never; c?: never; } |
{ a?: never; b: string; c?: never; } |
{ a?: never; b?: never; c: string; }
>
How can we get that or something like it programmatically? Well it's a bit tricky to explain, but my solution looks like this:
type ExactlyOneKey<K extends keyof any, V, KK extends keyof any = K> =
{ [P in K]: { [Q in P]: V } &
{ [Q in Exclude<KK, P>]?: never} extends infer O ?
{ [Q in keyof O]: O[Q] } : never
}[K];
type Foo = Array<ExactlyOneKey<"a" | "b" | "c", string>>;
The type ExactlyOneKey<K, V>
takes the key union K
and iterates over it. For each member P
of the union, it makes an object type with that key present and the other keys absent/missing. The type {[Q in P]: V}
(aka Record<P, V>
) has the present key and value, and the type {[Q in Exclude<KK, P>]?: never}
has all the rest of the keys as optional-and-never. We intersect those together with &
to get a type with both features. Then I do a little trick where ... extends infer O ? { [Q in keyof O]: O[Q] } : never
will take the type ...
and merge all intersections into a single object type. This isn't strictly necessary, but it will change {a: string} & {b?: never, c?: never}
to {a: string; b?: never; c?: never;}
which is more palatable.
And let's make sure it works:
const foo: Foo = [
{ a: 'foo' },
{ b: 'bar' },
{ c: 'baz' },
]; // okay
const badFoo: Foo = [
{ d: "nope" }, // error
{ a: "okay", b: "oops" } // error
];
Looks good.
Solution 2:[2]
Does this answer work for you?
type OneKey<K extends string, V = any> = {
[P in K]: (Record<P, V> &
Partial<Record<Exclude<K, P>, never>>) extends infer O
? { [Q in keyof O]: O[Q] }
: never
}[K]; //CREDITS TO JCALZ
type Foo = Array<OneKey<'a' | 'b' | 'c', string>>;
const foo: Foo = [
{ a: 'foo' },
{ b: 'bar' },
{ c: 'baz' },
]; //OK
const foo2: Foo = [
{ a: 'foo', b: 'bar', c: 'baz' },
{ a: 'foo', b: 'bar', c: 'baz' },
{ a: 'foo', b: 'bar', c: 'baz' },
]; //NOPE
const foo3: Foo = [{ a: 'foo', b: 'bar' }]; //NOPE
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | |
Solution 2 | Rubydesic |