Schema Extension and Reuse
Tags: typescript, zod, programming, node
Published: 2024-03-30 11:23:46


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:

```typescript
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:

```typescript
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:

```typescript
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.

```typescript
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.

```typescript
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:

```typescript
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.

```typescript
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](https://dev.to/shaharke/zod-zero-to-hero-chapter-4-513c) 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:

```typescript
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.