TypeScript makes JavaScript even stronger with a strongly-typed system, which is a merit for developers. TypeScript checks our static code before running it. That is so called type safety. However, runtime safety is checking our code at runtime with real data. In runtime, TypeScript files are already compiled to JavaScript. So static type check is not available.

It means: type safe does not mean runtime safe

The post is divided into two parts.

In part 1 here, I want to discuss about runtime validation. And in part 2, I want to share some use cases with Nuxt3 as example.

Prerequisite

  • Basic knowledge: JavaScript, TypeScript, zod

A Story

It starts with a story. Here we have two developers, John & Bill.

John wrote a utility function and shared to his team:

// file: utils.ts
export function printList(list: (string | number)[]): void {
  list.forEach((item) => {
    console.log(item);
  });
}

The parameter list is limited to array, TypeScript will take an eye on everyone who call this function, and check if type of list is correct. It seems no problem to John. So he transpiled it into JavaScript, here is the result:

// file: utils.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.printList = void 0;
function printList(list) {
    list.forEach(function (item) {
        console.log(item);
    });
}
exports.printList = printList;

One day, Bill imported this file to use printList function:

// file: product.js
const { printList } = require("./utils.js");

const myList = [1, 2, 3, "a", "b", "c"];

console.log(printList(myList));

const myIllegalData = "hello"; // <-- this is illegal!

console.log(printList(myIllegalData)); // <-- will throw error

Without type definition, Bill used illegal parameter accidentally, then the function was broken after ran it:

runtime error

When John created the function, he expected the parameter should be an array. But someone may use it in the wrong way. (without type definition file *.d.ts)

Expected parameter is different with actual parameter, which means type (or model) is different from data. I call it mismatch (or misalignment) between type and data personally. If John did runtime check, the function would be unbreakable.

Runtime Validation

It is crucial to do runtime check, more then type check in my own opinion. As an frontend developer, we always get data from outside our code, mostly from API. External sources are likely to change or update without notifying us. So it is important to do validation right away when we got some data.

I recently use a validation library called zod, which is very suitable for runtime validation.

As the documentation introduced itself:

TypeScript-first schema validation with static type inference

It has good capability to TypeScript, and can do validation with type inference.

From Model, Validator To Data

Personally, I would like to define a model first, because model is the blueprint of data. Second, we need validator to validate the data at runtime. Since I am using zod, it is very typed-friendly to infer an validator with model. Then when receive data, we use the validator to do the validation.

Here we have three parts:

  1. Model: Using type definition in TypeScript.
  2. Validator: Using zod to build up validators.
  3. Data: Validate the data using validators.

In fact, both model and validator make restriction to the data. The difference is, models restrict data statically (before runtime), validators restrict data dynamically (at runtime). Besides, validators can do stricter restriction than what models do. I will explain it later.

As Single Source of Truth or SSOT, our truth here is the model. The shape of a validator is inferred by the model, no matter what other strict validation we put on, it is still under restriction of the model. Then the data should follow the rule that validator provided.

Define the Model

Model is a blueprint of data, or origin of data. Each data value might vary, but data type should remain the same.

For example we have a model User to define what a user should look like:

enum Gender {
  Male,
  Female,
  Other
}

type User {
  name: string;
  age: number;
  gender: Gender;
}

So an user includes name, age, gender as above:

  1. Name is defined as string with no doubt.
  2. Age is a number, an positive integer actually, but TypeScript only provide number type, so we have no choice.
  3. Gender is defined as an enum called Gender, actually it is also a number.

Define the Validator

Here we use zod to build the validator, zod will check the validator with the type we provided:

const userValidator: z.ZodSchema<User> = z.object({
  name: z.string().min(1),
  age: z.number().int().nonnegative(),
  gender: z.nativeEnum(Gender),
})

As we can see, in validator, we define our value precisely:

  1. Name: Empty string is not allowed.
  2. Age: Only nonnegative integer is allowed. No -5 year old, no 15.5 year old.
  3. Gender: Here we only have male (0), female (1), other (2), other values are not allowed.

Here is what I said validator is stricter than model.

Execute the Validation

After that, we can validate data with it at anywhere we want:

const userValidation = userValidator.safeParse(userData);

if(userValidation.success){
  // validation is passed, congrats!
  // ...
} else {
  // validation is not passed, throw error, print logs or do anything to handle errors
  // ...
}

So we have much confident with our code right now! No more fear about being bombarded by illegal data!

In the next part, I want to discuss with some use cases.

Hope this post is helpful to you!

Happy coding.

Reference