← All articles

TypeScript Best Practices in 2026: Patterns That Separate Senior Engineers from Juniors

TypeScript has evolved from 'nice to have' to non-negotiable for serious frontend projects. But most teams barely scratch the surface β€” using any when stuck, ignoring strict mode, and missing advanced patterns that prevent entire categories of bugs. This guide covers the TypeScript techniques that define senior-level engineering in 2026.

TypeScript best practices 2026 developer workspace with type annotations, strict mode config, and interfaces

TypeScript has evolved from "nice to have" to "non-negotiable" for serious frontend projects. But most teams barely scratch the surface β€” using `any` when stuck, ignoring strict mode, and missing the advanced features that prevent entire categories of bugs. This guide covers the TypeScript patterns that separate senior engineers from juniors in 2026.

The Non-Negotiable Baseline

Strict Mode Is Not Optional

// tsconfig.json β€” Minimum viable config
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",

// THESE ARE NON-NEGOTIABLE "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noUnusedLocals": true, "noUnusedParameters": true,

// Modern output "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "esModuleInterop": true, "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true,

// Path aliases "baseUrl": ".", "paths": { "@/*": ["src/*"], "@/components/*": ["src/components/*"], "@/lib/*": ["src/lib/*"], "@/hooks/*": ["src/hooks/*"] } }, "include": ["src/**/*", "*.config.ts"], "exclude": ["node_modules", "dist", "build"] }

Why These Flags Matter

| Flag | What It Catches | Real-World Impact | |------|-----------------|-------------------| | `strict` | Implicit `any`, missing types | Eliminates 80% of runtime errors | | `noUncheckedIndexedAccess` | `arr[0]` possibly `undefined` | Eliminates "cannot read property of undefined" | | `exactOptionalPropertyTypes` | `{ foo?: string }` vs `{ foo: string \| undefined }` | Distinguishes missing vs explicit undefined | | `noImplicitReturns` | Missing return in code paths | Catches missing returns in complex functions | | `noUnusedLocals/Parameters` | Dead code | Keeps codebase clean, catches typos |

Advanced Type Patterns

1. Discriminated Unions (The Secret Weapon)

// ❌ BAD β€” Optional properties, unclear state
interface UserState {
  user?: User;
  error?: string;
  loading?: boolean;
}

// βœ… GOOD β€” Exhaustive, type-safe states type UserState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; user: User } | { status: 'error'; error: Error };

