I have the following two types
interface ChangeAction{
type: 'CHANGE'
payload: string
}
interface DeleteAction {
type: 'DELETE'
payload: number
}
Then i need prefix each Type of type
key with something like ON
, I do following
type MakePrefix<T extends {type: string}, P extends string> = Omit<T, 'type'> & {
type: `${P}${T['type']}`
}
Most of the time it works fine
Bug when use union type, looks like some types are missing
var changeAction: MakePrefix<ChangeAction, 'ON_'> = {
type: 'ON_CHANGE',
payload: '12'
}
type OperateAction = MakePrefix<ChangeAction | DeleteAction, 'ON_'>
var operateAction: OperateAction = {
type: 'ON_DELETE',
payload: "123" // string | number ???
}
I don't know if this is a bug or my Type-Operator is written incorrectly
CodePudding user response:
Here's a mapped type that you can reuse for prefixing the type
prop (and other props):
type Prefix<Pre extends string, Str extends string> = `${Pre}${Str}`;
type PrefixStringProp<Pre extends string, Prop extends string, T extends Record<Prop, string>> = {
[K in keyof T]: K extends Prop ? Prefix<Pre, T[K]> : T[K];
};
interface ChangeAction {
type: 'CHANGE';
payload: string;
}
interface DeleteAction {
type: 'DELETE';
payload: number;
}
const changeAction: PrefixStringProp<'ON_', 'type', ChangeAction> = {
type: 'ON_CHANGE',
payload: '12',
}; // ok
type OperateAction = PrefixStringProp<'ON_', 'type', ChangeAction | DeleteAction>
let operateAction: OperateAction;
operateAction = { /*
~~~~~~~~~~~~~
Type '{ type: "ON_DELETE"; payload: string; }' is not assignable to type 'OperateAction'.
Types of property 'payload' are incompatible.
Type 'string' is not assignable to type 'number'.(2322) */
type: 'ON_DELETE',
payload: '123',
};
operateAction = {
type: 'ON_DELETE',
payload: 123,
}; // ok
CodePudding user response:
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.