DEV Community

Shahar Kedar
Shahar Kedar

Posted on

Schema Extension and Reuse

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode

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() })
Enter fullscreen mode Exit fullscreen mode

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,
});

Enter fullscreen mode Exit fullscreen mode

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() })
Enter fullscreen mode Exit fullscreen mode

Alternatively we can use the .and method to create a slightly more readable code:

const MergedSchema = Schema1.and(Schema2);
Enter fullscreen mode Exit fullscreen mode

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()}))
Enter fullscreen mode Exit fullscreen mode

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 (0)