Utilizing type narrowing to accurately map objects within a mapper function

During the migration of my project to TypeScript, I encountered a challenge with a simple utility function:

function mapObject(obj, mapperFn) {
  return Object.fromEntries(
    Object.entries(obj).map(([key, value]) => mapperFn(key, value))
  );
}

This function is designed to transform keys and values of an object, as demonstrated below:

let obj = { a: 1, b: 2 };
let mappedObj = mapObject(obj, (key, value) => [
  key.toUpperCase(),
  value + 1,
]);

mappedObj; // { A: 2, B: 3 }

Edit To clarify, the output object may not have identical values to the input object. For example, the transformation could result in the output object having strings as values instead of numbers: { A: '2', B: '3' }, or it could simply return null for each entry: { A: null, B: null }. Only string keys are considered in both input and output objects for simplicity and consistency.

I would like the `mapperFn` function to preserve the type of values in object entries, allowing for type narrowing based on keys, similar to how `GetEntryOf` behaves in the following example:

type GetEntryOf<T> = {
  [K in keyof T]: [K, T[K]];
}[keyof T];

interface TestI {
  str: string;
  num: number;
}

let entry: GetEntryOf<TestI>;

if (entry[0] === 'str') {
  entry[1]; // string
} else if (entry[0] === 'num') {
  entry[1]; // number
} else {
  entry; // never
}

My current implementation using the `key in obj` operator still struggles with correctly narrowing the type of `value` inside the `mapperFn` function:

function mapObject<K>(
  obj: K,
  mapperFn: (key: keyof K, value: K[typeof key]) => [string, unknown]
): Record<string, unknown> {
  const newObj = {} as Record<string, unknown>;

  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      const value = obj[key];
      const [newKey, newValue] = mapperFn(key, value);

      newObj[newKey] = newValue;
    }
  }

  return newObj;
}

interface TestI {
  str: string;
  num: number;
}

let test: TestI;

mapObject(test, (key, value) => {
  if (key === 'str') {
    return [key, value]; // value should be string
  } else if (key === 'num') {
    return [key, value]; // value should be number
  } else {
    return [key, value]; // value should be never
  }
});

The issue lies in the incorrect type narrowing of `value`, which contrasts with the behavior of `GetEntryOf`. Is there a way to achieve the same type narrowing within the `mapperFn` function in TypeScript, similar to how `GetEntryOf` operates?

Note: The concept of type narrowing discussed here is inspired by a solution on Stack Overflow. While it introduces constraints, such as potentially having additional properties, it serves our purpose effectively.

Answer №1

I believe the solution you are seeking is something along these lines:

type Values<T> = T[keyof T]

type MakeTuple<T> = Values<{
  [Prop in keyof T]: { key: Prop, value: T[Prop] }
}>

type Output<T> = {
  [Prop in keyof T]: [Prop, T[Prop]]
}


const mapObject = <K,>(
  obj: K,
  mapperFn: (params: MakeTuple<K>) => [MakeTuple<K>['key'], MakeTuple<K>['value']]
) => (Object.keys(obj) as Array<keyof K>)
  .reduce((acc, key) => {
    const [newKey, newValue] = mapperFn({ key, value: obj[key] });

    return {
      ...acc,
      [newKey]: newValue
    }

  }, {} as Output<K>)


interface TestI {
  str: string;
  num: number;
}


declare let test: TestI;


const result = mapObject(test, (obj) => {
  // const key = params[0]
  // const value = params[1]
  if (obj.key === 'str') {
    return [obj.key, obj.value]
  } else if (obj.key === 'num') {
    return [obj.key, obj.value];
  } else {
    throw new Error();
  }
});

result.num // ['num', number]

Playground

It seems unnecessary to use

Object.prototype.hasOwnProperty.call(obj, key)
. You can utilize:

const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
  : obj is Obj & Record<Prop, unknown> =>
  Object.prototype.hasOwnProperty.call(obj, prop);

In fact, you have the option to override hasOwnProperty like demonstrated here

UPDATE Once this PR Control flow analysis for destructured discriminated unions gets merged, you will be able to use two separate arguments in



const result = mapObject(test, (key, value) => {
  if (key === 'str') {
    return [key, obj]
  } else if (key === 'num') {
    return [key, obj];
  } else {
    throw new Error();
  }
});

when this update is available.

Answer №2

After consulting captain-yossarian's response, the final implementation looks like this:

type Values<T> = T[keyof T];

type MakeTuple<T> = Values<{
  [Prop in keyof T]: { key: Prop; value: T[Prop] };
}>;

interface MapObjectMapper<K> {
  (params: MakeTuple<K>): [string, unknown];
}

export default function mapObject<K>(
  obj: K,
  mapperFn: MapObjectMapper<K>
): Record<string, unknown> {
  return Object.fromEntries(
    (Object.keys(obj) as Array<keyof K>).map((key) =>
      mapperFn({ key, value: obj[key] })
    )
  );
}

To improve readability, I refined the signature of the mapper function and adjusted the output type to [string, unknown] based on the suggestion mentioned in the question remarks regarding only string keys being used and the uncertainty of the return value.

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

Exploring the functionalities of arrays in Typescript: A beginner's guide

