08 Jul 2026
7 min

One Validator to Rule Them All

Have you ever worked with a fullstack project with a single source of truth concept? Maintaining the DTOs, mappers, different validations for frontend and backend, handling errors, and keeping everything in sync can be a nightmare.

Angular and NestJS are a great stack, but they use different validation libraries and approaches. This can lead to duplicated logic, inconsistencies, and a lot of boilerplate code.

What if we don’t stick to the default validation libraries and instead use a single validation library for both frontend and backend? This is where Zod comes in.

We can use Zod to define our validation schemas for Angular and NestJS and also use it to generate the types for our DTOs. This way we can have a single source of truth for our validation logic and types, and we can avoid duplicating code and logic.

In this article, we will explore how to use Zod to create a single source of truth with a working example of a full stack application using the latest and experimental Angular technologies with a very versatile modern UI library called Spartan UI. To make this example even more realistic, we will also use Nx to manage our monorepo and make it easier to share code between the frontend and backend.

Quick project setup

Tech stack:

  • Nx v21/v22: modern monorepo management with Project Crystal foundations.
  • Angular v21.2+: we will use signal forms with experimental schema-driven validation.
  • Zod 4: schema definitions with a standard schema contract.
  • NestJS v10+: type-safe backend powered by Zod-based validation pipes.
  • Spartan UI: accessible and customizable UI components.

We are going to use Nx with the new crystal project layout.

├── backend - NestJS backend application
├── frontend - Angular frontend application
├── shared
│   ├── schema - Zod schemas and DTOs
│   ├── spartan-ng - Spartan UI primitives and Tailwind-based styling
│   ├── testing - shared test utilities and testdata
│   └── validation - validation utilities

Our demo example will be a simple login form with username and password. This can represent frontend validation and backend validation and error handling.

Let’s break down the solution

Start with the Schema

The first step is to create a Zod schema. Since we’ll be handling both request and response DTOs, we need a little separation to hide the password from the response. We need the following schemas:

SchemaDescriptionUsage
BaseUserSchemaUsed when we need a password-less user dataprofile page
UserSchemaFull user representation, including the password.registration or user creation.
LoginCredentialsSchemaLogin validation and type safety.login requests.

There are two things we need:

  • schemas for validation
  • DTOs for type safety. The DTOs will be generated from the schemas using the z.infer utility.
//schema.ts
import { z } from 'zod';

//for validation
export const BaseUserSchema = z.object({
  id: z.string().min(5),
  email: z.email(),
});
//for type safety
export type BaseUserDto = z.infer<typeof BaseUserSchema>;

//schema.ts
export const UserSchema = BaseUserSchema.extend({
  password: BasicPasswordSchema,
});
export type UserDto = z.infer<typeof UserSchema>;

export const LoginCredentialsSchema = z.object({
  email: z.email(),
  password: BasicPasswordSchema,
});
export type LoginCredentialsDto = z.infer<typeof LoginCredentialsSchema>;

As you can see we have a BasicPasswordSchema which is a reusable schema for password validation. Let’s create a password.schemas.ts.

import { z } from 'zod';

const hasNumber = (value: string): boolean => /\d/.test(value);
const hasLetter = (value: string): boolean => /[a-zA-Z]/.test(value);

export const BasicPasswordSchema = z
  .string()
  .min(5)
  .superRefine((value, ctx) => {
    if (!hasNumber(value)) {
      ctx.addIssue({
        code: 'custom',
        message: 'Password must contain at least one number',
      });
    }

    if (!hasLetter(value)) {
      ctx.addIssue({
        code: 'custom',
        message: 'Password must contain at least one letter',
      });
    }
  });

This is a very simple password validation schema. We could use a simple regex for this, but we need superRefine to enhance this schema later.

Now it’s time to use these schemas.

Backend

The validation in NestJS is done using decorators and pipes. We don’t want to ruin this developer experience, so we will create a custom decorator and a custom pipe for Zod schemas.

How does NestJS validation look?

@Post()
login(@Body() loginCredentialsDto: LoginCredentialsDto) {
  return 'This action adds a new user';
}

In a normal NestJS app we also need to create these DTOs in the backend with class-validator and add the validation pipe to the global bootstrap. But we don’t want to do this — we want to use the same DTOs and validation schemas in both the backend and frontend.

