Strict types, Zod, tRPC, Prisma, Next.js App Router, NestJS, generics, ESM — complete 2026 guide with 40+ prompts.
TypeScript's type system is one of the most complex in mainstream programming — conditional types, mapped types, template literal types, infer, and variance annotations require deep knowledge to use correctly. Claude Code understands all of it. It reads your tsconfig.json, knows your strict settings, and generates code that passes the type-checker without casts.
For full-stack TypeScript stacks (Next.js + tRPC + Prisma + Zod), Claude navigates the entire type chain — from database schema to API layer to React component props — keeping types consistent end-to-end.
# TypeScript Project
## Stack
- TypeScript 5.x (strict)
- Framework: Next.js 15 App Router / NestJS 10 / Express
- ORM: Prisma 6 (schema at prisma/schema.prisma)
- Validation: Zod (schemas in src/schemas/)
- API: tRPC v11 (routers in src/server/routers/)
- Testing: Vitest + Testing Library
## Commands
- Type-check: pnpm tsc --noEmit
- Build: pnpm build
- Test: pnpm vitest run
- Lint: pnpm eslint . --ext .ts,.tsx
## Type Policy
- NEVER use `any` — use `unknown` and narrow, or define a proper type
- Prefer `z.infer` over manual interface duplication
- Use strict null checks — never use non-null assertion (!) except in tests
- Throw typed Error subclasses, never raw strings
## tsconfig paths
- @/* maps to ./src/*
- @db/* maps to ./prisma/generated/*
tsconfig.json compilerOptions block into CLAUDE.md. Claude reads it to know whether noUncheckedIndexedAccess is on (affecting array access types), whether exactOptionalPropertyTypes is active, and which module resolution mode you're using.// Prompt: "Write a generic groupBy function that groups an array of objects
// by a key, preserving the value types exactly."
function groupBy<T, K extends keyof T>(
arr: T[],
key: K
): Map<T[K], T[]> {
return arr.reduce((map, item) => {
const k = item[key];
return map.set(k, [...(map.get(k) ?? []), item]);
}, new Map<T[K], T[]>());
}
// Usage is fully typed — no assertions
const grouped = groupBy(users, 'role'); // Map<'admin'|'user', User[]>
// Prompt: "Write a utility type that extracts the resolved value
// from a Promise, or returns T if not a Promise."
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
// Prompt: "Create a DeepReadonly type that recursively marks every
// property and array element as readonly."
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
};
// Prompt: "Write a mapped type that converts all methods of a class
// to their async equivalents."
type Asyncify<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => infer R
? (...args: A) => Promise<R>
: T[K];
};
// Prompt: "Create event-name types from an object of event handlers."
type EventNames<T extends Record<string, unknown>> =
`on${Capitalize<string & keyof T>}`;
// Prompt: "Generate a Zod schema for a user registration form with
// password confirmation, email normalization, and age gate."
const RegisterSchema = z.object({
email: z.string().email().toLowerCase().trim(),
password: z.string().min(8).max(128),
confirmPassword: z.string(),
birthYear: z.number().int().max(new Date().getFullYear() - 18, {
message: 'Must be 18 or older',
}),
}).refine(d => d.password === d.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
export type RegisterInput = z.infer<typeof RegisterSchema>;
// Prompt: "Create a discriminated union schema for API responses —
// success has data, error has code and message."
const ApiResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
data: z.unknown(),
}),
z.object({
status: z.literal('error'),
code: z.enum(['NOT_FOUND', 'UNAUTHORIZED', 'VALIDATION_ERROR']),
message: z.string(),
}),
]);
export type ApiResponse = z.infer<typeof ApiResponseSchema>;
// Prompt: "Parse an ISO date string from a JSON API and convert it
// to a Date object, failing if invalid."
const EventSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(200),
startAt: z.string().datetime().transform(s => new Date(s)),
tags: z.array(z.string()).default([]),
});
// Prompt: "Create a typed product detail page with async params,
// generateStaticParams, and Suspense streaming."
// app/products/[slug]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { db } from '@/lib/db';
interface PageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<{ tab?: string }>;
}
export async function generateStaticParams() {
const products = await db.product.findMany({ select: { slug: true } });
return products.map(p => ({ slug: p.slug }));
}
export default async function ProductPage({ params, searchParams }: PageProps) {
const { slug } = await params;
const product = await db.product.findUnique({ where: { slug } });
if (!product) notFound();
return (
<main>
<h1>{product.title}</h1>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={product.id} />
</Suspense>
</main>
);
}
// Prompt: "Create a Route Handler for POST /api/subscribe that
// validates with Zod, returns typed JSON errors."
// app/api/subscribe/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
const Body = z.object({ email: z.string().email() });
export async function POST(req: Request) {
const json = await req.json();
const result = Body.safeParse(json);
if (!result.success) {
return NextResponse.json(
{ error: result.error.flatten() },
{ status: 422 }
);
}
// ... subscribe logic
return NextResponse.json({ ok: true });
}
// Prompt: "Add a tRPC router for posts with list, byId, and create
// procedures — create is protected and validates with Zod."
// server/routers/posts.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
const CreatePostInput = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).max(5).default([]),
});
export const postsRouter = router({
list: publicProcedure
.input(z.object({ limit: z.number().min(1).max(100).default(20) }))
.query(async ({ ctx, input }) => {
return ctx.db.post.findMany({
take: input.limit,
orderBy: { createdAt: 'desc' },
include: { author: { select: { name: true } } },
});
}),
byId: publicProcedure
.input(z.string().uuid())
.query(async ({ ctx, input }) => {
const post = await ctx.db.post.findUniqueOrThrow({ where: { id: input } });
return post;
}),
create: protectedProcedure
.input(CreatePostInput)
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: { ...input, authorId: ctx.session.user.id },
});
}),
});
// Client usage — fully typed, no codegen needed
const { data: posts } = trpc.posts.list.useQuery({ limit: 10 });
const createPost = trpc.posts.create.useMutation({
onSuccess: () => utils.posts.list.invalidate(),
});
// Prompt: "Create a NestJS users module with a service, controller,
// and DTOs — use class-validator and Swagger decorators."
// users/dto/create-user.dto.ts
import { IsEmail, IsString, MinLength, IsEnum } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export enum UserRole { Admin = 'admin', User = 'user' }
export class CreateUserDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email: string;
@ApiProperty({ minLength: 8 })
@IsString() @MinLength(8)
password: string;
@ApiProperty({ enum: UserRole, default: UserRole.User })
@IsEnum(UserRole)
role: UserRole = UserRole.User;
}
// users/users.service.ts
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateUserDto): Promise<User> {
const hashed = await bcrypt.hash(dto.password, 12);
return this.prisma.user.create({
data: { email: dto.email, passwordHash: hashed, role: dto.role },
});
}
}
// Prompt: "Write a Prisma query that fetches a user with their last 10
// posts, comment counts, and total post views — one query."
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
include: {
posts: {
take: 10,
orderBy: { createdAt: 'desc' },
include: {
_count: { select: { comments: true } },
},
},
_count: { select: { posts: true } },
},
});
// user.posts[0]._count.comments — fully typed, no assertions
// Prompt: "Write a Prisma transaction that creates an order, decrements
// inventory, and writes an audit log — rollback on any failure."
const result = await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: orderData });
const product = await tx.product.update({
where: { id: orderData.productId },
data: { inventory: { decrement: orderData.quantity } },
});
if (product.inventory < 0) {
throw new Error('Insufficient inventory');
}
await tx.auditLog.create({
data: { action: 'ORDER_CREATED', orderId: order.id, userId },
});
return order;
});
// Prompt: "Set up Vitest mocks for a service that depends on Prisma
// — mock only the methods used, keep types."
import { vi, describe, it, expect, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import { UsersService } from './users.service';
const mockPrisma = {
user: {
findUniqueOrThrow: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
} satisfies Partial<{ user: Partial<PrismaClient['user']> }>;
describe('UsersService', () => {
let service: UsersService;
beforeEach(() => {
vi.clearAllMocks();
service = new UsersService(mockPrisma as unknown as PrismaClient);
});
it('throws when user not found', async () => {
mockPrisma.user.findUniqueOrThrow.mockRejectedValueOnce(
new Error('Not found')
);
await expect(service.findOne('bad-id')).rejects.toThrow('Not found');
});
});
// Prompt: "Write a Vitest integration test for a tRPC procedure using
// createCallerFactory — no HTTP, fully typed."
import { createCallerFactory } from '@trpc/server';
import { appRouter } from '../server/root';
const createCaller = createCallerFactory(appRouter);
it('creates a post for authenticated user', async () => {
const caller = createCaller({
session: { user: { id: 'user-1' } },
db: testPrisma,
});
const post = await caller.posts.create({
title: 'Hello tRPC',
content: 'Testing end-to-end without HTTP overhead.',
});
expect(post.authorId).toBe('user-1');
expect(post.title).toBe('Hello tRPC');
});
// Prompt: "Update my tsconfig to use NodeNext module resolution
// for ESM, and fix the .js extension import requirement."
// tsconfig.json for ESM (Node 18+)
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"outDir": "dist"
}
}
// With NodeNext, imports need explicit .js extension (even for .ts files)
import { userService } from './services/users.js'; // ← required for ESM
// Prompt: "Convert this CommonJS package with require/module.exports
// to ESM while keeping CJS compatibility via dual exports."
// package.json dual export map
{
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs"
}
},
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.js"
}
// Prompt: "Set up type-safe env vars with Zod that fail at startup
// if required vars are missing."
// src/env.ts
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
NEXTAUTH_URL: z.string().url(),
OPENAI_API_KEY: z.string().startsWith('sk-').optional(),
PORT: z.coerce.number().int().positive().default(3000),
});
export const env = EnvSchema.parse(process.env);
// env.PORT is typed as number, not string
// Missing DATABASE_URL throws at startup with a clear message
Yes — Claude Code has deep understanding of TypeScript's type system. It correctly writes generic functions with constraints, conditional types, mapped types, template literal types, and infer. When you describe a type transformation in plain English, Claude produces the correct type rather than falling back to any. Prompt: "Write a DeepPartial<T> utility type that makes every nested property optional including arrays" — Claude handles the recursive conditional type correctly.
Claude Code produces strictly-typed code by default and understands every tsconfig option. It enables strictNullChecks, noUncheckedIndexedAccess, exactOptionalPropertyTypes, and noImplicitAny, explaining why each matters. Add your tsconfig.json to CLAUDE.md so Claude reads your exact compiler options. Prompt: "Audit my tsconfig.json for strict settings I should enable and explain the breaking changes each would cause."
Claude Code treats Zod as the standard runtime validation layer and co-generates the schema and the inferred TypeScript type together using z.infer<typeof Schema>. It writes .transform(), .refine(), and .superRefine() for custom logic, discriminated unions, and schema composition. Prompt: "Generate a Zod schema for this API response type that validates nested arrays, optional fields, and an enum status field, then export the inferred TypeScript type."
Yes. Claude Code understands tRPC v10/v11 — router definition, procedure builders, input/output Zod validation, middleware for auth context, and React Query client integration. It correctly threads the Context type and writes createTRPCRouter, publicProcedure, protectedProcedure. Prompt: "Add a tRPC procedure that takes a userId, queries Prisma, applies an auth middleware, and returns typed user data with related posts."
Claude Code reads your schema.prisma to understand the data model and generates correctly typed Prisma queries — including nested relations with include/select, transactions, and upserts. It knows the difference between findUnique (returns T | null) and findUniqueOrThrow (returns T) and uses the right one for your null-safety requirement. Prompt: "Add a Prisma query that fetches a user with their last 10 posts, ordered by createdAt, with comment counts — all in one query."
Claude Code is current with Next.js App Router: Server vs Client Components ('use client'), async page.tsx with awaited params and searchParams, Route Handlers, generateMetadata, generateStaticParams, and colocated loading.tsx/error.tsx. It correctly types PageProps, LayoutProps, and RouteHandlerContext.
Yes — Claude Code is excellent at JS-to-TS migrations. The recommended approach: start with allowJs: true and strict: false, then let Claude migrate one file at a time, adding explicit types rather than any. It generates declaration files for untyped third-party packages and flags ambiguous spots rather than silently casting them. Prompt: "Migrate src/utils/auth.js to TypeScript. Infer argument types from usage context, use unknown instead of any."
Essential CLAUDE.md items for TypeScript: (1) TypeScript version, (2) type-check command: tsc --noEmit, (3) test command, (4) lint command with ESLint, (5) key tsconfig paths aliases, (6) ORM and validation library, (7) no-any policy, (8) error handling pattern. Paste your compilerOptions block directly into CLAUDE.md so Claude knows which types are available in your project.