The focus here is solely on the typings for the callers of the match()
function, taking into account that the implementation may require type assertions or similar techniques to avoid compiler errors when dealing with complex generic call signatures.
One approach would be to create multiple versions of the match()
function - one to handle all possible cases exhaustively and another to cater to partial matches along with default actions defined by matchers.
For the partial matching scenario, it's essential to make match()
a generic function not only based on the union type T
for the variant argument but also considering the keys K
from the matchers object. To achieve precision in representing the argument types for all methods, it makes sense to introduce generics for both T
and K
within the Matchers
structure.
A potential definition for Matchers<T, K>
could look like this:
type Matchers<T extends Variant<string, any>, K extends PropertyKey> = {
[P in K]: (value: P extends typeof def ?
Exclude<T, { tag: Exclude<K, typeof def> }>["value"] :
Extract<T, { tag: P }>["value"]
) => any };
This setup essentially maps over each element P
in
K</code to generate callbacks returning values of <code>any
. Determining the type of the
value
parameter within the callback with key
P</code involves checking if <code>P
corresponds to the type of
def
, where the value represents variants not explicitly mentioned in
K</code, or if <code>P
is one of the tags from
T</code, in which case <code>value
corresponds to the specific variant value.
It's worth noting that if K
encompasses the entire union of T["tag"]
, then the [def]
callback will have a value
argument of type
never</code, which though peculiar, should not cause any issues. If desired, the type can be altered so that the complete property is of type <code>never</code rather than just the callback argument.</p>
<hr />
<p>The overloaded call signatures for <code>match()
are as follows:
declare function match<T extends Variant<string, any>, K extends T["tag"] | typeof def>(
variant: T, matchers: Matchers<T, K | typeof def>
): void;
declare function match<T extends Variant<string, any>>(
variant: T, matchers: Matchers<T, T["tag"]>
): void;
The first signature is designed for handling partial matches with the inclusion of the def
property. The inference process can be complex at times and specifying | typeof def
in both the constraint for
K</code and within the second argument to <code>Matchers</code becomes necessary to ensure accurate inference of <code>K</code from the actual keys provided in the <code>matchers
argument.
The second signature caters to scenarios where an exhaustive match is needed without the presence of the def
property, hence eliminating the need for K
to be generic since it always aligns with the full T["tag"]
union.
Testing against the following:
declare const adt:
| Variant<"num", number>
| Variant<"str", string>
| Variant<"dat", Date>;
For the "match all" use case:
match(adt, {
num: v => v.toFixed(),
dat: v => v.toISOString(),
str: v => v.toUpperCase()
});
Looks promising with the compiler understanding the types of v
in each callback. Next, excluding the str
key and introducing the [def]
key:
match(adt, {
num: v => v.toFixed(),
dat: v => v.toISOString(),
[def]: v => v.toUpperCase()
});
Compiler deduces that v
must be of type
string</code for the default matcher. Further, omitting the <code>dat
key:
match(adt, {
num: v => v.toFixed(),
[def]: v => typeof v === "object" ? v.toISOString() :
v.toUpperCase()
});
Types remain accurate with v
now being of type
Date | string</code. Lastly, testing exclusively with the default matcher:</p>
<pre><code>match(adt, {
[def]: v => typeof v === "number" ? v.toFixed() :
typeof v === "object" ? v.toISOString() :
v.toUpperCase()
})
The type of v
expands to the complete
number | Date | string</code union. Experimenting with all tags present alongside the default matcher:</p>
<pre><code>const assertNever = (x: never): never => { throw new Error("Uh oh") };
match(adt, {
num: v => v.toFixed(),
dat: v => v.toISOString(),
str: v => v.toUpperCase(),
[def]: v => assertNever(v)
});
No issues identified, default matcher accepted, and v
correctly assessed as type
never</code (since invoking the default matcher isn't expected).</p>
<p>Introducing mistakes to observe error messages - when <code>str
and the default matcher are omitted:
match(adt, {
num: v => v.toFixed(),
dat: v => v.toISOString(),
}); // error!
// No overload matches this call.
// Overload 1: Property '[def]' is missing
// Overload 2: Property 'str' is missing
Error message highlights failing to comply with either of the two call signatures and indicates that either [def]
or str
is absent. On mistyping a key:
// oops
match(adt, {
num: v => v.toFixed(),
dat: v => v.toISOString(),
strr: v => v.toUpperCase(), // error,
//strr does not exist, Did you mean to write 'str'?
})
Description of unrecognized key and suggestions provided depending on how close the misspelling is to a valid key name.
In conclusion, by incorporating a second generic call signature focusing on matcher keys, achieving the expected type inference for default callback arguments can be facilitated.
Link to Playground code snippet