1

Let's say I want to create an error structure that indicates if the error is on the server side or the client side. Example:

const serverErr = {
  server: "Error!"
};

const clientErr = {
  client: "Error!"
};

The error object must have only one property, and the name of that property must be server or client.

I tried the answer to this question question but it's not working.

Effort1

According to this answer, here is the definition of IsSingleKey:

export type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void)
    ? I
    : never;
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true;
type ISingleKey<K extends string, T> = IsUnion<K> extends true ? "Can only contain a single key" : Record<K, T>;
Trí Phan
  • 1,123
  • 2
  • 15
  • 33
  • Try `ISingleKey<"server", string> | ISingleKey<"client", string>` – Bergi Feb 19 '23 at 10:58
  • What if there are 20 names that could work? I don't want to join 20 "ISingleKey" together, and it's not good for maintaining. – Trí Phan Feb 19 '23 at 12:02
  • 1
    You can’t really prohibit extra properties in TypeScript; all you can do is discourage them. Would you accept a solution that takes a union of key names and produces a type that requires exactly one of those keys to have a defined property? – jcalz Feb 19 '23 at 16:54
  • I'm using that solution to get around this problem right now. When I work with the data, I have to do some extra work, though. – Trí Phan Feb 20 '23 at 02:52
  • When you say you're using "that solution" do you mean [something like this](https://tsplay.dev/we68VN)? Or do you mean something else? I'm happy to write up this technique as an answer, but only if it meets your needs. – jcalz Feb 20 '23 at 03:44
  • Oh, yes, that link has the answer I was looking for. I thought you said something about using `Record` with a union of names as keys. – Trí Phan Feb 20 '23 at 10:15

1 Answers1

3

One approach is to make an error type AllowedErrors which is a union of all the allowable types, each member of which has exactly one of the acceptable keys as required, and prohibits defined values for the other keys. It would look like this:

type AllowedErrors = 
  { server: string; client?: never; x?: never; y?: never; z?: never; } | 
  { client: string; server?: never; x?: never; y?: never; z?: never; } | 
  { x: string; server?: never; client?: never; y?: never; z?: never; } | 
  { y: string; server?: never; client?: never; x?: never; z?: never; } | 
  { z: string; server?: never; client?: never; x?: never; y?: never; };

Note that TypeScript doesn't explicitly support prohibiting a property key, but you can make it an optional property whose value type is the impossible never type, then the only value you might find at that key would be undefined.

This should behave how you want:

const serverErr: AllowedErrors = {
    server: "Error!"
};

const clientErr: AllowedErrors = {
    client: "Error!"
};

const bothErr: AllowedErrors = {
    server: "Error",
    client: "Error"
} // error! Type '{ server: string; client: string; }' 
// is not assignable to type 'AllowedErrors'

const neitherErr: AllowedErrors = {
    oops: "Error"
} // Type '{ oops: string; }' is not assignable 
// to type 'AllowedErrors'.

const okayErr = Math.random() < 0.5 ? { x: "" } : { y: "" };

So, that's great.


But obviously you don't want to have to write or modify your AllowedErrors type manually. You want to generate it from a union of the keys like

type AllowedErrorKeys = "server" | "client" | "x" | "y" | "z";

so that you just need to add/remove things there. Well, here's one approach:

type SingleKeyValue<K extends PropertyKey, V, KK extends PropertyKey = K> =
    K extends PropertyKey ?
    Record<K, V> & { [P in Exclude<KK, K>]?: never } extends infer O ?
    { [P in keyof O]: O[P] } : never : never;    

The type SingleKeyValue<K, V> produces the relevant union where each member has only one allowed key from K with value type V and the remainder of the keys from K are prohibited.

First, the type is wrapped with the seemingly no-op K extends PropertyKey ? ... : never, which actually a distributive conditional type that splits K up into its individual union members, operates on each member, and then unites the results back into another union.

The inner type is essentially Record<K, V> & { [P in Exclude<KK, K>]?: never }; the first piece using the Record<K, V> utility type to represent an object with the one key from K and the value type V, which is intersected with a type prohibiting all keys from Exclude<KK, K> using the Exclude<T, U> utility type. Wait, what's KK? That's a "dummy" type parameter I declared in the outer scope which defaults to K. This is really just a trick so that K is broken into its union pieces while KK is a copy of the original union K, so that we can express "all the members of the original union except htis one".

Anyway that's the type we need except it's pretty ugly (an intersection of utility types), so I use a technique described at How can I see the full expanded contract of a Typescript type?, ... extends infer O ? { [P in keyof O]: O[P] } : never, to copy the type to another type parameter and iterate its properties with a mapped type to get a single object type.


And now we can just write

type AllowedErrors = SingleKeyValue<AllowedErrorKeys, string>;

and use IntelliSense to verify that it evaluates to

/* type AllowedErrors = 
  { server: string; client?: never; x?: never; y?: never; z?: never; } | 
  { client: string; server?: never; x?: never; y?: never; z?: never; } | 
  { x: string; server?: never; client?: never; y?: never; z?: never; } | 
  { y: string; server?: never; client?: never; x?: never; z?: never; } | 
  { z: string; server?: never; client?: never; x?: never; y?: never; }*/

as desired.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Oh these horrible tricks are why I hate TypeScript. +1 for explaining them! – Bergi Feb 20 '23 at 16:30
  • Wow, thank you for your thorough response. The technique used to accomplish this is quite complicated. And now I see how the `never` type is used. – Trí Phan Feb 21 '23 at 00:37