The issue you are encountering with
type MakePrefix<T extends { type: string }, P extends string> =
Omit<T, 'type'> & { type: `${P}${T['type']}` }
lies in the fact that you anticipate it to distribute over unions within T
, but it doesn't. You desire for MakePrefix<A | B | C, P>
to be the same as
MakePrefix<A, P> | MakePrefix<B, P> | MakePrefix<C, P>
. While this seems like a valid expectation, certain type operations in TypeScript do not distribute this way. The
Omit<T, K>
utility type does not distribute across unions due to intentional design choices by Microsoft (as documented in
microsoft/TypeScript#46361). Even if it were distributive, an
intersection like
F<T> & G<T>
will not distribute over unions in
T</code, leading to unexpected outcomes. So, unfortunately, <code>MakePrefix<T, P>
does not behave in the intended manner. Your
OperateAction
is equivalent to:
type OperateAction = MakePrefix<ChangeAction | DeleteAction, 'ON_'>
/* type OperateAction = {
payload: string | number;
type: "ON_CHANGE" | "ON_DELETE";
} */
And it permits the unwanted "cross-terms" such as:
var operateAction: OperateAction;
operateAction = { type: 'ON_DELETE', payload: 123 }; // okay
operateAction = { type: 'ON_CHANGE', payload: "abc" }; // okay
operateAction = { type: 'ON_DELETE', payload: "xyz" }; // okay?!
Fortunately, you can easily convert non-distributive type functions into distributive ones. A type function of the structure
type F<T> = T extends U ? G<T> : H<T>
represents a
distributive conditional type, which automatically distributes over unions in
T</code. Therefore, if you have a non-distributive type <code>type NonDistrib<T> = X<T>
, you can create a distributive version by enclosing the definition in
T extends any ? ... : never
(or
unknown
or
T
in place of
any
), like so:
type Distrib<T> = T extends any ? X<T> : never
. Let's give it a shot:
type MakePrefix<T extends { type: string }, P extends string> =
T extends unknown ? (
Omit<T, 'type'> & { type: `${P}${T['type']}` }
) : never;
type OperateAction = MakePrefix<ChangeAction | DeleteAction, 'ON_'>;
/* type OperateAction = {
payload: string;
type: "ON_CHANGE";
} | {
payload: number;
type: "ON_DELETE";
} */
var operateAction: OperateAction;
operateAction = { type: 'ON_DELETE', payload: 123 }; // okay
operateAction = { type: 'ON_CHANGE', payload: "abc" }; // okay
operateAction = { type: 'ON_DELETE', payload: "xyz" }; // error, as desired
Looks promising. The revised OperateAction
type aligns with
MakePrefix<ChangeAction, 'ON_'> | MakePrefix<DeleteAction, 'ON_'>
as intended.
That addresses the query at hand. In this specific scenario, I would consider simplifying into a naturally distributive form using the homomorphic mapped type instead of employing Omit
and intersecting properties back. The homomorphic mapped type involves defining types using {[K in keyof XXX]: YYY}
with in keyof
, resulting in better clarity and ease of comprehension.