如何将对象转换为有序的可区分联合集

How to Transform a Object into an Ordered Set of Discriminated Unions

给定以下类型定义

type MailStatus = {
    InvoiceSent?: Date;
    ReminderSent?: { 
        date: Date;
        recipient: string;
    }
    FinalReminderSent?: {
        date: Date;
        recipient: string;
        text: string;
    }
}

我想要一个类型,我可以在其中定义需要 属性 的“顺序”,并创建一个具有更多必需属性的可区分联合。

例如

type OrderedMailStatus = MagicType<MailStatus, "InvoiceSent" | "ReminderSent" | "FinalReminderSent">
//or this
type OrderedMailStatus = MagicType<MailStatus, ["InvoiceSent", "ReminderSent","FinalReminderSent"]>

应该产生以下类型

type OrderedMailStatus =
| {
    kind: "InvoiceSentRequired";
    InvoiceSent: Date;          //InvoiceSent now required
    ReminderSent?: { 
        date: Date;
        recipient: string;
    };
    FinalReminderSent?: {
        date: Date;
        recipient: string;
        text: string;
    };
  }
| {
    kind: "ReminderSentRequired";
    InvoiceSent: Date;          //InvoiceSent required
    ReminderSent: {             //ReminderSent also required
        date: Date;
        recipient: string;
    };
    FinalReminderSent?: {
        date: Date;
        recipient: string;
        text: string;
    };
  }
| {
    kind: "FinalReminderSentRequired";
    InvoiceSent: Date;          //all
    ReminderSent: {             //3 properties
        date: Date;
        recipient: string;
    };
    FinalReminderSent: {       //are required
        date: Date;
        recipient: string;
        text: string;
    };
  }

这样我就可以完成以下作业

const s1 = {
    kind: "InvoiceSentRequired",
    InvoiceSent: new Date()
} //OK

const s2 = {
    kind: "ReminderSentRequired",
    InvoiceSent: new Date(),
    ReminderSent: {
        date: new Date(),
        recipient: "Somebody@somewhere.com"
    }
} //OK

const s3 = {
    kind: "FinalReminderSentRequired",
    ReminderSent: {
        date: new Date(),
        recipient: "Somebody@somewhere.com"
    },
    FinalReminderSent: {
        date: new Date(),
        recipient: "Somebody@somewhere.com",
        text: "YOU HAVE TO PAY!"
    }

} //FAILS because it is missing the property InvoiceSent

同样重要:属性的类型应自动采用它们在原始 MailStatus 中的类型。因此,即使在这个扩​​展示例中,您也不能做出任何假设 属性 具有哪种类型。

这个问题背后的主要思想与 Workflow 类似。一开始你有一个类型,它的属性都是可选的。随着这种类型在系统中传播,越来越多的属性成为强制性的

首先,值得创建一个具有预期键顺序的元组:

type Keys = ["InvoiceSent", "ReminderSent", "FinalReminderSent"]

现在我们需要创建一个实用程序类型,它将遍历 Keys 元组并创建预期的并集。

type Union<
    Tuple extends any[],
    Result extends {} = never
    > =
    // Obtain first element from the Tuple
    (Tuple extends [infer Head, ...infer Rest]
        // Check whether this element extends allowed keys
        ? (Head extends keyof MailStatus
            // call Union recursively with Rest
            // and unionize previous Result with newly created discriminated union
            ? Union<Rest, Result | MailStatus & Record<Head, Date> & { kind: `${Head}Required` }>
            : never)
        : Result)

游乐场

让我们测试一下:

type Result = Union<Keys>

// ok
const result: Result = {
    kind: 'InvoiceSentRequired',
    InvoiceSent: new Date()
}

// expected error
const result2: Result = {
    kind: 'InvoiceSentRequired',
}

// ok
const result3: Result = {
    kind: 'FinalReminderSentRequired',
    FinalReminderSent: new Date
}

看起来有效

P.S。如果您对某些特定订单感兴趣,请不要依赖 keyof 运算符。参见 this issue and my question

这是我对这个问题的解决方案:


type Id<T> = {} & { [P in keyof T]: T[P] }

type PickIfNotPrimitive<T, K extends keyof T, V = T[K]> = 
    V extends Date | string | number | bigint | boolean 
        ? Record<K, V>
        : Pick<T, K>
        
type Accumulate<T, Keys extends string[], B = {}, R = never> =
    Keys extends [infer Head, ...infer Tail] ? 
        Tail extends string[]
            ? Accumulate<
                T, 
                Tail,
                Required<PickIfNotPrimitive<T, Head & keyof T>> & B, 
                // New result
                    | R & Partial<PickIfNotPrimitive<T, Head & keyof T>> // Add new partial values
                    | Required<PickIfNotPrimitive<T, Head & keyof T>> & B & { type: `${Head & string}Required` }>
            : never
        :Id<R>

type X =  Accumulate<MailStatus, [
    "InvoiceSent", 
    "ReminderSent",
    "FinalReminderSent"
]>

Playground Link

我们在R中将结果一一累积。 B表示已经必填的字段。

我使用 Id 只是为了修饰类型。可以删除,但没有它结果不可读。

不确定我会推荐实际使用它,但它很有趣