12

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.

2
  • Your solution allows const foo: Foo = [{a: "foo", b: "bar"}] also. When you say "exactly" one key, do you mean "at least"? That is, your solution is equivalent to this, which may or may not be an answer to your question depending on your requirements. Commented Mar 26, 2020 at 16:58
  • Great catch and great question! My data is coming from a static JSON file in which there are no { a: '', b: '' } items. That being said I'm not sure if my typing needs to exclude that case. Basically I'm using if ('a' in obj) { ... } so in the case of { a: '', b: '' } my solution is order dependent but doesn't break because the source data doesn't have that case. That being said, let's assume I don't want to allow { a: '', b: '' }. I'll update the question :-) Commented Mar 26, 2020 at 17:21

3 Answers 3

10

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

Sign up to request clarification or add additional context in comments.

Comments

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

2 Comments

Great! I believe this is also correct and I think also makes my question a duplicate! But you've made things difficult for me :-) As you've linked to an answer from jcalz who has also posted an answer directly to this question I'm not sure which one to mark as an answer nor if/how to mark this question as a duplicate. I wonder if @jcalz could help me out in determining if their answer and yours are equivalent and if this question should be marked as duplicate?
@MarkLodato My bad, my answer is silly in that case. Accept his of course :)
0

Wanted to share my version of @jcalz solution that satisfied our ESLint extensive rules and my need of readability, hope it might helps:

/**
 * Create an object type that can only have one key from a KeyUnion type with a specific ValueType
 * @see https://stackoverflow.com/a/60873215
 * @example
 * const correctlyTypedArray : ObjectWithOnlyOneKeyFromUnion<"a" | "b", string> = [{a: "works"}, {b: "works too"}];
 */
export type ObjectWithOnlyOneKeyFromUnion<
    KeyUnion extends string | number,
    ValueType,
    KeyUnionCopy extends string | number = KeyUnion,
> =
    // make an object type with one key present
    {
        [Key in KeyUnion]: { [keyName in Key]: ValueType } & {
            // make all other keys from KeyUnion as optional-and-never
            [keyName in Exclude<KeyUnionCopy, Key>]?: never;
        } extends infer ObjectType
            ? //merge all intersections into a single object type for type linter readability purposes
              { [keyName in keyof ObjectType]: ObjectType[keyName] }
            : never;
    }[KeyUnion];

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.