I'm hoping to define a type
of objects that can have exactly one key.
Here is an attempt:
type OneKey<K extends string> = Record<K, any>
Unfortunately, this doesn't quite work because a variable can have a union type:
type OneKey<K extends string> = Record<K, any>
declare function create<
K extends string,
T extends OneKey<K>[K]
>(s: K): OneKey<K>
const a = "a";
const res = create(a);
// Good
const check: typeof res = { a: 1, b: 2 }
// ~~ Error, object may only specify known properties
declare const many: "a" | "b";
const res2 = create(many);
// **Bad**: I only want one key
const check2: typeof res2 = { a: 1, b: 2 }; // No error
declare const x: "k1" | "k2"
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With