TypeScript Best Practices in 2026: Write Better, Safer Code
TypeScript Best Practices in 2026: Write Better, Safer Code
Master TypeScript with these essential best practices for 2026. From advanced type patterns and error handling to performance optimization and project configuration, this comprehensive guide covers everything.
Introduction: TypeScript is the Standard
TypeScript has firmly established itself as the standard for professional JavaScript development. In 2026, over 80% of new JavaScript projects start with TypeScript, and for good reason: it catches bugs at compile time, improves code documentation through types, enables better IDE support, and makes large codebases maintainable.
But writing TypeScript is not the same as writing good TypeScript. Many developers use TypeScript as "JavaScript with type annotations" without taking advantage of its full power. This guide covers the best practices that will help you write cleaner, safer, and more maintainable TypeScript code in 2026.
Whether you are building a Next.js application, an Express API, a React Native mobile app, or a Node.js CLI tool, these practices apply across the TypeScript ecosystem.
Part 1: Type System Fundamentals Done Right
Use unknown Instead of any
The any type is TypeScript's escape hatch -- it disables all type checking. Using it defeats the purpose of TypeScript.
// BAD: any disables type checking
function processData(data: any) {
return data.name.toUpperCase(); // No error, but could crash at runtime
}
// GOOD: unknown forces you to check types
function processData(data: unknown) {
if (typeof data === 'object' && data !== null && 'name' in data) {
const record = data as { name: string };
return record.name.toUpperCase(); // Safe!
}
throw new Error('Invalid data format');
}
When any is acceptable:
- Migrating JavaScript to TypeScript (temporarily)
- Third-party libraries with no type definitions
- Generic utility functions where types genuinely cannot be known
Even in these cases, prefer unknown and add type guards.
Prefer Type Inference Where Obvious
TypeScript's type inference is powerful. Do not annotate types that the compiler can infer.
// UNNECESSARY: TypeScript already infers these
const name: string = "John";
const count: number = 42;
const items: string[] = ["a", "b", "c"];
// BETTER: Let TypeScript infer
const name = "John"; // inferred as string
const count = 42; // inferred as number
const items = ["a", "b"]; // inferred as string[]
// DO annotate: function parameters and return types
function calculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// DO annotate: when inference would be too wide
const status: "active" | "inactive" = "active"; // not just string
Use const Assertions for Literal Types
// Without const assertion: type is { name: string, role: string }
const config = { name: "admin", role: "superuser" };
// With const assertion: type is { readonly name: "admin", readonly role: "superuser" }
const config = { name: "admin", role: "superuser" } as const;
// Great for arrays that should be tuples
const colors = ["red", "green", "blue"] as const;
// Type: readonly ["red", "green", "blue"]
// Perfect for enum-like objects
const HttpStatus = {
OK: 200,
NOT_FOUND: 404,
SERVER_ERROR: 500,
} as const;
type StatusCode = typeof HttpStatus[keyof typeof HttpStatus]; // 200 | 404 | 500
Discriminated Unions Over Optional Properties
When an object can be in different states, use discriminated unions instead of optional properties.
// BAD: Everything is optional, hard to know what is valid
interface ApiResponse {
status: string;
data?: unknown;
error?: string;
retryAfter?: number;
}
// GOOD: Each state is clearly defined
type ApiResponse =
| { status: "success"; data: unknown }
| { status: "error"; error: string; retryAfter?: number }
| { status: "loading" };
function handleResponse(response: ApiResponse) {
switch (response.status) {
case "success":
console.log(response.data); // TypeScript knows data exists
break;
case "error":
console.error(response.error); // TypeScript knows error exists
break;
case "loading":
console.log("Loading...");
break;
}
}
Use satisfies for Type Validation Without Widening
The satisfies operator (introduced in TypeScript 4.9) validates a value against a type without losing its specific type information.
type Route = {
path: string;
method: "GET" | "POST" | "PUT" | "DELETE";
};
// Using type annotation: loses specific string information
const route: Route = { path: "/api/users", method: "GET" };
// route.path is just `string`
// Using satisfies: validates AND preserves literal types
const route = {
path: "/api/users",
method: "GET",
} satisfies Route;
// route.path is "/api/users" (literal type preserved)
// route.method is "GET" (literal type preserved)
// Great for configuration objects
type Config = Record<string, { url: string; timeout: number }>;
const apiConfig = {
users: { url: "/api/users", timeout: 5000 },
products: { url: "/api/products", timeout: 3000 },
} satisfies Config;
// apiConfig.users is known to exist (autocomplete works!)
Part 2: Advanced Type Patterns
Template Literal Types
Template literal types let you create string types from combinations.
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type ApiVersion = "v1" | "v2";
type Endpoint = `/${ApiVersion}/${string}`;
// Create event handler types automatically
type EventName = "click" | "focus" | "blur";
type HandlerName = `on${Capitalize<EventName>}`;
// Result: "onClick" | "onFocus" | "onBlur"
// CSS unit types
type CSSUnit = "px" | "rem" | "em" | "%";
type CSSValue = `${number}${CSSUnit}`;
function setWidth(value: CSSValue) { /* ... */ }
setWidth("100px"); // OK
setWidth("2rem"); // OK
setWidth("100"); // Error: not a valid CSSValue
Utility Types You Should Know
TypeScript provides built-in utility types that save you from writing boilerplate.
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
// Partial: Make all properties optional
type UpdateUser = Partial<User>;
// Pick: Select specific properties
type UserPublic = Pick<User, "id" | "name" | "email">;
// Omit: Remove specific properties
type UserWithoutPassword = Omit<User, "password">;
// Required: Make all properties required
type CompleteUser = Required<Partial<User>>;
// Record: Create object type with specific keys
type UserRoles = Record<string, "admin" | "user" | "moderator">;
// Extract / Exclude: Filter union types
type StringOrNumber = string | number | boolean;
type OnlyStrings = Extract<StringOrNumber, string>; // string
type NoStrings = Exclude<StringOrNumber, string>; // number | boolean
// ReturnType: Get the return type of a function
function createUser() { return { id: 1, name: "John" }; }
type NewUser = ReturnType<typeof createUser>; // { id: number; name: string }
// Parameters: Get parameter types of a function
type CreateUserParams = Parameters<typeof createUser>; // []
// Awaited: Unwrap Promise types
type UserData = Awaited<Promise<User>>; // User
Generic Constraints and Defaults
Write flexible but safe generic types.
// Constrained generic: T must have an id property
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
// Generic with default type
interface ApiOptions<T = unknown> {
endpoint: string;
method: HttpMethod;
body?: T;
}
// Multiple constraints
function merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b };
}
// Conditional types
type IsArray<T> = T extends unknown[] ? true : false;
type Test1 = IsArray<string[]>; // true
type Test2 = IsArray<string>; // false
// Infer keyword in conditional types
type ElementType<T> = T extends (infer U)[] ? U : never;
type Item = ElementType<string[]>; // string
Branded Types for Type Safety
Use branded types to prevent mixing up values that have the same underlying type.
// Without branded types: easy to mix up IDs
function getUser(userId: string) { /* ... */ }
function getOrder(orderId: string) { /* ... */ }
const userId = "user_123";
const orderId = "order_456";
getUser(orderId); // No error! But this is a bug.
// With branded types: compiler catches the mistake
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
function getUser(userId: UserId) { /* ... */ }
function getOrder(orderId: OrderId) { /* ... */ }
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
getUser(orderId); // ERROR: Argument of type 'OrderId' is not assignable to 'UserId'
getUser(userId); // OK!
Part 3: Error Handling Best Practices
Use Result Types Instead of Throwing
Throwing errors is unpredictable -- callers might forget to catch them. Result types make errors explicit.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseJSON(text: string): Result<unknown, string> {
try {
return { ok: true, value: JSON.parse(text) };
} catch (e) {
return { ok: false, error: `Invalid JSON: ${(e as Error).message}` };
}
}
// Usage: caller MUST handle both cases
const result = parseJSON(userInput);
if (result.ok) {
console.log(result.value); // TypeScript knows this is the success case
} else {
console.error(result.error); // TypeScript knows this is the error case
}
You can test JSON parsing with our JSON Formatter tool to validate your inputs.
Type-Safe Error Handling with Custom Errors
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number,
public readonly context?: Record<string, unknown>
) {
super(message);
this.name = "AppError";
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(
`${resource} with id ${id} not found`,
"NOT_FOUND",
404,
{ resource, id }
);
}
}
class ValidationError extends AppError {
constructor(field: string, message: string) {
super(
`Validation failed for ${field}: ${message}`,
"VALIDATION_ERROR",
400,
{ field }
);
}
}
// Type-safe error handling
function handleError(error: unknown) {
if (error instanceof NotFoundError) {
// TypeScript knows error.context has resource and id
return { status: 404, message: error.message };
}
if (error instanceof ValidationError) {
return { status: 400, message: error.message };
}
if (error instanceof AppError) {
return { status: error.statusCode, message: error.message };
}
return { status: 500, message: "Internal server error" };
}
Exhaustive Switch Statements
Use the never type to ensure switch statements handle all cases.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function calculateArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// If you add a new shape but forget to handle it,
// this line will cause a compile error!
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}
Part 4: Project Configuration
Recommended tsconfig.json for 2026
{
"compilerOptions": {
// Type Checking
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
// Modules
"module": "NodeNext",
"moduleResolution": "NodeNext",
"resolveJsonModule": true,
// Emit
"target": "ES2022",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
// Interop
"esModuleInterop": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
// Other
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Key Compiler Options Explained
strict: true-- Enables all strict type checking options. Non-negotiable.noUncheckedIndexedAccess-- Array access returnsT | undefined, catching out-of-bounds errors.exactOptionalPropertyTypes-- Distinguishes betweenundefinedand "missing" properties.verbatimModuleSyntax-- Enforces consistent import/export syntax.isolatedModules-- Required for tools like esbuild, SWC, and Babel that compile files independently.
Part 5: Patterns for Modern TypeScript
Type-Safe API Clients
// Define your API schema
interface ApiSchema {
"/users": {
GET: { response: User[] };
POST: { body: CreateUserInput; response: User };
};
"/users/:id": {
GET: { response: User };
PUT: { body: UpdateUserInput; response: User };
DELETE: { response: void };
};
}
// Type-safe fetch wrapper
async function api<
Path extends keyof ApiSchema,
Method extends keyof ApiSchema[Path]
>(
path: Path,
method: Method,
options?: { body?: unknown }
): Promise<ApiSchema[Path][Method] extends { response: infer R } ? R : never> {
const response = await fetch(path as string, {
method: method as string,
headers: { "Content-Type": "application/json" },
body: options?.body ? JSON.stringify(options.body) : undefined,
});
return response.json();
}
// Usage: fully type-safe!
const users = await api("/users", "GET"); // User[]
const user = await api("/users/:id", "GET"); // User
const newUser = await api("/users", "POST", { // User
body: { name: "John", email: "john@example.com" }
});
Builder Pattern with Types
class QueryBuilder<T extends Record<string, unknown>> {
private conditions: string[] = [];
private params: unknown[] = [];
where<K extends keyof T>(
field: K,
operator: "=" | "!=" | ">" | "<" | "LIKE",
value: T[K]
): this {
this.conditions.push(`${String(field)} ${operator} ?`);
this.params.push(value);
return this;
}
build(): { query: string; params: unknown[] } {
const whereClause = this.conditions.length
? `WHERE ${this.conditions.join(" AND ")}`
: "";
return {
query: `SELECT * FROM table ${whereClause}`,
params: this.params,
};
}
}
// Usage
interface UserTable {
id: number;
name: string;
age: number;
email: string;
}
const query = new QueryBuilder<UserTable>()
.where("age", ">", 18) // TypeScript knows age must be number
.where("name", "LIKE", "%John%") // TypeScript knows name must be string
.build();
Zod for Runtime Validation
While TypeScript checks types at compile time, you still need runtime validation for external data (API requests, user input, file reads).
import { z } from "zod";
// Define schema
const UserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150),
role: z.enum(["admin", "user", "moderator"]),
metadata: z.record(z.string(), z.unknown()).optional(),
});
// Automatically infer the TypeScript type
type User = z.infer<typeof UserSchema>;
// Result: { name: string; email: string; age: number; role: "admin" | "user" | "moderator"; metadata?: Record<string, unknown> }
// Validate at runtime
function createUser(input: unknown): User {
const result = UserSchema.safeParse(input);
if (!result.success) {
throw new ValidationError(result.error.message);
}
return result.data; // Fully typed!
}
Part 6: Performance Tips
Use const enum for Zero-Runtime Enums
// Regular enum: generates JavaScript code
enum Direction {
Up = "UP",
Down = "DOWN",
}
// Compiles to an object at runtime
// const enum: inlined at compile time (zero runtime overhead)
const enum Direction {
Up = "UP",
Down = "DOWN",
}
// References are replaced with literal values
Avoid Unnecessary Type Assertions
// BAD: Type assertion masks potential issues
const data = fetchData() as UserData;
// GOOD: Type guard validates the data
function isUserData(data: unknown): data is UserData {
return (
typeof data === "object" &&
data !== null &&
"id" in data &&
"name" in data
);
}
const data = fetchData();
if (isUserData(data)) {
// data is safely typed as UserData here
}
Use Type Narrowing Effectively
// TypeScript narrows types automatically after checks
function processValue(value: string | number | null) {
if (value === null) return "No value";
// TypeScript knows value is string | number here
if (typeof value === "string") {
return value.toUpperCase(); // string methods available
}
return value.toFixed(2); // number methods available
}
// Use 'in' operator for object type narrowing
function handleEvent(event: MouseEvent | KeyboardEvent) {
if ("key" in event) {
console.log(event.key); // KeyboardEvent
} else {
console.log(event.clientX); // MouseEvent
}
}
Part 7: Testing TypeScript Code
Type Testing with expect-type
import { expectTypeOf } from "expect-type";
// Verify function return types
expectTypeOf(createUser).returns.toEqualTypeOf<User>();
// Verify parameter types
expectTypeOf(createUser).parameter(0).toEqualTypeOf<CreateUserInput>();
// Verify that types are not assignable
expectTypeOf<string>().not.toBeAssignableFrom<number>();
Testing Tips
- Test types alongside runtime behavior -- Use tools like
tsdorexpect-type - Use
as unknown as Typefor test fixtures -- Create partial mock data without satisfying every property - Test error types -- Ensure functions throw the right error types
- Test generic functions with multiple type parameters -- Verify behavior for different type combinations
Conclusion
TypeScript is much more than "JavaScript with types." When used to its full potential, it is a powerful tool for building reliable, maintainable software. The practices in this guide represent the state of the art in 2026:
- Use the type system fully --
unknownoverany, discriminated unions, branded types - Let inference work -- Only annotate when necessary
- Make errors explicit -- Result types, exhaustive checks, custom error classes
- Configure strictly --
strict: trueis the baseline - Validate at boundaries -- Use Zod or similar for runtime validation
- Test your types -- Types are part of your codebase and should be tested
Start applying these practices today, and you will notice fewer bugs, better IDE support, and more confident refactoring.
Related Resources
- Web Development Trends 2026 -- The latest in web technology
- AI Tools for Developers 2026 -- AI tools that understand TypeScript
- JSON Formatter -- Format and validate JSON data
- Regex Tester -- Test regular expressions for TypeScript validation
- UUID Generator -- Generate UUIDs for your TypeScript projects