Mastering Typescript generics for accurate mapping between keys and values through indirection

I've been struggling to understand how to create a specialized mapper that can indirectly map methods based on one object's values corresponding to another object's keys.

The mapper used in the code snippet below is not mine; it's an external JavaScript-based tool. As I don't have the liberty to modify this mapper, I've devised a typed wrapper named mapTyped as showcased in the code snippet below.

This implementation represents my best effort so far, drawing inspiration from various resources on the subject.

It successfully achieves the following objectives:

  • Ensures alignment of method definitions between Repository and MappedFunctions
  • Prevents typographical errors in the lookup names; for instance, using
    mapTyped<MappedFunctions>()({baz: "bazIntrnal"})
    will result in a compiler error due to misspelling
  • Works flawlessly if only one item is provided in the mapping, accurately inferring function arguments and return types

However, it encounters issues when multiple items are included in the mapping.

In such cases, all mapped functions end up comprising a union of all the defined functions' signatures.

How can I adjust the mapTyped function to yield unique values per "row" in the Record matching the key? (instead of a union of values like it does currently)

Refer to the comments within the minimal reproducible code below, available at this playground link.

// Add your code here

For the updated solution thanks to @captain-yossarian, please refer to the added code segment below.

// Updated solution code goes here

To explore the updated playground showcasing the revised code, visit this link.

You can also access a gist illustrating the usage of this typed wrapper with Vuex 4 and Vue 3 by clicking here.

Answer №1

For more related answers, please click on the following links: here and here

Now, when you hover over the methods.baz, it reveals that the baz method is a union of two functions:

((fooArg: number) => number) | ((bazArg: boolean) => (string | boolean)[])
.

If you refer to the related answers, you'll understand that invoking a function which is a union of multiple functions results in intersecting arguments.

In this scenario, it equates to number & boolean === never.

Let's delve into the mapper.

I suggest breaking down mapper into two distinct strategies - mapperArray and mapperObject:

const functions = {
  fooInternal: (fooArg: number) => 1 + fooArg,
  barInternal: (barArg: string) => "barArg: " + barArg,
  bazInternal: (bazArg: boolean) => ["some", "array", "with", bazArg]
} as const

type FunctionsKeys = keyof typeof functions;

type MappedFunctions = {
  fooInternal: (fooArg: number) => number,
  bazInternal: (bazArg: boolean) => (string | boolean)[]
}

type Values<T> = T[keyof T]

type MethodType = (...args: any[]) => any;
type Repository = Record<string, MethodType>;

const mapperArray = <Elem extends FunctionsKeys, Elems extends Elem[]>(map: [...Elems]) =>
  map.reduce<Repository>((acc, elem) => ({
    ...acc,
    [elem]: functions[elem]
  }), {})

const mapperObject = <
  Key extends PropertyKey,
  Value extends FunctionsKeys
>(map: Record<Key, Value>) =>
  (Object.entries(map) as Array<[Key, Value]>)
    .reduce<Repository>((acc, elem) => ({
      ...acc,
      [elem[0]]: functions[elem[1]]
    }), {})

const mapper = <Key extends FunctionsKeys>(map: Record<string, Key> | Key[]) =>
  Array.isArray(map)
    ? mapperArray(map)
    : mapperObject(map)

Considering that the mapper is from a third-party library, feel free to utilize only the final version. I've covered all scenarios regarding imported functions and your own, hence the detailed typing.

With our main utilities in place, we can typify our primary function:


type GetKeyBaValue<Obj, Value> = {
  [Prop in keyof Obj]: Obj[Prop] extends Value ? Prop : never
}[keyof Obj]

type Test = GetKeyBaValue<{ age: 42, name: 'Serhii' }, 42> // age

const mapTyped = <
  Keys extends keyof MappedFunctions,
  Mapper extends <Arg extends Record<string, Keys> | Array<Keys>>(arg: Arg) => Repository
>(mpr: Mapper) => {
  function anonymous<Prop extends string, Mp extends Record<Prop, Keys>>(map: Mp): {
    [P in Keys as GetKeyBaValue<Mp, P>]: MappedFunctions[P]
  }
  function anonymous<Prop extends string, Mp extends Record<Prop, Keys>>(map: Mp) {
    return mpr(map)
  }
  return anonymous
}

