Enforcing a discriminated union's match requirements

Challenging Situation

I'm new to typescript and facing the task of converting the mapProps function provided below into typescript

const addOne = x => x + 1
const upperCase = x => x.toUpperCase()

const obj = {
  entry: 'legend',
  fauna: {
    unicorns: 10,
    zombies: 3
  },
  other: {
    hat: 2
  }
}

const fns = {
  entry: upperCase,
  fauna: {
    unicorns: addOne
  },
  other: obj => ({
      hat: addOne(obj.hat)
  })
}

const mapProps = (obj, fns) => 
    Object.keys(fns).reduce(
        (acc, cur) => ({
            ...acc,
            [cur]: fns[cur] instanceof Function
                ? fns[cur](obj[cur])
                : mapProps(obj[cur], fns[cur])
        }),
        obj
    )

mapProps(obj, fns) 
// ​​​​​{ entry: 'LEGEND',​​​​​ fauna: { unicorns: 11, zombies: 3 },​​​​​ other: { hat: 3 } }​​​​​

Current Struggle

type MapPropFuncsOf<T> = {
    [P in keyof T]?: ((x:T[P]) => T[P]) | MapPropFuncsOf<T[P]>
}

const mapProps = <T>(obj:T, fns: MapPropFuncsOf<T>) => 
    (Object.keys(fns) as (keyof T)[]).reduce(
        (acc, cur) => ({
            ...acc,
            [cur]: fns[cur] instanceof Function
                ? fns[cur]!(obj[cur]) // compiler warning
                : mapProps(obj[cur], fns[cur]) // compiler warning
        }),
        obj
  )

Obstacles Faced

Despite validating that fns[cur] instanceof Function, I am struggling to call fns[cur]. The typescript compiler raises the following errors:

Cannot invoke an expression whose type lacks a call signature. Type 'MapPropFuncsOf | ((x: T[keyof T]) => T[keyof T])' has no compatible call signatures.

The call to mapProps(obj[cur], fns[cur]) also encounters issues with the compiler reporting:

Argument of type 'MapPropFuncsOf | ((x: T[keyof T]) => T[keyof T]) | undefined' is not assignable to parameter of type 'MapPropFuncsOf'. Type 'undefined' is not assignable to type 'MapPropFuncsOf'.

Playground Link

Answer №1

The primary issue arises from typescript's inability to narrow index expressions containing dynamic values. A straightforward solution is to store the value in a variable.

declare var o: { [s: string]: string | number }
declare var n: string
if(typeof o[n] === "string") { o[n].toLowerCase(); } //error

let v = o[n]
if(typeof v === "string") { v.toLowerCase(); } // ok

This modification ensures that the function call does not throw an error:

const mapProps = <T>(obj:T, fns: MapPropFuncsOf<T>): T => 
    (Object.keys(fns) as (keyof T)[]).reduce(
        (acc, cur) =>
        {
          var fn = fns[cur];
          return ({
            ...acc,
            [cur]: typeof fn === "function"
                ? fn(obj[cur], null) 
                : mapProps(obj[cur], fns[cur]) 
        })
      },
        obj
  )

Although the updated code isn't flawless. The issue with calling fn with two arguments is due to an inconsistency in the codebase. It's crucial to remember that nothing prevents T from having function properties, leading to unintentional function calls. To address this, we can constrain T to exclude any functions:

type MapPropFuncsOf<T extends Values> = {
    [P in keyof T]?: ((x:T[P]) => T[P]) | (T[P] extends Values ? MapPropFuncsOf<T[P]>: T[P])
}

interface Values {
  [s: string] : string | number | boolean | null | undefined | Values
}

const mapProps = <T extends Values>(obj:T, fns: MapPropFuncsOf<T>) => 
    (Object.keys(fns) as (keyof T)[]).reduce(
        (acc, cur) =>
        {
          var fn = fns[cur];
          return ({
            ...acc,
            [cur]: typeof fn === "function"
                ? fn(obj[cur])  // fully checked now
                : mapProps(obj[cur], fns[cur]) 
        })
      },
        obj
  )

Further issues arise when calling mapProps, stemming from similar underlying problems. With the added constraint T extends Values, only compatible inputs can invoke mapProps. Using a type assertion or custom guards can resolve the final issue where object types fail to narrow down properly.