To use our Zod schemas with the same NestJS-like developer experience we need to create a custom decorator and a custom pipe.

Let’s start with the Pipes:

We are not using the global validation pipe, so we need our custom ones.

//core/pipes/zod-validation.pipe.ts
import { BadRequestException, PipeTransform } from '@nestjs/common';

import { ZodType } from 'zod';

export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodType) {}

  transform(value: unknown) {
    const result = this.schema.safeParse(value);

    if (!result.success) {
      throw new BadRequestException(result.error.issues);
    }

    return result.data;
  }
}

A little explanation:

  • transform is the NestJS method for pipe transformation. We use the safeParse method of the Zod schema to validate the incoming value. If the validation fails, we throw a BadRequestException with the validation issues. issues is the array where Zod stores the errors under the hood.

Decorators:

Decorators are very simple — we just need to create a factory function to wrap the Body and Param decorators with our custom pipe.

//core/decorators/zod.decorator.ts
import { Body, Param } from '@nestjs/common';
import { ZodValidationPipe } from '../pipes';
import { ZodType } from 'zod';

/**
 * Custom decorator to validate request body using Zod schema
 * @param schema
 * @returns
 */
export const ZodBody = (schema: ZodType) => Body(new ZodValidationPipe(schema));
/**
 * Custom decorator to validate request parameters using Zod schema
 * @param paramName - The name of the parameter to validate
 * @param schema
 * @returns
 */
export const ZodParam = (paramName: string, schema: ZodType) =>
  Param(paramName, new ZodValidationPipe(schema));

How to use the decorators and pipes in the backend?

This is the best part — we can use them the same way we use the default NestJS decorators and pipes. We just need to import our custom decorators and pipes and use them in the controller.

//app.controller.ts
import { Controller, Post } from '@nestjs/common';
import { AppService } from './app.service';
import {
  LoginCredentialsSchema,
  BaseUserDto,
  LoginCredentialsDto,
} from '@one-validator-to-rule-them-all/shared/schema';
import { ZodBody } from '../app/core';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Post('login')
  async login(
    @ZodBody(LoginCredentialsSchema) loginDto: LoginCredentialsDto,
  ): Promise<BaseUserDto> {
    return this.appService.login(loginDto);
  }
}

The service implementation in this example is very simple — we just return a hardcoded user, and use the built-in error handling of NestJS to generate an error response.

//app.service.ts
//Little delay to simulate a real backend
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

@Injectable()
export class AppService {
  async login(loginBody: LoginCredentialsDto): Promise<BaseUserDto> {
    await delay(500);

    const testUser = {
      id: 'user_123',
      password: 'test123',
      email: 'admin@example.com',
    };
    if (
      loginBody.email !== testUser.email ||
      loginBody.password !== testUser.password
    ) {
      throw new UnauthorizedException('Invalid credentials');
    }

    return { id: testUser.id, email: testUser.email };
  }
}

Angular Implementation

The Angular implementation can be very similar and simple. To use the Zod schemas in Angular we need the newest experimental validateStandardSchema, which can accept custom validation schemas. This works with signal forms.

Let’s see how we can use the Zod schema in Angular.

Component implementation

//app.ts

export class AppComponent {
  private appService = inject(AppService);

  loginData = signal({ email: '', password: '' });

  loginForm = form(
    this.loginData,
    (path) => {
      validateStandardSchema(path, LoginCredentialsSchema);
    },
    {
      submission: {
        action: async (data) => {
          this.appService.login(data().value());
        },
      },
    },
  );
}

Let’s break down the code:

  • We are using signal to create a reactive state for the login data. In some examples this is also called a model.
  • We are using the new signal form to create a form, and we are passing the loginData signal as the initial value of the form.
  • We are also passing a validation function to the form.

We can use regular Angular validation here like this:

const nameForm = form(signal({first: '', last: ''}), (name) => {
  required(name.first);
  pattern(name.last, /^[a-z]+$/i, {message: 'Alphabet characters only'});
});

But in our case we are harnessing the power of the new validateStandardSchema function to integrate our Zod schema into Angular:

(path) => {
    validateStandardSchema(path, LoginCredentialsSchema);
  },

