In certain cases, the inferred type in Typescript may end up being resolved as 'any' when

I'm currently working on implementing robust typesafe constraints for a JSON mapping function. This particular function accepts an object as its first parameter and returns a mapped representation of that object by utilizing mapping functions provided as the second parameter.

From the perspective of a consumer, the contract would look something like this:

let mappedResult = mapJson(
    // Standard plain object literal data usually obtained from the server-side, often defined by an interface
    // We'll refer to this type as SRC
    { date: "2018-10-04T00:00:00+0200", date2: 1538604000000, aString: "Hello%20World", idempotentValue: "foo" },
    // Application of specific mappings aimed at transforming the input values and altering their type representations
    // The rules are:
    // - Keys should be a subset of SRC's keys, except for any new computed keys
    // - Values should be functions that take SRC[key] as input and return a new type NEW_TYPE[key], which we aim to capture in order to reference it in the result type of mapJson()
    // Let's label this type as TARGET_MAPPINGS
    { date: Date.parse, date2: (ts: number) => new Date(ts), aString: unescape, computed: (_, obj) => `${obj ? `${obj.aString}__${obj.idempotentValue}` : ''}` }
); 
// The resulting type (NEW_TYPE) should be a mapping with keys being the union of SRC keys and TARGET_MAPPINGS keys according to these guidelines:
// - If the key exists solely in SRC, then NEW_TYPE[key] = SRC[key}
// - Otherwise (key existing in TARGET_MAPPINGS), then NEW_TYPE[key] = ResultType<TARGET_MAPPINGS[key]>  
// In this example, the expected output is:
//   mappedResult = { date: Date.parse("2018-10-04T00:00:00+0200"), date2: new Date(1538604000000), aString: unescape("Hello%20World"), idempotentValue: "foo", computed: "Hello%20World__foo" }
// .. indicating that the anticipated type would be { date: number, date2: Date, aString: string, idempotentValue: string, computed: string }

With some assistance (refer to this SO question), I've made significant progress and have implemented the following types:

type ExtractField<ATTR, T, FALLBACK> = ATTR extends keyof T ? T[ATTR] : FALLBACK;
type FunctionMap<SRC> = {
    [ATTR in string]: (value: ExtractField<ATTR, SRC, never>, obj?: SRC) => any
}
type MappedReturnType<SRC, TARGET_MAPPINGS extends FunctionMap<SRC>> = {
    [ATTR in (keyof TARGET_MAPPINGS | keyof SRC)]:
        ATTR extends keyof TARGET_MAPPINGS ? ReturnType<Extract<TARGET_MAPPINGS[ATTR], Function>> : ExtractField<ATTR, SRC, never>
}

export function mapJson<
    SRC extends object,
    TARGET_MAPPINGS extends FunctionMap<SRC>
>(src: SRC, mappings: TARGET_MAPPINGS): MappedReturnType<SRC, TARGET_MAPPINGS> {
    // Implementation details are not the focus of this inquiry
}

Everything seems to be functioning correctly except for the scenario involving the "computed" property, where the resolved type is any instead of string.

let mappedResult = mapJson(
    { date: "2018-10-04T00:00:00+0200", date2: 1538604000000, aString: "Hello%20World", idempotentValue: "foo" },
    { date: Date.parse, date2: (ts: number) => new Date(ts), aString: unescape, computed: (_, obj) => `${obj ? `${obj.aString}__${obj.idempotentValue}` : ''}` }
);

let v1 = mappedResult.date; // number, as expected
let v2 = mappedResult.date2; // Date, as expected
let v3 = mappedResult.aString; // string, as expected
let v4 = mappedResult.idempotentValue; // string, as expected
let v5 = mappedResult.computed; // any, NOT what was expected (expected type was string here!)

I suspect this might be attributed to the type resolution using infer, but I am unsure why it behaves differently for properties existing in both SRC and TARGET_MAPPINGS (date, date2, & aString) compared to properties only present in TARGET_MAPPINGS.

Could this be potentially a bug?

Thank you in advance for your assistance.

Answer №1

FunctionMap isn't functioning as intended. TypeScript doesn't support mapped types with string as the constraint type and different property types based on the string used. When attempting to declare such a mapped type, the compiler simply turns it into a type with a string index signature and replaces all occurrences of the key variable with string, resulting in:

type FunctionMap<SRC> = {
    [ATTR: string]: (value: ExtractField<string, SRC, never>, obj?: SRC) => any
}

Due to string not extending keyof SRC for the types of SRC being used, the type of the value parameter always ends up being never. As a result, when evaluating the type of the computed property of

MappedReturnType<SRC, TARGET_MAPPINGS>
, the evaluation fails because
Extract<TARGET_MAPPINGS[ATTR], Function>
is
(value: never: obj?: SRC) => any
, which is incompatible with the constraint (...args: any[]) => any of ReturnType. The compiler handles this failure by changing the type to any, although it should ideally report an error. This issue doesn't seem related to Issue 25673.

In attempting to solve the original problem, I was unsuccessful in getting TypeScript to infer the proper types without splitting the map into two separate maps: one for original properties and another for computed properties. Even setting aside the aforementioned issue, using never as the type for the value parameter of a computed property is flawed since a value is passed for that parameter, usually undefined. With a single map, I couldn't find a way for TypeScript to correctly infer that value is SRC[ATTR] for original properties and undefined for other properties. Here's what I came up with:

type FieldMap<SRC> = {
    [ATTR in keyof SRC]?: (value: SRC[ATTR], obj: SRC) => any
};
type ComputedMap<SRC> = {
    [ATTR in keyof SRC]?: never
} & {
    [ATTR: string]: (value: undefined, obj: SRC) => any
};
type MappedReturnType<SRC, FM extends FieldMap<SRC>, CM extends ComputedMap<SRC>> = {
    [ATTR in keyof CM]: ReturnType<CM[ATTR]>
} & {
    [ATTR in keyof SRC]: ATTR extends keyof FM
        ? FM[ATTR] extends (value: SRC[ATTR], obj: SRC) => infer R ? R : SRC[ATTR]
        : SRC[ATTR]
}

export function mapJson<
    SRC extends object, FM extends FieldMap<SRC>, CM extends ComputedMap<SRC>
>(src: SRC, fieldMap: FM, computedMap: CM): MappedReturnType<SRC, FM, CM> {
    // impl .. not the point of the question
    return undefined!;
}

let mappedResult = mapJson(
    { date: "2018-10-04T00:00:00+0200", date2: 1538604000000, aString: "Hello%20World", idempotentValue: "foo" },
    // Without `: number`, `ts` is inferred as any:
    // probably https://github.com/Microsoft/TypeScript/issues/24694.
    { date: Date.parse, date2: (ts: number) => new Date(ts), aString: unescape},
    {computed: (_, obj) => `${obj?`${obj.aString}__${obj.idempotentValue}`:''}` }
);

let v1 = mappedResult.date; // number, expected
let v2 = mappedResult.date2; // Date, expected
let v3 = mappedResult.aString; // string, expected
let v4 = mappedResult.idempotentValue; // string, expected
let v5 = mappedResult.computed; // string, expected

Note that the contextual typing of the parameters of field mapping functions still isn't working, possibly due to this TypeScript issue, which you can vote for.

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

What's the most effective method for adding fields to an enum in Angular, similar to Swift or Kotlin?

