Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TypeScript Through the Layers: End-to-End Type Safety with Zod

For .NET engineers who know: EF Core models, DTOs, Data Annotations, FluentValidation, NSwag client generation, and ASP.NET Core model binding You’ll learn: How TypeScript achieves end-to-end type safety across the full stack — database through UI — using Prisma, Zod, NestJS, tRPC, and React, and why runtime validation is a structural requirement rather than an afterthought Time: 25-30 min read

This is one of the two most important articles in this curriculum. If you take one architectural insight from the entire course, it should be this: in the TypeScript stack, types are a compile-time fiction. Nothing enforces them at runtime unless you deliberately add that enforcement. Zod is how you add it. Everything else in this article builds on that premise.


The .NET Way (What You Already Know)

In ASP.NET Core, type safety flows naturally from the CLR’s enforced type system. When a request arrives at your API, the runtime itself participates in validation and type enforcement at every step.

Consider the standard layered flow in a .NET application:

// 1. EF Core model — the database schema expressed as a C# class
[Table("users")]
public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string Email { get; set; } = null!;
    public DateTime CreatedAt { get; set; }
}

// 2. DTO — a separate class that shapes the API surface
public class CreateUserDto
{
    [Required]
    [StringLength(100, MinimumLength = 2)]
    public string Name { get; set; } = null!;

    [Required]
    [EmailAddress]
    public string Email { get; set; } = null!;
}

// 3. Controller — ASP.NET binds the request body to CreateUserDto,
//    validates Data Annotations, and provides a strongly typed parameter.
//    [ApiController] returns 400 automatically if validation fails.
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly AppDbContext _db;

    public UsersController(AppDbContext db) => _db = db;

    [HttpPost]
    public async Task<ActionResult<UserResponseDto>> Create([FromBody] CreateUserDto dto)
    {
        var user = new User
        {
            Name = dto.Name,
            Email = dto.Email,
            CreatedAt = DateTime.UtcNow
        };
        _db.Users.Add(user);
        await _db.SaveChangesAsync();

        return Ok(new UserResponseDto { Id = user.Id, Name = user.Name, Email = user.Email });
    }
}

// 4. NSwag generates a typed C# client from the OpenAPI spec.
//    The frontend (Blazor or a separate .NET client) uses it with full type safety.
var client = new UsersClient(httpClient);
var result = await client.CreateAsync(new CreateUserDto { Name = "Alice", Email = "alice@example.com" });
// result.Id, result.Name, result.Email — all typed

Notice what the CLR does for you behind the scenes:

  • [FromBody] binding physically constructs a CreateUserDto instance from the JSON body
  • Data Annotations are verified by the model binder at request time — the CLR will not call your action with an invalid dto
  • The User EF entity is a CLR object. If you assign user.Email = 42, the compiler refuses
  • NSwag reads your compiled assembly’s reflection metadata to generate the OpenAPI spec, which is then used to produce a typed client

The C# type system is enforced by the runtime. Types are not descriptions — they are contracts the CLR upholds.


The TypeScript Stack Way

TypeScript’s type system is erased at runtime. This single fact explains almost every architectural decision in this article.

After tsc compiles your TypeScript to JavaScript, every type annotation, every interface, every generic parameter vanishes. The resulting JavaScript has no concept of User, CreateUserDto, or string. What remains is a plain JavaScript object with properties. If JSON comes in from an HTTP request and you tell TypeScript it is a User, TypeScript believes you — because at runtime, TypeScript is not there to disagree.

This is not a flaw to work around. It is the trade-off TypeScript made: a rich, expressive type system at development time, with zero runtime overhead. The consequence is that you must construct your own runtime type enforcement deliberately. Zod is the standard tool for doing so.

Here is the same layered flow, rebuilt in TypeScript:

graph TD
    subgraph dotnet[".NET Stack"]
        D1["EF Model (C# class)"]
        D2["DTO (C# class)"]
        D3["Data Annotations (declarative)"]
        D4["Controller [FromBody]"]
        D5["NSwag → C# client"]
        D6["Blazor/Razor (C# types)"]
        D1 --> D2 --> D3 --> D4 --> D5 --> D6
    end

    subgraph ts["TypeScript Stack"]
        T1["Prisma schema → generated TS types"]
        T2["Zod schema → z.infer<> → TS type"]
        T3["Zod .parse() (runtime validation)"]
        T4["NestJS Pipe + Zod schema"]
        T5["tRPC router → typed client"]
        T6["React component (inferred TS types)"]
        T1 --> T2 --> T3 --> T4 --> T5 --> T6
    end

Each layer of that diagram is a section of this article. We will walk through each one — with complete, runnable code — building a single feature from schema to UI.

The feature: creating and retrieving a user with a name, email, and optional bio. Simple enough to be clear, realistic enough to surface the patterns you actually need.


Layer 1: Database to TypeScript — Prisma Schema