Additionally, recursively calling mapProps might be unnecessary for primitive types. Implementing a custom guard solely checking for object addresses this concern.

type MapPropFuncsOf<T extends Values> = {
    [P in keyof T]?: ((x:T[P]) => T[P]) | (T[P] extends Values ? MapPropFuncsOf<T[P]>: T[P])
}

interface Values {
  [s: string] : string | number | boolean |  Values
}
function isValues(o: Values[string]) : o is Values {
  return typeof o === "object"
}
const mapProps = <T extends Values>(obj:T, fns: MapPropFuncsOf<T>) : T=> 
    (Object.keys(fns) as (keyof T)[]).reduce(
        (acc, cur) =>
        {
          var fn = fns[cur];
          var o = obj[cur];
          return ({
            ...acc,
            [cur]: typeof fn === "function"
                ? fn(obj[cur]) 
                : (isValues(o) ? mapProps(o, fn as any) : o)
        })
      },
        obj
  )

The challenge of ensuring fn conforms to MapPropFuncsOf persists, necessitating the use of fn as any. While striving for full type integrity in the function implementation is commendable, TypeScript's limitations may require slight compromises for practicality. Explicitly addressing such deviations and justifying them are essential in maintaining a balance between type safety and functionality. An alternative version utilizing type assertions is available in another response, obviating the need for duplication here.

Answer №2

Let's kick things off from the very start. Your modifiers seem to be lacking types. Let's rectify that.

const increment = (x: number) => x + 1;
const capitalize = (x: string) => x.toUpperCase();

Both of these functions can be classified as identity functions — they take an argument of a certain type and return the same type.

type Identity<T> = (argument: T) => T;

Your set of fns consists of such identity functions. Once we're finished, the type of obj won't change. But it's not always so straightforward — sometimes the identity function applies to a primitive value, other times to a part of your object. If we were to represent the relationship between obj and fns, it might look something like this:

type DeepIdentity<T> = {
  [K in keyof T]?: T[K] extends object
    ? DeepIdentity<T[K]> | Identity<T[K]>
    : Identity<T[K]>
}

Let's test our hypothesis.

const fns: DeepIdentity<typeof obj> = {
  entry: capitalize,
  creatures: {
    dragons: increment,
  },
  additional: obj => ({
      crown: increment(obj.crown)
  })
}

Now, your processing function could be written as:

const process = <T>(input: T, transformers: DeepIdentity<T>): T =>
  (Object.keys(input) as (keyof T)[])
    .reduce(
      (accumulator, property) => {
        const transformer = transformers[property];
        const value = input[property];

        return Object.assign(accumulator, {
          [property]: (transformer === undefined)
            ? value
            : (transformer instanceof Function)
              ? transformer(value)
              : process(value, transformer as DeepIdentity<typeof value>)
        });
      },
      {} as T
    )

process(obj, fns); // { "entry": "ROYALTY", "creatures": { "dragons": 13, "elves": 8 }, "additional": { "crown": 8 } }

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

Adding a fresh element to an array in Angular 4 using an observable

I am currently working on a page that showcases a list of locations, with the ability to click on each location and display the corresponding assets. Here is how I have structured the template: <li *ngFor="let location of locations" (click)="se ...

What could be causing Typescript Compile Errors to occur during runtime?

