prscrew.com

Unlocking the Mysteries of Type Generation in TypeScript

Written on

Chapter 1: Introduction to Type Generation

In my journey across the country to present at a TypeScript meetup, I was eager to share my insights. After my talk, another speaker took the stage, discussing a topic that seemed simple but was incredibly important: data validation in TypeScript.

Having started my career as a Java developer, I frequently used various validation libraries. However, upon transitioning to TypeScript, I noticed a significant lack of similar tools. It appeared that the concept of validation was just starting to gain traction within the JavaScript ecosystem.

The speaker introduced io-ts, which can derive types from validation schemas with ease. This notion sparked my curiosity: could I generate types from diverse objects, such as a database schema? I began to delve into the workings of tools like Sequelize, Prisma, and Zod to grasp type inference better. This narrative encapsulates my discoveries and outlines how to produce types using a tailored schema.

Chapter 2: Types of Type Generation

I have categorized type generation in TypeScript into two main types: static and dynamic. Static generation utilizes code generators, while dynamic generation employs TypeScript inference to deliver real-time types.

Section 2.1: Static Type Generation

Many libraries or frameworks can create type definitions based on schema files. A prime example of this is Prisma, which generates both types and client code.

You can envision static type generation as a three-step process:

  1. Create or update schema files.
  2. Execute the code generator via the CLI.
  3. Incorporate the generated code into your project.

Typically, schema files can be written in any language allowed by the tool's creators. Some may even choose a domain-specific language (DSL) to enable non-TypeScript users to contribute to schema files.

For instance, the developers of Prisma created a data modeling language. Below is a sample snippet:

generator client {

provider = "prisma-client-js"

output = "./client"

}

datasource db {

provider = "sqlite"

url = env("DATABASE_URL")

}

model User {

id String @unique

}

This snippet illustrates both the instructions for code generation and the model itself.

To generate TypeScript code, I can execute the following command:

npm exec prisma generate

Prisma will then create code in the specified directory, as shown in the example below:

import { randomBytes } from "node:crypto";

import { PrismaClient } from "./prisma/client";

const createUser = async () => {

const prisma = new PrismaClient();

try {

const id = randomBytes(16).toString("base64url");

return await prisma.user.create({

data: {

id,

},

});

} finally {

await prisma.$disconnect();

}

}

If you're interested in designing database schemata, consider reading my related article.

Section 2.2: Advantages of Static Type Generation

Static type generation allows us to separate TypeScript from the structures of business logic. It also empowers non-developers to independently contribute to schema files, making it simpler to explain DSL concepts rather than conventional programming principles.

Moreover, schema files are typically organized in specific directories with characteristic extensions, making them easier to locate in the codebase.

While TypeScript inference is powerful, it can also be limiting in certain scenarios. I argue that language architects need to impose restrictions on inference to maintain the language's manageability. I previously wrote a case study on the necessary limitations of TypeScript inference.

Section 2.3: Challenges of Static Type Generation

Thus far, I've discussed how to produce code and types using domain-specific languages. Interestingly, we can also generate code based on existing TypeScript files.

Consider an example where you need to enforce specific directory structures and naming conventions for each project file. For instance, you might organize database models into files within a 'models' directory, with each filename reflecting the model's name. Each file must export the model schema under its designated name.

To ensure proper type discipline, you can implement a generator that reads the folder structure and constructs type definitions. After creating the models, you need to run the generator.

However, this method can lead to ongoing type discrepancies. If you alter the models, the changes might not be mirrored in the existing type definitions. To ensure consistency, you must run the generator continuously until no TypeScript errors arise.

TypeScript Type Generation Overview

Additionally, all schema modifications necessitate running a command afterward, which inexperienced engineers may easily overlook.

Given that TypeScript boasts impressive type inference, one might question the need for static type generation altogether. A sound rationale is essential for pursuing this approach.

Should we include the generated types in source control? This likely depends on what is generated.

Chapter 3: Dynamic Type Generation

Now, let’s explore dynamic type generation through an example. Imagine we are tasked with developing a new ORM in TypeScript. We would begin by creating an abstraction for SQL data types. First, we need to recognize that columns may permit nullable values.

We can use unique symbols to differentiate between nullable and non-nullable columns:

export const NULL: unique symbol = Symbol();

export const NOT_NULL: unique symbol = Symbol();