In EF Core, your C# entity class both defines the database schema and provides the CLR type you work with in code. In Prisma, these roles are filled by a schema.prisma file.

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  name      String   @db.VarChar(100)
  email     String   @unique @db.VarChar(255)
  bio       String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

After running prisma generate, the Prisma CLI generates a fully typed client from this schema. You never write the TypeScript types for your database models — they are derived from the schema automatically.

// This type is generated by Prisma — you do not write it manually
// It mirrors the model definition exactly
type User = {
  id: number;
  name: string;
  email: string;
  bio: string | null;  // Optional fields become T | null in Prisma
  createdAt: Date;
  updatedAt: Date;
};

The Prisma client then gives you typed query methods:

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// Return type is inferred as User | null — equivalent to .FindAsync() in EF
const user = await prisma.user.findUnique({ where: { id: 1 } });

// Return type is inferred as User[]
const users = await prisma.user.findMany({
  where: { email: { contains: '@example.com' } },
  orderBy: { createdAt: 'desc' },
});

This is the .NET equivalent: EF’s DbSet<User> mapped to a User class, with LINQ providing typed queries. The key difference is that Prisma derives the types from the schema file, while EF Core derives the schema from the C# class. Neither approach is superior — they solve the same problem from opposite directions.

EF Core vs. Prisma at a glance:

ConcernEF CorePrisma
Schema source of truthC# class (code-first)schema.prisma file
Type generationC# class IS the typeprisma generate creates types
MigrationsAdd-Migration, Update-Databaseprisma migrate dev, prisma migrate deploy
Query APILINQPrisma Client (fluent builder)
Change trackingYes (DbContext tracks entities)No (stateless by design)
Raw SQLFromSqlRaw, ExecuteSqlRawprisma.$queryRaw, prisma.$executeRaw
RelationsInclude(), ThenInclude()include: { relation: true }

Layer 2: The Zod Schema — Where Types Meet Runtime

This is the core concept of the article. Read this section carefully.

In .NET, your DTO class serves two purposes simultaneously:

  1. It declares the type — the C# class definition tells the compiler what shape the object has
  2. It carries validation rules — Data Annotations like [Required], [StringLength], [EmailAddress] declare what constitutes a valid value

Both happen in one class because the CLR can inspect the class at runtime via reflection. Data Annotations are not erased — they are metadata stored in the compiled assembly.

In TypeScript, you cannot do this with a single interface or type. A TypeScript interface can declare the shape, but at runtime that interface is gone. You need a separate mechanism for runtime validation. Historically, teams used two separate things: a TypeScript interface (for the type) plus a validation library like class-validator or Joi (for the runtime check). This creates duplication: you declare the same shape twice and must keep them synchronized manually.

Zod eliminates this duplication. You define a Zod schema once. From that schema, Zod infers the TypeScript type. You get runtime validation AND compile-time types from a single source of truth.

import { z } from 'zod';

// Define the schema once — this is both your validator AND your type source
const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  bio: z.string().max(500).optional(),
});

// Extract the TypeScript type from the schema — no separate interface needed
// This is equivalent to writing the CreateUserDto class in C#
type CreateUserInput = z.infer<typeof createUserSchema>;
// Inferred type:
// {
//   name: string;
//   email: string;
//   bio?: string | undefined;
// }

// Runtime validation — equivalent to Data Annotations being checked by [ApiController]
const result = createUserSchema.safeParse({
  name: 'Alice',
  email: 'not-an-email',
  bio: 'Software engineer',
});

if (!result.success) {
  // result.error is a ZodError with structured field-level errors
  console.log(result.error.flatten());
  // {
  //   fieldErrors: { email: ['Invalid email'] },
  //   formErrors: []
  // }
}

if (result.success) {
  // result.data is typed as CreateUserInput — TypeScript knows the shape
  const { name, email, bio } = result.data;
  // name: string, email: string, bio: string | undefined
}

The two Zod methods you will use constantly:

  • schema.parse(data) — validates and returns typed data, or throws a ZodError if invalid. Use inside try/catch blocks or NestJS pipes.
  • schema.safeParse(data) — never throws; returns { success: true, data: T } or { success: false, error: ZodError }. Use when you need to handle validation errors gracefully.

Building a richer schema that mirrors FluentValidation:

import { z } from 'zod';

// Equivalent to a FluentValidation AbstractValidator<CreateUserDto>
const createUserSchema = z.object({
  name: z
    .string({ required_error: 'Name is required' })
    .min(2, 'Name must be at least 2 characters')
    .max(100, 'Name must not exceed 100 characters')
    .trim(),

  email: z
    .string({ required_error: 'Email is required' })
    .email('Must be a valid email address')
    .toLowerCase(),

  bio: z
    .string()
    .max(500, 'Bio must not exceed 500 characters')
    .optional(),

  // Equivalent to [Range(18, 120)] in Data Annotations
  age: z
    .number()
    .int('Age must be a whole number')
    .min(18, 'Must be at least 18 years old')
    .max(120)
    .optional(),
});

