Exploring the depths of generic type narrowing in Typescript

Currently, I am in the process of constructing a form schema that prioritizes type safety. One particular aspect of the form requires performing keyof checking on a subset of the form type. However, I am facing challenges when it comes to narrowing down and passing the generic type to the sub type.

If you're interested, here is a link to the TypeScript playground where I've attempted to create a simplified version of my ongoing work: playground

My main objective is to ensure that the properties within the fields section of the FieldArray are also type safe, just like the Schema type. Unfortunately, I'm struggling with how to effectively narrow down or pass the type for the fields property.

type FieldType = "text-input" | "number" | "dropdown" | "checkbox";

type Field = {
  label: string;
  type: FieldType;
};

type FieldName<T> = T[keyof T] extends (infer I)[] ? I : never;

type FieldArray<T> = {
  type: "array";
  groupLabel: string;
  fields: Record<keyof FieldName<T> & string, Field>;
};

type SchemaField<T> = Field | FieldArray<T>;

type Schema<T> = Record<keyof T, SchemaField<T>>;

type Form = {
  workflowName: string;
  id: number;
  rules: { ruleName: string; isActive: boolean; ruleId: number }[];
  errors: { errorName: string; isActive: boolean; errorId: number }[];
};

const formSchema: Schema<Form> = {
  workflowName: { type: "text-input", label: "Name" },
  id: { type: "number", label: "Id" },
  rules: {
    type: "array",
    groupLabel: "Rules",
    fields: {
      ruleName: { label: "Rule Name", type: "text-input" },
      isActive: { label: "Is Active", type: "checkbox" },
      ruleId: { label: "Rule Id", type: "number" },
    },
  },
  errors: {
    type: "array",
    groupLabel: "Errors",
    fields: {
      errorName: { label: "Error Name", type: "text-input" },
      isActive: { label: "Is Active", type: "checkbox" },
      errorId: { label: "Error Id", type: "number" },
    },
  },
};

My expectation is that the fields property within rules and errors should adhere strictly to their definitions in Form.

For instance, any attempt to add additional properties in the fields object of errors beyond errorName, isActive, and errorId as defined in the Schema should trigger a type warning. However, this is not happening currently.

errors: {

    fields: {
      foo: { label: "Error Name", type: "text-input" },
      bar: { label: "Is Active", type: "checkbox" },
      baz: { label: "Error Id", type: "number" },
    },
  },

Answer №1

After reviewing your code snippet, my suggestion would be to define Schema<T> as shown below:

type Schema<T> = { [Key in keyof T]: SchemaProp<T[Key]> };

type SchemaProp<T> = T extends readonly (infer U)[] ?
  { type: 'array', groupLabel: string, fields: Schema<U> }
  : { type: FieldType, label: string }

Instead of utilizing the Record<K, V> utility type, where there is no direct relationship between the keys in K and their corresponding values in V, I opted for a structure-preserving approach using a mapped type. This way, each key K in keyof T is mapped to the property type SchemaProp<T[K]>.

The SchemaProp<T> function takes a property type T and transforms it into an appropriate "field" type. It employs a conditional type check to ascertain if T represents an array with element type U; if affirmative, it outputs an "array"-typed field with the fields property encapsulating a Schema<U>. Consequently, Schema<T> acts as a recursive type, allowing schemas of types containing nested arrays of various depths.


To validate this implementation, consider the following example:

const goodFormSchema: Schema<Form> = {
  workflowName: { type: 'text-input', label: 'Name' },
  id: { type: 'number', label: 'Id' },
  rules: {
    type: 'array',
    groupLabel: 'Rules',
    fields: {
      ruleName: { label: 'Rule Name', type: 'text-input' },
      isActive: { label: 'Is Active', type: 'checkbox' },
      ruleId: { label: 'Rule Id', type: 'number' },
    },
  },
  errors: {
    type: 'array',
    groupLabel: 'Errors',
    fields: {
      errorName: { label: 'Error Name', type: 'text-input' },
      isActive: { label: 'Is Active', type: 'checkbox' },
      errorId: { label: 'Error Id', type: 'number' }
    }
  }
}; // valid

This exemplar adheres to the defined schema structure. In contrast, an erroneous schema setup triggers informative feedback:

const badFormSchema: Schema<Form> = {
  workflowName: { type: 'text-input', label: 'Name' },
  id: { type: 'number', label: 'Id' },
  rules: {
    type: 'array',
    groupLabel: 'Rules',
    fields: {
      ruleName: { label: 'Rule Name', type: 'text-input' },
      isActive: { label: 'Is Active', type: 'checkbox' },
      ruleId: { label: 'Rule Id', type: 'number' },
    },
  },
  errors: {
    type: 'array',
    groupLabel: 'Errors',
    fields: {
      foo: { label: 'Error Name', type: 'text-input' }, // triggers error
      bar: { label: 'Is Active', type: 'checkbox' },
      baz: { label: 'Error Id', type: 'number' },
    }
  }
};

Note that depending on specific requirements, adjustments might be necessary to accommodate object types or union types within properties. Proper testing to ensure schema integrity is highly recommended.

For further customization or advanced scenarios, explore the provided Playground link to experiment with the code

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 is the best way to connect a toArray function to an interface property?

