I am working on a TypeScript library (here it is) for algebraic data types (or whatever one wishes to call them), and am struggling with some of the more intricate typing.
The algebraic data types work like this:
// Create ADT instantiator
const value_adt = adt({
num: (value: number) => value,
str: (value: string) => value,
obj: (value: object) => value,
});
// Extract union of variants
type ValueADT = Variants<typeof value_adt>;
// 'ValueADT' will be typed as:
// { [tag]: "num", value: number } |
// { [tag]: "str", value: string } |
// { [tag]: "obj", value: object }
// Create instance of 'value_adt'
const my_value = value_adt.str("hello") as ValueADT;
And I have created a match function (yes, this is very much inspired by Rust) for matching, and extracting associated data from the matched variant, that works like this:
match(my_value, {
num: value => console.log(`number: ${value}`);
str: value => console.log(`string: ${value}`);
obj: value => console.log(`object: ${value}`);
});
The parameter typing for the provided matchers work like a charm. However, I have added the option to omit one or more matchers and replace them with a default case:
match(my_value, {
num: value => console.log(`number: ${value}`);
[def]: value => console.log(`not number: ${value}`);
});
I have managed to type the default matcher's parameter as the union of all the variants' value types (ValueADT["value"] in this case), but that only really makes sense if all matchers are omitted and replaced with the default matcher.
I would very much like to be able to type the value parameter of the default matcher based on which other matchers are provided, e.g. it should be string | object in the above example, not number | string | object., so my question is what (if any) advanced TypeScript sorcery is available to achieve something like that?
Here is the link to the matcher source code and types as is.
Note:
I am not sure about this question's title, suggestions are welcome.
Update:
Here is some self-contained code that illustrates the undesired behavior, i.e. that the default matcher's parameter type is a union that contains number, which it logically cannot be:
type Variant<T, U> = { tag: T, value: U };
const adt = { tag: "num", value: 1 } as
| Variant<"num", number>
| Variant<"str", string>
| Variant<"obj", object>;
type MatchAll<T extends Variant<string, any>> = {
[K in T["tag"]]: T extends Variant<K, infer V>
? (value: V) => any
: never
};
const def = Symbol("[def]ault matcher");
type Matchers<T extends Variant<string, any>> =
| MatchAll<T>
| Partial<MatchAll<T>> & {
[def]: (value: T["value"]) => any;
};
function match<
T extends Variant<string, any>,
U extends Matchers<T>,
>(
variant: T,
matchers: U,
) {
const matcher = matchers[variant.tag as keyof U];
if (matcher === undefined) {
throw new Error(`No match for '${variant.tag}'!`);
}
matcher(variant.value);
}
match(adt, {
num: value => console.log(`number: ${value}`),
[def]: value => console.log(`not number: ${value}`),
});
...and a playground link.
Update 2:
After this was answered, I managed to also correctly infer the return type of match (it returns the returned value from the called matcher). This is a bit of an aside with respect to what I asked here, but it might be interesting for anyone who comes across this in the future: playground.
In what follows I am concerned only with the typings, and only from the point of view of the callers of
match(); the implementation may need type assertions or the like to prevent compiler errors, since complicated generic call signatures tend to be hard to verify.My inclination here would be to overload
match()so that the fully exhaustive "match all" case is handled separately from the matchers-with-default "partial" case.For the "partial" case, we want
match()to be generic not only inT, the union type of thevariantargument, but also inK, the keys of thematchersargument. And we might as well makeMatchersgeneric in bothTandKtoo, so that we can represent the argument type for all the methods as precisely as possible.So here is a possible definition of
Matchers<T, K>:The idea is that we map over each element
PofKto get callbacks returningany. To find thevalueparameter type of the callback with keyP: ifPis the type ofdef, thenvaluecorresponds to all the variants not mentioned inK(which we build using theExclude<T, U>utility type). Otherwise, we assumePis one of the tags fromT, in which casevaluecorresponds to the value for the variant with that tag (which we build using theExtract<T, U>utility type).Note that if
Khappens to be the full union ofT["tag"], then the[def]callback would have avalueargument of typenever. This is maybe a little strange, but probably harmless. If it really matters, you could change this type so that the whole property is of typeneverinstead of just the callback argument.And here are our overloaded call signatures:
The first signature handles the "partial" case with the
defproperty. The inference is a little tricky here; I found that I needed to specify| typeof defboth in the constraint forK, and inside the second argument toMatchers. Otherwise the compiler would tend to "give up" on inferringKfrom the actual keys of thematchersargument.The second signature handles the "match all" case without the
defproperty, and does not need to be generic inK(since it will always be just the fullT["tag"]union).Let's test it against:
First, let's try the "match all" case:
Looks good. The compiler knows the types of
vin each callback. Now let's leave out thestrkey, and add in the[def]key:Also looks good; the compiler knows that
vhas to bestringin the default matcher. Now let's leave out thedatkey as well:Still good; the type of
vis nowDate | string. And finally let's have only the default matcher:The type of
vis the fullnumber | Date | stringunion. Okay now let's try including all the tags and the default matcher:That is also good, I guess. The default matcher is accepted, and
vis of typenever(since we never expect the default matcher to be called).Let's make mistakes and see what it says. If we leave out
strand the default matcher, we get an error:The error describes how we've failed to properly call either of the two call signatures, and that either
[def]orstris missing. If we misspell one of the keys:The error describes how it doesn't expect to see that misspelled key, and (depending on how close our spelling is) suggests the fix.
So there you go, by adding a second call signature which is generic in the matcher keys, we can get the sort of inference for the default callback argument you're looking for.
Playground link to code