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