// You can also define a response schema — equivalent to a UserResponseDto
const userResponseSchema = z.object({
  id: z.number().int().positive(),
  name: z.string(),
  email: z.string().email(),
  bio: z.string().nullable(),  // nullable() means string | null (not undefined)
  createdAt: z.string().datetime(),
});

// Types inferred from the schemas
type CreateUserInput = z.infer<typeof createUserSchema>;
type UserResponse = z.infer<typeof userResponseSchema>;

// Transformations — equivalent to [FromBody] + AutoMapper mapping in one step
const createUserSchema = z.object({
  name: z.string().min(2).max(100).trim(),
  email: z.string().email().toLowerCase().trim(),
  bio: z.string().max(500).optional(),
}).transform((data) => ({
  ...data,
  // Any transform applied during parsing
  displayName: data.name.split(' ')[0],
}));

Where to put your schemas in a monorepo:

In a monorepo with a shared package, schemas that are used on both the frontend and backend belong in a shared location:

packages/
  shared/
    src/
      schemas/
        user.schema.ts      // createUserSchema, userResponseSchema
        product.schema.ts
      index.ts
apps/
  api/                      // NestJS — imports from @repo/shared
  web/                      // Next.js — imports from @repo/shared

This gives you the equivalent of sharing FluentValidation rules between a .NET API and a Blazor WebAssembly frontend — the same validation logic runs on both sides. In .NET this is hard because your validation lives in C# and your frontend is likely a different language. In a TypeScript monorepo, the shared validation is automatic.


Layer 3: API Validation with NestJS Pipes

NestJS pipes are the equivalent of ASP.NET Core’s model binding pipeline. A pipe receives the raw request data (already parsed from JSON by the NestJS framework), validates it, transforms it, and hands the result to your controller action.

Without a validation pipe, your controller receives any. With a Zod pipe, it receives a correctly typed, validated object.

Here is the Zod pipe implementation:

// src/common/pipes/zod-validation.pipe.ts
import {
  PipeTransform,
  Injectable,
  ArgumentMetadata,
  BadRequestException,
} from '@nestjs/common';
import { ZodSchema, ZodError } from 'zod';

@Injectable()
export class ZodValidationPipe implements PipeTransform {
  constructor(private readonly schema: ZodSchema) {}

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

    if (!result.success) {
      // Format Zod errors into a structure the frontend can consume
      const errors = this.formatZodErrors(result.error);
      throw new BadRequestException({
        message: 'Validation failed',
        errors,
      });
    }

    return result.data;
  }

  private formatZodErrors(error: ZodError) {
    return error.errors.map((e) => ({
      path: e.path.join('.'),
      message: e.message,
      code: e.code,
    }));
  }
}

Now the controller:

// src/users/users.controller.ts
import {
  Controller,
  Post,
  Get,
  Body,
  Param,
  ParseIntPipe,
  UsePipes,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { z } from 'zod';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { UsersService } from './users.service';
import { createUserSchema, userResponseSchema } from '@repo/shared';

// Import the inferred type — the controller parameter is fully typed
type CreateUserInput = z.infer<typeof createUserSchema>;
type UserResponse = z.infer<typeof userResponseSchema>;

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  // @UsePipes with ZodValidationPipe is the equivalent of [FromBody] + [ApiController].
  // The pipe validates the body against createUserSchema before the method is called.
  // If validation fails, ZodValidationPipe throws BadRequestException (-> 400).
  // If validation passes, `dto` is typed as CreateUserInput — not `any`.
  @Post()
  @HttpCode(HttpStatus.CREATED)
  @UsePipes(new ZodValidationPipe(createUserSchema))
  async create(@Body() dto: CreateUserInput): Promise<UserResponse> {
    return this.usersService.create(dto);
  }

  @Get(':id')
  async findOne(@Param('id', ParseIntPipe) id: number): Promise<UserResponse> {
    return this.usersService.findOne(id);
  }
}

And the service, where Prisma and Zod types meet:

// src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { createUserSchema, userResponseSchema } from '@repo/shared';
import { z } from 'zod';

type CreateUserInput = z.infer<typeof createUserSchema>;
type UserResponse = z.infer<typeof userResponseSchema>;

@Injectable()
export class UsersService {
  constructor(private readonly prisma: PrismaService) {}

  async create(dto: CreateUserInput): Promise<UserResponse> {
    // dto.name and dto.email are already validated and typed
    // No need to re-check: if we are here, the pipe succeeded
    const user = await this.prisma.user.create({
      data: {
        name: dto.name,
        email: dto.email,
        bio: dto.bio ?? null,
      },
    });

    // Map the Prisma User type to the UserResponse shape
    // This is the equivalent of AutoMapper or a manual DTO projection in .NET
    return {
      id: user.id,
      name: user.name,
      email: user.email,
      bio: user.bio,
      createdAt: user.createdAt.toISOString(),
    };
  }