function renderUserState(state: UserState) { switch (state.status) { case 'idle': return

Ready
; case 'loading': return ; case 'success': return ; // state.user is User case 'error': return ; // state.error is Error } }

2. Template Literal Types for API Safety

// HTTP Methods
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

// API Endpoints with params type ApiEndpoint = | `/users/${string}` | `/posts/${string}` | `/users/${string}/posts/${string}`;

// Type-safe fetch wrapper async function apiRequest( method: HttpMethod, endpoint: ApiEndpoint, body?: unknown ): Promise { const res = await fetch(`/api${endpoint}`, { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, });

if (!res.ok) throw new Error(`API Error: ${res.status}`); return res.json(); }

// Usage β€” fully typed const user = await apiRequest('GET', `/users/${userId}`); const posts = await apiRequest('GET', `/users/${userId}/posts`);

3. Branded Types for Domain Safety

// Prevents mixing up UserId vs PostId
type Brand<T, B> = T & { __brand: B };

type UserId = Brand; type PostId = Brand; type Email = Brand;

// Constructors const createUserId = (id: string): UserId => id as UserId; const createEmail = (email: string): Email => { if (!email.includes('@')) throw new Error('Invalid email'); return email as Email; };

// Usage β€” compiler prevents mixing function getUser(id: UserId) { /* ... */ } function getPost(id: PostId) { /* ... */ }

const userId = createUserId('123'); const postId = createPostId('456');

getUser(userId); // βœ… getUser(postId); // ❌ Type '"PostId"' not assignable to '"UserId"'

4. Conditional Types for API Responses

// Extract response type from API function
type ApiResponse<T> = T extends Promise<{ data: infer D }> ? D : never;

// Usage async function fetchUser(id: string) { const res = await fetch(`/api/users/${id}`); return res.json(); // { data: User } }

type User = ApiResponse>; // ^? User = User

5. Mapped Types for Form Handling

// Transform any type into form field config
type FormFields<T> = {
  [K in keyof T]: {
    label: string;
    type: T[K] extends string ? 'text' : 
         T[K] extends number ? 'number' :
         T[K] extends boolean ? 'checkbox' : 'text';
    required: true;
    placeholder?: string;
  };
};

interface UserForm { name: string; email: string; age: number; isActive: boolean; }

// Result type: /* { name: { label: string; type: 'text'; required: true; placeholder?: string }; email: { label: string; type: 'text'; required: true; placeholder?: string }; age: { label: string; type: 'number'; required: true; placeholder?: string }; isActive: { label: string; type: 'checkbox'; required: true; placeholder?: string }; } */

// Auto-generate form fields function generateFields(config: FormFields) { return Object.entries(config).map(([key, field]) => ({ name: key, ...field })); }

Utility Types Mastery

Essential Built-in Utilities

// Partial β€” All properties optional
type PartialUser = Partial<User>;
// { name?: string; email?: string; age?: number }

// Required β€” All properties required type RequiredUser = Required;

// Pick β€” Select subset type UserPreview = Pick;

// Omit β€” Exclude properties type UserWithoutPassword = Omit;

// Record β€” Object with specific keys type UserRoles = Record;

// Exclude/Extract β€” Union manipulation type NonAdminRoles = Exclude; // 'user' | 'guest'

// Parameters/ReturnType β€” Function introspection type FetchUserParams = Parameters; // [userId: string]

type FetchUserReturn = ReturnType; // Promise

// ConstructorParameters/InstanceType β€” Class introspection class UserService { constructor(private api: ApiClient) {} async getUser(id: string) { /* ... */ } }

type ServiceDeps = ConstructorParameters; // [api: ApiClient]

type ServiceInstance = InstanceType; // UserService

Advanced: Infer in Conditional Types

// Extract array element type
type ArrayElement<T> = T extends (infer U)[] ? U : never;
type StringArray = string[];
type Element = ArrayElement<StringArray>; // string

// Extract Promise resolution type Awaited = T extends Promise ? U : T; type UserPromise = Promise; type User = Awaited; // User

// Extract function return type type ReturnType = T extends (...args: any[]) => infer R ? R : never;

// Extract component props type ComponentProps = T extends React.ComponentType ? P : never; type ButtonProps = ComponentProps; // { variant?: 'primary' | 'secondary'; children: ReactNode; }

Type-Safe API Layer

End-to-End Type Safety with tRPC

// server/routers/user.ts
import { z } from 'zod';
import { createTRPCRouter, publicProcedure } from '../trpc';

export const userRouter = createTRPCRouter({ getById: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { return ctx.db.user.findUnique({ where: { id: input.id } }); }),

create: publicProcedure .input(z.object({ name: z.string().min(2).max(100), email: z.string().email(), age: z.number().min(13).max(120).optional(), })) .mutation(async ({ ctx, input }) => { return ctx.db.user.create({ data: input }); }),

list: publicProcedure .input(z.object({ limit: z.number().min(1).max(100).default(20), cursor: z.string().optional(), })) .query(async ({ ctx, input }) => { const users = await ctx.db.user.findMany({ take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: 'desc' }, });

let nextCursor: string | undefined; if (users.length > input.limit) { const next = users.pop(); nextCursor = next!.id; }

return { users, nextCursor }; }), });

// Frontend β€” Fully typed, no manual types needed
import { trpc } from '@/lib/trpc';

function UserList() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = trpc.user.list.useInfiniteQuery({ limit: 20, getNextPageParam: (lastPage) => lastPage.nextCursor, });

// data is fully typed: { pages: User[][]; pageParams: string[] } return (

{data?.pages.flatMap(page => page.users).map(user => ( ))}
); }

Zod + TypeScript = Runtime + Compile-time Safety

// schemas/user.ts
import { z } from 'zod';

export const UserSchema = z.object({ id: z.string().uuid(), name: z.string().min(2).max(100), email: z.string().email(), age: z.number().int().min(13).max(120).optional(), role: z.enum(['user', 'admin', 'moderator']).default('user'), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), });

export type User = z.infer; // Type: { id: string; name: string; email: string; age?: number; role: 'user' | 'admin' | 'moderator'; createdAt: string; updatedAt: string; }

// Form validation with React Hook Form import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod';

function UserForm() { const form = useForm({ resolver: zodResolver(UserSchema), defaultValues: { role: 'user' } });

// form.handleSubmit, form.register β€” fully typed! return

...
; }

React Patterns with TypeScript

Component Props with Discriminated Unions

// Variants as discriminated union
type ButtonProps =
  | { variant: 'primary'; onClick: () => void; children: React.ReactNode }
  | { variant: 'secondary'; onClick: () => void; children: React.ReactNode }
  | { variant: 'link'; href: string; children: React.ReactNode }
  | { variant: 'destructive'; onClick: () => void; children: React.ReactNode; confirm?: string };

