Navigating Circular Relationships in TypeScript

Herein lies a question about statically inferring the signature of runtime types, as seen in popular libraries like zod and io-ts.

If you want to see an example in action, check out this TS playground link.

Let's say we're attempting to model type information for runtime usage. To start off, we can define the following enum called Type:

enum Type {
  Boolean = "Boolean",
  Int = "Int",
  List = "List",
  Union = "Union",
}

This runtime type system should be able to handle booleans, integers, unions, and lists.

The base type structure looks like this:

interface Codec<T extends Type> {
  type: T;
}

For boolean and integer types, they utilize this base type in the following manner:

Boolean:

class BooleanCodec implements Codec<Type.Boolean> {
  type = Type.Boolean as const;
}

Integer:

class IntCodec implements Codec<Type.Int> {
  type = Type.Int as const;
}

The union type takes an array of types to combine them:

class UnionCodec<C extends Codec<Type>> implements Codec<Type.Union> {
  type = Type.Union as const;
  constructor(public of: C[]) {}
}

And the list type defines the type of which its elements are made up:

class ListCodec<C extends Codec<Type>> implements Codec<Type.List> {
  type = Type.List as const;
  constructor(public of: C) {}
}

Now let's create a list consisting of booleans or integers:

const listOfBooleanOrIntCodec = new ListCodec(
  new UnionCodec([
    new BooleanCodec(),
    new IntCodec(),
  ]),
);

This results in the object below:

{
  type: Type.List,
  of: {
    type: Type.Union,
    of: [
      {
        type: Type.Boolean,
      },
      {
        type: Type.Int,
      },
    ]
  }
}

Such codec would have a signature of

ListCodec<UnionCodec<BooleanCodec | IntCodec>>
.

Sometimes there might be cycles within a given codec, making mapping the type signature more complex. How do we go from the above representation to (boolean | number)[]? Also, does it incorporate deep nesting of codecs?

Decoding BooleanCodec or IntCodec is relatively straightforward... However, decoding UnionCodec and ListCodec requires recursive operation. I attempted the following:

type Decode<C extends Codec<Type>> =
  // if it's a list
  C extends ListCodec<Codec<Type>>
    ? // and we can infer what it's a list of
      C extends ListCodec<infer O>
      ? // and the elements are of type codec
        O extends Codec<Type>
        ? // recurse to get an array of the element(s') type
          Decode<O>[]
        : never
      : never
    : // if it's a union
    C extends UnionCodec<Codec<Type>>
    // and we can infer what it's a union of
    ? C extends UnionCodec<infer U>
      // and it's a union of codecs
      ? U extends Codec<Type>
        // recurse to return that type (which will be inferred as the union)
        ? Decode<U>
        : never
      : never
      // if it's a boolean codec
    : C extends BooleanCodec
    // return the boolean type
    ? boolean
    // if it's ant integer codec
    : C extends IntCodec
    // return the number type
    ? number
    : never;

Regrettably, it throws errors like

Type alias 'Decode' circularly references itself
and Type 'Decode' is not generic.

I am curious if achieving this kind of cyclical type-mapping is possible and how to make a utility like Decode function effectively. Any assistance on this matter would be highly appreciated. Thank you!

Answer №1

My approach usually involves defining types first and then deriving a generic codec based on them, rather than explicitly constructing codecs.

For instance: Start by defining your types with some data and encoding their relationships (such as list items and union values):

type Type = Integer | List<any> | Union<any>;
interface Integer {
  type: 'integer';
}
interface List<T extends Type> {
  type: 'list';
  item: T;
}
type UnionValues = Type[];
interface Union<T extends UnionValues> {
  type: 'union';
  values: T;
}

Add helper functions for creating these types:

const integer: Integer = { type: 'integer' };
const list = <T extends Type>(item: T): List<T> => ({
  type: 'list',
  item
});
const union = <T extends UnionValues>(...values: T): Union<T> => ({
  type: 'union',
  values
});

Next, implement a recursive type-mapping function that links a Type to its corresponding JavaScript type:

type Decode<T> =
  // Base case: Integer is mapped to a number
  T extends Integer ? number :
  // Extract the Item from the list and construct an Array recursively
  T extends List<infer I> ? Decode<I>[] :
  // For unions, loop through and decode each type
  T extends Union<infer U> ? {
    [i in Extract<keyof U, number>]: Decode<U[i]>;
  }[[Extract<keyof U, number>]] :
  never
  ;

Define your codec as a mapping function from Type to Value:

interface Codec<T extends Type, V> {
  type: T;
  read(value: any): V;
}

Create a function that maps a type instance to its Codec:

function codec<T extends Type>(type: T): Codec<T, Decode<T>> {
  // todo
}

Now you can easily map between your custom type system and JavaScript types:

const i = codec(integer);
const number: number = i.read('1');

const l = codec(list(integer));
const numberArray: number[] = l.read('[1, 2]');

const u = codec(union(integer, list(integer)));
const numberOrArrayOfNumbers: number | number[] = u.read('1');

The idea here is to mimic the process where developers define codecs that encode their specific types. While it may be complex due to tuple mapping, it captures the essence of your original goal.

For example, the Integer codec simply maps Integer to a number:

class IntegerCodec implements Codec<Integer, number> {
  public readonly type: Integer = integer;

  public read(value: any): number {
    return parseInt(value, 10);
  }
}

In ListCodec, we recursively compute a mapping from List to an array of ItemValues:

namespace Codec {
  export type GetValue<C extends Codec<any, any>> = C extends Codec<any, infer V> ? V : never;
}

class ListCodec<Item extends Codec<any, any>> implements Codec<List<Item['type']>, Codec.GetValue<Item>[]> {
  public readonly type: List<Item['type']>;
  constructor(public readonly item: Item)  {
    this.type = list(item.type);
  }

  public read(value: any): Codec.GetValue<Item>[] {
    return value.map((v: any) => this.item.read(v));
  }
}

Mapping over a tuple of codecs to determine types and values poses a challenge in UnionCodec:

We start by computing the Union Type from a tuple of Codecs:

type ComputeUnionType<V extends Codec<any, any>[]> = Union<Type[] & {
  [i in Extract<keyof V, number>]: V[i]['type']
}>;

Then, derive the Union JS Type from a Tuple of Codecs:

type ComputeUnionValue<V extends Codec<any, any>[]> = {
  [i in Extract<keyof V, number>]: Codec.GetValue<V[i]>;
}[Extract<keyof V, number>];

Finally, create UnionCodec to calculate the Type and JS Type of a Union:

class UnionCodec<V extends Codec<any, any>[]> implements Codec<
  ComputeUnionType<V>,
  ComputeUnionValue<V>
> {
  public readonly type: ComputeUnionType<V>;

  constructor(public readonly codecs: V) {}
  public read(value: any): ComputeUnionValue<V> {
    throw new Error("Method not implemented.");
  }
}

With these implementations, your example should now pass type-checking successfully:

const ic = new IntegerCodec();
const lc: ListCodec<IntegerCodec> = new ListCodec(new IntegerCodec());
const uc: UnionCodec<[ListCodec<IntegerCodec>, IntegerCodec]> = new UnionCodec([lc, ic]);

const listValue: number | number[] = uc.read('1');

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

Using the tensorflow library with vite

Greetings and apologies for any inconvenience caused by my relatively trivial inquiries. I am currently navigating the introductory stages of delving into front-end development. Presently, I have initiated a hello-world vite app, which came to life throug ...

What causes Node Express to interpret Django's boolean values as strings?

For some unknown reasons, I have a django backend that is required to send a request to an express nodejs API endpoint. However, the express server interprets the boolean field from django as a string. How can I resolve this issue? Here is an example of t ...

Async function causing Next JS router to not update page

I'm diving into the world of promises and JavaScript, but I've encountered an issue while working on a registration page following a tutorial on YouTube. Currently, I am using next.js with React and TypeScript to redirect users to the home page i ...

An error occured: Unable to access the 'taxTypeId' property since it is undefined. This issue is found in the code of the View_FullEditTaxComponent_0, specifically in the update

I am encountering an issue with a details form that is supposed to load the details of a selected record from a List Form. Although the details are displayed correctly, there is an error displayed on the console which ultimately crashes the application. T ...

ag-grid-angular failing to present information in a table layout

