Introduction
In today’s fast-paced world of web development, building robust and scalable apps is crucial for any application. JavaScript offers the ability to write scalable apps quickly but at the expense of
robustness, which is mainly due to the lack of a type safety layer during development and runtime.
Typescript and Zod have become popular tools for tackling the problem of JavaScript type safety during development and runtime respectively. Typescript adds a compilation layer to make JavaScript verbose and specific. Zod works best during runtime, to add a default layer for handling user input validation.
The Shortcomings of Vanilla JavaScript
Compile Errors
JavaScript is an interpreted language, and it doesn’t have a compilation step in the traditional sense like languages such as Java or C#. Instead, JavaScript code is executed through a runtime environment — for example through a browser’s JavaScript engine or nodeJS runtime environment.
Compile-time errors, or static errors, are detected by development tools and occur before the JavaScript code executes. These errors are usually the result of syntax violations or type mismatches. A few compile errors are syntax, type mismatch, and undefined variable errors.
The addition of a compilation layer before code execution would help catch these errors. This can be achieved through an additional layer of Typescript which needs to be compiled.
Consider the following code snippet below:
function addNumService(numOne, numTwo) {
return numOne + numTwo;
}
The method in the snippet — addNumService – should take two numbers as parameters. Then, add and produce a number as the result. Consider calling our method – addNumService – with these parameters.
console.log(addNumService(“string”, 2)); // Output: “string2”
As denoted in the output comment, this would produce an unintended output. We expect to add two numbers, but “2” is concatenated to the end of the word “string”.
The expectation is that addNumService should only take numbers as valid parameters. Since Javascript isn’t specific about what its methods should take as parameters, it would lead to unexpected errors occurring during execution. Ideally, these errors would be caught during a compilation step — this would ensure that we can specify exactly what is expected during execution.
An update to this code could involve additional type checking before adding the two numbers to ensure that the correct types are being passed.
function addNumService(numOne, numTwo) {
if(typeof numOne === "number" && typeof numTwo === "number") {
return numOne + numTwo;
} else {
console.log(“Invalid params: Please enter two numbers”);
}
}
// Both would return undefined in this case
const output = addNumService(true, 2);
const outputTwo = addNumService(“1”, 2);
Runtime Errors
Runtime errors, also known as exceptions or unhandled errors, occur during the execution of a program. These errors can disrupt the normal flow of the program and may lead to program crashes or unexpected behavior. A few runtime errors are type, reference, and range errors.
Consider the following code snippet below:
// Controller - API Endpoint
function addNumApiEndpoint(body) {
return body.numOne + body.numTwo;
}
A common issue with APIs is the need to validate user inputs. In the snippet above, the expectation is that the body parameter passed should be a JSON object containing two attributes – numOne and numTwo.
The following JSON object is passed to the pseudo-API endpoint:
{
"notNumOne": "string",
"numTwo": “true”
}
This input would produce unexpected behavior (runtime error) for the following reasons:
- numOne is missing as an attribute
- numTwo is a string (while we can add strings using the “+” symbol, the expectation for this function is to add two numbers)
The issue with this is how can we validate these inputs. We can go the manual route as we did for our addNumService and validate the body’s attributes. However, just like the previous issue, this can lead to bloated code and an overall reduction in productivity when thinking about all potential edge cases. We know what we want to be present, so why not allow for a clear definition of parameters
The common theme is that we know what the expected data “should” be, but it isn’t reflected within the source code.
Validating data in JavaScript at runtime presents challenges due to the language’s dynamic typing, where type errors may only surface during execution, resulting in unexpected production issues. Compile-time validation through tools like linters can catch errors earlier, but they aren’t foolproof, leading to false positives and missed issues, especially in complex scenarios. A comprehensive validation approach in JavaScript often involves a combination of runtime and compile-time validation, complemented by thorough testing and meticulous error handling to ensure code quality and reliability.
Our validation solution has been Typescript and Zod, which help mitigate compile-time and runtime validation errors. These tools help JavaScript become more descriptive and enable us to define what data “should” be expected during both compile-time and runtime.
What is Typescript?
TypeScript is a strongly typed programming language that is a superset of JavaScript, offering improved tooling at any scale. It comprehends JavaScript and employs type inference to provide exceptional tooling, making it a powerful choice for enhancing code quality and productivity across various project sizes.
Typescript ultimately helps mitigate developer-created and unexpected validation errors by adding a type-safety filter on top of JavaScript.
What is Zod?
Zod is a TypeScript-oriented schema declaration and validation library, streamlining developer-friendly type declarations by inferring TypeScript types, minimizing redundancy, and simplifying type composition for data structures.
The core problem Zod solves for us is that while it helps compile-time validation, it is also used for validation during runtime. It abstracts the data validation process of a schema and provides error-handling defaults.
We are here to solve problems!
Now that we know Typescript and Zod can help mitigate validation errors. Let’s see that in action!
Solving Development Validation with TypeScript
In the function definition below, the addNumService function is used to denote a method that takes two numbers as input.
// Typescript
function addNumService(numOne: number, numTwo: number) {
return numOne + numTwo;
}
// Argument of type "string" is not assignable to parameter of type number
addNumService("string", 2);
The key thing about typescript is that during development when you call functions before compiling your code, an error would be thrown by the typescript compiler or even through your code editors linting, to indicate that passing any type other than a number for each parameter would throw a runtime error.
Another added benefit of visualizing types is the ease of parsing code and understanding what is happening at a glance. Typescript is a more descriptive JavaScript.
Solving Runtime Validation with Zod
In the function definition below addNumApiEndpoint function denotes a method a user would access via an API endpoint – that takes two numbers as input. Unlike addNumService, a zod schema object describes what valid inputs are required for this function (API endpoint).
import { z } from "zod";
const zodSchema = z.object({
numOne: z.number(),
numTwo: z.number(),
});
function addNumApiEndpoint(schema: z.infer) {
return schema.numOne + schema.numTwo;
}
addNumApiEndpoint({
numOne: 1,
numTwo: 2
});
addNumApiEndpoint({
numOne: "1",
numTwo: 2
});
The main difference between a method that is exposed to a user — via an API endpoint — is that we have less control over what data external users pass to our method. Zod provides a default mechanism for handling the validation of these inputs.
A popular library that utilizes Zod for runtime validation is TRPC. This library was built with Typescript during development and Zod during runtime to validate user inputs and create type-safe APIs.
Combining Typescript and Zod for the Ultimate Validation!
Zod is Typescript-focused and can build new types based on our Zod schemas. The simplest way to build a zod schema and utilize the zod feature of inference is to create a new type.
import { z } from "zod";
const zodSchema = z.object({
numOne: z.number(),
numTwo: z.number(),
});
type ZodSchemaType = z.infer
function addNumApiEndpoint(schema: ZodSchemaType) {
return addNumService(schema.numOne, schema.numTwo);
}
Complete Code Snippet
Here is a fully updated code snippet showing all this in action. This updated snippet utilizes the addNumService method to handle all business logic, which is used in the addNumApiEndpoint as the entry point for our pseudo-API.
import { z } from "zod";
// Creating Zod schema for data validation
const addNumSchema = z.object({
numOne: z.number(),
numTwo: z.number(),
});
type AddNumType = z.infer;
// Service
function addNumService(numOne: number, numTwo: number) {
return numOne + numTwo;
}
// Controller - API Endpoint
function addNumApiEndpoint(schema: AddNumType) {
return addNumService(schema.numOne, schema.numTwo);
}
addNumApiEndpoint({
numOne: 1,
numTwo: 2
});
While both Typescript and Zod provide a great (type) safety net for handling code quality, this can be further improved through the introduction of testing — unit and integration testing. Testing can provide a means of testing many scenarios within a system and ensures proper functionality during production. Typescript and Zod are used for code quality on a micro-level to ensure data integrity. However, Code Testing ensures the working functionality of a singular function or entire system.
Conclusion
In summary, the collaboration between Zod and TypeScript for runtime and compile-time validation provides robust data validation and type safety, ensuring early error detection and runtime data integrity. Zod’s concise schema definition and TypeScript’s static type checking streamline code development and maintenance. In a fast-evolving software landscape, this combination equips developers to create durable, error-free code. Embracing Zod and TypeScript is a step towards efficient software development.