  async findOne(id: number): Promise<UserResponse> {
    const user = await this.prisma.user.findUnique({ where: { id } });

    if (!user) {
      // Equivalent to returning NotFound() in a .NET controller
      throw new NotFoundException(`User with id ${id} not found`);
    }

    return {
      id: user.id,
      name: user.name,
      email: user.email,
      bio: user.bio,
      createdAt: user.createdAt.toISOString(),
    };
  }
}

The NestJS module wires everything together, analogous to registering services in IServiceCollection:

// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { PrismaModule } from '../prisma/prisma.module';

@Module({
  imports: [PrismaModule],
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

At this point, the backend layer is complete. The flow is:

  1. JSON body arrives
  2. NestJS parses JSON to unknown
  3. ZodValidationPipe calls createUserSchema.safeParse(value)
  4. On success, controller.create(dto) receives a correctly typed CreateUserInput
  5. UsersService.create(dto) calls Prisma with typed inputs
  6. Prisma returns a typed User from the database
  7. The service returns UserResponse to the controller
  8. NestJS serializes it to JSON

No any types. No runtime surprises. Validated at the boundary.


Layer 4: API to Frontend — tRPC vs. OpenAPI

This is where .NET and the TypeScript stack diverge most sharply. In .NET, the standard approach is:

  1. ASP.NET generates an OpenAPI spec (via Swashbuckle or NSwag)
  2. NSwag generates a typed C# client from the spec
  3. The client is used in Blazor or a separate client project

There is a code generation step — a separate artifact that must be regenerated whenever the API changes.

tRPC eliminates code generation entirely.

tRPC works by sharing a TypeScript type — the router definition — between the server and the client. The client knows the exact input and output types of every procedure because they share the same type at build time, through TypeScript’s module system. No HTTP, no OpenAPI, no generated client. Just TypeScript inference across the network boundary.

The trade-off is coupling: both client and server must be TypeScript and must be in the same monorepo (or at least share a type package). If your API is consumed by multiple clients in different languages, tRPC is not viable. If you have a .NET backend, tRPC is not an option — see Article 4B.1 for that architecture.

For a monorepo where the frontend and API are both TypeScript, tRPC is the best available option for type safety.

Here is the complete tRPC setup:

Server — define the router:

// apps/api/src/trpc/router.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import { PrismaClient } from '@prisma/client';
import { createUserSchema, userResponseSchema } from '@repo/shared';

const prisma = new PrismaClient();

// Initialize tRPC — equivalent to configuring ASP.NET Core services
const t = initTRPC.create();

export const router = t.router;
export const publicProcedure = t.procedure;

// Define the user router — equivalent to a UsersController
const usersRouter = router({
  // Equivalent to [HttpPost] Create
  create: publicProcedure
    .input(createUserSchema)       // Zod schema — validates input automatically
    .output(userResponseSchema)    // Zod schema — validates output (optional but recommended)
    .mutation(async ({ input }) => {
      // input is typed as CreateUserInput — TypeScript knows the exact shape
      const user = await prisma.user.create({
        data: {
          name: input.name,
          email: input.email,
          bio: input.bio ?? null,
        },
      });

      return {
        id: user.id,
        name: user.name,
        email: user.email,
        bio: user.bio,
        createdAt: user.createdAt.toISOString(),
      };
    }),

  // Equivalent to [HttpGet("{id}")] FindOne
  findOne: publicProcedure
    .input(z.object({ id: z.number().int().positive() }))
    .output(userResponseSchema)
    .query(async ({ input }) => {
      const user = await prisma.user.findUnique({
        where: { id: input.id },
      });

      if (!user) {
        // tRPC error codes map to HTTP status codes
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: `User with id ${input.id} not found`,
        });
      }

      return {
        id: user.id,
        name: user.name,
        email: user.email,
        bio: user.bio,
        createdAt: user.createdAt.toISOString(),
      };
    }),
});

// The root router — equivalent to the full controller registration in Program.cs
export const appRouter = router({
  users: usersRouter,
});

// This type is exported and shared with the client
// It contains no runtime code — only types
export type AppRouter = typeof appRouter;

Server — expose as HTTP endpoint (NestJS or standalone Express):

// apps/api/src/main.ts (standalone Express, for simplicity)
import express from 'express';
import * as trpcExpress from '@trpc/server/adapters/express';
import { appRouter } from './trpc/router';

const app = express();

app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    // createContext can inject request context (auth, db, etc.)
    createContext: ({ req, res }) => ({ req, res }),
  }),
);

app.listen(3001, () => console.log('API running on port 3001'));

Client — consume the router with full type inference:

