TypeScript

Time to meet your match

Materials for a Leisure Hour
Image by William Harnett, Public domain, via Wikimedia Commons

Adding the missing utility, the inline switch.

I like switch. It's a great statement. It makes it easy and readable to disambiguate values. Comparatively to an if-statement we can create scenarios where we are required to cover all cases and that no code is executed between different cases.

type T = { type: "a", a: string } | { type: "b", b: string }

const x = { type: "a", a: string } as T

switch (x.type) {
  case "a":
    console.log(x.a);
    break;
  case "b":
    console.log(x.b);
    break;
}

In this example we're able to access the properties a and b in the different cases. And this works good for this imperative approach, however if we wanted to get the value from either a or b then we'd either have to use a variable created using let no, fuck you var that we mutate, a switch using the same approach or break this out into a function that returns the value.

// With if
let res: string;
if (x.type === "a") res = x.a;
if (x.type === "b") res = x.b;

// With switch
let res: string;
switch (x.type) {
  case "a": 
    res = x.a;
    break;
  case "b": 
    res = x.b;
    break;
}

// With function
const f = (v: T) => {
  switch (v.type) {
    case "a": return v.a;
    case "b": return v.b;
  }
}

const res = f(x);

The function approach is definitely more ideal as it lets us keep the semantics of const. However, I would have liked to be able to do this inline.

// FAKE PSEUDO CODE
const y = switch (x.type) {
  case "a": x.a;
  case "b": x.b;
};

But that's just not reality. So let's get into the nitty gritty and try to make a function that does what I want it to do. Now, if we were only dealing JavaScript we could probably be able to get away with a simple object lookup. It should be clear with some intuition that by passing in the value as the property value, and having the property values as keys we'll get the correct value in the end.

const y = {
  a: x.a,
  b: x.b
}[x.type]

This isn't exactly like a switch, because the values are eagerly computed which isn't the case in a switch statement. This could be a definitive problem if we had for example, the path x.b.c and b would be undefined in one branch. Then we'd try to do lookup on undefined which would result in an error. If we had something that took a lot of time to execute then it'd also be an issue.

So the least we can do is lazily execute the branches.

const y = {
  a: () => x.a,
  b: () => x.b
}[x.type]()

But this is a mess. It's rather verbose. It's not immediately clear what is being switched over because the argument is passed last. There's a necessary function call at the end. And given that this is something we want to re-use over and over then it's important that this is readable and ergonomic.

Let's break it out into its own function. I'm going to call this match because switch is already a reserved keyword. The interface we're going to have is passing the object, then the property name, then the cases that we want to match on.

function match([object, property], cases) {
  const value = object[property];
  const f = cases[value];
  return f();
}


// And used like this
match([x, "type"], {
  a: () => x.a,
  b: () => x.b
})

Now this is pretty good! This reads pretty well and is pretty ergonomic to use. However, there are no types. Absolute bleh. Nothing's stopping us from flipping the x.a and x.b and then having a runtime error. If you've come this far, I'm gonna give you the end result á la "Draw an owl"-style.

2022-06-18T13:39:28.068Z

There are some updates that we have to do to make this work. We'll be passing the object back again as the first argument in the functions that are defined in the cases. The reason we want to do this is that we want to narrow the type to the matching part in the union, and this is only an assumption we can do when we run the actual case function.

These types aren't perfect, at least internally. The interface works, but you will see that we have to make a concession when it comes to passing back the object to the function. We'll have to type it any as we're using the object lookup as a way of validating the property and that is not a proper way of narrowing down unions in TypeScript.

type CaseKey = string | number;

type Literal<V extends CaseKey> =  `${V}` extends `${infer X}` ?
  string extends X ? never: `${number}` extends X ? never : V : never

export function match<
  Union extends { [key in Property]: string | number },
  Property extends keyof Union,
  Cases extends {
    [Key in Union[Property]]: (
      value: Extract<Union, { [_ in Property]: Key }>
    ) => Value;
  },
  Value
>(
  [o, p]: [Union, Literal<Union[Property]> extends never ? never : Property],
  cases: Extract<keyof Cases, Union[Property]> extends never ? Cases : never
): Value {
  const value = o[p];
  const f = cases[value];
  return f(o as any); // The one concession we have to make
}

Then we can use it as such.

match([x, "type"], {
  a: ({ a }) => a,
  b: ({ b }) => b
})

This TypeScript setup lets us leverage similar features to a switch. If we don't cover all the cases we get an error. If we reference a property that doesn't exist we get an error. We can now narrow down the type in the function cases and that's why we're able to deconstruct the first argument in the different cases without a type error.

The only thing that we are not covering with this is a default case. In fact this setup blocks the use of too generic properties such as type string or number, as that's basically an infinite amount of cases. I've made variants where there is an extra default case that you can cover anything that you don't define in, but this complicates the types quite a bit. This is optimized for the most common case where you want to cover all the cases.

This can certainly be improved further! Or altered, if you have a different use-case. For example, it's also useful to match on a primitive literal type and not only object properties. I've posted a more complete version below which has overloads for these two use-cases. You can play around with the same code on the TypeScript playground here

type CaseKey = string | number;

type Literal<V extends CaseKey> =  `${V}` extends `${infer X}` ?
  string extends X ? never: `${number}` extends X ? never : V : never

/**
 * Split on a specific value and narrow the value
 * @param p The value to split on
 * @param cases The matching cases
 *
 * @example
 * ```tsx
 * type T = 'a' | 'b'
 * const x = 'a' as T
 * match(x, {
 *   a: () => "Hello A",
 *   b: () => "Hello B"
 * })
 * ```
 */
export function match<
  Property extends CaseKey,
  Cases extends {
    [Key in Property]: (value: Key) => Value;
  } & Record<never, never>,
  Value
>(
  p: Literal<Property>,
  cases: Exclude<keyof Cases, Property> extends never ? Cases : never
): unknown extends Value ? ReturnType<Cases[keyof Cases]> : Value;

/**
 * Split object on a specific property and narrow the value
 * @param p The value to split on
 * @param cases The matching cases
 *
 * @example
 * ```tsx
 * type T = { key: 'a', hello: 'world' } | { key: 'b', world: 'hello' }
 * const x = { key: 'a', hello: 'world' } as T
 * match([x, 'key'], {
 *   a: ({ hello }) => hello,
 *   b: ({ world }) => world
 * })
 * ```
 */
export function match<
  Union extends { [key in Property]: CaseKey },
  Property extends keyof Union,
  Cases extends {
    [Key in Union[Property]]: (
      value: Extract<Union, { [_ in Property]: Key }>
    ) => Value;
  },
  Value
>(
  [o, p]: [Union, Literal<Union[Property]> extends never ? never : Property],
  cases: Exclude<keyof Cases, Union[Property]> extends never ? Cases : never
): unknown extends Value ? ReturnType<Cases[keyof Cases]> : Value;

export function match(
  keys: [Record<string, unknown>, string] | string,
  cases: Record<string, (value: unknown) => unknown>
): unknown {
  if (typeof keys === "string" || typeof keys === "number") {
    const f = cases[keys];
    if (f === undefined) {
      throw new Error(`Missing case '${keys}' in match`);
    }
    return f(keys);
  } else {
    const [o, p] = keys;
    const value = o[p];
    const f = cases[value as CaseKey];
    if (f === undefined) {
      throw new Error(`Missing case '${value}' in match`);
    }
    return f(o);
  }
}