Home > database >  Some type operations confuse me
Some type operations confuse me

Time:03-03

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):

TS Playground

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 Omitting 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

  • Related