Search code examples
typescriptmapped-types

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


given the following type definition

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

I'd like to have type where I can define the "order" in which a property is required and which creates a discriminated union which have ever more required properties.

For example

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

should yield the following type

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;
    };
  }

so that I could do the following assignments

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

const s2 = {
    kind: "ReminderSentRequired",
    InvoiceSent: new Date(),
    ReminderSent: {
        date: new Date(),
        recipient: "[email protected]"
    }
} //OK

const s3 = {
    kind: "FinalReminderSentRequired",
    ReminderSent: {
        date: new Date(),
        recipient: "[email protected]"
    },
    FinalReminderSent: {
        date: new Date(),
        recipient: "[email protected]",
        text: "YOU HAVE TO PAY!"
    }

} //FAILS because it is missing the property InvoiceSent

Also important: The types of the properties should be automatically taken what ever they are in the original MailStatus. So even in this expanded example you can not make any assumptions which property has which type.

The principle idea behind this question is something along the lines of a Workflow. Where in the beginning you have a type whose properties are all optional. As this type travels across the system more and more properties become mandatory


Solution

  • Here's my solution to this problem:

    
    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

    We build up in R the result one by one. B represents the fields that are already required.

    I use Id just to pretty up the types. Can be removed, but the results are unreadable without it.

    Not sure I would recommend actually using this, but it is fun 😅