Streamlining Full-Stack Validation

Building a full-stack project with a single source of truth can be challenging. Keeping DTOs, mappers, validation logic, and error handling synchronized across frontend and backend often turns into a maintenance burden.

Angular and NestJS make a solid combination, yet their default validation strategies differ. That divergence typically leads to duplicated code, subtle inconsistencies, and an unwieldy amount of boilerplate.

What if we look beyond the standard libraries? By employing a single validation solution across both ends of the stack, we eliminate redundancy. Zod fits this role perfectly.

With Zod, we can author validation schemas once and reuse them in Angular and NestJS. The same schemas can also drive TypeScript type generation for our DTOs. This approach gives us a unified source of truth for validation and types, reducing duplicate effort.

This guide demonstrates how to build a full-stack example using Zod as that central source of truth. We'll incorporate the latest experimental Angular features, pair them with the modern Spartan UI library, and use Nx for monorepo management to simplify code sharing.

Setting Up the Project

Technology overview:

  • Nx v21/v22: latest monorepo tooling built on Project Crystal concepts.
  • Angular v21.2+: leveraging signal forms with experimental schema-driven validation.
  • Zod 4: defining schemas with a standard schema contract.
  • NestJS v10+: adding type-safe validation through Zod-based custom pipes.
  • Spartan UI: providing accessible, customizable UI components.

We'll organize the project with Nx's new crystal 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 focuses on a simple login form with username and password fields. This setup covers frontend validation, backend validation, and error handling in one realistic scenario.

Breaking Down the Approach

Defining the Schema First

The first task involves creating a Zod schema. Since we need to handle both request and response DTOs, we must separate concerns—particularly to keep the password out of any response payload. Here’s what we need:

Schema Description Usage
BaseUserSchema Used when we need a password-less user data profile page
UserSchema Full user representation, including the password. registration or user creation.
LoginCredentialsSchema Login validation and type safety. login requests.

Two key elements are required:

  • The schemas themselves for validation purposes.
  • DTOs for type safety, which we generate 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>;

The code shows a BasicPasswordSchema serving as a reusable password validation pattern. Now we'll assemble the password.schemas.ts file.

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 password schema is intentionally straightforward. A simple regex could suffice initially, but we'll need superRefine later to extend its capabilities.

Time to put these schemas to work.

Backend Integration

NestJS handles validation through a combination of decorators and pipes. We want to preserve that familiar developer experience, so we'll build our own decorator and Zod-compatible pipe.

Here's how validation typically looks in NestJS:

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

In a typical NestJS setup, we'd create these DTOs with class-validator, then register a global validation pipe during bootstrap. But we're taking a different path—reusing the same DTOs and schemas across our entire stack.

To bring Zod schemas into NestJS while keeping that native feel, we need two custom pieces: a decorator and a pipe.

Creating the Pipes:

We're skipping the standard global validation pipe in favor of our custom implementation.

//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;
  }
}

Clarifying the details:

  • transform is the method where NestJS applies pipe logic. We call the Zod schema's safeParse method to validate incoming data. When validation fails, a BadRequestException is thrown with the validation issues—Zod's internal error structure.

Adding Decorators:

These decorators are straightforward; we simply wrap the existing Body and Param decorators with our custom pipe using a factory function.

//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));

Applying Decorators and Pipes:

The key advantage: we use these custom elements exactly as we would NestJS defaults. Import our decorators, then apply them within controllers.

//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 layer stays minimal—we return a hardcoded user while relying on NestJS's built-in error mechanics to produce appropriate responses.

//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 side is equally streamlined. We leverage the experimental validateStandardSchema function, which permits custom schemas for validation and works seamlessly with signal forms.

Let's see the Zod schema in action within Angular.

Building the Component

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

Examining the component:

  • A signal named loginData establishes reactive state for the form model.
  • We instantiate a new signal form, passing the loginData signal as its initial value.
  • The form also receives a custom validation function.

Standard Angular validation looks like this:

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

Our approach, however, taps into validateStandardSchema to merge Zod directly into the Angular form lifecycle:

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

The submission variable manages form submission flow. The action property defines the callback invoked on submit—here, it calls the login method from our AppService with the form's data.

Wiring the App Service

To complete the application, we need a basic AppService for login operations. It uses HttpClient to post credentials to the backend.

I've included additional signals to track login status, retrieve user details, and capture error responses.

//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();
  }
}

Designing the Template

This demo employs SpartanUI components for the login form. The formRoot directive ties the form to the template. For error displays, the hlm-field-error component pairs nicely with standard Angular validation; see how it adapts to Zod as well.

  <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>

Backend errors are handled through the hlm-alert component, which surfaces 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>
  }

We must adjust the component to support error display as well:

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

For better usability, I added convenience features including autofill buttons and a debugger panel to visualize the state.

The complete source code is available in the GitHub repository.

Where We Stand

  • The backend validates request bodies and returns structured error responses.
  • Shared Zod schemas power validation and type safety across both frontend and backend.
  • The frontend validates forms and presents errors within the template.

Current limitation: While everything functions correctly, our schema embeds hardcoded error messages—which is less than ideal for multilingual apps or user-friendly messaging. A message like Too small: expected string to have >=5 characters isn't particularly helpful. While we could enhance Zod's messages, schema-level messaging isn't its core responsibility. Additionally, desiring distinct frontend versus backend errors introduces schema duplication, which we want to minimize.

Part 2 will address this shortcoming, focusing on advanced error handling and multilingual support for the Zod schemas.