export function Button(props: ButtonProps) { switch (props.variant) { case 'primary': return ; case 'secondary': return ; case 'link': return {props.children}; case 'destructive': return ( ); } }

Generic Components with Constraints

// Type-safe select with generics
interface SelectOption<T> {
  value: T;
  label: string;
}

interface SelectProps { options: SelectOption[]; value: T; onChange: (value: T) => void; placeholder?: string; disabled?: boolean; }

function Select({ options, value, onChange, placeholder, disabled }: SelectProps) { return ( ); }

// Usage β€” fully typed const statuses = ['active', 'inactive', 'pending'] as const; type Status = typeof statuses[number];

options={statuses.map(s => ({ value: s, label: s }))} value={status} onChange={setStatus} />

Context with Proper Typing

// context/AuthContext.tsx
interface AuthContextType {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  isLoading: boolean;
}

const AuthContext = createContext(null);

export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null);

const login = async (email: string, password: string) => { const user = await authApi.login(email, password); setUser(user); };

return ( {children} ); }

// Typed hook β€” no null checks needed export function useAuth() { const context = useContext(AuthContext); if (!context) throw new Error('useAuth must be used within AuthProvider'); return context; }

Type-Safe Forms with React Hook Form + Zod

// schemas/contact.ts
import { z } from 'zod';

export const ContactSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters').max(100), email: z.string().email('Invalid email address'), subject: z.enum(['support', 'sales', 'partnership', 'other']), message: z.string().min(10, 'Message too short').max(5000), newsletter: z.boolean().default(false), });

export type ContactForm = z.infer;

// Component import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod';

export function ContactForm() { const form = useForm({ resolver: zodResolver(ContactSchema), defaultValues: { subject: 'support', newsletter: false }, });

const onSubmit = async (data: ContactForm) => { await api.post('/contact', data); toast.success('Message sent!'); };

return (

); }

Migration Strategies

From JavaScript to TypeScript

# 1. Add TypeScript
npm install -D typescript @types/react @types/node

# 2. Create tsconfig.json (use strict config above)

# 3. Rename files incrementally # .js β†’ .tsx (components), .ts (utils)

# 4. Add types incrementally # Start with: interfaces for props, function signatures # Then: strict null checks, generics, advanced patterns

# 5. Enable strict mode gradually # "strict": false β†’ true (after fixing errors)

Common Migration Patterns

// Before (JS)
function Button({ children, onClick, variant = 'primary' }) {
  return <button className={`btn btn-${variant}`} onClick={onClick}>
    {children}
  </button>;
}

// After (TS) β€” Incremental interface ButtonProps { children: React.ReactNode; onClick?: () => void; variant?: 'primary' | 'secondary'; }

function Button({ children, onClick, variant = 'primary' }: ButtonProps) { return ; }

The UI Designer TypeScript Standard

Every project we build enforces:

// tsconfig.json β€” Our standard
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noPropertyAccessFromIndexSignature": true
  }
}

**Our rules:**

  1. **Zero `any`** β€” Use `unknown` + type guards instead
  2. **Zero `@ts-ignore`** β€” Fix the type, don't suppress
  3. **Discriminated unions over optional props** β€” State machines, not maybe-props
  4. **Generics for reusable components** β€” Select, Table, Modal, Form
  5. **Zod for runtime validation** β€” Schema = source of truth
  6. **tRPC for APIs** β€” End-to-end type safety
  7. **Zero `type: any` in tests** β€” Test types too

The 2026 TypeScript Checklist

  • [ ] `strict: true` in all projects
  • [ ] Zero `any` in production code
  • [ ] Zero `@ts-ignore` / `@ts-expect-error`
  • [ ] Discriminated unions for all state machines
  • [ ] Zod schemas for all external data (API, forms, env)
  • [ ] tRPC or typed fetch for all API calls
  • [ ] Generics for all reusable components
  • [ ] Branded types for domain IDs (UserId, PostId, etc.)
  • [ ] Template literal types for API endpoints
  • [ ] `noUnusedLocals/Parameters` enabled
  • [ ] Type coverage β‰₯95% (check with `type-coverage`)
  • [ ] Mutation testing on critical types (Stryker)

Ready to Level Up Your TypeScript?

**Book a TypeScript architecture audit** β€” we'll audit your codebase, identify type safety gaps, set up strict config, and implement advanced patterns your team will actually use.

ui-designer.in/typescript-audit