The use of symbols is intentional. Here’s the definition for the TNULLABLE type, which anchors nullability within specific data types:

type TNULLABLE = typeof NULL | typeof NOT_NULL;

In this case, we won't control nullability with a simple boolean; instead, we’ll embed it within a generic type associated with the NULL and NOT_NULL symbols.

Next, let’s define the TCHAR type:

type TCHAR = Readonly<{

type: "char";

length: L;

nullable: N;

}>;

In this definition, N denotes nullability, while L sets the length of a SQL CHAR column.

Then, we create a builder for a CHAR column, using the term "builder" instead of class constructor:

const CHAR = (nullable: N, length: L): TCHAR => ({

type: "char",

length,

nullable,

});

We can now use CHAR(NULL, 255) instead of CHAR(false, 255), making the former expression clearer without needing additional context.

Next, let’s define the SQL INTEGER columns:

type TINTEGER = Readonly<{

type: "integer";

nullable: N;

}>;

const INTEGER = (nullable: N): TINTEGER => ({

type: "integer",

nullable,

});

Now, we are ready to define the schema type, which can be viewed as an object containing key-value pairs, where keys represent column names and values provide corresponding column definitions:

export type Schema = Readonly<{

[K in string]?: TCHAR | TINTEGER;

}>;

After establishing the schema type, we can proceed with an example:

const schema = {

a: CHAR(NULL, 255),

b: CHAR(NOT_NULL, 1),

c: INTEGER(NULL),

d: INTEGER(NOT_NULL),

} satisfies Schema;

I purposely used the satisfies keyword to avoid assigning the schema to the Schema type directly, which could adversely affect inference.

Now, let’s delve into the most intriguing aspect — inference! We’ll define two helper types as follows:

type NullableString = N extends typeof NOT_NULL

? string : string | null;

type NullableNumber = N extends typeof NOT_NULL

? number : number | null;

The generic type will toggle nullability on and off. I consider these helpers a common strategy in TypeScript type generation.

Finally, we can define the type used for inference:

type Attributes = {

[K in keyof T]: T[K] extends TCHAR

? NullableString : T[K] extends TINTEGER

? NullableNumber : never;

};

Let me break this down:

  1. The generic type corresponds to the schema type.
  2. Only the values are transformed; the keys remain unchanged.
  3. If the value is of the TCHAR type, we infer the nullability generic type N and pass it to the NullableString helper, resulting in either string | null or string based on N.
  4. A similar process is applied for the TINTEGER type, with never assigned if no specific type matches.

You can utilize the Attributes type as follows:

type A = Attributes;

You should receive the following definition for type A:

type A = {

a: string | null;

b: string;

c: number | null;

d: number;

}

Chapter 4: Advantages of Dynamic Type Generation

One of the primary advantages of dynamic type generation is that it keeps the schema within TypeScript itself.

  1. No need to invent another language for the schema.
  2. Any changes to the schema lead to immediate type implications across the project.
  3. There’s no need to run generators after each schema modification.

Chapter 5: Disadvantages of Dynamic Type Generation

As previously mentioned, TypeScript's inference has limitations. In certain situations, switching to static type generation may alleviate the need for crafting complex type inference helpers.

Moreover, keeping the schema in TypeScript relies on developers for updates. It’s unrealistic to expect non-technical individuals to handle the intricacies of a programming language.

Conclusions

Before opting for static or dynamic type generation, it’s crucial to evaluate the problem and explore all available options. Is type generation genuinely the best solution to my challenge? Are there more straightforward but less technical alternatives?

Making informed decisions is transformative in our industry!

We should also acknowledge that TypeScript architects will likely enhance type inference capabilities over time. Having used the language for six years, I have been astounded by the numerous new features and improvements!

I hope this narrative has clarified the nuances of type generation and sparked your interest in type inference.

For further reading, check out my article on the practical implementation of QR codes below:

Share the page:

Twitter Facebook Reddit LinkIn

-----------------------

Recent Post:

Embracing Change: My Journey from Therapist to New Beginnings

Discover how I recognized my need for change in my career, transitioning from family therapy to pursuing new passions.

Science and the Rise of Atheism: A Copernican Perspective

This article explores how the Copernican Revolution reshaped our understanding of the universe, undermining theistic beliefs and promoting atheism.

Boost Your Productivity Instantly with These 5 Strategies

Discover five effective strategies to enhance your productivity and manage distractions in your daily life.