Utilizing TypeScript to perform typing operations on subsets of unions

A TypeScript library is being developed by me for algebraic data types (or other names they may go by), and I am facing challenges with the more complex typing aspects.

The functionality of the algebraic data types is as follows:

// Creating ADT instatiator
const value_adt = adt({
    num: (value: number) => value,
    str: (value: string) => value,
    obj: (value: object) => value,
});

// Extracting 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 }

// Creating instance of 'value_adt'
const my_value = value_adt.str("hello") as ValueADT;
...

...and a playground.

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.

Answer №1

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

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Encountering Angular 8 error TS2304 at line 39: Cannot find the specified name after shutting down WebStorm

After working on my Angular project and closing the IDE last night, I encountered an odd problem today. The project doesn't seem to recognize certain libraries such as Int8Array, Int16Array, Int32Array... And many others. While the project is still ab ...

The intricate field name of a TypeScript class

I have a TypeScript class that looks like this - export class News { title: string; snapshot: string; headerImage: string; } In my Angular service, I have a method that retrieves a list of news in the following way - private searchNews(sor ...

Retrieve the input type corresponding to the name and return it as a string using string template literals

type ExtractKeyType<T extends string, K extends number> = `${T}.${K}`; type PathImpl<T, Key extends keyof T> = Key extends string ? T[Key] extends readonly unknown[] ? ExtractKeyType<Key, 0 | 1> : T[Key] extends Record<str ...

What causes an array to accumulate duplicate objects when they are added in a loop?

I am currently developing a calendar application using ExpressJS and TypeScript. Within this project, I have implemented a function that manages recurring events and returns an array of events for a specific month upon request. let response: TEventResponse ...

Issue with RxDB: Collection not found upon reload

Exploring the integration of RxDB in my Angular project. I wanted to start with a simple example: export const LANG = { version: 0, title: "Language Key", type: "object", properties: { key: { type: "string", primary: true } }, requ ...

Unable to delete event listeners from the browser's Document Object Model

Issue at hand involves two methods; one for initializing event listeners and the other for deleting them. Upon deletion, successful messages in the console confirm removal from the component's listener array. However, post-deletion, interactions with ...

Tips for exporting/importing only a type definition in TypeScript:

Is it possible to export and import a type definition separately from the module in question? In Flowtype, achieving this can be done by having the file sub.js export the type myType with export type myType = {id: number};, and then in the file main.js, i ...

Can you explain the distinction between using get() and valueChanges() in an Angular Firestore query?

Can someone help clarify the distinction between get() and valueChanges() when executing a query in Angular Firestore? Are there specific advantages or disadvantages to consider, such as differences in reads or costs? ...

Top tips for handling HTML data in JSON format

I'm looking to fetch HTML content via JSON and I'm wondering if my current method is the most efficient... Here's a sample of what I'm doing: jsonRequest = [ { "id": "123", "template": '<div class=\"container\"&g ...

Enhancing performance with React.memo and TypeScript

I am currently developing a react native application. I am using the Item component within a flatlist to display data, however, I encountered an error in the editor regarding the second parameter of React.memo: The error message reads: 'Type 'bo ...

What is the best way to globally incorporate tether or any other feature in my Meteor 1.3 TypeScript project?

I've been working hard to get my ng2-prototype up and running in a meteor1.3 environment. Previously, I was using webpack to build the prototype and utilized a provide plugin to make jQuery and Tether available during module building: plugins: [ ne ...

Alert me in TypeScript whenever a method reference is detected

When passing a function reference as a parameter to another function and then calling it elsewhere, the context of "this" gets lost. To avoid this issue, I have to convert the method into an arrow function. Here's an example to illustrate: class Mees ...

Retrieving Data from Angular Component within a Directive

Currently, I am in the process of creating an "autocomplete" directive for a project. The aim is to have the directive query the API and present a list of results for selection. A component with a modal containing a simple input box has been set up. The ob ...

When working with Angular 12, the target environment lacks support for dynamic import() syntax. Therefore, utilizing external type 'module' within a script is not feasible

My current issue involves using dynamic import code to bring in a js library during runtime: export class AuthService { constructor() { import('https://apis.google.com/js/platform.js').then(result => { console.log(resul ...

What is the best way to include documentation for custom components using jsDoc?

Within my Vuejs inline template components, we typically register the component in a javascript file and define its template in html. An example of such a component is shown below: Vue.component('compare-benefits', { data() { // By return ...

strange complications with importing TypeScript

In my Typescript projects, I frequently use an npm module called common-types (repository: https://github.com/lifegadget/common-types). Recently, I added an enum for managing Firebase projects named FirebaseEvent. Here is how it is defined: export enum Fi ...

Karma Unit test: Issue with accessing the 'length' property of an undefined value has been encountered

While running karma unit tests, I encountered a similar issue and here is what I found: One of my unit tests was writing data to a json file, resulting in the following error: ERROR in TypeError: Cannot read property 'length' of undefined a ...

Preventing me from instantiating objects

I've been struggling with an issue for a while now consider the following: export abstract class abstractClass { abstract thing(): string } export class c1 extends abstractClass { thing(): string { return "hello" } } export cla ...

What is the best way to organize objects based on their timestamps?

I am faced with the task of merging two arrays of objects into a single array based on their timestamps. One array contains exact second timestamps, while the other consists of hourly ranges. My goal is to incorporate the 'humidity' values from t ...

The switch statement and corresponding if-else loop consistently produce incorrect results

I'm currently facing an issue where I need to display different icons next to documents based on their file types using Angular framework. However, no matter what file type I set as the fileExtension variable (e.g., txt or jpg), it always defaults to ...