return type for pattern matching function in typescript
The way I see it there are two approaches that you can take with this.
1. The input type is known beforehand
If you want to enforce that the initialisation of the final function takes a particular type then that type must be known beforehand:
// Other types omitted for clarity:
const match = <T>(tag) => (transforms) => (source) => ...
In this example you specify T
at the time of the first call, with the following type constraints as a consequence:
tag
must be a key ofT
transforms
must be an object with keys for all values ofT[typeof tag]
source
must be of typeT
In other words, the type that substitutes T
determines the values that tag
, transforms
and source
can have. This seems the most straightforward and understandable to me, and I'm going to try to give an example implementation for this. But before I do, there's also approach 2:
2. the input type is inferred from the last call
If you want to have more flexibility in the type for source
based on the values for tag
and transforms
, then the type can be given at, or inferred from, the last call:
const match = (tag) => (transforms) => <T>(source) => ...
In this example T
is instantiated at the time of the last call, with the following type constraints as a consequence:
source
must have a keytag
typeof source[tag]
must be a union of at most alltransforms
keys, i.e.keyof typeof transforms
. In other words,(typeof source[tag]) extends (keyof typeof transforms)
must always be true for a givensource
.
This way, you are not constrained to a specific substitution of T
, but T
might ultimately be any type that satisfies the above constraints. A major downside to this approach is that there will be little type checking for the transforms
, since that can have any shape. Compatibility between tag
, transforms
and source
can only be checked after the last call, which makes things a lot harder to understand, and any type-checking errors will probably be rather cryptic. Therefore I'm going for the first approach below (also, this one is pretty tough to wrap my head around ;)
Because we specify the type in advance, that's going to be a type slot in the first function. For compatibility with the further parts of the function it must extend Record<string, any>
:
const match = <T extends Record<string, any>>(tag: keyof T) => ...
The way we would call this for your example is:
const result = match<WatcherEvent>('code') (...) (...)
We are going to need the type of
tag
for further building the function, but to parameterise that, e.g. withK
would result in an awkward API where you have to write the key literally twice:const match = <T extends Record<string, any>, K extends keyof T>(tag: K) const result = match<WatcherEvent, 'code'>('code') (...) (...)
So instead I'm going for a compromise where I'll write
typeof tag
instead ofK
further down the line.
Next up is the function that takes the transforms
, let's use the type parameter U
to hold its type:
const match = <T extends Record<string, any>>(tag: keyof T) => (
<U extends ?>(transforms: U) => ...
)
The type constraint for U
is where it gets tricky. So U
must be an object with one key for each value of T[typeof tag]
, each key holding a function that transforms a WatcherEvent
to anything you like (any
). But not just any WatcherEvent
, specifically the one that has the respective key as its value for code
. To type this we'll need a helper type that narrows down the WatcherEvent
union to one single member. Generalising this behaviour I came up with the following:
// If T extends an object of shape { K: V }, add it to the output:
type Matching<T, K extends keyof T, V> = T extends Record<K, V> ? T : never
// So that Matching<WatcherEvent, 'code', 'ERROR'> == { code: "ERROR"; error: Error }
With this helper we can write the second function, and fill in the type constraint for U
as follows:
const match = <T extends Record<string, any>>(tag: keyof T) => (
<U extends { [V in T[typeof tag]]: (input: Matching<T, typeof tag, V>) => any }>(transforms: U) => ...
)
This constraint will make sure that all function input signatures in transforms
fit the inferred member of the T
union (or WatcherEvent
in your example).
Note that the return type
any
here does not loosen the return type ultimately (because we can infer that later on). It simply means that you're free to return anything you want from functions intransforms
.
Now we've come to the last function -- the one that takes the final source
, and its input signature is pretty straightforward; S
must extend T
, where T
was WatcherEvent
in your example, and S
is going to be the exact const
shape of the given object. The return type uses the ReturnType
helper of typescript's standard library to infer the return type of the matching function. The actual function implementation is equivalent to your own example:
const match = <T extends Record<string, any>>(tag: keyof T) => (
<U extends { [V in T[typeof tag]]: (input: Matching<T, typeof tag, V>) => any }>(transforms: U) => (
<S extends T>(source: S): ReturnType<U[S[typeof tag]]> => (
transforms[source[tag]](source)
)
)
)
That should be it! Now we could call match (...) (...)
to obtain a function f
that we can test against different inputs:
// Disobeying some common style rules for clarity here ;)
const f = match<WatcherEvent>("code") ({
START : () => ({ type: "START" }),
ERROR : ({ error }) => ({ type: "ERROR", error }),
BUNDLE_END : ({ duration, result }) => ({ type: "UPDATE", duration, result }),
})
And giving this a try with the different WatcherEvent
members gives the following result:
const x = f({ code: 'START' }) // { type: string; }
const y = f({ code: 'BUNDLE_END', duration: 100, result: 'good' }) // { type: string; duration: number; result: "good" | "bad"; }
const z = f({ code: "ERROR", error: new Error("foo") }) // { type: string; error: Error; }
Note that when you give f
a WatcherEvent
(union type) instead of a literal value, the returned type will also be the union of all return values in transforms, which seems like the proper behaviour to me:
const input: WatcherEvent = { code: 'START' }
const output = f(input)
// typeof output == { type: string; }
// | { type: string; duration: number; result: "good" | "bad"; }
// | { type: string; error: Error; }
Lastly, if you need specific string literals in the return types instead of the generic string
type, you can do that by just altering the functions that you define as transforms
. For example you could define an additional union type, or use 'as const
' annotations in the function implementations.
Here's a TSPlayground link, I hope this is what you're looking for!