1

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

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

robkuz
  • 9,488
  • 5
  • 29
  • 50
  • 2
    Fields and `keyof MailStatus` are not ordered (or at least the order is not defined). If you are willing to put the fields in a tuple, we can derive all of the other types from the tuple. – Titian Cernicova-Dragomir Jan 19 '22 at 12:10
  • @TitianCernicova-Dragomir Tuples will do fine – robkuz Jan 19 '22 at 12:19
  • Neither `keyof SomeType` nor `Object.keys()` nor even js specification gives you a guarantee of `keys` order in a hash map data structure. AFAIK, only `Map.keys()` [gives you](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/keys) a collection of keys in order of insertion – captain-yossarian from Ukraine Jan 19 '22 at 12:21
  • 2
    @captain-yossarian - The JavaScript specification defines an order for object properties, it has done for several years. It's not a good idea to *use* it for anything, but it is defined. :-) (And yes, the various `Map` iteration methods -- `keys`, `entries`, `values` -- iterate in insertion order. [For objects, it's more complicated than that -- one reason for not using it. :-) ]) – T.J. Crowder Jan 19 '22 at 12:29
  • @robkuz Let me know if [this](https://tsplay.dev/Nlx3ON) works – captain-yossarian from Ukraine Jan 19 '22 at 12:32
  • @T.J.Crowder wooow, thank you for enlightening me. Could you please give a link? – captain-yossarian from Ukraine Jan 19 '22 at 12:33
  • 1
    @captain-yossarian - https://stackoverflow.com/questions/30076219/ ES2015 started the process but didn't define the order for `for-in` or `Object.keys` (which is kind of a problem); subsequent specs defined order for "own" properties and as of ES2020 or ES2021 (I forget which), there's order even for inherited ones. Spec links: [OrdinaryOwnKeys](https://tc39.es/ecma262/#sec-ordinaryownpropertykeys) and [EnumerateObjectProperties](https://tc39.es/ecma262/#sec-enumerate-object-properties). But the rules are complex. The old guidance not to rely on it is arguably just as solid now as ever. :-) – T.J. Crowder Jan 19 '22 at 12:43
  • @T.J.Crowder thanks you very much it is good to know. SO in theory - there is order but in practice it is safer to not rely on it, tight ? – captain-yossarian from Ukraine Jan 19 '22 at 12:46
  • 1
    @captain-yossarian - Yeah, b/c it's so complex and easy to get wrong. (I think all modern engines implement it correctly.) Ex: the objects created by the literals `{x: 1, y: 2}` and `{y: 2, x: 1}` have different orders (`["x", "y"]` and `["y", "x"]`), but `{1: "a", x: 1, 2: "b"}`, `{x: 1, 1: "a", 2: "b"}`, and `{x: 1, 2: "b", 1: "a"}` all have the same order (`["1", "2", "x"]`). But `{5.1: "a", 5.2: "b"}` and `{5.2: "b", 5.1: "a"}` have different orders! (B/c those numbers aren't [array indexes](https://tc39.es/ecma262/#array-index).) Then if you get into inherited properties... :-D – T.J. Crowder Jan 19 '22 at 13:07
  • @robkuz I think my solution works with the new requirements. Just put the appropriate type instead of day in the tuple – Titian Cernicova-Dragomir Jan 19 '22 at 13:40

2 Answers2

2

First of all it worth creating a tuple with expected order of keys:

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

Now we need to create an utility type which will iterate through Keys tuple and create expected union.

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)

Playground

Lets test it:

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
}

Looks like it works

P.S. Don't rely on keyof operator if you are interested in some particular order. See this issue and my question

  • 1
    You beat me to it :). had a similar solution: https://www.typescriptlang.org/play?#code/C4TwDgpgBAsghgSwDYGVh2AVwM4DEERIAm2UAvFANoBQUdUA3lAJIB2AbgPYIDGEKEVsABcUACIZoAXwA0tekwBKEALYJWRCACcBQ0RODS59RlHys4SZWo3bdI8ZKizqAXWqhIUAKqsEnVgAVTjZDLWwIHmB-VgAebwA+cigACm8oCAAPQw1SOFYQKAB+VIBrUW8ASjIErgQiKFFWCHZtSozswRJUlPKodQAzbRZq2u4idpLmRqhm1q0PcGhmIljApIoGKSgAMlNKAAV+1ihSiBBOAahA11FAw9cpRa8AQR4eTBVMJEk1jpzuphWKVWJwAO6sSiuGRQABCyS2G3kdEC-y6pEog2GAAkIHAiDCAHTErFaa6IJCuYpQZEmKBvD5fH6GNYUmG4-G7KDKACOmAQWggq1hST2TE8EFEZwuVw5DW2AB8oAc4FpopZYnLRXCoElhLSTLDqM9oAANZIsVYMz7fX7wZBoDA4fCEEgJBJAA – Titian Cernicova-Dragomir Jan 19 '22 at 12:42
  • 1
    @TitianCernicova-Dragomir Finally :D, I have a hard time when you are online :D – captain-yossarian from Ukraine Jan 19 '22 at 12:44
  • @TitianCernicova-Dragomir feel free to publish your solution, my solution does not work as expected – captain-yossarian from Ukraine Jan 19 '22 at 13:03
  • Why doesn't it work ? – Titian Cernicova-Dragomir Jan 19 '22 at 13:10
  • @TitianCernicova-Dragomir I did not test it properly, so required property don\t stack – captain-yossarian from Ukraine Jan 19 '22 at 13:14
  • I am sorry Sirs! The solution is not correct and additionally my example was not well choosen. I will update the question – robkuz Jan 19 '22 at 13:24
2

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

robkuz
  • 9,488
  • 5
  • 29
  • 50
Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • Yeah, this works. But it is somehow ugly because now one has to define "the type" as a tuple of partial types. So the question is: can one transform a `record|object` type into a tuple of partial types. But that is maybe a question of its own. Btw. It is always fun with you guys. totally mind melding – robkuz Jan 19 '22 at 13:45
  • @robkuz See if this is better :) – Titian Cernicova-Dragomir Jan 19 '22 at 14:05
  • the `Id` type is missing – robkuz Jan 19 '22 at 14:16