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:
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:
- Model: Using type definition in TypeScript.
- Validator: Using
zod
to build up validators. - 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:
- Name is defined as string with no doubt.
- Age is a number, an positive integer actually, but TypeScript only provide
number
type, so we have no choice. - Gender is defined as an enum called
Gender
, actually it is also anumber
.
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:
- Name: Empty string is not allowed.
- Age: Only nonnegative integer is allowed. No -5 year old, no 15.5 year old.
- 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.