TypeScript: type for an object with only one key (no union type allowed as a key) [duplicate]

If I understand correctly, you want OneKey<"a" | "b"> to be something like {a: any, b?: never} | {a?: never, b: any}. Meaning that it either has an a key or a b key but not both. So you want the type to be some sort of union to represent the either-or part of it. Furthermore, the union type {a: any} | {b: any} isn’t restrictive enough, since types in TypeScript are open/extendible and can always have unknown extra properties… meaning types are not exact. So the value {a: 1, b: 2} does match the type {a: any}, and there’s currently no support in TypeScript to represent concretely something like Exact<{a: any}> which allows {a: 1} but prohibits {a: 1, b: 2}.

That being said, TypeScript does have excess property checking, where object literals are treated as if they were of exact types. This works for you in the check case (the error “Object literal may only specify known properties” is specifically a result of excess property checking). But in the check2 case, the relevant type will be a union like {a: any} | {b: any}… and since both a and b are both present in at least one member of the union, excess property checking won’t kick in there, at least as of TS3.5. That is considered a bug; presumably {a: 1, b: 2} should fail the excess property check since it has excess properties for each member of the union. But it’s not clear when or even if that bug will be addressed.

In any case, it would be better to have OneKey<"a" | "b"> evaluate to a type like {a: any, b?: never} | {a?: never, b: any}… the type {a: any, b?: never} will match {a: 1} because b is optional, but not {a: 1, b: 2}, because 2 is not assignable to never. This will give you the but-not-both behavior you want.

One last thing before we get started with code: the type {k?: never} is equivalent to the type {k?: undefined}, since optional properties can always have an undefined value (and TypeScript doesn’t do a great job of distinguishing missing from undefined).

Here’s how I might do it:

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];

I’ve allowed V to be some value type other than any if you want to specifically use number or something, but it will default to any. The way it works is to use a mapped type to iterate over each value P in K and produce a property for each value. This property is essentially Record<P, V> (so it does have a P key) intersected with Partial<Record<Exclude<K, P>, never>>Exclude removes members from unions, so Record<Exclude<K, P>, never> is an object type with every key in K except P, and whose properties are never. And the Partial makes the keys optional.

The type Record<P, V> & Partial<Record<Exclude<K, P>, never>> is ugly, so I use a conditional type inference trick to make it pretty again… T extends infer U ? {[K in keyof U]: U[K]} : never will take a type T, “copy” it over to a type U, and then explicitly iterate through its properties. It will take a type like {x: string} & {y: number} and collapse it to {x: string; y: number}.

Finally, the mapped type {[P in K]: ...} itself is not what we want; we need its value types as a union, so we lookup these values via {[P in K]: ...}[K].

Note that your create() function should be typed like this:

declare function create<K extends string>(s: K): OneKey<K>;

without that T in it. Let’s test it:

const a = "a";
const res = create(a);
// const res: { a: any; }

So res is still the type {a: any} as you want, and behaves the same:

// Good
const check: typeof res = { a: 1, b: 2 };
//                                ~~ Error, object may only specify known properties

Now, though, we have this:

declare const many: "a" | "b";
const res2 = create(many);
// const res2: { a: any; b?: undefined; } | { b: any; a?: undefined; }

So that’s the union we want. Does it fix your check2 problem?

const check2: typeof res2 = { a: 1, b: 2 }; // error, as desired
//    ~~~~~~ <-- Type 'number' is not assignable to type 'undefined'.

Yes!


One caveat to consider: if the argument to create() is just a string and not a union of string literals, the resulting type will have a string index signature and can take any number of keys:

declare const s: string
const beware = create(s) // {[k: string]: any}
const b: typeof beware = {a: 1, b: 2, c: 3}; // no error

There’s no way to distribute across string, so there’s no way to represent in TypeScript the type “an object type with a single key from the set of all possible string literals“. You could possibly change create() to disallow arguments of type string, but this answer is long enough as it is. It’s up to you if you care enough to try to deal with that.


Okay, hope that helps; good luck!

Link to code

Leave a Comment