In the Visual Studio React + Redux template project, I have created a react component with the following "render()" method: public render() { return ( <React.Fragment> <h1>Welcome to the Adventure Company {th ...

Leverage TypeScript to generate a unified object that combines keys from multiple objects

Consider the object below: const myObject = { one: { fixed: { a: 1 } }, two: { fixed: { b: true } }, three: { fixed: { c: 'foo' } } } Is there a way to use this object to define a type simila ...

How to display specific JSON objects that meet particular criteria in HTML cards using Ionic and Angular?

As a beginner in Ionic/Angular, I am attempting to fetch data from a JSON file and display it using cards in the HTML. The JSON contains numerous objects that are either marked as "deTurno == true" or "deTurno == false". Here is what I have so far: publi ...

Handling linting errors for getInitialProps return type in NextJS with Typescript

I've been grappling with this problem for an extended period, and despite numerous attempts, I haven't been able to resolve it. My issue revolves around a basic page named one.tsx. Its structure is as follows: https://i.sstatic.net/xP89u.png T ...

You can easily search and select multiple items from a list using checkboxes with Angular and TypeScript's mat elements

I am in need of a specific feature: An input box for text entry along with a multi-select option using checkboxes all in one place. Unfortunately, I have been unable to find any references or resources for implementing these options using the Angular Mat ...

Guide on executing get, modify, append, and erase tasks on a multi-parameter JSON array akin to an API within Angular

I have a JSON array called courseList with multiple parameters: public courseList:any=[ { id:1, cName: "Angular", bDesc: "This is the basic course for Angular.", amt: "$50", dur: & ...

Exploring the power of nested components within Angular 2

I am encountering an issue with a module that contains several components, where Angular is unable to locate the component when using the directive syntax in the template. The error message I receive states: 'test-cell-map' is not a known elemen ...

Making Angular Material compatible with Webpack and TypeScript

I am facing a challenge with my angular-typescript project managed by webpack. The issue arises when I attempt to integrate the angular-material library, and I am encountering significant difficulties. Currently, the html portion of my code appears as fol ...

Angular Form Validations - input values must not match the initial values

These are my current reactive form validations: ngOnInit(): void { this.userForm = this.formBuilder.group({ status: {checked: this.selectedUser.status == 1}, username: [this.selectedUser.username, [Validators.required, Validators.minLeng ...

Looking to migrate my current firebase/react project to typescript. Any tips on how to batch.update?

Having difficulty resolving a typescript error related to batch.update in my code. const batch = db.batch(); const listingDoc = await db.collection("listings").doc(listingID).get(); const listingData = listingDoc.data( ...

Vuetify's v-data-table is experiencing issues with displaying :headers and :items

I'm facing an issue while working on a Vue project with typescript. The v-data-table component in my Schedule.vue file is not rendering as expected. Instead, all I can see is the image below: https://i.sstatic.net/AvjwA.png Despite searching extensi ...

What is the best way to incorporate node modules into my tsconfig file?

I've taken some raw angular typescript components and packaged them into a private NPM module for sharing between various projects. Although I import these components like any other npm library, I encounter an error message when trying to serve my ap ...

What is the best way to combine two functions for the "value" attribute in a TextField?

How can I make a TextField force all uppercase letters for the user when they type, while also storing the text inputted by the user? I have managed to make the TextField display all uppercase letters, but then I can't submit to Excel. On the other ha ...

Encountering an issue with TS / yarn where an exported const object cannot be utilized in a consuming

I am currently working on a private project using TypeScript and Yarn. In this project, I have developed a package that is meant to be utilized by one or more other applications. However, as I started to work on the consumer application, I encountered an ...

Issue with Figma React plugin's PostMessage functionality not behaving as anticipated

I am currently working on developing a plugin for Figma, following the react example provided on their GitHub page: https://github.com/figma/plugin-samples/tree/master/react One of the functionalities I have implemented is a button that triggers a specifi ...

Encasing event handler callback within Observable pattern

I am currently exploring how to wrap an event callback from a library to an RxJS Observable in a unique way. This library I am working with sets up handlers for events like so: const someHandler = (args) => {}; const resultCallback = (result) => {} ...

Having trouble getting Lodash find to work properly with TypeScript overload signatures

I have been attempting to utilize _.find in TypeScript on an Object in order to retrieve a value from this object. The original code snippet looked like this: const iconDict = { dashboard: <DataVisualizer />, settings: <SettingsApp /> ...

What is the best way to send various parameters to a component using [routerLink] or router.navigate?

My app-routing.module.ts is configured as shown below: const routes: Routes = [ { path: "branches/:branch", component: BranchesComponent }, // ... ]; In addition, in my app.component.html, I have the following code: <li> ...

What is the best way to ensure that my mat-slide-toggle only changes when a specific condition is met?

I'm having an issue with a function that toggles a mat-slide-toggle. I need to modify this function to only toggle when the result is false. Currently, it toggles every time, regardless of the result being true or false. I want it to not toggle when t ...