Coming from the world of mobile development, I am currently delving into Angular for a new project. I find it quite convenient to add fields to an enum in languages like Swift or Kotlin. For instance, in Swift: enum Status: Int { case connected = 0, di ...

Implementing React custom component with conditional typing

My goal is to enable other developers to set a click handler for a button only if the button's type is set to button. Users can only set the type to either button or submit. I want to restrict developers from setting the onClick property on the comp ...

Error: Code layer not located while utilizing "sam invoke local" in AWS

Currently, I am engaged in an AWS project where I am developing two lambda functions. Both of these functions rely on a common codebase stored in the node_modules directory, which is placed in a separate layer named AWS::Lambda::LayerVersion, not to be con ...

Alert: [Vue warning]: No valid handler found for event "click"

Currently, I am utilizing Vue 2 with class syntax and Typescript with TSX syntax. Despite encountering similar inquiries before, none of the proposed solutions seem to fit my specific situation. My scenario involves creating an object array and displaying ...

The Observable<Response> type cannot be assigned to an Observable<List<Todo>> type

I'm currently working on a project that is somewhat inspired by this example, but I've encountered a TypeScript error and I would appreciate some guidance on what might be causing it. As far as I can tell, I am following the correct procedures. ...

Typescript headaches: Conflicting property types with restrictions

Currently, I am in the process of familiarizing myself with Typescript through its application in a validation library that I am constructing. types.ts export type Value = string | boolean | number | null | undefined; export type ExceptionResult = { _ ...

What is the proper way to define the type when passing a function as a component prop, with or without parameters?

import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; import getQueryClient from '@/lib/react-query/getQueryClient'; export async function RQBoundary<T>({ children, queryKey, fn, }: { children: React.Reac ...

What could be the reason for my Angular 2 app initializing twice?

Can someone help me figure out why my app is running the AppComponent code twice? I have a total of 5 files: main.ts: import { bootstrap } from '@angular/platform-browser-dynamic'; import { enableProdMode } from '@angular/core'; impor ...

Verify that each interface in an array includes all of its respective fields - Angular 8

I've recently created a collection of typed interfaces, each with optional fields. I'm wondering if there is an efficient method to verify that all interfaces in the array have their fields filled. Here's the interface I'm working wit ...

Encountering error 2307 "Cannot find module" when using Vue 3 with script setup and TypeScript

I am currently attempting to run unit tests in my Vue3 project using vue/test-utils and jest. Upon running the npm run test script, the test fails due to an error with the import: error TS2307: Cannot find module 'pathtofile/file.vue' I have tr ...

Is it possible to use Eclipse for debugging AngularJS and TypeScript code?

I recently dove into the world of TypEcs and am currently working on developing a webpage using Typescript and AngularJS that I'd like to debug in Eclipse. Is it feasible to debug a TypeScript and Angular page in Eclipse? If so, could you provide m ...

Tips for synchronizing response JSON with TypeScript interface in Angular 6

I am retrieving a list of files that have been uploaded from a backend endpoint, and it comes back in this format: [ { "filename": "setup.cfg", "id": 1, "path": C:\\back-end\\uploads\\setup.cfg", ...

Encountering an issue: JwPagination in angular 9 throws an error of "Cannot read property 'currentValue' of undefined"

Currently using Jw pagination with a page size that changes on 5, 10, or 15 using a dropdown. The Angular version being used is angular 9. The HTML code snippet for this functionality looks like: <div class="col-md-6"> <div ...

Angular 4 - The "ngForm" directive is missing the "exportAs" setting and cannot be found

I've encountered various responses to this issue on stackoverflow regarding the configuration of the app. However, despite being confident in the correctness of my configuration as the app runs smoothly, my Karma test fails inexplicably. Below is my ...

Converting an array into an object by using a shared property in each element of the array as the key

I have an item that looks like this: const obj = [ { link: "/home", title: "Home1" }, { link: "/about", title: "About2" }, { link: "/contact", title: "Contact1" } ] as const and I want to p ...

React with Mobx does not allow me to change the default value set by props

It seems that I am facing a common problem related to my understanding of React. I have recently started working on a major application using React and have encountered an issue. The problem can be summarized as follows: I have a class that is storing va ...

Opening a modal from a different component in Angular 6

I am attempting to launch a modal that is associated with a separate component. However, I encountered an error ERROR TypeError: Cannot read property 'show' of undefined Here is my code: product-catalog.component.html <app-cart-table-modal& ...

Struggling with making updates to an interface through declaration merging

I am encountering challenges with implementing declaration merging on an interface from a library that I created. An example illustrating the issue using StackBlitz can be viewed here: https://stackblitz.com/edit/typescript-qxvrte (issues persist in both ...

There is no accessor value for the form control identified by the name ''

An issue has arisen: No value accessor for form control with name: 'sNo' It seems that formControlName cannot be found in my DOM On the component side: this.form = new FormGroup({ 'sNo': new FormControl, 'question': new F ...

Angular 4 in combination with ngx-datatable is showing a 404 error for the @swimlane/ngx-datatable package

Just starting out with Angular and I kicked things off by running this command: git clone https://github.com/angular/quickstart appName I've made the upgrade to Angular 4 and everything seems to be in order. Here's the output I got after running ...