TypeScript has become the default choice for serious JavaScript projects. But simply adding TypeScript to a project isn’t enough — you need to use it effectively. Poorly typed TypeScript is worse than no TypeScript at all because it gives a false sense of safety.
This guide covers the patterns and practices that separate good TypeScript from great TypeScript.
Enable Strict Mode
The single most important thing you can do is enable strict mode in your tsconfig.json. It enables a set of type-checking rules that catch the most common bugs.
{
"compilerOptions": {
"strict": true,
// strict enables these flags:
// "noImplicitAny": true,
// "strictNullChecks": true,
// "strictFunctionTypes": true,
// "strictBindCallApply": true,
// "strictPropertyInitialization": true,
// "noImplicitThis": true,
// "useUnknownInCatchVariables": true,
// "alwaysStrict": true
// Additional recommended flags
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
Use unknown Instead of any
any is a TypeScript escape hatch that disables type checking. unknown forces you to narrow the type before using it.
// ❌ any disables type checking entirely
function parseJSON(json: string): any {
return JSON.parse(json);
}
const user = parseJSON('{"name":"Alice"}');
user.nonExistentMethod(); // No error! This will crash at runtime.
// ✅ unknown forces proper type narrowing
function parseJSON(json: string): unknown {
return JSON.parse(json);
}
const data = parseJSON('{"name":"Alice"}');
// Must narrow before using
if (typeof data === "object" && data !== null && "name" in data) {
console.log((data as { name: string }).name); // Safe!
}
Discriminated Unions
Model state machines and variants with discriminated unions. They enable exhaustive type checking and make impossible states impossible.
// ❌ Everything is optional, invalid states are possible
interface ApiState {
loading?: boolean;
data?: User;
error?: string;
}
// ✅ Discriminated union - each state is precise
type ApiState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
type UserState = ApiState<User>;
function renderUser(state: UserState) {
switch (state.status) {
case 'idle':
return null;
case 'loading':
return <Spinner />;
case 'success':
return <UserCard user={state.data} />; // TypeScript knows data exists here!
case 'error':
return <ErrorMessage message={state.error} />;
// TypeScript will warn if you don't handle all cases (with noImplicitReturns)
}
}
Branded Types for Domain Safety
Prevent mixing up values of the same primitive type by using branded types:
// Without branding: These are both `string` and can be accidentally swapped
function sendEmail(to: string, from: string) {
/* ... */
}
sendEmail(fromEmail, toEmail); // Oops! No TypeScript error.
// With branded types: Type-safe at compile time, zero runtime cost
type Email = string & { readonly _brand: "Email" };
type UserId = string & { readonly _brand: "UserId" };
function createEmail(email: string): Email {
if (!email.includes("@")) throw new Error("Invalid email");
return email as Email;
}
function sendEmail(to: Email, from: Email) {
/* ... */
}
const toEmail = createEmail("alice@example.com");
const userId = "user-123" as UserId;
sendEmail(toEmail, userId); // ✅ TypeScript Error! UserId is not Email
sendEmail(userId, toEmail); // ✅ TypeScript Error!
sendEmail(toEmail, toEmail); // ✅ Works correctly
Generics Done Right
Generics make code reusable without sacrificing type safety:
// ❌ Non-generic: Duplicated for every type
function getFirstUser(items: User[]): User | undefined {
return items[0];
}
function getFirstPost(items: Post[]): Post | undefined {
return items[0];
}
// ✅ Generic: Works for any type
function getFirst<T>(items: T[]): T | undefined {
return items[0];
}
// With constraints: T must have an 'id' field
function findById<T extends { id: string }>(
items: T[],
id: string,
): T | undefined {
return items.find((item) => item.id === id);
}
// With conditional types: The return type changes based on the input
type MaybeArray<T> = T | T[];
function normalizeToArray<T>(value: MaybeArray<T>): T[] {
return Array.isArray(value) ? value : [value];
}
Master Utility Types
TypeScript’s built-in utility types are incredibly powerful:
interface User {
id: string;
name: string;
email: string;
role: "admin" | "user" | "guest";
createdAt: Date;
updatedAt: Date;
}
// Partial: All fields become optional
type UserUpdate = Partial<User>;
// → { id?: string; name?: string; email?: string; ... }
// Required: All fields become required
type FullUser = Required<User>;
// Pick: Select specific fields
type UserPreview = Pick<User, "id" | "name" | "role">;
// → { id: string; name: string; role: 'admin' | 'user' | 'guest' }
// Omit: Exclude specific fields
type UserCreate = Omit<User, "id" | "createdAt" | "updatedAt">;
// → { name: string; email: string; role: 'admin' | 'user' | 'guest' }
// Record: Create a map type
type RolePermissions = Record<User["role"], string[]>;
// → { admin: string[]; user: string[]; guest: string[] }
// ReturnType & Parameters: Infer from functions
type ComponentProps = Parameters<typeof MyComponent>[0];
type QueryResult = ReturnType<typeof useQuery>;
The satisfies Operator
The satisfies operator (TypeScript 4.9+) validates a value matches a type while preserving the narrower inferred type:
type Config = {
port: number | string;
host: string;
debug?: boolean;
};
// ❌ Type annotation loses narrow type
const config: Config = {
port: 3000,
host: "localhost",
};
config.port.toFixed(2); // Error! 'toFixed' doesn't exist on 'number | string'
// ✅ satisfies validates AND preserves narrow type
const config = {
port: 3000,
host: "localhost",
} satisfies Config;
config.port.toFixed(2); // ✅ Works! TypeScript knows port is number here
config.nonExistent; // ❌ Error! Config doesn't have this property
Template Literal Types
Build precise string types using template literals:
type EventName = "click" | "focus" | "blur";
type EventHandler = `on${Capitalize<EventName>}`;
// → "onClick" | "onFocus" | "onBlur"
type CSSProperty = `${string}-${string}`;
// Build a type-safe event emitter
type EventMap = {
userLogin: { userId: string };
userLogout: { userId: string; reason: string };
pageView: { path: string; referrer?: string };
};
type EventEmitter = {
on<K extends keyof EventMap>(
event: K,
handler: (payload: EventMap[K]) => void,
): void;
emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void;
};
Type Guards and Assertions
Write robust type guards for runtime validation:
// Type predicate: Tells TypeScript what type something is after the check
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
typeof (value as User).id === "string" &&
typeof (value as User).name === "string" &&
typeof (value as User).email === "string"
);
}
// Assertion function: Throws if the condition isn't met
function assertIsUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new Error(`Expected User, got: ${JSON.stringify(value)}`);
}
}
// Usage
const response = await fetch("/api/user");
const data: unknown = await response.json();
if (isUser(data)) {
// TypeScript narrows `data` to User inside this block
console.log(data.name.toUpperCase()); // Safe!
}
// Or assert and use directly
assertIsUser(data);
console.log(data.name); // TypeScript knows it's a User here
Avoid These Common Mistakes
Don’t use ! Non-null Assertion Carelessly
// ❌ Lying to TypeScript - will crash if element doesn't exist
const element = document.getElementById("app")!;
element.innerHTML = "Hello";
// ✅ Check properly
const element = document.getElementById("app");
if (!element) throw new Error("#app element not found");
element.innerHTML = "Hello";
Don’t Use as for Type Coercion
// ❌ You're lying to TypeScript about the type
const user = apiResponse as User;
// ✅ Validate first
if (isUser(apiResponse)) {
const user = apiResponse; // TypeScript narrows correctly
}
Prefer Interfaces for Object Shapes You’ll Extend
// Use interface for object shapes (especially if extended)
interface Animal {
name: string;
sound(): string;
}
interface Dog extends Animal {
breed: string;
}
// Use type for unions, intersections, and computed types
type StringOrNumber = string | number;
type DogOrCat = Dog | Cat;
type Readonly<T> = { readonly [K in keyof T]: T[K] };
Configuration Recommendation
Here’s a robust tsconfig.json for a modern TypeScript project:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"skipLibCheck": true,
"esModuleInterop": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
}
}
Conclusion
TypeScript’s power comes from using it properly. Enable strict mode from the start, model your domain with discriminated unions, use generics to write reusable yet type-safe code, and rely on utility types to avoid repetition.
The goal isn’t to placate the type checker — it’s to encode your business logic into the type system so TypeScript can catch entire categories of bugs at compile time instead of runtime.
TypeScript done right means fewer bugs, better IDE support, and code that’s self-documenting by design.