TypeScript Strict
Enforce strict TypeScript conventions for safer and more maintainable code.
Strict TypeScript conventions: strict mode config, no-any rules, utility types, discriminated unions, branded types, and error handling patterns.
Strict Mode Configuration
Always enable strict mode. Use this tsconfig.json base:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true
}
}
What strict: true Enables
strictNullChecks—nullandundefinedare their own typesstrictFunctionTypes— function parameter types are checked strictlystrictBindCallApply—bind,call,applyare typed correctlystrictPropertyInitialization— class properties must be initializednoImplicitAny— no implicitanytypesnoImplicitThis—thismust have an explicit typealwaysStrict— emit"use strict"in every file
No any Rules
- Never use
any— useunknownwhen the type is truly unknown - Use
unknownand narrow — type guards,instanceof,typeof - Use generics for flexible types —
function parse<T>(input: string): T - Use
Record<string, unknown>overobject— more explicit - Suppress with
// @ts-expect-error(not// @ts-ignore) —@ts-expect-errorfails if the error is fixed
// Bad
function parse(data: any): any {
return JSON.parse(data);
}
// Good
function parse(data: string): unknown {
return JSON.parse(data);
}
// Good — with type guard
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"email" in value
);
}
const data: unknown = parse(input);
if (isUser(data)) {
console.log(data.email); // typed as User
}
Utility Types
Use built-in utility types instead of manual type construction:
| Utility | Use Case | Example |
|---|---|---|
Partial<T> | All properties optional | Update DTOs: Partial<User> |
Required<T> | All properties required | Override optionals |
Pick<T, K> | Select specific properties | Pick<User, "id" | "name"> |
Omit<T, K> | Remove specific properties | Omit<User, "password"> |
Readonly<T> | All properties readonly | Immutable state |
Record<K, V> | Object with known key/value types | Record<string, number> |
Extract<T, U> | Extract union members matching U | Extract<Status, "active" | "pending"> |
Exclude<T, U> | Remove union members matching U | Exclude<Status, "deleted"> |
NonNullable<T> | Remove null/undefined | NonNullable<string | null> -> string |
ReturnType<T> | Extract return type of function | ReturnType<typeof fetchUser> |
Awaited<T> | Unwrap Promise type | Awaited<Promise<User>> -> User |
// Compose utility types for DTOs
interface User {
id: string;
email: string;
name: string;
password: string;
role: "admin" | "user";
createdAt: Date;
}
type CreateUserInput = Omit<User, "id" | "createdAt">;
type UpdateUserInput = Partial<Omit<User, "id" | "createdAt">>;
type PublicUser = Omit<User, "password">;
Discriminated Unions
Use discriminated unions for type-safe state management and variant types:
// Define a discriminated union with a literal "type" field
type ApiResult<T> =
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderResult<T>(result: ApiResult<T>) {
switch (result.status) {
case "loading":
return <Spinner />;
case "success":
return <DataView data={result.data} />; // data is available
case "error":
return <ErrorView error={result.error} />; // error is available
}
}
Rules
- Use a literal
typeorkindfield as the discriminant - Handle all variants — enable
noFallthroughCasesInSwitch - Use exhaustive checks — add a
neverdefault case
// Exhaustive switch — compile error if a variant is missed
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
function handleEvent(event: AppEvent) {
switch (event.type) {
case "click":
return handleClick(event);
case "keypress":
return handleKeypress(event);
default:
return assertNever(event); // Compile error if new variant is added
}
}
Common Patterns
// Action types for reducers
type Action =
| { type: "SET_USER"; payload: User }
| { type: "SET_ERROR"; payload: string }
| { type: "RESET" };
// API responses
type Response =
| { ok: true; data: User[] }
| { ok: false; error: string };
// Form field types
type FormField =
| { kind: "text"; value: string; maxLength?: number }
| { kind: "number"; value: number; min?: number; max?: number }
| { kind: "select"; value: string; options: string[] }
| { kind: "checkbox"; value: boolean };
Branded Types
Use branded types to prevent mixing semantically different values that share a primitive type:
// Define branded types
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
type Email = string & { readonly __brand: "Email" };
// Constructor functions with validation
function UserId(id: string): UserId {
if (!id.startsWith("usr_")) throw new Error("Invalid user ID");
return id as UserId;
}
function Email(value: string): Email {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new Error("Invalid email");
}
return value as Email;
}
// Type safety — can't mix them up
function getUser(id: UserId): Promise<User> { /* ... */ }
function getOrder(id: OrderId): Promise<Order> { /* ... */ }
const userId = UserId("usr_abc123");
const orderId = OrderId("ord_xyz789");
getUser(userId); // OK
getUser(orderId); // Compile error — OrderId is not UserId
Error Handling Patterns
Result Type
Use a Result type instead of throwing exceptions for expected errors:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
// Helper constructors
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// Usage
async function createUser(input: CreateUserInput): Promise<Result<User, CreateUserError>> {
const existing = await db.findByEmail(input.email);
if (existing) {
return err({ code: "EMAIL_EXISTS", message: "Email already registered" });
}
const user = await db.create(input);
return ok(user);
}
// Caller handles both cases
const result = await createUser(input);
if (result.ok) {
console.log("Created:", result.value.id);
} else {
console.error("Failed:", result.error.message);
}
Custom Error Classes
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500,
public readonly cause?: unknown,
) {
super(message);
this.name = this.constructor.name;
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, "NOT_FOUND", 404);
}
}
class ValidationError extends AppError {
constructor(
message: string,
public readonly fields: Record<string, string>,
) {
super(message, "VALIDATION_ERROR", 400);
}
}
Rules
- Use Result types for expected failures — validation, not-found, business rules
- Throw exceptions for unexpected failures — programming errors, system failures
- Type your errors — don't use
catch (e: any) - Use
causefor error chains —new Error("Failed to save", { cause: originalError })
Type Narrowing Techniques
// typeof
function format(value: string | number): string {
if (typeof value === "string") return value.trim();
return value.toFixed(2);
}
// instanceof
function handleError(error: unknown): string {
if (error instanceof AppError) return error.code;
if (error instanceof Error) return error.message;
return String(error);
}
// in operator
function getArea(shape: Circle | Rectangle): number {
if ("radius" in shape) return Math.PI * shape.radius ** 2;
return shape.width * shape.height;
}
// Custom type guard
function isDefined<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
const users = [getUser(1), getUser(2), null].filter(isDefined);
// type: User[]
Anti-patterns
- Using
anyto silence type errors — find the correct type or useunknown - Type assertions (
as) without validation — narrow with type guards instead - Enums — prefer union types of string literals for better tree-shaking
Booleanconstructor as filter —.filter(Boolean)loses type narrowing; use.filter(isDefined)- Ignoring
strictNullChecks— the single most valuable strict check - Overusing
!non-null assertion — it lies to the compiler; handle the null case - Complex conditional types in application code — keep them in library/utility code