Currently, I am working on a react project and building a store within it. Below is the code snippet I have implemented: import React, { useReducer, useEffect } from 'react'; import { v4 as uuid } from 'uuid'; import { Movie, MoviesAct ...

"Exploring the power of D3, TypeScript, and Angular 2

I am facing challenges incorporating D3 v4 with Angular 2 (Typescript). While exploring D3 v4, I have referred to several solutions on StackOverflow without success. I have imported most of the necessary D3 libraries and typings (utilizing TS 2.0) and adde ...

What is the suggested method for supplying optional parameters to a callback as outlined in the Typescript documentation?

While going through the do's and don'ts section of the Typescript documentation, I came across a guideline regarding passing optional parameters to a callback function. The example provided was: /* WRONG */ interface Fetcher { getObject(done: ( ...

Guide on successfully sending an object to axios.post for request handling in a Vue Component

I am attempting to send an object using axios.post, but it seems like I might be doing it incorrectly as I am encountering the following error: Error: "Request failed with status code 500" Interestingly, I do not face any issues when I send the data as a ...

How can React TypeScript bind an array to routes effectively?

In my project, I am using the standard VisualStudio 2017 ASP.NET Core 2.0 React Template. There is a class Home included in the template: import { RouteComponentProps } from 'react-router'; export class Home extends React.Component<Rout ...

What could be causing my Page to not update when the Context changes?

In my Base Context, I store essential information like the current logged-in user. I have a User Page that should display this information but fails to re-render when the Context changes. Initially, the Context is empty (isLoaded = false). Once the init fu ...

Require using .toString() method on an object during automatic conversion to a string

I'm interested in automating the process of utilizing an object's toString() method when it is implicitly converted to a string. Let's consider this example class: class Dog { name: string; constructor(name: string) { this.name = na ...

Tips on retrieving a strongly typed value from a method using Map<string, object>

Having had experience working with C# for a while, I recently ventured into a Node.js project using TypeScript V3.1.6. It was exciting to discover that TypeScript now supports generics, something I thought I would miss from my C# days. In my C# code, I ha ...

Issue with TypeScript Generics: The operand on the left side of the arithmetic operation must be of type 'any', 'number', or 'bigint'

I seem to be encountering an error that I can't quite decipher. Even though I've clearly set the type of first as a number, the code still doesn't seem to work properly. Can someone provide insights on how to fix this issue? function divide& ...

Executing multiple service calls in Angular2

Is there a way to optimize the number of requests made to a service? I need to retrieve data from my database in batches of 1000 entries each time. Currently, I have a loop set up like this: while (!done) { ... } This approach results in unnecessary re ...

Having trouble displaying the button upon initial load using ngIf

My goal is to display a button when editing an input form. Initially, the button is hidden when the page loads but it should appear once any of the input fields are edited. I have also implemented highlighting for the input box that is being edited. Howeve ...

Activating the microphone device on the MediaStream results in an echo of one's own voice

I am in the process of creating an Angular application that enables two users to have a video call using the Openvidu calling solution. As part of this application, I have implemented a feature that allows users to switch between different cameras or micr ...

Guide on properly defining typed props in Next.js using TypeScript

Just diving into my first typescript project and finding myself in need of some basic assistance... My code seems to be running smoothly using npm run dev, but I encountered an error when trying to use npm run build. Error: Binding element 'allImageD ...

What is the most effective method for distributing TypeScript functions that are used by services and span multiple components?

I have a set of TypeScript functions that are currently scattered across components. These functions are being duplicated unnecessarily, and I am looking for a way to centralize them so all components can access them without redundancies. Since these fun ...

Error encountered in TypeScript when attempting to override a method: The distinction between Overriding and Overloading

In my Angular 9 application, I have two classes that I am using as services: class A and class B, where class B extends class A. class A{ exportToCSV(data: any[], headers: any[]){ .... } } class B extends A{ exportToCSV(data: any[], headers: ...

AngularJS is encountering issues with dependency injections when using WebStorm and TypeScript

A TypeScript AngularJS component: class MyComponentCtrl { static $inject = ['MyService']; constructor(private MyService) { MyService.testfn(55); // No error in typescript } } class MyComponent implements ng.IComponentOptions { ...

A guide on combining two similar entities in Laravel

I require assistance with merging two similar objects into one object in Laravel. How can this be achieved? Below are the objects: {"course_code":"UGRC110","venue_id":22,"exam_date":"May 6, 2017","exam_time":"3:30 pm","student_no":400} and {"course_co ...

Adding a QR code on top of an image in a PDF using TypeScript

Incorporating TypeScript and PdfMakeWrapper library, I am creating PDFs on a website integrated with svg images and QR codes. Below is a snippet of the code in question: async generatePDF(ID_PRODUCT: string) { PdfMakeWrapper.setFonts(pdfFonts); ...

Effortless transfer of a module from one TypeScript file to another

I'm facing an issue with importing classes from one .ts file to another .ts file. Here is the structure of my project: https://i.sstatic.net/gZM57.png I'm attempting to import print.ts into testing.ts This is how my tsconfig.json appears: ht ...

Is it possible to conceal dom elements within an ng-template?

Utilizing ng-bootstrap, I am creating a Popover with HTML and bindings. However, the ng-template keeps getting recreated every time I click the button, causing a delay in the initialization of my component. Is there a way to hide the ng-template instead? ...