TypeScript
Time to meet your match
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.
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); } }