GetKeyBaValue - retrieves the key by its corresponding value.

You might have noticed that mapTypes is a curried function. Moreover, it returns an overloaded function named anonymous. It could be transformed into an arrow one, but then you'd need to use type assertion with as. In my opinion, overloading is safer, although it behaves bivariantly, resulting in some limitations. The choice is yours.

The full code snippet is provided below:

const functions = {
  fooInternal: (fooArg: number) => 1 + fooArg,
  barInternal: (barArg: string) => "barArg: " + barArg,
  bazInternal: (bazArg: boolean) => ["some", "array", "with", bazArg]
} as const

type FunctionsKeys = keyof typeof functions;

type MappedFunctions = {
  fooInternal: (fooArg: number) => number,
  bazInternal: (bazArg: boolean) => (string | boolean)[]
}

type Values<T> = T[keyof T]

type MethodType = (...args: any[]) => any;
type Repository = Record<string, MethodType>;

const mapperArray = <Elem extends FunctionsKeys, Elems extends Elem[]>(map: [...Elems]) =>
  map.reduce<Repository>((acc, elem) => ({
    ...acc,
    [elem]: functions[elem]
  }), {})

const mapperObject = <
  Key extends PropertyKey,
  Value extends FunctionsKeys
>(map: Record<Key, Value>) =>
  (Object.entries(map) as Array<[Key, Value]>)
    .reduce<Repository>((acc, elem) => ({
      ...acc,
      [elem[0]]: functions[elem[1]]
    }), {})

const mapper = <Key extends FunctionsKeys>(map: Record<string, Key> | Key[]) =>
  Array.isArray(map)
    ? mapperArray(map)
    : mapperObject(map)

type GetKeyBaValue<Obj, Value> = {
  [Prop in keyof Obj]: Obj[Prop] extends Value ? Prop : never
}[keyof Obj]

type Test = GetKeyBaValue<{ age: 42, name: 'Serhii' }, 42> // age

const mapTyped = <
  Keys extends keyof MappedFunctions,
  Mapper extends <Arg extends Record<string, Keys> | Array<Keys>>(arg: Arg) => Repository
>(mpr: Mapper) => {
  function anonymous<Prop extends string, Mp extends Record<Prop, Keys>>(map: Mp): {
    [P in Keys as GetKeyBaValue<Mp, P>]: MappedFunctions[P]
  }
  function anonymous<Prop extends string, Mp extends Record<Prop, Keys>>(map: Mp) {
    return mpr(map)
  }
  return anonymous
}


const methods = {
  ...mapTyped(mapper)({ baz: "bazInternal", foo: "fooInternal" }),
  otherMethod: () => 42
};

methods.baz(true) // ok
methods.foo(42) // ok

methods.otherMethod() // number

Explore the code further in the Playground

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

Can you guide me on how to record a value in Pulumi?

According to Pulumi's guidance on inputs and outputs, I am trying to use console.log() to output a string value. console.log( `>>> masterUsername`, rdsCluster.masterUsername.apply((v) => `swag${v}swag`) ); This code snippet returns: & ...

The sanitizer variable becomes null when accessed outside of the NgOnInit function in Angular using TypeScript

