The problem you are having with
type MakePrefix<T extends { type: string }, P extends string> =
Omit<T, 'type'> & { type: `${P}${T['type']}` }
is you expect it to distribute over unions in T
, but it doesn't. What I mean is that you want MakePrefix<A | B | C, P>
to be equivalent to MakePrefix<A, P> | MakePrefix<B, P> | MakePrefix<C, P>
. That's a reasonable thing to want, and some type operations in TypeScript do distribute this way. But not all of them do. The Omit<T, K>
utility type does not distribute across unions (this is working as intended as per microsoft/TypeScript#46361, although it tends to surprise people). Even if it were, an intersection of the form F<T> & G<T>
will not be distributive over unions in T
even if F<T>
and G<T>
are (F<A | B> & G<A | B>
will contain unexpected "cross-terms" like F<A> & G<B>
). So MakePrefix<T, P>
just doesn't distribute the way you want. Your OperateAction
is equivalent to:
type OperateAction = MakePrefix<ChangeAction | DeleteAction, 'ON_'>
/* type OperateAction = {
payload: string | number;
type: "ON_CHANGE" | "ON_DELETE";
} */
And it allows those "cross-terms" you don't like:
var operateAction: OperateAction;
operateAction = { type: 'ON_DELETE', payload: 123 }; // okay
operateAction = { type: 'ON_CHANGE', payload: "abc" }; // okay
operateAction = { type: 'ON_DELETE', payload: "xyz" }; // okay?!
Luckily, there is an easy fix for turning non-distributive type functions into distributive ones. A type function of the form type F<T> = T extends U ? G<T> : H<T>
is a distributive conditional type, and will automatically distribute over unions in T
. So if you have a non-distributive type type NonDistrib<T> = X<T>
you can make a distribute version by wrapping the definition in T extends any ? ... : never
(or unknown
or T
instead of any
), like type Distrib<T> = T extends any ? X<T> : never
. Let's try it:
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 good. The new OperateAction
type is equivalent to MakePrefix<ChangeAction, 'ON_'> | MakePrefix<DeleteAction, 'ON_'>
as desired.
That's the answer to the question as asked, although in the particular example here I'd be inclined to refactor to a simpler form which is naturally distributive in unions: the homomorphic mapped type (The docs for homomorphic mapped types seem to be deprecated now, unfortunately, but you can look at this SO question and its answer for more details. Roughly, a type of the form {[K in keyof XXX]: YYY}
with in keyof
is a homomorphic mapped type):
type MakePrefix<T extends { type: string }, P extends string> = {
[K in keyof T]: K extends "type" ? `${P}${T['type']}` : T[K]
}
Instead of Omit
ting the type
property and then intersecting another type
property back in, you just map over all of the properties in T
, and only modify the value type of the type
property. This produces an equivalent OperateAction
and is arguably easier to understand.
Playground link to code