TypeScript truly shines in large projects where we frequently encounter a wide array of complex types. To tackle such complexity, two commonly used strategies are inheritance and composition. These approaches encourage code reuse by allowing us to leverage existing functions and types across different code paths. By doing so, we establish a single source of truth for business logic and data models, fostering consistency and maintainability throughout the codebase.
Inheritance vs Composition
Inheritance is a concept where a new type (child) inherits properties and methods from an existing type (parent). On the other hand, composition is a way of combining types or objects together into a new type.
For example, let's say we have a Person
type and an Employee
type that extends from Person
. With inheritance, Employee
inherits all properties and methods from Person
, and we can add additional properties or methods specific to Employee
. This is achieved using the extends keyword:
class Person {
name: string;
age: number;
}
class Employee extends Person {
employeeId: string;
salary: number;
}
With composition, we create new types by combining existing types together. Instead of inheriting properties from a parent type, we create a new type that contains instances of other types as properties. This is often done using object literals or interfaces:
interface Address {
street: string;
city: string;
state: string;
}
interface PersonDetails {
name: string;
age: number;
address: Address;
}
In the above example, the PersonDetails
type is composed of properties like name
and age
, as well as an instance of the Address
type.
Just like in TypeScript, Zod also enables us to reuse schemas whether by extending them or composing them.
The .extend() Method
The core of Zod's extension functionality is the .extend()
method available on every schema instance. This method allows us to create a new schema based on an existing one while adding, removing, or transforming properties.
Here's a basic example:
import { z } from 'zod';
const Base = z.object({
name: z.string(),
age: z.number().positive(),
});
const ExtendedSchema = Base.extend({
email: z.string().email(),
});
type ExtendedType = z.infer<typeof extendedSchema>;
// Equivalent to { name: string; age: number; email: string; }
In the example above, we start with a Base
that defines name
and age
properties. We then use .extend()
to create a new schema ExtendedSchema
that includes all properties from Base
and adds an email
property.
Beyond adding new properties, .extend()
also allows us to override existing properties from the base schema. This can be useful when we want to apply additional validation rules.
const OverriddenSchema = Base.extend({
age: z.number().int().positive(),
});
Here, we extend Base
but override the age property to enforce that it must be a positive integer.
The .pick() and .omit() Methods
Sometimes, we may want to create a new schema by picking or omitting certain properties from an existing schema. Zod provides the .pick()
and .omit()
methods for this purpose.
const PickedSchema = Base.pick({ name: true });
// Equivalent to z.object({ name: z.string() })
const OmittedSchema = Base.omit({ age: true });
// Equivalent to z.object({ name: z.string() })
Schema Composition
The simplest form of schema composition, is using existing schemas for defining properties of other schemas:
import { z } from 'zod';
const Address = z.object({
street: z.string(),
city: z.string(),
state: z.string(),
});
const PersonDetails = z.object({
name: z.string(),
age: z.number(),
address: Address,
});
Zod also allows us to merge multiple schemas together using the .merge() method. This can be handy when we have different schema fragments that we want to combine.
const Schema1 = z.object({ name: z.string() });
const Schema2 = z.object({ age: z.number().positive() });
const MergedSchema = Schema1.merge(Schema2);
// Equivalent to z.object({ name: z.string(), age: z.number().positive() })
Alternatively we can use the .and
method to create a slightly more readable code:
const MergedSchema = Schema1.and(Schema2);
Merge vs Extend
When should we use merge
and when extend
? We can always use merge
instead of extend
. However, in many cases, it would be an overhead to define an independent schema just to extend an existing schema. If, however, the extension is likely to be used independently, we should probably use merge
from the beginning.
Extending Union Schemas
In the previous chapter we talked about Zod unions and discriminated unions. One gotcha with unions is that they lack the .extend
method. So how do we extend union schemas? We use the .and
method:
const Success = z.object({ type: z.literal('success'), code: z.number() })
const Error = z.object({ type: z.literal('error'), message: z.string() })
const Result = z.discriminatedUnion('type', [Success, Error])
// The following code does not compile
// const HttpResult = Result.extend( { httpStatus: z.number()})
// Use .and instead
const HttpResult = Result.and(z.object({httpStatus: z.number()}))
Summary
Schema extension through inheritance and compositions is a powerful feature allowing us developers to really use Zod as a single source of truth for our types.
Top comments (1)
Saved my head. Thank you!