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

Playground link to code

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