At first, I added DomSanitizer to the component: import { DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; Next, a class was created and included in the constructor: export class BlocklyComponent implements OnInit { primar ...

Creating a Blob or ArrayBuffer in Ionic 2 and Cordova: Step-by-Step Guide

Is there a way to generate a blob or an arrayBuffer with TypeScript when using the Camera.getPicture(options) method? I am currently working on an Ionic 2/Cordova project. var options = { quality: 90, destinationType: Camera.DestinationType.FILE_ ...

TS2349 emerges when incorporating lazy-loading in React

I've been working on refactoring a React 18 app to incorporate lazy loading, following the guidelines provided in the official documentation: One effective method to implement code-splitting in your application is through the dynamic import() syntax ...

What is the best way to parse JSON data with Typescript?

I am dealing with JSON data structured as follows: jsonList= [ {name:'chennai', code:'maa'} {name:'delhi', code:'del'} .... .... .... {name:'salem', code:'che'} {name:'bengaluru' ...

Using `new Date(device.timestamp).toLocaleString()` in React with typescript results in an invalid date

The timestamp I am receiving is in unix time format. {devices.map((device, index) => { return ( <tr key={index} className="bg-white border-b "> <td className="py-4 px-6"> {getSensor ...

Having trouble adding the Vonage Client SDK to my preact (vite) project

I am currently working on a Preact project with Vite, but I encountered an issue when trying to use the nexmo-client SDK from Vonage. Importing it using the ES method caused my project to break. // app.tsx import NexmoClient from 'nexmo-client'; ...

Working with intricately structured objects using TypeScript

Trying to utilize VS Code for assistance when typing an object with predefined types. An example of a dish object could be: { "id": "dish01", "title": "SALMON CRUNCH", "price": 120, ...

Are 'const' and 'let' interchangeable in Typescript?

Exploring AngularJS 2 and Typescript led me to create something using these technologies as a way to grasp the basics of Typescript. Through various sources, I delved into modules, Typescript concepts, with one particularly interesting topic discussing the ...

What is the best way to invoke a method in a child component from its parent, before the child component has been rendered?

Within my application, I have a parent component and a child component responsible for adding and updating tiles using a pop-up component. The "Add" button is located in the parent component, while the update functionality is in the child component. When ...

Getting pictures dynamically from the backend with unspecified file types

Greetings to my fellow Stackoverflow-Users, Lately, I was tasked with the requirement of loading images dynamically from the backend into my application. Up until now, it was always assumed that we would only be dealing with SVG images since there was no ...

What is the best way to showcase the outcomes of arithmetic calculations on my calculator?

In the midst of creating a calculator, I have encountered some issues in getting it to display the correct result. Despite successfully storing the numbers clicked into separate variables, I am struggling with showing the accurate calculation outcome. l ...

"NgFor can only bind to Array objects - troubleshoot and resolve this error

Recently, I've encountered a perplexing error that has left me stumped. Can anyone offer guidance on how to resolve this issue? ERROR Error: Cannot find a differ supporting object '[object Object]' of type 'object'. NgFor only supp ...

Upon updating AngularFire, an error is thrown stating: "FirebaseError: Expected type 'Ea', but instead received a custom Ta object."

I have recently upgraded to AngularFire 7.4.1 and Angular 14.2.4, along with RxFire 6.0.3. After updating Angular from version 12 to 15, I encountered the following error with AngularFire: ERROR FirebaseError: Expected type 'Ea', but it was: a c ...

Transforming FormData string key names into a Json Object that is easily accessible

Recently, I encountered an issue where my frontend (JS) was sending a request to my backend (Node Typescript) using FormData, which included images. Here is an example of how the data was being sent: https://i.stack.imgur.com/5uARo.png Upon logging the r ...

unable to see the new component in the display

Within my app component class, I am attempting to integrate a new component. I have added the selector of this new component to the main class template. `import {CountryCapitalComponent} from "./app.country"; @Component({ selector: 'app-roo ...

Glitch causing incorrect images to appear while scrolling through FlashList

Currently, I am using the FlashList to showcase a list of items (avatars and titles). However, as I scroll through the list, incorrect images for the items are being displayed in a mixed-up manner. Based on the explanation provided in the documentation, t ...

Number that is not zero in TypeScript

Trying to find a solution in TypeScript for defining a type that represents a non-zero number: type Task = { id: number }; const task: Task = { id: 5 }; const tasks: { [taskId: number]: Task } = { 5: task }; function getTask(taskId: number | undefined): T ...

React with Typescript allows us to refine the callback type depending on the presence of an optional boolean prop

In my project, there's a component <Selector /> that can optionally accept a parameter called isMulti: boolean. It also requires another parameter called onSelected, whose signature needs to change depending on the value of isMulti (whether it i ...

Tips for adding and verifying arrays within forms using Angular2

Within my JavaScript model, this.profile, there exists a property named emails. This property is an array composed of objects with the properties {email, isDefault, status}. Following this, I proceed to define it as shown below: this.profileForm = this ...