Consider the following scenario: interface D { bar: string } interface B { C: Record<string, D> // ... additional properties here } const example: B = { C: { greeting: { bar: "hi" } } // ... additional properties here } Now I would like t ...

Utilize Typescript type declarations within a switch case block

Struggling to develop a versatile function that returns the d3 scale, encountering an issue where it's recognizing the wrong type following the switch statement. import * as D3Scale from 'd3-scale'; enum scaleIdentites { linear, time, } i ...

Tips for modifying a static property in TypeScript

I am currently developing a class that wraps around a WebSocket to function as an ingestor. After successfully setting up the ingestion process and passing a function to the class instance for processing incoming messages, I encountered an issue. I need t ...

Issue arising in Angular 7 due to the binding between the TypeScript (ts) file and HTML file for the property [min]

I am currently utilizing Angular Cli version 7.3.9 In my project, I have implemented a date type input that is intended to display, in its datepicker, starting from the next day after the current date. This is how I approached it in my .ts file: debugger ...

Using Angular to condense or manipulate an array

I am working with a JSON response that contains arrays of arrays of objects. My goal is to flatten it in an Angular way so I can display it in a Material Table. Specifically, I want to flatten the accessID and desc into a flat array like [ADPRATE, QUOCON, ...

Extracting data from an array using Angular

Currently, I am developing an Angular application that involves handling an array structured like the one below. [ { Code: "123", Details:[ { Id: "1", Name: "Gary" }, { ...

The specified object '[object Object]' is not compatible with NgFor, which only supports binding to iterable data types like Arrays

I have some code that is attempting to access objects within objects in a template. <table class="table table-striped"> <tr *ngFor="let response of response"> <td><b>ID</b><br>{{ response.us ...

Utilizing absolute path imports in Vite originating from the src directory

What are the necessary changes to configuration files in a react + ts + vite project to allow for imports like this: import x from 'src/components/x' Currently, with the default setup, we encounter the following error: Failed to resolve import ...

Testing Material-UI's autocomplete with React Testing Library: a step-by-step guide

I am currently using the material-ui autocomplete component and attempting to test it with react-testing-library. Component: /* eslint-disable no-use-before-define */ import TextField from '@material-ui/core/TextField'; import Autocomplete from ...

Transform an array of FirebaseListObservables into an array of strings

UPDATED FOR MORE DETAIL: Imagine I have a collection of Survey objects and SurveyTaker objects in Firebase, with a relationship set up as follows: +-- surveyTakersBySurvey | +-- survey1 | | | +-- surveyTaker1 = true | +-- survey2 ...

Why do I keep encountering the error "global is not defined" when using Angular with amazon-cognito-identity-js?

To start, run these commands in the command line: ng new sandbox cd .\sandbox\ ng serve Now, navigate to http://localhost:4200/. The application should be up and running. npm install --save amazon-cognito-identity-js In the file \src&bso ...

How can I create an interceptor in Angular2 to detect 500 and 404 errors in my app.ts file?

Creating an Angular2 Interceptor for Handling 500 and 404 Errors in app.ts In my app.ts file, I am looking to implement an interceptor that can detect a 500 or 404 error so that I can appropriately redirect to my HTML 404 or HTML 500 pages. Is this funct ...

The retrieved item has not been linked to the React state

After successfully fetching data on an object, I am attempting to assign it to the state variable movie. However, when I log it to the console, it shows as undefined. import React, {useState, useEffect} from "react"; import Topbar from '../H ...

Error 405: Angular encounters a method not supported while attempting to delete the entity

I have developed an application that performs CRUD operations on a list of entities. However, when attempting to delete an entity, the dialog box does not appear as expected. To start, I have a HttpService serving as the foundation for the CRUD operations ...

Creating a loading screen in Angular 4: Insert an item into the HTML and then set it to disappear automatically after

I'm dealing with a loading screen that typically takes between 15-30 seconds to load about 50 items onto the page. The loading process displays each item on the page using the message: Loading item x For each data call made to the database, an obser ...

What is the best way to loop through an object in TypeScript and replace a string value with its corresponding number?

My situation involves handling data from a 3rd party API that consists of multiple properties, all stored as strings. Unfortunately, even numbers and booleans are represented as strings ("5" and "false" respectively), which is not ideal ...

A step-by-step guide on bundling a TypeScript Language Server Extensions LSP using WebPack

Currently, I am working on a language server extension for vs-code that is based on the lsp-sample code. You can find the code here: https://github.com/microsoft/vscode-extension-samples/tree/master/lsp-sample My challenge lies in WebPacking the extension ...

What is the best way to leverage TypeScript for destructuring from a sophisticated type structure?

Let's consider a scenario where I have a React functional component that I want to implement using props that are destructured for consistency with other components. The component in question is a checkbox that serves two roles based on the amount of ...

Tips for merging arrays conditionally in JavaScript

Currently facing a dilemma without a clear solution, aiming to provide minimal context and focus solely on the problem at hand A worker logs their daily hours at work, stored in the "dayli_data" array. If the worker misses logging hours, it triggers the " ...

Angular local storage override problem

For system authentication purposes, I am storing the access token in the local storage. When User-A logs in, their access token is saved in the local storage without any issues. However, if User-B opens another tab of the same project and logs in, their ...