The submission property is used to handle the form submission. We are using the action property to define a function that will be called when the form is submitted. In this case, we are calling the login method of the AppService and passing the form data as an argument.

App service implementation

Our application is not ready yet — we need to create a basic AppService to handle the login request. We will use the HttpClient to send a POST request to the backend.

I also added some signals to handle the login status, the user data, and the error response.

//app.service.ts

export type LoginStatus = 'idle' | 'loading' | 'success' | 'error';

export interface BackendError {
  message: string;
  statusCode: number;
}

@Injectable({ providedIn: 'root' })
export class AppService {
  private readonly http = inject(HttpClient);

  readonly status = signal<LoginStatus>('idle');
  readonly value = signal<BaseUserDto | null>(null);
  readonly error = signal<BackendError | null>(null);

  login(data: LoginCredentialsDto): void {
    this.status.set('loading');

    this.http
      .post<BaseUserDto>('/api/login', data)
      .pipe(
        tap((response) => {
          this.value.set(response);
          this.status.set('success');
        }),
        catchError((err) => {
          this.error.set(err?.error ?? err);
          this.status.set('error');
          return of(null);
        }),
      )
      .subscribe();
  }
}

Let’s move on to the template

In this example we are using SpartanUI components to create a simple login form. We are using the formRoot directive to bind the form to the template. The hlm-field-error component is used to display the validation errors. It works very well with basic Angular validation, but let’s see how we can use it with Zod validation.

  <div hlmCardContent>
    <form [formRoot]="loginForm" id="loginFormId">
      <div hlm-field class="flex flex-col gap-6">
        <div class="grid gap-2">
          <label hlmLabel for="email"
            >Login</label
          >
          <input
            type="email"
            id="email"
            placeholder="Admin@example.com"
            [formField]="loginForm.email"
            hlmInput
          />
          @for (error of loginForm.email().errors(); track error) {
          <hlm-field-error> {{ error.message }} </hlm-field-error>
          }
        </div>
        <div class="grid gap-2">
          <div class="flex items-center">
            <label hlmLabel for="password"
              >Password</label
            >
          </div>
          <input
            [formField]="loginForm.password"
            type="password"
            id="password"
            hlmInput
          />
          @for (error of loginForm.password().errors(); track error) {
          <hlm-field-error> {{ error.message }} </hlm-field-error>
          }
        </div>
      </div>
    </form>
  </div>

We also have a backend which can return an error response, and we need to display this error in the template. We are using the hlm-alert component to display the error message.

  @if (loginStatus() === 'error') {
  <hlm-alert variant="destructive" class="max-w-md">
    <ng-icon name="lucideAlertCircle" />
    <h4 hlmAlertTitle>Login Error</h4>
    <p hlmAlertDescription>{{ errorResponse()?.message }}</p>
  </hlm-alert>
  }

To display this error we need to update our component too:

//app.ts
//Insert this after the service injection
  loginStatus = this.appService.status;
  // Derives the value shown in the debug panel and the error alert from plain signals —
  // no constructor effect needed.
  backendResponse = computed(() =>
    this.appService.status() === 'error'
      ? this.appService.error()
      : this.appService.value(),
  );

  errorResponse = computed(() =>
    this.appService.status() === 'error' ? this.appService.error() : null,
  );

I also added a few quality of life features like autofill buttons and a debugger panel component to this demo for better representation.

You can find the full source code of this demo in the GitHub repository.

Summarize our current progress

  • We have a backend which validates the request body and returns a proper error response.
  • We have Zod schemas which are used in both the frontend and backend for validation and type safety.
  • We have a frontend which validates the form and can display the errors in the template.

Limitation: Our code is perfectly working, but it has a limitation: the error messages are hardcoded in the Zod schema, which is not ideal if we want multilingual support or just more user-friendly error messages. Too small: expected string to have >=5 characters is not the best error message for the user. Of course we can customize our Zod schemas to have better error messages, but it’s not the schema’s responsibility to do that. Also, if we want different errors in the backend and the frontend we would need to create two different schemas, which is not ideal.

In Part 2 of this article we will solve this limitation and create better error handling and multilingual support for our Zod schemas.

Share this post

Sign up for our newsletter

Stay up-to-date with the trends and be a part of a thriving community.