What steps should I take to correctly identify the type in this specific situation?

Let's consider the function f, defined as follows:

function f<T extends Fields = Fields>(props: Props<T>) {
  return null;
}

In this context, T represents a generic type that extends Fields. The concept of Fields is captured by the following definition:

export type Fields = { [key: string]: unknown };

Additionally, the Props interface is outlined as:

export interface Props<T extends Fields = Fields> {
  fields: Config<T>;
  onSubmit?: (values: Values<T>) => void;
}

The Props interface accommodates a generic type T which extends Fields. It consists of two key properties: fields and onSubmit. The fields property conforms to the type Config<T>, while the onSubmit property encapsulates an optional function that accepts values of type Values<T> and returns void.

To provide further clarification, let's explore the definitions of Config and Values:

type BaseProps<T> = {
  initialValue: T;
  hidden?: boolean;
};

export interface TextInput extends BaseProps<string>, TextInputProps {
  type: 'text';
}

export interface Checkbox extends BaseProps<boolean> {
  type: 'checkbox';
}

type Config<T> = { [K in keyof T]: TextInput | Checkbox };
export type Values<T extends Fields> = {
  [K in keyof T]: Config<T>[K]['initialValue'];
};

In essence, Config<T> generates a mapped type where each key in T corresponds to either a TextInput or Checkbox. On the other hand, Values<T> transforms each key in T into the initial value associated with the respective field in Config<T>.

Based on these definitions, function f expects props of type Props containing information about form fields and an optional submit function. The fields property leverages a mapped type (Config<T>), while the initial values are extracted using another mapped type (Values<T>).

A central question emerges regarding the accuracy of automatic type inference for Values. Currently, the type inferred for values.age is string | boolean. This ambiguity arises from the 'or' operator within Config, allowing for either a TextInput or Checkbox.

This concern delves beyond technical considerations, touching upon design implications. Are there structural adjustments that can refine type inference precision? Or does this inherent ambiguity align with the existing design?

In essence, we aim to refine the type inference mechanism to ascertain the precise type of values.age.

f({
  fields: {
    name: {
      type: 'text',
      initialValue: 'John Doe',
    },
    age: {
      initialValue: true,
      type: 'checkbox',
    },
  },
  onSubmit: (values) => {
    console.log(values.age);
  },
});

The comprehensive content is provided here for easy reference.


type BaseProps<T> = {
  initialValue: T;
  hidden?: boolean;
};

export interface TextInput extends BaseProps<string> {
  type: 'text';
}

export interface Checkbox extends BaseProps<boolean> {
  type: 'checkbox';
}

type Config<T> = { [K in keyof T]: TextInput | Checkbox };
export type Fields = { [key: string]: unknown };
export type Values<T extends Fields> = {
  [K in keyof T]: Config<T>[K]['initialValue'];
};

export interface Props<T extends Fields = Fields> {
  fields: Config<T>;
  onSubmit?: (values: Values<T>) => void;
}

function f<T extends Fields = Fields>(props: Props<T>) {
  return null;
}

f({
  fields: {
    name: {
      type: 'text',
      initialValue: 'John Doe',
    },
    age: {
      initialValue: true,
      type: 'checkbox',
    },
  },
  onSubmit: (values) => {
    console.log(values.age);
  },
});

Multiple approaches were attempted to enhance type inference accuracy, but unfortunately, no definitive solution was achieved.

Answer №1

To narrow down the fields to an object with properties that are part of the union TextInput | Checkbox, based on the type property, we can create a mapping from that property to the allowed union members:

type AllowableProps = TextInput | Checkbox;

type TypeMap = { [T in AllowableProps as T['type']]: T };
/* type TypeMap = {
    text: TextInput;
    checkbox: Checkbox;
} */

(Feel free to add more members to the AllowableProps as required.)

This mapping allows us to introduce generics in an object type T, linking field names to the input type, such as

{name: "text", age: "checkbox"}
:

type Config<T extends Record<keyof T, keyof TypeMap>> =
  { [K in keyof T]: { type: T[K] } & TypeMap[T[K]] };

interface Props<T extends Record<keyof T, keyof TypeMap>> {
  fields: Config<T>;
  onSubmit?: (values: { [K in keyof T]: TypeMap[T[K]]['initialValue'] }) => void;
}

function f<T extends Record<keyof T, keyof TypeMap>>(props: Props<T>) {
  return null;
}

