Infer specific type of a union based on the discriminator value

Viewed 117

I've been using tagged unions (also known as "algebraic datatypes") in my TypeScript code, and I'm very happy with the code this pattern produces.

However, I've been running into situations like this following example repeatedly:

// 1. Define a tagged union (example taken from Scott Wlaschin, https://www.youtube.com/watch?v=2JB1_e5wZmU)
type PaymentMethod =
  | { method: "cash" }
  | { method: "check", checkNumber: CheckNumber }
  | { method: "credit", cardType: CreditCardType, cardNumber: CreditCardNumber }

// 2. To evaluate a PaymentMethod value, I need to use an immediately-invoked function wrapping a switch-statement, since switch is not an expression
const payment: PaymentMethod = /*-- snip --*/;
const description = (() => {
  switch (payment.method) {
    case 'cash': return `Paid with cash`;
    case 'check': return `Paid with check nr. ${payment.checkNumber}`;
    case 'credit': return `Paid with a ${payment.cardType} credit card, nr. ${payment.cardNumber}`;
  }
})();

Using this pattern, I can leverage TypeScript's type inference and automatically check for exhaustiveness of the union type PaymentMethod['method']. The disadvantage is the unwieldy syntax of this match.

For this purpose, I wanted to build a utility function that would look something like this:

// 1. See declaration above

// 2.1. Instantiate a matching function using `PaymentMethod['method']` as discriminator
const matchPaymentMethod = matchingWith<PaymentMethod, 'method'>('method');

// 2.2. Use match function to evaluate PaymentMethod value
const payment: PaymentMethod = /*-- snip --*/;
const description = matchPaymentMethod(payment, {
  cash: cash => `Paid with cash`,
  check: check => `Paid with check nr. ${check.checkNumber}`,
  credit: credit => `Paid with a ${credit.cardType} credit card, nr. ${credit.cardNumber}`,
});

This all works, except for one part - I can't figure out how to infer the actual type of the variant of the union based on the value of the tag.

type MatchingWith<T, K extends keyof T> =
  (tagKey: K)
  => <R>(value: T, matching: {[C in K]: (matched: /*??? Type of T with tag value C ???*/) => R})
  => R;

const matchingWith: MatchingWith<T> = /*-- snip --*/;

Is it somehow possible to infer the type of T with a specific tag value? I have been looking for some construct like T where T[K] is C, but have not been able to find such a construct.


Edit: Here's a Playground Link to show the example live.

1 Answers

Thanks to @spender for the "magic sauce"!

The intersection of a tagged union and it's tag value actually does infer the remaining keys for the given tag, so that can be used to implement the matching function.

Here's my implementation based on spender's solution, with the remaining squiggles sorted out:

// -- Declaration --

// 0. Define the type and implementation of the `matchingWith` function
type MatchingWith =
    <T extends { [_ in K]: string | number | symbol }, K extends keyof T = keyof T>(tagKey: K) =>
        <R, >(matching: { [M in T[K]]: (matched: T & { [_ in K]: M }) => R }) =>
            (value: T) =>
                R;

const matchingWith: MatchingWith = tagKey => matching => value => matching[value[tagKey]](value);
// -- Example --

// 1. Define a tagged union (example taken from Scott Wlaschin, https://www.youtube.com/watch?v=2JB1_e5wZmU)
type CheckNumber = string;
type CreditCardType = "ec" | "visa" | "mastercard";
type CreditCardNumber = string;
type PaymentMethod =
    | { method: "cash" }
    | { method: "check", checkNumber: CheckNumber }
    | { method: "credit", cardType: CreditCardType, cardNumber: CreditCardNumber }

// 2. Instantiate a function creating PaymentMethod matches
const matchPaymentMethod = matchingWith<PaymentMethod>('method');

// 3. Instantiate a function to describe PaymentMethod values
const describePaymentMethod = matchPaymentMethod({
    cash: cash => `Paid in cash`,
    check: check => `Paid with check nr. ${check.checkNumber}`,
    credit: credit => `Paid with ${credit.cardType} card nr. ${credit.cardNumber}`,
});

// 4. Create an example PaymentMethod value and evaluate it using `describePaymentMethod` 
const exampleMethod: PaymentMethod = {method: 'credit', cardType: 'ec', cardNumber: '123456789'};
const exampleDescription = describePaymentMethod(exampleMethod);
console.log(`exampleDescription = ${exampleDescription}`);

I also used a Haskell-like syntax to declare the function signature type before the function, and made it fully-curried for maximum functional usage.


Here's a Playground Link to the implementation, thank you for your help and have fun with this utility function.

Related