+1 (726) 227-3745

One Schema, Two Layers: Angular Signal Forms with Shared Zod Validation on Express 5

Every MEAN codebase we inherit has the same quiet bug: the form says the display name can be 40 characters, the API says 32, and the Mongoose schema says 200. Nobody notices until a customer pastes a long name and gets a 500 instead of a red label. The fix is not more validation — it is one set of rules, defined once and consumed by both layers.

This tutorial wires a single Zod schema into three places in a MEAN app: an Angular 22 signal-driven form, an Express 5 request validator, and the Mongoose 8 model that persists the result. Versions used: Node.js 24 LTS, Angular 22, Express 5, Mongoose 8, Zod 4.

A note on Angular's Signal Forms. Angular's first-party signal forms API (@angular/forms/signals) is still in developer preview and its surface has been moving between releases. To keep this tutorial useful whichever release you are on, the form below is built from plain signals plus a thin field() helper — roughly 40 lines you own. If and when you adopt the official API, the schema-sharing pattern here transfers unchanged; only the binding layer moves.

1. A workspace layout that lets both sides import the same file

The whole point is a module the browser build and the Node build can both resolve. In an Angular CLI workspace, a plain TypeScript library is enough:

ng generate library contracts --skip-install

Then add a path alias in the root tsconfig.json (the CLI usually does this for you) and make sure the API's tsconfig.json extends the same base:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app/contracts": ["projects/contracts/src/public-api.ts"]
    }
  }
}

Two rules keep this library safe to share:

  1. No Angular imports and no Node imports. The library must be pure TypeScript, or one of the two builds will break.
  2. No environment access. Rules only. Secrets, database URIs and feature flags stay out.

2. The schema is the contract

projects/contracts/src/lib/profile.ts:

import { z } from 'zod';

export const HANDLE_PATTERN = /^[a-z0-9_]{3,20}$/;

export const profileSchema = z.object({
  displayName: z
    .string()
    .trim()
    .min(2, 'Display name must be at least 2 characters')
    .max(40, 'Display name must be 40 characters or fewer'),
  handle: z
    .string()
    .trim()
    .toLowerCase()
    .regex(HANDLE_PATTERN, 'Use 3-20 lowercase letters, numbers or underscores'),
  email: z.string().trim().email('Enter a valid email address'),
  bio: z.string().trim().max(280, 'Bio must be 280 characters or fewer').default(''),
  plan: z.enum(['free', 'team', 'enterprise']),
  seats: z.coerce.number().int().min(1).max(500),
}).refine((v) => v.plan !== 'free' || v.seats === 1, {
  message: 'The free plan is limited to a single seat',
  path: ['seats'],
});

export type Profile = z.infer<typeof profileSchema>;

Note the cross-field refine. That rule is exactly the kind of thing that normally gets implemented in the component and forgotten in the API.

Add one helper the client will need — validating a single field without running the whole object:

export function fieldIssues<K extends keyof Profile>(
  key: K,
  value: unknown,
  all: Partial<Profile>,
): string[] {
  const result = profileSchema.safeParse({ ...all, [key]: value });
  if (result.success) return [];
  return result.error.issues
    .filter((issue) => issue.path[0] === key)
    .map((issue) => issue.message);
}

Because it parses the whole object and then filters, cross-field rules like the seats/plan refinement surface on the correct field.

3. A 40-line signal form helper

src/app/forms/field.ts:

import { signal, computed, Signal, WritableSignal } from '@angular/core';

export interface Field<T> {
  value: WritableSignal<T>;
  touched: WritableSignal<boolean>;
  serverErrors: WritableSignal<string[]>;
  errors: Signal<string[]>;
  showErrors: Signal<boolean>;
}

export function field<T>(
  initial: T,
  validate: (value: T) => string[],
): Field<T> {
  const value = signal(initial);
  const touched = signal(false);
  const serverErrors = signal<string[]>([]);
  const errors = computed(() => [...validate(value()), ...serverErrors()]);
  return {
    value,
    touched,
    serverErrors,
    errors,
    showErrors: computed(() => touched() && errors().length > 0),
  };
}

No Validators.maxLength(40) anywhere. The validator argument will be a closure over the shared Zod schema.

4. The component