// apps/web/src/lib/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../../api/src/trpc/router';
// Note: we import a TYPE — no runtime code crosses the module boundary

export const trpc = createTRPCReact<AppRouter>();

The AppRouter import is a type-only import. At runtime, the web app does not import any server code. TypeScript extracts only the type information, which it uses to provide autocomplete and type checking in the client. When the server’s router changes, TypeScript immediately flags incompatible usages in the client — before running a single test.


Layer 5: Frontend Component with TanStack Query

Now the full end-to-end picture, from a React component’s perspective.

// apps/web/src/components/CreateUserForm.tsx
'use client';  // Next.js — this runs in the browser

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { trpc } from '../lib/trpc';
import { createUserSchema } from '@repo/shared';
import { z } from 'zod';

// The same schema that runs on the server now runs on the client too.
// The form is validated with the same rules — zero duplication.
type CreateUserInput = z.infer<typeof createUserSchema>;

export function CreateUserForm() {
  // React Hook Form + Zod resolver
  // zodResolver connects the Zod schema to React Hook Form's validation engine
  // This is the TypeScript equivalent of Blazor's EditForm + DataAnnotationsValidator
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<CreateUserInput>({
    resolver: zodResolver(createUserSchema),
    defaultValues: {
      name: '',
      email: '',
      bio: '',
    },
  });

  // tRPC mutation — equivalent to calling the typed NSwag client in .NET
  // The input type is inferred from the router — TypeScript enforces it
  const createUser = trpc.users.create.useMutation({
    onSuccess: (data) => {
      // data is typed as UserResponse — TypeScript knows id, name, email, bio, createdAt
      console.log(`Created user: ${data.name} (id: ${data.id})`);
      reset();
    },
    onError: (error) => {
      // tRPC maps server errors to structured error objects
      console.error('Failed to create user:', error.message);
    },
  });

  const onSubmit = (data: CreateUserInput) => {
    // TypeScript enforces that data matches CreateUserInput
    // The tRPC client enforces that it matches the server's input schema
    createUser.mutate(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" type="text" {...register('name')} />
        {/* errors.name is typed — TypeScript knows its shape */}
        {errors.name && <p role="alert">{errors.name.message}</p>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register('email')} />
        {errors.email && <p role="alert">{errors.email.message}</p>}
      </div>

      <div>
        <label htmlFor="bio">Bio (optional)</label>
        <textarea id="bio" {...register('bio')} />
        {errors.bio && <p role="alert">{errors.bio.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting || createUser.isPending}>
        {createUser.isPending ? 'Creating...' : 'Create User'}
      </button>

      {createUser.isError && (
        <p role="alert">{createUser.error.message}</p>
      )}
    </form>
  );
}

Reading a user:

// apps/web/src/components/UserProfile.tsx
'use client';

import { trpc } from '../lib/trpc';

interface UserProfileProps {
  userId: number;
}

export function UserProfile({ userId }: UserProfileProps) {
  // trpc.users.findOne.useQuery — equivalent to TanStack Query's useQuery,
  // but with the input and output types inferred from the tRPC router.
  // data is typed as UserResponse | undefined — never `any`
  const { data: user, isLoading, error } = trpc.users.findOne.useQuery(
    { id: userId },
    {
      // TanStack Query options — same as if you wrote useQuery manually
      staleTime: 5 * 60 * 1000,  // 5 minutes
      retry: 1,
    },
  );

  if (isLoading) return <div>Loading...</div>;

  if (error) {
    // error.data?.code is 'NOT_FOUND' if the user doesn't exist
    if (error.data?.code === 'NOT_FOUND') {
      return <div>User not found.</div>;
    }
    return <div>Error: {error.message}</div>;
  }

  if (!user) return null;

  // user is typed as UserResponse — all fields are known and typed
  return (
    <article>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      {user.bio && <p>{user.bio}</p>}
      <time dateTime={user.createdAt}>
        Member since {new Date(user.createdAt).toLocaleDateString()}
      </time>
    </article>
  );
}

Wiring tRPC into the Next.js app:

// apps/web/src/app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '../lib/trpc';
import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: process.env.NEXT_PUBLIC_API_URL + '/trpc',
        }),
      ],
    }),
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}
// apps/web/src/app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Layer 6: Shared Schemas and Monorepo Structure

The full power of Zod becomes clear in a monorepo. The schema defined once in packages/shared runs in three places:

  1. NestJS API — validates incoming request bodies (server-side, runtime)
  2. tRPC router — validates procedure inputs and outputs (server-side, runtime)
  3. React form — validates user input before submission (client-side, runtime)

The TypeScript type derived from the schema is used by:

  1. NestJS controllers and services — compile-time type checking
  2. tRPC client — compile-time type checking on all queries and mutations
  3. React Hook Form — compile-time type checking on form field names and values

Here is the shared package structure:

// packages/shared/src/schemas/user.schema.ts
import { z } from 'zod';

export const createUserSchema = z.object({
  name: z.string().min(2).max(100).trim(),
  email: z.string().email().toLowerCase().trim(),
  bio: z.string().max(500).optional(),
});

export const updateUserSchema = createUserSchema.partial();
// Equivalent to Partial<CreateUserDto> — all fields become optional.
// This is the PATCH pattern. In .NET you would define a separate UpdateUserDto.

export const userResponseSchema = z.object({
  id: z.number().int().positive(),
  name: z.string(),
  email: z.string().email(),
  bio: z.string().nullable(),
  createdAt: z.string().datetime(),
});

// Export the inferred types alongside the schemas
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type UserResponse = z.infer<typeof userResponseSchema>;
// packages/shared/src/index.ts
export * from './schemas/user.schema';
// Export all schemas and types from a single entry point
// packages/shared/package.json
{
  "name": "@repo/shared",
  "version": "0.0.1",
  "main": "./src/index.ts",
  "exports": {
    ".": "./src/index.ts"
  }
}

Both the API and the web app declare a dependency on @repo/shared in their package.json:

{
  "dependencies": {
    "@repo/shared": "workspace:*",
    "zod": "^3.23.0"
  }
}

The workspace:* protocol is pnpm’s way of referencing another package in the same monorepo — equivalent to a project reference in a .csproj file.


Key Differences

Concept.NET / ASP.NET CoreTypeScript Stack
Runtime type enforcementCLR enforces types nativelyTypeScript types erased; Zod provides runtime enforcement
DTO definitionSeparate C# classZod schema + z.infer<> — one source for both
Request validationData Annotations + [ApiController]ZodValidationPipe calls schema.safeParse()
Validation sharingSeparate FluentValidation + JS librarySame Zod schema imported on frontend and backend
API client type safetyNSwag generates typed client from OpenAPI spectRPC shares types directly via TypeScript module system
Code generationNSwag runs as a build steptRPC requires none; types flow from router definition
ORM type sourceC# class defines both schema and typeschema.prisma defines schema; prisma generate creates types
Null handlingNullable reference types (C# 8+)z.nullable() vs z.optional() — distinct concepts
Validation errorsModelStateDictionary → 400 responseZodErrorBadRequestException → 400 response
Cross-language clientNSwag works across any languagetRPC only works TypeScript-to-TypeScript

Gotchas for .NET Engineers

1. z.optional() and z.nullable() are different things

In C#, a nullable reference type (string?) means the value can be null. TypeScript and Zod distinguish between two different concepts:

  • z.optional() — the field may be absent from the object entirely (its value is undefined)
  • z.nullable() — the field must be present, but its value may be null

These are separate and both different from a required, non-null field:

const schema = z.object({
  required: z.string(),              // Must be present, must be a string
  optional: z.string().optional(),   // May be absent; if present, must be a string
  nullable: z.string().nullable(),   // Must be present; may be null
  both: z.string().nullish(),        // May be absent OR null (equivalent to string? in C#)
});

type T = z.infer<typeof schema>;
// {
//   required: string;
//   optional?: string | undefined;
//   nullable: string | null;
//   both?: string | null | undefined;
// }

Prisma also distinguishes these. In Prisma’s generated types, a field marked String? in the schema becomes string | null in TypeScript (nullable, not optional). This trips up .NET engineers who expect string? to mean “might not be there” — in TypeScript it specifically means “is there but its value is null.”

When mapping between Prisma types and Zod schemas, be deliberate:

// Prisma generates: bio: string | null
// The correct Zod equivalent for an API response:
const responseSchema = z.object({
  bio: z.string().nullable(),  // Correct — matches Prisma's generated type
  // NOT:
  bio: z.string().optional(), // Wrong — would expect undefined, not null
});

2. TypeScript does not validate API responses — you must use Zod explicitly

In C#, if your controller returns a UserResponseDto, the CLR verifies that the returned object IS a UserResponseDto. If you accidentally return null where a non-nullable field is expected, the compiler or runtime catches it.

In TypeScript, annotating a function as async getUser(): Promise<UserResponse> does not cause TypeScript to validate the returned value at runtime. TypeScript trusts you. If the Prisma query returns a shape that does not match UserResponse, TypeScript cannot detect this at runtime — the mismatch travels to the client silently.

Two places where this matters:

Parsing API responses on the client (without tRPC): If you are consuming an external API without tRPC, always parse the response through a Zod schema. Do not trust that the API returns what its documentation says.

// Without Zod — TypeScript believes you, but there is no runtime check
const response = await fetch('/api/users/1');
const user = (await response.json()) as UserResponse; // Dangerous cast — no validation

// With Zod — validated at the boundary
const response = await fetch('/api/users/1');
const raw = await response.json();
const user = userResponseSchema.parse(raw); // Throws if the shape is wrong

tRPC output validation: tRPC’s .output(schema) validates what the server returns before sending it to the client. This is optional but recommended because it catches server-side bugs before they become client-side bugs.

findOne: publicProcedure
  .input(z.object({ id: z.number() }))
  .output(userResponseSchema)  // Validates the return value before sending
  .query(async ({ input }) => {
    // If this returns a shape that doesn't match userResponseSchema,
    // tRPC throws a server error rather than sending malformed data
    return usersService.findOne(input.id);
  }),

3. Zod’s type inference is deep — do not write types manually

A common mistake from .NET engineers learning Zod is to define the Zod schema and then also write a separate TypeScript interface that manually mirrors it:

// Do NOT do this — it defeats the purpose
const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
});