We've limited T to a type where the property values are keys from TypeMap.

The Config<T> type maps the properties of T to the appropriate fields property type; for each key K in keyof T, T[K] corresponds to the relevant key in TypeMap. Therefore, TypeMap[T[K]] will be either TextInput or Checkbox</code based on <code>K. By intersecting with {type: T[K]}, the compiler can infer T from fields by inspecting the type property.

The onSubmit type is a function with an argument of mapped type

{ [K in keyof T]: TypeMap[T[K]]['initialValue'] }
, allowing retrieval of the underlying data type by indexing into either TextInput or
Checkbox</code using the <code>initialValue
key.


Let's try it out:

f({
  fields: {
    name: {
      type: 'text',
      initialValue: 'John Doe',
    },
    age: {
      initialValue: true,
      type: 'checkbox',
    },
  },
  onSubmit: (values) => {
    console.log(values.age);
    //                 ^? (property) age: boolean
  },
});

Everything seems to work correctly. When you check the f call with IntelliSense, you'll notice that T is inferred as {name: 'text', age: 'checkbox'}, as expected. Consequently, values will be contextually typed as {name: string, age: boolean}, delivering the intended behavior.

Playground link for code testing

Answer №2

Starting from scratch seemed like the easier route for me:

type Controls = {
    checkbox: boolean,
    text: string,
}

type Field = {[C in keyof Controls]: {
    type: C,
    initialValue: Controls[C],
}}[keyof Controls];

function f<F extends {[K in string]: Field}>(props: {
    fields: F;
    onSubmit: (values: {[K in keyof F]: Controls[F[K]['type']]}) => void;
}) {

}

If you use it as follows:

f({
  fields: {
    name: {
      type: 'text',
      initialValue: 'John Doe',
    },
    age: {
      type: 'checkbox',
      initialValue: true,
    },
  },
  onSubmit: (values) => {

  },
});

The compiler validates:

  1. That type is a recognized control type,
  2. That the specified initialValue is allowed by this control type

and deduces that values has the type

(parameter) values: {
    name: string;
    age: boolean;
}

Great attention was given to ensure that the inference of values only considers the types of the controls, not the type of the initialValue: We don't want age to be inferred as true simply because the initialValue is known to be true.

Update

Your original approach is sound, but it limits the ability to provide different props for the TextInput and Checkbox interfaces in this scenario

In such cases, you can begin with a union type and derive the mapping from there:

type Field = {
    type: "checkbox";
    initialValue: boolean;
} | {
    type: "text";
    initialValue: string;
    maxLength: number;
}

type FieldType = {[T in Field['type']]: (Field & {type: T})['initialValue']};

function f<F extends {[K in string]: Field}>(props: {
    fields: F;
    onSubmit: (values: {[K in keyof F]: FieldType[F[K]['type']]}) => void;
}) {

}


f({
  fields: {
    name: {
      type: 'text',
      initialValue: 'John Doe',
      maxLength: 50,
    },
    age: {
      type: 'checkbox',
      initialValue: true,
    },
  },
  onSubmit: (values) => {

  },
});

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

Angular does not permit the use of the property proxyConfig

Click here to view the image I encountered an issue when attempting to include a proxy config file in angular.json, as it was stating that the property is not allowed. ...

Creating a dynamic selection in Angular without duplicate values

How can I prevent repetition of values when creating a dynamic select based on an object fetched from a database? Below is the HTML code: <router-outlet></router-outlet> <hr> <div class="row"> <div class="col-xs-12"> & ...

Passing asynchronous data from method1 to method2 without impacting the functionality of the script responsible for fetching the asynchronous data in method1