import { Component, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { fieldIssues, Profile, profileSchema } from '@app/contracts';
import { field } from './forms/field';

@Component({
  selector: 'app-profile-form',
  templateUrl: './profile-form.html',
})
export class ProfileFormComponent {
  private http = inject(HttpClient);

  private draft = computed<Partial<Profile>>(() => ({
    displayName: this.displayName.value(),
    handle: this.handle.value(),
    email: this.email.value(),
    bio: this.bio.value(),
    plan: this.plan.value(),
    seats: this.seats.value(),
  }));

  displayName = field('', (v) => fieldIssues('displayName', v, this.draft()));
  handle = field('', (v) => fieldIssues('handle', v, this.draft()));
  email = field('', (v) => fieldIssues('email', v, this.draft()));
  bio = field('', (v) => fieldIssues('bio', v, this.draft()));
  plan = field<'free' | 'team' | 'enterprise'>('free', (v) => fieldIssues('plan', v, this.draft()));
  seats = field(1, (v) => fieldIssues('seats', v, this.draft()));

  private fields = () => [this.displayName, this.handle, this.email, this.bio, this.plan, this.seats];

  valid = computed(() => profileSchema.safeParse(this.draft()).success);
  saving = signal(false);
  formError = signal<string | null>(null);

  async save() {
    this.fields().forEach((f) => f.touched.set(true));
    const parsed = profileSchema.safeParse(this.draft());
    if (!parsed.success) return;

    this.saving.set(true);
    this.formError.set(null);
    this.fields().forEach((f) => f.serverErrors.set([]));
    try {
      await firstValueFrom(this.http.post('/api/profile', parsed.data));
    } catch (err: any) {
      this.applyServerErrors(err?.error);
    } finally {
      this.saving.set(false);
    }
  }

  private applyServerErrors(body: any) {
    const map: Record<string, string[]> = body?.fieldErrors ?? {};
    let matched = false;
    for (const [key, messages] of Object.entries(map)) {
      const target = (this as any)[key];
      if (target?.serverErrors) {
        target.serverErrors.set(messages);
        target.touched.set(true);
        matched = true;
      }
    }
    if (!matched) this.formError.set(body?.error ?? 'Could not save your profile. Please try again.');
  }
}

Note that save() posts parsed.data, not the raw draft — so the server receives values already trimmed and lower-cased by the schema, and the client never has to duplicate that normalisation.

The template stays boring:

<form (submit)="$event.preventDefault(); save()" novalidate>
  <label for="handle">Handle</label>
  <input id="handle"
         [value]="handle.value()"
         (input)="handle.value.set($any($event.target).value)"
         (blur)="handle.touched.set(true)"
         [attr.aria-invalid]="handle.showErrors() || null"
         [attr.aria-describedby]="handle.showErrors() ? 'handle-errors' : null">
  @if (handle.showErrors()) {
    <ul id="handle-errors" role="alert">
      @for (message of handle.errors(); track message) { <li>{{ message }}</li> }
    </ul>
  }

  <button type="submit" [disabled]="saving()">{{ saving() ? 'Saving…' : 'Save profile' }}</button>
  @if (formError()) { <p role="alert">{{ formError() }}</p> }
</form>

One accessibility point worth keeping: do not disable the submit button on !valid(). A disabled button gives a user with a screen reader no explanation. Let them submit, mark everything touched, and move focus to the first invalid control.

5. The same schema in Express 5

import express from 'express';
import { profileSchema } from '@app/contracts';
import { Profile as ProfileModel } from './models.js';

export const router = express.Router();

function validate(schema: typeof profileSchema) {
  return (req: express.Request, res: express.Response, next: express.NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      const fieldErrors: Record<string, string[]> = {};
      for (const issue of result.error.issues) {
        const key = String(issue.path[0] ?? '_');
        (fieldErrors[key] ??= []).push(issue.message);
      }
      return res.status(422).json({ error: 'Validation failed', fieldErrors });
    }
    res.locals.body = result.data;
    next();
  };
}

router.post('/profile', validate(profileSchema), async (req, res) => {
  const body = res.locals.body;
  const existing = await ProfileModel.findOne({ handle: body.handle }).lean();
  if (existing && String(existing._id) !== req.user.profileId) {
    return res.status(409).json({
      error: 'Handle already taken',
      fieldErrors: { handle: ['That handle is already taken'] },
    });
  }
  const saved = await ProfileModel.findByIdAndUpdate(req.user.profileId, body, { new: true, upsert: true });
  res.status(200).json(saved);
});