// Manual duplication — now you must keep these in sync manually
// This is exactly the problem Zod was designed to solve
interface CreateUserInput {
  name: string;
  email: string;
}

Use z.infer<> exclusively:

// Correct — one source of truth
const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
});

type CreateUserInput = z.infer<typeof createUserSchema>;
// TypeScript derives the interface automatically from the schema
// Change the schema -> type changes automatically

This also applies to response types. Define a userResponseSchema, infer the type, and use that everywhere. Do not write a UserResponse interface separately.

4. tRPC is not suitable for public APIs or multi-language consumers

tRPC’s type safety mechanism works by importing TypeScript types from the server into the client at build time. This requires both ends to be TypeScript in the same build environment.

Consequences:

  • tRPC cannot be used if your API is consumed by a mobile app written in Swift or Kotlin
  • tRPC cannot be used if your API is consumed by a .NET service
  • tRPC cannot be used if you are building a public API that third parties consume

In these cases, use NestJS with @nestjs/swagger to generate an OpenAPI spec, and use openapi-typescript or orval on the client to generate typed bindings — the same pattern as NSwag in .NET, but targeting TypeScript instead of C#. See Article 4B.4 for the full OpenAPI bridge architecture.

The rule: tRPC for internal TypeScript-to-TypeScript communication, OpenAPI for anything else.

5. ZodError structures are nested — format them before returning from your API

ZodError contains an errors array of ZodIssue objects. Each issue has a path array representing the nested location of the error (e.g., ['address', 'city'] for a nested object). If you return a raw ZodError to the client, it is verbose and inconsistent with typical API error response formats.

The ZodValidationPipe shown earlier flattens these errors into a clean structure. Make sure your global exception filter in NestJS also handles ZodError consistently:

// src/common/filters/zod-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, BadRequestException } from '@nestjs/common';
import { ZodError } from 'zod';
import { Response } from 'express';

@Catch(ZodError)
export class ZodExceptionFilter implements ExceptionFilter {
  catch(exception: ZodError, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();

    response.status(400).json({
      statusCode: 400,
      message: 'Validation failed',
      errors: exception.errors.map((e) => ({
        field: e.path.join('.'),
        message: e.message,
      })),
    });
  }
}

Register it globally in main.ts:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ZodExceptionFilter } from './common/filters/zod-exception.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new ZodExceptionFilter());
  await app.listen(3001);
}

bootstrap();

6. Zod schemas are not automatically used by NestJS — you must wire the pipe

A NestJS controller does not automatically validate its @Body() with a Zod schema just because you annotate the parameter with a type derived from Zod. You must explicitly apply the ZodValidationPipe. If you forget, the body parameter receives unvalidated any from the request, and TypeScript’s type annotation is meaningless — the type says CreateUserInput but the runtime value is whatever the client sent.

This is the TypeScript equivalent of forgetting [ApiController] on a controller in ASP.NET Core — your Data Annotations exist but nothing calls them.

Two ways to apply the pipe — per-handler (explicit) or globally:

// Option 1: Per-handler — most flexible
@Post()
@UsePipes(new ZodValidationPipe(createUserSchema))
async create(@Body() dto: CreateUserInput) { ... }

// Option 2: Per-parameter — more granular
@Post()
async create(@Body(new ZodValidationPipe(createUserSchema)) dto: CreateUserInput) { ... }

// Option 3: Global — catches all @Body() parameters, requires each pipe instance to carry its schema
// Less common with Zod; more common with class-validator's ValidationPipe
app.useGlobalPipes(new ValidationPipe());

For teams using class-validator (the other common approach), the global ValidationPipe is standard. With Zod, per-handler or per-parameter pipes are more explicit and less magical — easier to audit and debug.


Hands-On Exercise

Build the complete type-safe user feature described in this article, from scratch. The goal is to have every layer connected and to observe TypeScript catching type errors across the stack.

Setup:

# Create a pnpm monorepo
mkdir user-demo && cd user-demo
pnpm init

# Create the workspace configuration
cat > pnpm-workspace.yaml << 'EOF'
packages:
  - 'apps/*'
  - 'packages/*'
EOF