When working with TypeScript, I encountered an issue while trying to invoke an external script called SPCalendarPro within a private method that asynchronously fetches data. The script is invoked in the following manner: private _getSPCalendarPro() { con ...

Running an ESNext file from the terminal: A step-by-step guide

Recently, I delved into the world of TypeScript and developed an SDK. Here's a snippet from my .tsconfig file that outlines some of the settings: { "compilerOptions": { "moduleResolution": "node", "experimentalDecorators": true, "module ...

"Exploring the Power of TypeScript Types with the .bind Method

Delving into the world of generics, I've crafted a generic event class that looks something like this: export interface Listener < T > { (event: T): any; } export class EventTyped < T > { //Array of listeners private listeners: Lis ...

Example TypeScript code: Use the following function in Angular 5 to calculate the total by summing up the subtotals. This function multiplies the price by the quantity

I have a table shown in the image. I am looking to create a function that calculates price* quantity = subtotal for each row, and then sum up all the subtotals to get the total amount with Total=Sum(Subtotal). https://i.stack.imgur.com/4JjfL.png This is ...

Having trouble with Angular's ActivatedRoute and paramMap.get('id')?

Currently, I am attempting to retrieve information from my server using the object's ID. The ID can be found in the URL as well: http://xyz/detail/5ee8cb8398e9a44d0df65455 In order to achieve this, I have implemented the following code in xyz.compo ...

What is the best way to pass a variable from a class and function to another component in an Angular application?

One of the components in my project is called flow.component.ts and here is a snippet of the code: var rsi_result: number[]; @Component({ selector: 'flow-home', templateUrl: './flow.component.html', styleUrls: ['./flow.comp ...

Adding an event listener to the DOM through a service

In the current method of adding a DOM event handler, it is common to utilize Renderer2.listen() for cases where it needs to be done outside of a template. This technique seamlessly integrates with Directives and Components. However, if this same process i ...

Why does the CSHTML button containing a JavaScript onclick function only function intermittently?

I've implemented a download button on a webpage that dynamically assigns an ID based on the number of questions posted. Below is the code for the button: <input data-bind="attr: { id: $index() }" type="button" value="Downlo ...

"React with Typescript - a powerful combination for

I'm facing an issue trying to create a simple list of items in my code. Adding the items manually works, but when I try to map through them it doesn't work. Apologies for any language mistakes. import './App.css' const App = () => { ...

Create a function that takes in a function as an argument and generates a new function that is identical to the original function, except it has one

I want to establish a function called outerFn that takes another function (referred to as innerFn) as a parameter. The innerFn function should expect an object argument with a mandatory user parameter. The main purpose of outerFn is to return a new functio ...

Implementing a conditional chaining function in TypeScript

I'm currently facing an issue while implementing a set of chained functions. interface IAdvancedCalculator { add(value: number): this; subtract(value: number): this; divideBy(value: number): this; multiplyBy(value: number): this; calculate( ...

Encountering error TS2304: Cannot resolve name 'classes' when attempting to apply styling in React using Typescript

I'm having trouble customizing the styles on my website, specifically when trying to add a custom className. Error Message: Cannot find name 'classes'. TS2304 Below is the code I am currently working with: import React from 'react& ...

Exciting Update: Previously, webpack version 5 did not automatically include polyfills for node.js core modules (such as React JS, TypeScript, and JWT)!

Having trouble verifying the jwt token in React with TypeScript and encountering this error, how can I fix it? ` const [decodedToken, setDecodedToken] = useState<null | JwtPayload | string>(null); const verifyToken = (token: string) => { t ...

Assigning enum type variable using string in TypeScript

How can I dynamically assign a value to a TypeScript enum variable? Given: enum options { 'one' = 'one', 'two' = 'two', 'three' = 'three'} let selected = options.one I want to set the variable " ...

Issue encountered when attempting to run "ng test" in Angular (TypeScript) | Karma/Jasmine reports an AssertionError stating that Compilation cannot be undefined

My development setup includes Angular with TypeScript. Angular version: 15.1.0 Node version: 19.7.0 npm version: 9.5.1 However, I encountered an issue while running ng test: The error message displayed was as follows: ⠙ Generating browser application ...

Ensuring TypeScript recognizes a class property as definitively initialized when set using a Promise constructor

I have a simple class definition that is giving me an error in TypeScript. class Container { resolveData: (s: string) => void // not definitely initialized error! data: Promise<string> constructor() { this.data = new Promise&l ...

A step-by-step guide on dynamically binding an array to a column in an ag

I am currently working with the ag-grid component and I need to bind a single column in a vertical format. Let's say I have an array ["0.1", "0.4", "cn", "abc"] that I want to display in the ag-grid component as shown below, without using any rowData. ...

Angular: ChangeDetection not being triggered for asynchronous processes specifically in versions greater than or equal to Chrome 64

Currently, I'm utilizing the ResizeObserver in Angular to monitor the size of an element. observer = new window.ResizeObserver(entries => { ... someComponent.width = width; }); observer.observe(target); Check out this working example ...