Three things to notice:

  • res.locals.body, not req.body. Assigning the parsed result back onto req.body fights TypeScript's Express types and hides the fact that the value has been transformed. Put the trusted object somewhere new.
  • Express 5 needs no asyncHandler. A rejected promise in that route propagates to your error middleware automatically. This is the single best reason to finish the Express 4 → 5 migration.
  • The 409 uses the same fieldErrors shape as the 422. That is what makes the client's applyServerErrors a dozen lines instead of a switch statement.

And because Zod already guarantees shape and length, the Mongoose model can stay thin — it enforces storage concerns (indexes, uniqueness) rather than re-litigating business rules:

const profileSchemaMongoose = new Schema({
  displayName: { type: String, required: true },
  handle: { type: String, required: true, unique: true, index: true },
  email: { type: String, required: true },
  bio: { type: String, default: '' },
  plan: { type: String, enum: ['free', 'team', 'enterprise'], required: true },
  seats: { type: Number, required: true },
}, { timestamps: true });

Keep the unique index. Validation is not a concurrency control: two simultaneous requests can both pass the findOne check, and only the index will stop the duplicate. Catch the E11000 error in your error middleware and translate it into the same fieldErrors payload.

6. Async validation without hammering the API

Handle availability can't be checked in the browser from a schema. Add it as a separate, debounced signal effect rather than folding it into Zod:

import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, filter, switchMap, map, startWith } from 'rxjs';

handleTaken = toSignal(
  toObservable(this.handle.value).pipe(
    debounceTime(400),
    distinctUntilChanged(),
    filter((v) => fieldIssues('handle', v, this.draft()).length === 0),
    switchMap((v) => this.http.get<{ available: boolean }>(`/api/handles/${v}`)),
    map((r) => !r.available),
    startWith(false),
  ),
  { initialValue: false },
);

The filter matters: never spend a network round trip on a value the shared schema has already rejected. Rate-limit /api/handles/:handle on the server too — it is an enumeration endpoint, and returning only a boolean keeps it cheap to abuse but useless to harvest.

7. Test the contract once

With one schema, one test file covers both layers:

import { describe, it, expect } from 'vitest';
import { profileSchema } from '@app/contracts';

describe('profileSchema', () => {
  const valid = { displayName: 'Ada L', handle: 'ada_l', email: 'ada@example.com', bio: '', plan: 'team', seats: 4 };

  it('accepts a valid profile', () => {
    expect(profileSchema.safeParse(valid).success).toBe(true);
  });

  it('normalises the handle', () => {
    const out = profileSchema.parse({ ...valid, handle: '  ADA_L ' });
    expect(out.handle).toBe('ada_l');
  });

  it('rejects multi-seat free plans on the seats field', () => {
    const result = profileSchema.safeParse({ ...valid, plan: 'free', seats: 3 });
    expect(result.success).toBe(false);
    expect(result.error!.issues[0].path).toEqual(['seats']);
  });
});

Then one Supertest case asserting the API returns 422 with fieldErrors.seats, and one Playwright case asserting the message renders. You are testing the wiring, not the rules.

Where this pattern stops paying off

It is not free. The shared library adds a build dependency between two projects, so a schema change forces both to be rebuilt and redeployed together — fine for a single-team monorepo, painful when a third-party client consumes your API on its own schedule. In that case, publish the contracts package to a private registry with semantic versioning and treat a tightened rule as a breaking change. And if your front end and API live in different repositories or different languages, generate both from an OpenAPI or JSON Schema document instead; the principle (one source of truth) is the same, only the mechanism changes.

The rule we apply on client projects: share the schema when the same team owns both sides and deploys them together. Otherwise, version it.

Migration order for an existing app

You do not need a rewrite to get here.

  1. Create the contracts library and move one form's rules into it — pick the form with the worst bug history.
  2. Add the Zod middleware to that form's endpoint. Leave the existing validation in place for a release and log any disagreement between the two; that log is a list of real bugs.
  3. Remove the duplicated client-side Validators for that form.
  4. Standardise the { error, fieldErrors } response shape across the API before converting the second form — the payoff compounds only once every endpoint speaks it.
  5. Repeat, form by form. Reactive Forms and signal-driven fields can coexist in the same app indefinitely.

Need a second pair of eyes on your Angular and Express validation layers? StackPilots does MEAN stack consulting, Express 4→5 migrations and AngularJS/Angular upgrades for teams that need the work finished rather than started. Get in touch with a short description of your stack and we will tell you what we would do first.