I have implemented ag-grid-angular to showcase data in a structured table format, but the information appears jumbled up in one column. The data for my ag-grid is sourced directly from the raw dataset. https://i.stack.imgur.com/sjtv5.png Below is my com ...

Provide the remaining arguments in a specific callback function in TypeScript while abiding by strict mode regulations

In my code, I have a function A that accepts another function as an argument. Within function A, I aim to run the given function with one specific parameter and the remaining parameters from the given function. Here's an example: function t(g: number, ...

React Native error - Numeric literals cannot be followed by identifiers directly

I encountered an issue while utilizing a data file for mapping over in a React Native component. The error message displayed is as follows: The error states: "No identifiers allowed directly after numeric literal." File processed with loaders: "../. ...

The value of "metadata" is not a valid export entry for Next.js

After I installed Next.js 14 with TypeScript, I encountered an error related to my metadata type definition. import type { Metadata } from "next"; export const metadata: Metadata = { title: "next app", description: "next app 1 ...

"What sets apart the usage of `import * as Button` from `import {Button}`

As a newcomer to Typescript, I am facing an issue while trying to import a react-bootstrap Button. In scenario 1: import {Button} from 'react-bootstrap/lib/Button' In scenario 2: import * as Button from 'react-bootstrap/lib/Button' B ...

Whenever I attempt to host my Node.js app using the GCP deploy command, it fails to work properly. The error message that appears states: "Module 'express' cannot be found."

My NodeJS application is written in TypeScript and utilizes the Express framework. I'm looking to host it on the GCP cloud using the gcloud app deploy command. First, I compile my TS sources to JavaScript - is this the correct approach? Afterwards, I ...

How can I effectively transfer parameters to the onSuccess callback function?

In my react-admin project, I'm utilizing an Edit component and I wish to trigger a function upon successful completion. <Edit onSuccess= {onSuccess } {...props}> // properties </Edit> Here's the TypeScript code for the onSuccess fun ...

Beautiful parentheses for Typescript constructors

I'm working on a project where I've installed prettier. However, I've noticed that it always reformats the code snippet below: constructor(public url: string) { } It changes it to: constructor(public url: string) {} Is there any way to sto ...

Surprising fraction of behavior

Looking for some clarification on the types used in this code snippet: interface UserDTO { id: string; email: string; } const input: Partial<UserDTO> = {}; const userDTO: Partial<UserDTO> = { id: "", ...input }; const email = us ...

Guide to accessing component methods within slots using the Vue 3 Composition API

I have child components within a slot in a parent component and I am trying to call methods on them. Here are the steps I followed: Use useSlots to retrieve the child components as objects Expose the method in the child component using defineExpose Call t ...

What is the correct way to link an array with ngModel using ngFor loop in Angular?

Utilizing ngModel within an ngFor iteration to extract values from a single input field like this : <mat-card class="hours" > <table id="customers"> <thead > <th >Project</th> ...

The message "Expected a string literal for Angular 7 environment variables" is

I'm currently working on setting up different paths for staging and production environments in my Angular project, following the documentation provided here. I have a relative path that works perfectly fine when hardcoded like this: import json_data f ...

Creating a 3D model by superimposing a map onto a DTM and exporting it as a

Seeking advice on how to overlay a map onto a DTM (digital terrain model) or DEM. I've already attempted using QGIS plugins openLayers and QGIS2Threejs, but the 3D model export doesn't include UV mapping which causes issues with textures when loa ...

Display real-time information in angular material table segment by segment

I need to incorporate service data into an Angular mat table with specific conditions as outlined below: If the difference between the start date and end date is less than 21 days, display 'dd/mm' between the 'start_date' and 'end ...

Can a string array be transformed into a union type of string literals?

Can we transform this code snippet into something like the following? const array = ['a', 'b', 'c']; // this will change dynamically, may sometimes be ['a', 'e', 'f'] const readonlyArray = arr ...

Error in Typescript React Component: Type 'Element' is not compatible with the parameter type

It's puzzling why there is an error here, to be honest, I can't figure it out. https://i.sstatic.net/Gm2Uj.jpg generateLinkSetsForNation function generateLinkSetsForNation(nation: Nation, enterprises: Array<Enterprise>) { let enterpri ...