#Introduction: Why TypeScript Is Non-Negotiable in 2026
TypeScript is no longer a preference — it is an industry standard. According to the 2025 State of JavaScript survey, TypeScript adoption reached 91% among professional developers, up from 78% in 2022 [1]. Major frameworks including Angular, Next.js, Remix, NestJS, and tRPC are TypeScript-first. Even Node.js 22+ ships with experimental native TypeScript stripping.
#What TypeScript Gives You
- Compile-time error detection — catch bugs before they reach production
- IDE superpowers — autocomplete, inline docs, refactoring confidence
- Self-documenting code — function signatures tell you everything
- Safer refactoring — rename a type and TypeScript tells you every callsite that breaks
- Team scalability — new developers understand unfamiliar code faster
#TypeScript Is Not Just "JavaScript with Types"
TypeScript's type system is Turing-complete [2]. You can encode complex invariants, model domain logic, and eliminate entire categories of runtime errors — all at zero runtime cost, since types are erased at compile time.
// Without TypeScript — runtime surprise
function formatCurrency(amount, currency) {
return `${currency}${amount.toFixed(2)}`;
}
formatCurrency("100", "USD"); // Runtime: TypeError — "100".toFixed is not a function
// With TypeScript — caught at compile time
function formatCurrency(amount: number, currency: string): string {
return `${currency}${amount.toFixed(2)}`;
}
formatCurrency("100", "USD"); // ❌ Compile error: Argument of type 'string' is not assignable to parameter of type 'number'
Caption: TypeScript shifts error detection from runtime (your production server) to compile time (your local machine or CI pipeline). The earlier a bug is caught, the cheaper it is to fix.
#TypeScript 5.x — What's New
TypeScript 5.0 through 5.7 introduced a series of features that make the type system more expressive and the developer experience significantly better.
#const Type Parameters
// TypeScript 5.0+
// Without const — types are widened
function identity<T>(value: T): T {
return value;
}
const result = identity(['a', 'b', 'c']);
// result: string[] — lost the tuple information
// With const — types are preserved as literals
function identityConst<const T>(value: T): T {
return value;
}
const result2 = identityConst(['a', 'b', 'c']);
// result2: readonly ['a', 'b', 'c'] — preserved as tuple literal
Caption:
consttype parameters behave likeas constapplied to the inferred type argument. This is particularly useful for functions that work with route definitions, configuration objects, or anything where literal types matter.
#Variadic Tuple Improvements
// TypeScript 5.x — spread in the middle of tuples
type Strings = [string, string];
type Numbers = [number, number];
type Combined = [...Strings, boolean, ...Numbers];
// type Combined = [string, string, boolean, number, number]
// Practical use: typed function pipelines
function pipeline<T, U, V>(
value: T,
fn1: (v: T) => U,
fn2: (v: U) => V
): V {
return fn2(fn1(value));
}
#using — The Explicit Resource Management Keyword
// TypeScript 5.2+ — Symbol.dispose protocol
class DatabaseConnection {
private connection: Connection;
constructor(url: string) {
this.connection = openConnection(url);
console.log('Connection opened');
}
[Symbol.dispose]() {
this.connection.close();
console.log('Connection closed automatically');
}
query(sql: string) {
return this.connection.execute(sql);
}
}
// using — automatically calls Symbol.dispose at end of block
async function getUsers() {
using db = new DatabaseConnection(process.env.DATABASE_URL!);
// db.connection is alive here
const users = await db.query('SELECT * FROM users');
return users;
// db[Symbol.dispose]() is automatically called here — connection closed
}
// No try/finally needed, no forgetting to close resources
Caption: The
usingdeclaration is TypeScript's equivalent of C#'susingor Python'swithstatement. It guarantees resource cleanup even if an exception is thrown, eliminating a common source of resource leaks with database connections, file handles, and timers.
#Improved infer with extends
// TypeScript 5.4+
type GetReturnType<T> = T extends (...args: never[]) => infer R ? R : never;
// With conditional infer constraints
type UnwrapPromise<T> = T extends Promise<infer R extends string> ? R : T;
// R is now constrained to string — no need for a second conditional check
#The Type System: Foundations You Must Master
#Primitive Types and Literal Types
// Primitives
let name: string = "Alice";
let age: number = 30;
let active: boolean = true;
let id: bigint = 9007199254740991n;
let sym: symbol = Symbol('unique');
// Literal types — the exact value is the type
let direction: 'north' | 'south' | 'east' | 'west' = 'north';
let statusCode: 200 | 201 | 400 | 401 | 404 | 500 = 200;
let version: 1 | 2 | 3 = 1;
// Template literal types — combine strings at the type level
type EventName = 'click' | 'focus' | 'blur';
type HandlerName = `on${Capitalize<EventName>}`;
// type HandlerName = "onClick" | "onFocus" | "onBlur"
type CSSProperty = `${string}-${string}`;
// Matches "background-color", "font-size", etc.
Caption: Literal types narrow the set of valid values to exact constants. This is the foundation of discriminated unions, exhaustive switch statements, and type-safe event systems.
#Union and Intersection Types
// Union — A OR B
type StringOrNumber = string | number;
type SuccessOrError =
| { status: 'success'; data: User }
| { status: 'error'; message: string };
function handleResult(result: SuccessOrError) {
if (result.status === 'success') {
console.log(result.data.name); // TypeScript knows .data exists here
} else {
console.log(result.message); // TypeScript knows .message exists here
}
}
// Intersection — A AND B (merges types)
type AdminUser = User & { role: 'admin'; permissions: string[] };
type WithTimestamps<T> = T & {
createdAt: Date;
updatedAt: Date;
};
type ProductWithTimestamps = WithTimestamps<Product>;
// Has all Product fields + createdAt + updatedAt
Caption: Discriminated unions (unions where each member has a unique literal field like
status) are the most powerful pattern in TypeScript. They allow exhaustive handling of all cases, and TypeScript narrows the type automatically inside each branch.
#Type Narrowing
TypeScript narrows types based on control flow analysis:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2; // TypeScript knows: { kind: 'circle', radius: number }
case 'rectangle':
return shape.width * shape.height; // TypeScript knows: { kind: 'rectangle', width, height }
case 'triangle':
return 0.5 * shape.base * shape.height;
default:
// Exhaustiveness check — if you add a new shape and forget to handle it,
// TypeScript will error here at compile time
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${JSON.stringify(_exhaustive)}`);
}
}
Caption: Assigning to
neverin the default case is the canonical exhaustiveness check. If you add a new shape variant but forget to handle it in the switch, TypeScript will raise a compile error on theneverassignment — before any code runs.
#Type Guards
// typeof guard
function padValue(value: string | number, padding: number): string {
if (typeof value === 'string') {
return value.padStart(padding); // value: string
}
return String(value).padStart(padding); // value: number
}
// instanceof guard
function formatError(error: unknown): string {
if (error instanceof Error) {
return `${error.name}: ${error.message}`; // error: Error
}
if (typeof error === 'string') {
return error; // error: string
}
return JSON.stringify(error);
}
// User-defined type guard with `is`
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
typeof (value as User).id === 'string'
);
}
// Assertion function — throws if condition is false
function assertUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new TypeError(`Expected User, got: ${JSON.stringify(value)}`);
}
}
// Usage
const data: unknown = await fetchUser(id);
assertUser(data); // throws if data is not a User
console.log(data.name); // TypeScript knows data is User here
Caption: The
asserts value is Tsignature tells TypeScript that if the function returns (without throwing), the value is guaranteed to be typeT. This is more ergonomic than anif (!isUser(data)) throw new Error(...)everywhere.
#Generics: Writing Reusable, Type-Safe Code
Generics are parameters for types — they let you write one function that works correctly with many different types.
#Generic Functions
// Without generics — loses type information
function first(arr: unknown[]): unknown {
return arr[0];
}
const val = first([1, 2, 3]); // val: unknown — not useful
// With generics — type flows through
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = first([1, 2, 3]); // num: number | undefined ✅
const str = first(['a', 'b']); // str: string | undefined ✅
#Generic Constraints
// Constrain T to objects with a specific shape
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: '1', name: 'Alice', age: 30 };
const name = getProperty(user, 'name'); // type: string ✅
const age = getProperty(user, 'age'); // type: number ✅
const bad = getProperty(user, 'email'); // ❌ Compile error: "email" not in keyof user
// Multiple constraints
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const merged = merge({ name: 'Alice' }, { age: 30 });
// merged: { name: string } & { age: number }
console.log(merged.name); // ✅
console.log(merged.age); // ✅
#Generic Classes
class Repository<T extends { id: string }> {
private store = new Map<string, T>();
add(item: T): T {
this.store.set(item.id, item);
return item;
}
findById(id: string): T | undefined {
return this.store.get(id);
}
findAll(): T[] {
return Array.from(this.store.values());
}
update(id: string, patch: Partial<Omit<T, 'id'>>): T | undefined {
const existing = this.store.get(id);
if (!existing) return undefined;
const updated = { ...existing, ...patch };
this.store.set(id, updated);
return updated;
}
delete(id: string): boolean {
return this.store.delete(id);
}
}
// Fully typed for each entity
const userRepo = new Repository<User>();
const productRepo = new Repository<Product>();
userRepo.add({ id: '1', name: 'Alice', email: 'alice@example.com' });
userRepo.update('1', { name: 'Alice Smith' }); // ✅ Only non-id fields
productRepo.add({ id: 'p1', name: 'Keyboard', price: 79.99 });
Caption: The constraint
T extends { id: string }ensures the repository only works with entities that have a stringidfield — enforced at the call site, not at runtime.Partial<Omit<T, 'id'>>creates a type for update patches that excludes theidfield and makes all other fields optional.
#Generic Interfaces for API Design
// A type-safe Result type — avoids throwing errors across layers
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function safeJsonFetch<T>(url: string): Promise<Result<T>> {
try {
const res = await fetch(url);
if (!res.ok) {
return { success: false, error: new Error(`HTTP ${res.status}: ${res.statusText}`) };
}
const data: T = await res.json();
return { success: true, data };
} catch (e) {
return { success: false, error: e instanceof Error ? e : new Error(String(e)) };
}
}
// Usage — forced to handle both cases
const result = await safeJsonFetch<User[]>('/api/users');
if (result.success) {
renderUsers(result.data); // result.data: User[]
} else {
showError(result.error.message); // result.error: Error
}
Caption: The
Result<T, E>pattern (borrowed from Rust and functional programming) makes error handling explicit in the type signature. The caller cannot ignore the error case — TypeScript will complain if you accessdatawithout first checkingsuccess.
#Utility Types: The Standard Library of Types
TypeScript ships with a set of built-in generic types that transform other types. These are the ones you will use daily.
#The Essential Utility Types
interface User {
id: string;
name: string;
email: string;
password: string;
role: 'admin' | 'user' | 'moderator';
createdAt: Date;
}
// Partial — all fields become optional (useful for update DTOs)
type UpdateUserDTO = Partial<User>;
// { id?: string; name?: string; email?: string; ... }
// Required — all fields become required (opposite of Partial)
type RequiredUser = Required<User>;
// Pick — select specific fields
type UserPublic = Pick<User, 'id' | 'name' | 'role'>;
// { id: string; name: string; role: 'admin' | 'user' | 'moderator' }
// Omit — exclude specific fields
type UserWithoutPassword = Omit<User, 'password'>;
// Everything except password — safe to send to clients
// Readonly — all fields become readonly
type ImmutableUser = Readonly<User>;
// Record — construct object type from keys and value type
type RolePermissions = Record<User['role'], string[]>;
// { admin: string[]; user: string[]; moderator: string[] }
// Extract / Exclude — filter union members
type AdminOrMod = Extract<User['role'], 'admin' | 'moderator'>;
// 'admin' | 'moderator'
type NonAdmin = Exclude<User['role'], 'admin'>;
// 'user' | 'moderator'
// ReturnType / Parameters — introspect function types
type FetchUserReturn = ReturnType<typeof fetchUser>;
// Promise<User>
type FetchUserParams = Parameters<typeof fetchUser>;
// [id: string]
// Awaited — unwrap Promise
type ResolvedUser = Awaited<ReturnType<typeof fetchUser>>;
// User
Caption:
Omit<User, 'password'>is one of the most common patterns in web development — it creates a type safe for sending over the wire, where sensitive fields are stripped at the type level. If you add a new sensitive field toUser, you must explicitly decide whether to include it in the public type.
#Building Custom Utility Types
// Deep Partial — recursively makes all nested fields optional
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
// Deep Readonly — recursively makes all fields readonly
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
// NonNullable — removes null and undefined from a type
type Email = string | null | undefined;
type SafeEmail = NonNullable<Email>; // string
// Mutable — removes readonly from all fields
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
// Optional — makes specific keys optional (the opposite of Required<Pick<T, K>>)
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type UserCreationDTO = Optional<User, 'id' | 'createdAt'>;
// id and createdAt are optional, everything else is required
Caption: Custom utility types are mapped types — they use
[K in keyof T]to iterate over every key in a type and transform the value. The-readonlyand-?modifiers remove existing modifiers, enabling types likeMutableand reverse-Optional.
#Advanced Type Patterns
#Conditional Types
// Basic conditional type: T extends U ? TrueType : FalseType
type IsArray<T> = T extends unknown[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>; // false
// Distributive conditional types — distributes over union members
type Flatten<T> = T extends Array<infer Item> ? Item : T;
type C = Flatten<string[]>; // string
type D = Flatten<number | string[]>; // number | string
// Inferring types within conditionals
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type E = UnpackPromise<Promise<User>>; // User
type F = UnpackPromise<string>; // string (not wrapped in Promise)
// Deeply unwrap nested promises
type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;
#Template Literal Types for Type-Safe Strings
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type APIVersion = 'v1' | 'v2';
type Resource = 'users' | 'products' | 'orders';
// Build valid API endpoint strings at the type level
type APIEndpoint = `/${APIVersion}/${Resource}`;
// "/v1/users" | "/v1/products" | "/v1/orders" | "/v2/users" | ...
// Type-safe CSS-in-JS
type CSSUnit = 'px' | 'rem' | 'em' | '%' | 'vw' | 'vh';
type CSSValue = `${number}${CSSUnit}`;
function setWidth(value: CSSValue): void {
document.body.style.width = value;
}
setWidth('100px'); // ✅
setWidth('1.5rem'); // ✅
setWidth('100'); // ❌ Compile error: not a valid CSSValue
// Event map with type-safe event names
type EventMap = {
'user:created': { user: User };
'user:deleted': { userId: string };
'order:placed': { order: Order };
};
class TypedEventEmitter {
emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
// implementation
}
on<K extends keyof EventMap>(
event: K,
handler: (payload: EventMap[K]) => void
): void {
// implementation
}
}
const emitter = new TypedEventEmitter();
emitter.on('user:created', ({ user }) => console.log(user.name)); // ✅ Fully typed
emitter.emit('user:created', { userId: '1' }); // ❌ Wrong payload shape
Caption: Template literal types allow TypeScript to validate string formats at compile time — not with regex at runtime. The typed event emitter pattern eliminates the need for cast-heavy
as SomeTypeworkarounds when handling events.
#Mapped Types for Schema Validation
// Build a Zod-like schema validator type
type Validator<T> = {
[K in keyof T]: T[K] extends string
? { type: 'string'; minLength?: number; maxLength?: number }
: T[K] extends number
? { type: 'number'; min?: number; max?: number }
: T[K] extends boolean
? { type: 'boolean' }
: { type: 'unknown' };
};
type UserSchema = Validator<Pick<User, 'name' | 'email'>>;
/* {
name: { type: 'string'; minLength?: number; maxLength?: number };
email: { type: 'string'; minLength?: number; maxLength?: number };
} */
// Builder pattern with mapped types
type Builder<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => Builder<T>;
} & {
build(): T;
};
#The satisfies Operator
Introduced in TypeScript 4.9, satisfies validates a value against a type without widening it:
type Config = {
host: string;
port: number;
db: string;
};
// Without satisfies — type is widened to Config, losing literal types
const config: Config = {
host: 'localhost',
port: 5432,
db: 'mydb',
};
// config.port is number — lost the literal 5432
// With satisfies — validated against Config, but keeps literal types
const config2 = {
host: 'localhost',
port: 5432,
db: 'mydb',
} satisfies Config;
// config2.port is 5432 (literal) — AND TypeScript validated the shape ✅
// Practical use: palette map
const palette = {
red: [255, 0, 0],
green: '#00ff00',
blue: [0, 0, 255],
} satisfies Record<string, string | number[]>;
// palette.red is number[] — not string | number[]
// palette.green is string — not string | number[]
Caption:
satisfiessolves a long-standing tension: you want TypeScript to validate that an object matches a type, but you also want to keep the narrower inferred type for downstream use. Withsatisfies, you get both validation and inference without a type annotation.
#Decorators: The Stable Standard
TypeScript 5.0 shipped Stage 3 TC39 decorators — a stable, standards-compliant implementation. These replace the older experimentalDecorators flag.
// Class decorator
function singleton<T extends { new(...args: unknown[]): unknown }>(constructor: T) {
let instance: InstanceType<T>;
return class extends constructor {
constructor(...args: unknown[]) {
super(...args);
if (instance) return instance;
instance = this as InstanceType<T>;
}
};
}
@singleton
class DatabasePool {
constructor(public readonly maxConnections: number = 10) {
console.log('Pool created');
}
}
const pool1 = new DatabasePool(20);
const pool2 = new DatabasePool(30);
console.log(pool1 === pool2); // true — same instance
// Method decorator
function log(target: unknown, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
return function(this: unknown, ...args: unknown[]) {
console.log(`[${methodName}] called with:`, args);
const result = (target as Function).apply(this, args);
console.log(`[${methodName}] returned:`, result);
return result;
};
}
class UserService {
@log
async createUser(name: string, email: string): Promise<User> {
// implementation
return { id: crypto.randomUUID(), name, email, role: 'user', createdAt: new Date() } as User;
}
}
// Field decorator for validation
function minLength(min: number) {
return function(_: undefined, context: ClassFieldDecoratorContext) {
return function(this: unknown, value: string): string {
if (value.length < min) {
throw new RangeError(
`${String(context.name)} must be at least ${min} characters, got ${value.length}`
);
}
return value;
};
};
}
class CreateUserDTO {
@minLength(2)
name: string = '';
@minLength(5)
email: string = '';
}
Caption: TC39 Stage 3 decorators use
ClassMethodDecoratorContextandClassFieldDecoratorContextinstead of the olderPropertyDescriptor-based API. If you are migrating fromexperimentalDecorators, note that the signatures are different — the new API is cleaner but not backward compatible.
#TypeScript with React
#Typing Components
import { ReactNode, ComponentPropsWithoutRef, forwardRef } from 'react';
// Basic component props
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
children: ReactNode;
onClick?: () => void;
}
// Extend native HTML element props
interface InputProps extends ComponentPropsWithoutRef<'input'> {
label: string;
error?: string;
hint?: string;
}
// forwardRef with TypeScript
const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, hint, ...props }, ref) => (
<div>
<label>{label}</label>
<input ref={ref} aria-invalid={!!error} aria-describedby={hint ? 'hint' : undefined} {...props} />
{hint && <p id="hint">{hint}</p>}
{error && <p role="alert">{error}</p>}
</div>
)
);
Input.displayName = 'Input';
Caption: Extending
ComponentPropsWithoutRef<'input'>gives the component all native HTML input props (likevalue,onChange,disabled,placeholder) automatically. The...propsspread passes them to the underlying element without having to enumerate each one.
#Typing Hooks
// Generic hook with full type inference
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
},
[key, storedValue]
);
return [storedValue, setValue];
}
// Usage — T is inferred from initialValue
const [user, setUser] = useLocalStorage<User | null>('user', null);
const [theme, setTheme] = useLocalStorage('theme', 'dark');
// theme: string, setTheme: (value: string | ((prev: string) => string)) => void
#Typing Context
import { createContext, useContext, useState, ReactNode } from 'react';
interface AuthContextValue {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
}
// Avoid the need to check for null in every consumer
const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
async function login(email: string, password: string) {
setIsLoading(true);
try {
const user = await authService.login(email, password);
setUser(user);
} finally {
setIsLoading(false);
}
}
function logout() {
setUser(null);
authService.logout();
}
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
}
Caption: Initializing context with
nulland throwing in the hook if it is missing is safer than providing a fake default value. If a component callsuseAuth()outside ofAuthProvider, it crashes immediately with a clear error rather than silently receiving stale or empty data.
#TypeScript with Node.js & Express
#Setting Up a Type-Safe Express App
// src/types/express.d.ts — augment Express types
declare global {
namespace Express {
interface Request {
user?: User; // Added by auth middleware
requestId: string;
}
}
}
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'No token provided' });
return;
}
try {
const payload = verifyJWT(token);
req.user = payload.user; // TypeScript knows req.user is User (from declaration merging)
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}
#Type-Safe Route Handlers
import { Request, Response } from 'express';
import { z } from 'zod';
// Define schemas as the single source of truth
const CreateProductSchema = z.object({
name: z.string().min(1).max(100),
price: z.number().positive(),
description: z.string().optional(),
categoryId: z.string().uuid(),
});
const ProductParamsSchema = z.object({
id: z.string().uuid(),
});
// Infer TypeScript types from Zod schemas — no duplication
type CreateProductBody = z.infer<typeof CreateProductSchema>;
type ProductParams = z.infer<typeof ProductParamsSchema>;
// A small helper to validate and type requests
function validate<TBody, TParams>(
bodySchema: z.ZodType<TBody>,
paramsSchema?: z.ZodType<TParams>
) {
return (
req: Request,
res: Response,
next: NextFunction
) => {
const bodyResult = bodySchema.safeParse(req.body);
if (!bodyResult.success) {
res.status(400).json({ errors: bodyResult.error.flatten() });
return;
}
req.body = bodyResult.data;
if (paramsSchema) {
const paramsResult = paramsSchema.safeParse(req.params);
if (!paramsResult.success) {
res.status(400).json({ errors: paramsResult.error.flatten() });
return;
}
req.params = paramsResult.data as Record<string, string>;
}
next();
};
}
// Usage
router.post(
'/products',
requireAuth,
validate(CreateProductSchema),
async (req: Request<{}, {}, CreateProductBody>, res: Response) => {
const product = await productService.create(req.body);
res.status(201).json(product);
}
);
Caption: Using
z.infer<typeof Schema>means your TypeScript types and your validation logic never go out of sync — they are derived from the same definition. Add a new required field to the Zod schema and TypeScript will propagate that requirement everywhere.
#Type-Safe Database Queries with Prisma
import { PrismaClient, Prisma } from '@prisma/client';
const prisma = new PrismaClient();
// Prisma generates types from your schema automatically
// PrismaClient provides full IntelliSense for all queries
async function getUserWithOrders(userId: string) {
return prisma.user.findUnique({
where: { id: userId },
include: {
orders: {
include: { items: { include: { product: true } } },
orderBy: { createdAt: 'desc' },
take: 10,
},
},
});
// Return type: (User & { orders: (Order & { items: (OrderItem & { product: Product })[] })[] }) | null
// Fully typed — no manual type definition needed
}
// Type-safe where clauses
type UserWhereInput = Prisma.UserWhereInput;
async function searchUsers(filters: UserWhereInput) {
return prisma.user.findMany({
where: filters,
select: { id: true, name: true, email: true, role: true },
});
}
Caption: Prisma generates TypeScript types from your database schema. Every query, filter, and return type is fully inferred — including nested relations. This eliminates an entire category of runtime errors caused by incorrect field names or mismatched types.
#TypeScript Configuration Deep Dive
#The Recommended tsconfig.json for 2026
{
"compilerOptions": {
// Target & Module
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
// Output
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
// Strictness — enable ALL of these
"strict": true, // Enables all strict checks below
"noUncheckedIndexedAccess": true, // array[i] returns T | undefined
"exactOptionalPropertyTypes": true, // { x?: string } !== { x: string | undefined }
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
// Extra checks
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true,
// Features
"useDefineForClassFields": true,
"experimentalDecorators": false, // Use TC39 decorators (TS 5.0+)
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true, // Required for esbuild/SWC/Vite
// Path aliases (update baseUrl accordingly)
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Caption:
noUncheckedIndexedAccessis the single most impactful strict option not included in"strict": true. It adds| undefinedto every array index and object index signature access, forcing you to handle the case where an element does not exist — one of the most common sources of runtimeTypeErrorin TypeScript codebases.
#Understanding strict: true
strict: true is a shorthand that enables several individual flags:
Flag | What It Catches |
|---|---|
| Prevents accessing properties on |
| Catches contravariant function parameter mismatches |
| Types |
| Ensures class fields are initialized in constructor |
| Bans |
| Bans |
| Emits |
#Type-Safe API Patterns
#tRPC — End-to-End Type Safety Without Code Generation
// server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
users: t.router({
list: t.procedure
.input(z.object({ page: z.number().default(1), limit: z.number().default(20) }))
.query(async ({ input }) => {
const { page, limit } = input;
const [users, total] = await Promise.all([
db.users.findMany({ skip: (page - 1) * limit, take: limit }),
db.users.count(),
]);
return { users, total, page, totalPages: Math.ceil(total / limit) };
}),
create: t.procedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ input }) => {
return db.users.create({ data: input });
}),
}),
});
export type AppRouter = typeof appRouter;
// client/api.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/router';
export const trpc = createTRPCReact<AppRouter>();
// In a React component — fully typed, no separate type definitions
function UserList() {
const { data, isLoading } = trpc.users.list.useQuery({ page: 1 });
// data is typed as { users: User[], total: number, page: number, totalPages: number }
if (isLoading) return <Spinner />;
return (
<ul>
{data?.users.map(user => (
<li key={user.id}>{user.name} — {user.email}</li>
))}
</ul>
);
}
Caption: tRPC achieves end-to-end type safety without any code generation step. The client imports the
AppRoutertype directly from the server — TypeScript infers procedure input and output types across the network boundary at zero runtime cost.
#Typed Fetch Wrapper
// A fully typed fetch wrapper for REST APIs
interface APIConfig {
baseURL: string;
headers?: Record<string, string>;
}
class TypedAPIClient {
constructor(private config: APIConfig) {}
private async request<T>(
method: string,
path: string,
options?: { body?: unknown; params?: Record<string, string> }
): Promise<T> {
const url = new URL(path, this.config.baseURL);
if (options?.params) {
Object.entries(options.params).forEach(([k, v]) => url.searchParams.set(k, v));
}
const res = await fetch(url.toString(), {
method,
headers: {
'Content-Type': 'application/json',
...this.config.headers,
},
body: options?.body ? JSON.stringify(options.body) : undefined,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new APIError(res.status, error.message);
}
return res.json() as Promise<T>;
}
get<T>(path: string, params?: Record<string, string>) {
return this.request<T>('GET', path, { params });
}
post<TBody, TResponse>(path: string, body: TBody) {
return this.request<TResponse>('POST', path, { body });
}
put<TBody, TResponse>(path: string, body: TBody) {
return this.request<TResponse>('PUT', path, { body });
}
delete<T>(path: string) {
return this.request<T>('DELETE', path);
}
}
// Usage
const api = new TypedAPIClient({ baseURL: 'https://api.example.com' });
const users = await api.get<User[]>('/users', { page: '1' });
const newUser = await api.post<CreateUserBody, User>('/users', { name: 'Alice', email: 'alice@example.com' });
#Testing TypeScript Code
#Vitest with Full TypeScript Support
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
exclude: ['**/*.d.ts', '**/index.ts'],
},
},
resolve: {
alias: { '@': resolve(__dirname, './src') },
},
});
// src/utils/currency.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency, parseCurrency } from './currency';
describe('formatCurrency', () => {
it('formats USD correctly', () => {
expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56');
});
it('formats EUR correctly', () => {
expect(formatCurrency(1000, 'EUR')).toBe('€1,000.00');
});
it('handles zero', () => {
expect(formatCurrency(0, 'USD')).toBe('$0.00');
});
});
// Type-level testing with ts-expect-error
it('rejects non-numeric amounts at compile time', () => {
// @ts-expect-error — TypeScript should reject this
formatCurrency('100', 'USD');
});
#Mocking with TypeScript
import { vi, type MockedFunction } from 'vitest';
import { UserService } from './UserService';
import { sendEmail } from '../email/sendEmail';
vi.mock('../email/sendEmail');
const mockSendEmail = sendEmail as MockedFunction<typeof sendEmail>;
describe('UserService.create', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSendEmail.mockResolvedValue({ messageId: 'mock-id' });
});
it('sends a welcome email after creating user', async () => {
const service = new UserService();
await service.create({ name: 'Alice', email: 'alice@example.com' });
expect(mockSendEmail).toHaveBeenCalledOnce();
expect(mockSendEmail).toHaveBeenCalledWith({
to: 'alice@example.com',
subject: expect.stringContaining('Welcome'),
body: expect.any(String),
});
});
});
Caption: Casting with
sendEmail as MockedFunction<typeof sendEmail>gives the mock variable all Vitest mock assertion methods (toHaveBeenCalledOnce,toHaveBeenCalledWith) while keeping the original function's parameter and return types — noanyrequired.
#Common Mistakes & How to Fix Them
#Mistake 1: Using any Instead of unknown
// ❌ BAD — any disables all type checking
async function fetchData(url: string): Promise<any> {
const res = await fetch(url);
return res.json();
}
const data = await fetchData('/api/user');
data.nonExistentField.nested; // No error — TypeScript has given up
// ✅ GOOD — unknown forces you to narrow before use
async function fetchData(url: string): Promise<unknown> {
const res = await fetch(url);
return res.json();
}
const data = await fetchData('/api/user');
// data.name; // ❌ Compile error — must narrow first
if (isUser(data)) {
console.log(data.name); // ✅ Safe
}
#Mistake 2: Non-Null Assertions Everywhere
// ❌ BAD — runtime crash waiting to happen
function getUserName(id: string): string {
const user = userCache.get(id);
return user!.name; // ! tells TypeScript to trust you — but what if it is null?
}
// ✅ GOOD — handle the null case explicitly
function getUserName(id: string): string {
const user = userCache.get(id);
if (!user) {
throw new Error(`User ${id} not found in cache`);
}
return user.name;
}
// ✅ ALSO GOOD — provide a fallback
function getUserDisplayName(id: string): string {
return userCache.get(id)?.name ?? 'Anonymous';
}
#Mistake 3: Type Assertions Instead of Type Guards
// ❌ BAD — casting without checking
const data = JSON.parse(rawString) as User;
data.name.toUpperCase(); // Runtime crash if data is not actually a User
// ✅ GOOD — validate before casting
const data = JSON.parse(rawString);
if (!isUser(data)) {
throw new TypeError(`Invalid user data: ${rawString}`);
}
data.name.toUpperCase(); // Safe — TypeScript and runtime agree
#Mistake 4: Typing catch Variables as Error
// ❌ BAD — in TypeScript 4.0+, catch variables are unknown, not Error
try {
await riskyOperation();
} catch (e: Error) { // ❌ Compile error in strict mode
console.error(e.message);
}
// ✅ GOOD — always narrow catch variables
try {
await riskyOperation();
} catch (e) {
if (e instanceof Error) {
console.error(e.message);
} else {
console.error('Unknown error:', String(e));
}
}
// ✅ ALSO GOOD — a utility function
function toError(e: unknown): Error {
return e instanceof Error ? e : new Error(String(e));
}
try {
await riskyOperation();
} catch (e) {
const error = toError(e);
logger.error(error.message, { stack: error.stack });
}
Caption: JavaScript can throw anything — strings, numbers, plain objects — not just
Errorinstances. TypeScript correctly typescatch (e)asunknownto force you to handle this reality. Never annotate a catch variable asErrordirectly.
#Mistake 5: Forgetting readonly for Immutable Data
// ❌ BAD — arrays and objects can be mutated accidentally
interface Config {
allowedOrigins: string[];
features: { name: string; enabled: boolean }[];
}
function processConfig(config: Config) {
config.allowedOrigins.push('evil.com'); // No compile error — but this is a bug
}
// ✅ GOOD — use readonly to signal and enforce immutability
interface Config {
readonly allowedOrigins: readonly string[];
readonly features: ReadonlyArray<{ readonly name: string; readonly enabled: boolean }>;
}
function processConfig(config: Config) {
config.allowedOrigins.push('evil.com'); // ❌ Compile error: push does not exist on readonly array
}
#References
[1] Greif, S., & Benitte, R. (2025). State of JavaScript 2025. Retrieved from https://stateofjs.com
[2] Laamiri, B. (2019). TypeScript's Type System is Turing Complete. Retrieved from https://github.com/microsoft/TypeScript/issues/14833
[3] TypeScript Team. (2024). TypeScript 5.0 Release Notes. Microsoft. Retrieved from https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html
[4] TypeScript Team. (2024). TypeScript 5.2 using and await using. Microsoft. Retrieved from https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html
[5] TypeScript Team. (2025). The TypeScript Handbook. Microsoft. Retrieved from https://www.typescriptlang.org/docs/handbook/intro.html
[6] Hejlsberg, A. (2024). TypeScript Design Goals. GitHub. Retrieved from https://github.com/microsoft/TypeScript/wiki/TypeScript-Design-Goals
[7] Dodds, K. C. (2023). Type-Safe Error Handling in TypeScript. kentcdodds.com. Retrieved from https://kentcdodds.com/blog/get-a-catch-block-error-message-with-typescript
[8] Tan, T. (2025). tRPC Documentation. Retrieved from https://trpc.io/docs
[9] Colinhacks. (2025). Zod Documentation. Retrieved from https://zod.dev
[10] Prisma Team. (2025). Prisma with TypeScript. Retrieved from https://www.prisma.io/docs/orm/prisma-schema
[11] Vitest Team. (2025). Vitest Documentation. Retrieved from https://vitest.dev/guide/
[12] Vanderkam, D. (2021). Effective TypeScript: 62 Specific Ways to Improve Your TypeScript. O'Reilly Media. ISBN: 978-1492053743
Last updated: April 2026. All code examples use TypeScript 5.x and Node.js 22+.
Comments (0)
Be the first to comment
No comments yet. Start the conversation.