Using Zod and TypeScript to Write Type Safe Code
What if I told you that using TypeScript guarantees your code type safety about as much as wearing sneakers guarantees you're ready to run a marathon?
Sure, TypeScript throws hints your way - like yelling when you pass a number
to a party that only invited strings
. But remember, that's just a slice of the cake.
TypeScript is like a superhero cape slapped onto JavaScript; it looks heroic but doesn’t save the day when runtime rolls around and JavaScript takes back control. It's easy to get cozy in the TypeScript fantasyland, forgetting that once the runtime hits, it’s every type for itself.
Today, we're diving deep! We'll whip up some type guards, jazz them up with Zod
, and unveil the mystical realm of true type safety - plus, we'll examine the costs that come with it. Get ready to explore its limits and impacts, because who doesn't love a good reality check?
Understanding Type Safety
Check the following code, do you think it's type safe?
const sum = (...numbers: number[]): number => {
return numbers.reduce((sum, number) => number + sum, 0);
};
console.log(sum(1, 3, 4)); // Gives 8
Of course, when you try to pass another data type, TypeScript will yield.
However, you can tell for TypeScript, that he is stupid, and you know better.
Of course, if you're smart you will never do such things. If you execute the above code, the 100% unpredictable result will be as outcome, and if you're lucky, you'll not have an exception.
This case is unrealistic (I hope you're not doing that), but what if the numbers
that you want to calculate, are coming from an API?
const getNumbersFromAPI = async () => {
const res = await fetch(`/api/numbers/`);
const numbers = await res.json();
return numbers as number[];
};
const numbers = await getNumbersFromAPI();
sum(...numbers); // Works in theory
This case is your daily workflow. You're fetching stuff from API's, assigning TypeScript definition as a response, and you're assuming that it will be a defined shape. It's naive...
There may be a lot of stuff that happens, and the API will not return numbers
- a bug in BE code, wrong parsing in middleware, or someone changed the row in the database, and manually changed numbers to something else...
This is the moment to say - "This code is not type safe!". It just has types of definitions, nothing more. They are useful and protect us from passing wrong arguments or doing something stupid, but they will not protect you from having exceptions at runtime.
So, before we change this code, we need to understand what type safety is.
Type safety in TypeScript refers to the assurance that operations on variables are performed on values of compatible types, as determined at compile time.
Here you've type-safe version of the previous code:
// Defines a function to sum any number of numerical arguments.
const sum = (...numbers: number[]): number => {
return numbers.reduce((sum, number) => number + sum, 0);
};
// Asynchronously fetches numbers from an API.
const getNumbersFromAPI = async (): Promise<unknown> => {
const res = await fetch(`/api/numbers/`);
const numbers = await res.json();
// Forces the determination of the type using a "type guard" before using these values.
return numbers as unknown;
};
// Type guard that iterates through each element and checks if each is a number.
const areNumbers = (maybeNumbers: unknown): maybeNumbers is number[] => {
if (Array.isArray(maybeNumbers)) {
return maybeNumbers.every((num) => typeof num === `number`);
}
return false;
};
// Loads and processes numbers from an API.
const load = async () => {
const numbers = await getNumbersFromAPI();
if (areNumbers(numbers)) {
// At this point, it's guaranteed that `numbers` contain only real numbers.
sum(...numbers);
}
};
load();
The game-changer here? We've called in the unknown
. Think of unknown
as the bouncer at the club - it's not letting anyone pass without a thorough check. Want to add those numbers? Nope, not so fast! First, you gotta prove they're really numbers.
Next, we've introduced a type guard - the areNumbers
function, which checks if each item in maybeNumbers
(of type unknown
) is a number
, returning false
for any non-numbers or if it's not even an array.
To add a touch of meme-inspired humor and clarify the situation with your type checks, you might say:
"What is the IDE doing?" Inside the if
statement, numbers
is confidently typeof number[]
, but outside, they revert to their mysterious unknown
selves. Now we're shielded from runtime surprises with a 100% guarantee!
Handling Error Cases
Sometimes, the data just isn't ready for the spotlight - like trying to add numbers that aren't really numbers. What to do? In our earlier example, we checked first, but in a real app, you'd likely show an error, or calculate and show the sum. Here’s how you handle that:
const load = async () => {
const numbers = await getNumbersFromAPI();
if (areNumbers(numbers)) {
document.body.innerHTML = sum(...numbers).toString();
return;
}
document.body.innerHTML = `Ups error, numbers are not numbers - LOL`;
};
However, that approach might seem a bit old school. Nowadays, many applications, especially those built with React, utilize error boundaries. You can simply throw the error, and the error boundary will catch it and display a user-friendly screen. No more manual UI changes are needed!
import { numbersStore } from 'store/numbers';
// Function to load and display.
const load = async () => {
const numbers = await getNumbersFromAPI();
if (!areNumbers(numbers)) throw Error(`Not a numbers`);
numbersStore.setNumbers(sum(...numbers).toString());
};
// In React components tree.
<ErrorBoundary>
<ComponentThatUsingSumFunction />
</ErrorBoundary>
Making API Communication Type-Safe
The rule is the same, but... Who normally wants to do it like that?
// Defines an interface for a User object.
interface User {
id: number;
name: string;
email: string;
}
// Type guard to check if a variable is an object.
const isObject = (
maybeObj: unknown,
): maybeObj is Record<string | number | symbol, unknown> =>
typeof maybeObj === `object` && maybeObj !== null;
// Type guard that verifies if an object conforms to the User interface.
const isUser = (obj: unknown): obj is User => {
if (isObject(obj)) {
return (
typeof obj.id === `number` &&
typeof obj.name === `string` &&
typeof obj.email === `string`
);
}
return false;
};
// Validates an array of objects ensuring each is a User.
const areUsers = (maybeUsers: unknown): maybeUsers is User[] => {
if (!Array.isArray(maybeUsers)) return false;
return maybeUsers.every(isUser);
};
// Custom error class for user validation failures.
class UserError extends Error {}
// Validates an array of objects ensuring each is a User, otherwise throws an error.
const getSafeUsers = (maybeUsers: unknown): User[] => {
if (!areUsers(maybeUsers)) {
throw new UserError(`Not all entries are valid users`);
}
return maybeUsers;
};
// Fetches and validates users from an API.
const getUsers = async () => {
try {
const response = await fetch(`/api/users/`);
const maybeUsers = await response.json();
// Validates user data; throws an exception if any entry is invalid.
return getSafeUsers(maybeUsers);
} catch (err: unknown) {
if (err instanceof UserError) {
// Handles user-specific errors.
console.log(err.message);
return;
}
// Handles other potential errors, such as network issues.
console.error(`Error fetching users:`, err);
}
};
// Usage example: fetches and handles users.
const weAreRealUsers = await getUsers();
This is where the Zod
library struts onto the scene! Just run npm install zod
, whip up a schema
, and let it do the heavy lifting. The schema
checks for consistency using the validators you define, keeping everything safe.
import { z } from 'zod';
// Defines a Zod schema for a User object.
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
// Type is automatically determined based on schema!
type User = z.infer<typeof UserSchema>;
// Custom error class for user validation failures.
class UserError extends Error {}
// Validates an array of objects ensuring each conforms to the User schema.
const getSafeUsers = (maybeUsers: unknown[]): User[] => {
const result = UserSchema.array().safeParse(maybeUsers);
if (!result.success) {
throw new UserError(`Validation failed: ${result.error}`);
}
return result.data;
};
// Fetches and validates users from an API.
const getUsers = async () => {
try {
const response = await fetch(`/api/users/`);
const maybeUsers = await response.json();
// Validates user data against the User schema; throws an exception if invalid.
const users = getSafeUsers(maybeUsers);
return users;
} catch (err: unknown) {
if (err instanceof UserError) {
// Handles user-specific errors.
console.log(err.message);
return;
}
// Handles other potential errors, such as network issues.
console.error(`Error fetching users:`, err);
}
};
// Usage example: fetches and handles users.
const weAreRealUsers = await getUsers();
The main difference is readability and reduced duplication. We defined a UserSchema
, then derived the type with the snazzy z.infer<typeof UserSchema>
. It's incredible how powerful this is!
Now, instead of updating the User
interface and then realigning the type guards each time, you simply adjust the UserSchema
, and voilà - Zod
takes care of the rest. Here's how it looks in the IDE:
Keep in mind that runtime validation is still performed because z.string(), z.number()
, and the like, are real validation functions - similar to the type guards we discussed at the beginning of the article!
Finding the Balance
Remember, in runtime, your JavaScript is actively running and performing real computations. So, the data you use in your app, especially data that are not generated by your own code but loaded from external sources like APIs, local storage, cookies, or elsewhere, should be thoroughly checked during loading. Then, and only then, your app should assume that these checks have been performed.
Imagine a scenario where you perform these checks in every React component because you're aiming for 100% type safety - LOL, talk about being ultra-cautious!
The same principle applies to backend code. When you're adding an entry to a database, run these validations to ensure type safety. However, when retrieving data, I'd argue that it's not always necessary to validate every piece of data again - especially if you're dealing with a huge array. This could significantly slow down your responses, making your system less efficient.
So, here are the situations where striving for 100% type safety really pays off, without causing any bottlenecks:
- Initial Data Entry: When data is first received or entered into the system - be it from user input, file uploads, or external APIs - ensuring it meets all types of requirements can prevent a lot of headaches down the line.
- Data Processing and Transformations: Before performing any operations or transformations on data, validating its type can prevent errors and ensure that functions behave as expected.
- Before Storing Data: Validating data just before it's stored in a database can safeguard against corrupt data persisting in your data storage, maintaining data integrity.
- Critical Business Logic: For any operations that involve critical business decisions or calculations, applying strict type checks can avoid costly mistakes and ensure that the logic executes as intended.
By focusing on these key areas, you can enjoy the benefits of type safety without the overhead of unnecessary validations, especially in performance-sensitive parts of your application.
Summary
Let's wrap up what we've covered: type safety essentially means verifying in runtime that the data types declared in TypeScript match the actual data types being used at runtime.
Moreover, implementing type safety is a strategic choice - it comes with costs. It's most beneficial when loading data from APIs, storage, saving data, or within critical business logic, where precision is crucial.
Lastly, the Zod
library is a game-changer, making the creation of type-safe code much easier. It leverages z.infer
to determine types and enhances runtime validation with straightforward functions like z.string()
, and more. This combination helps ensure that your code not only compiles but also runs as expected, safely handling the data it processes.