7

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"
Max Heiber
  • 14,346
  • 12
  • 59
  • 97
  • Why don't you create an interface to that? or like a factory method which will verify type and return "undefined" if its not a string (or throw an error, etc), or the desired object otherwise? – Gibor Aug 20 '19 at 10:34
  • 1
    I'm interested in how to do the static check that an object has only one key. – Max Heiber Aug 20 '19 at 10:40
  • `Object.keys(yourObj).length` should do the trick :) – Gibor Aug 20 '19 at 11:38
  • @MaxHeiber Can you describe what is your use case for this? Seems quite counter intuitive to me. – Nenad Aug 20 '19 at 16:06
  • Possible duplicate of [typescript restrict number of object's properties](https://stackoverflow.com/questions/39190154/typescript-restrict-number-of-objects-properties) and there is also this: https://github.com/Microsoft/TypeScript/issues/10667 – DAG Aug 20 '19 at 16:07
  • @DaGardner thanks, I marked as a duplicate – Max Heiber Aug 20 '19 at 16:31

1 Answers1

22

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • 8
    Thanks for your reply. I think what I'm looking for is disallowing unions. I am apparently a capitalist pig. – Max Heiber Aug 20 '19 at 16:30
  • But `declare const many: "a" | "b"; const res2 = create(many);` is specifically using a union type. You wanted *that* line to be an error? – jcalz Aug 20 '19 at 16:37
  • maybe. Or maybe it would be OK if create(many) gave me a union of types of object with just one key each. What I am looking for in this case is the ability to restrict each object to have only one key each—whether `create(many)` distributes differently in this case or errors would be fine either way. Perhaps distributing differently would be nicer. – Max Heiber Aug 21 '19 at 08:38
  • Okay well I guess this answer is the "distribute differently" answer, and I could also come up with a way to make `create(many)` give a compiler error if `many` is of a union type. But at this point the question is marked as a duplicate and the question which it supposedly duplicates doesn't really touch on these points at all, so adding such an answer there would seem off-topic. I'm not sure it makes any sense to proceed here, unless you'd like to reopen this question. – jcalz Aug 21 '19 at 11:34
  • Thanks @jcalz, is there really a way to make `create(many)` error if `many` is of a union type? If so, I'll gladly open a new question and link it here! – Max Heiber Sep 03 '19 at 22:00
  • It's certainly [possible](https://repl.it/@jcalz/WhimsicalHappyCensorware) although maybe uglier than you'd want. (I assume you want `K` to be a single string literal value, and a union or `string`) – jcalz Sep 03 '19 at 23:48
  • A recent version of TypeScript now does distinguish between missing and undefined properties... – ErikE Jan 16 '23 at 06:25