# Create packages
mkdir -p packages/shared/src/schemas
mkdir -p apps/api/src
mkdir -p apps/web/src

# Install Zod in shared
cd packages/shared && pnpm init && pnpm add zod

Step 1 — Shared schemas. Create packages/shared/src/schemas/user.schema.ts with the createUserSchema, updateUserSchema, and userResponseSchema from this article. Export the inferred types.

Step 2 — Prisma schema. In apps/api, initialize Prisma (pnpm add prisma @prisma/client && pnpm prisma init), write the User model, run prisma migrate dev, and run prisma generate.

Step 3 — NestJS API. Build the UsersController, UsersService, and ZodValidationPipe. Verify that the controller’s @Body() parameter type matches CreateUserInput and that the service’s return type matches UserResponse.

Step 4 — Introduce a deliberate type error. In the service’s create method, attempt to return an object that includes a field that does not exist on UserResponse. Observe that TypeScript flags the error before you run the application.

Step 5 — tRPC router. Add the tRPC router with users.create and users.findOne. Export AppRouter.

Step 6 — React form. Build CreateUserForm using React Hook Form with zodResolver. Verify that the form field names are type-checked against CreateUserInput — attempt to use register('nome') (Italian for “name”) and observe the TypeScript error.

Step 7 — Break the contract. Rename a field in createUserSchema — for example, rename name to fullName. Without changing anything else, observe which files TypeScript immediately flags as errors. This is the value of end-to-end type safety: the compiler surfaces the full blast radius of a schema change before you run a single test.


Quick Reference

.NET ConceptTypeScript EquivalentNotes
[Required], [StringLength]z.string().min(n).max(m)Zod validator replaces Data Annotations
[EmailAddress]z.string().email()Same semantic, different syntax
[Range(min, max)]z.number().min(n).max(m)Identical concept
DTO class definitionz.object({ ... })Schema IS the type source
DTO class + typez.infer<typeof schema>Extract TypeScript type from schema
[FromBody] + [ApiController]ZodValidationPipe on @Body()Must be applied explicitly; not automatic
DbSet<User>prisma.userTyped Prisma client accessor
EF model classschema.prisma model blockGenerates types via prisma generate
T? nullable reference typez.string().nullish().nullable() = null, .optional() = undefined, .nullish() = both
Partial<T> for PATCH DTOschema.partial()Makes all schema fields optional
NSwag generated clienttRPC React clientNo codegen; types flow via TypeScript modules
UserResponseDto classz.infer<typeof userResponseSchema>Same pattern as input schemas
FluentValidation shared libraryZod schema in shared monorepo packageImported by both API and frontend
ModelStateDictionary errorsZodError.flatten().fieldErrorsStructured field-level error map
schema.parse(data)N/A (Zod-specific)Throws on failure; use in try/catch
schema.safeParse(data)N/A (Zod-specific)Never throws; returns { success, data/error }
prisma.user.findUnique()dbContext.Users.FindAsync()Returns typed result or null
prisma.user.create()dbContext.Users.Add() + SaveChanges()Create and return in one call
TRPCError({ code: 'NOT_FOUND' })NotFoundExceptiontRPC maps to 404 HTTP status
trpc.users.findOne.useQuery()useQuery from TanStack QueryInput/output typed from router
trpc.users.create.useMutation()useMutation from TanStack QueryInput/output typed from router

Key Zod validators at a glance:

z.string()              // any string
z.string().min(n)       // minimum length
z.string().max(n)       // maximum length
z.string().email()      // valid email format
z.string().url()        // valid URL format
z.string().uuid()       // UUID format
z.string().regex(/.../) // custom regex
z.string().trim()       // transform: trim whitespace
z.string().toLowerCase()// transform: lowercase
z.number()              // any number
z.number().int()        // integers only
z.number().positive()   // > 0
z.number().min(n)       // >= n
z.number().max(n)       // <= n
z.boolean()             // true or false
z.date()                // Date object
z.enum(['a', 'b'])      // union of literals
z.array(z.string())     // array of strings
z.object({ ... })       // object with shape
z.union([schemaA, schemaB]) // either schema
z.optional()            // field may be undefined
z.nullable()            // field may be null
z.nullish()             // field may be null or undefined
z.default(value)        // use value if field is absent
z.transform(fn)         // transform after validation
z.refine(fn, msg)       // custom validation predicate
z.superRefine(fn)       // custom validation with full access to ZodError

Further Reading

  • Zod Documentation — The authoritative reference. The “Basic usage” and “Schema methods” sections cover everything used in this article.
  • tRPC Documentation — Start with “Quickstart” and “Routers”. The React Query integration section covers useQuery and useMutation.
  • Prisma Documentation — TypeScript — Covers type generation in detail, including how Prisma’s types relate to your schema.
  • React Hook Form — Zod Integration — The schema validation section shows the zodResolver integration used in the form example in this article.