TypeScript Best Practices for Real-World Projects
TypeScript has fundamentally changed how I write JavaScript. But adopting TypeScript is not just about adding type annotations to your variables — it is about leveraging the type system to catch bugs at compile time, make refactoring safe, and communicate intent through your code. After years of using TypeScript in production projects, here are the practices that have made the biggest difference.
Start with Strict Mode
If you are starting a new TypeScript project, enable strict mode in your tsconfig.json from day one:
{
"compilerOptions": {
"strict": true
}
}
This single flag enables a bundle of stricter checks: strictNullChecks, strictFunctionTypes, strictBindCallApply, noImplicitAny, noImplicitThis, and strictPropertyInitialization. Each of these catches a different category of bugs.
The most important one is strictNullChecks. Without it, TypeScript lets you treat null and undefined as valid values for any type, which defeats the purpose of having types in the first place. With it enabled, you are forced to handle the possibility of missing values explicitly.
// Without strictNullChecks — this compiles but crashes at runtime
function getLength(str: string) {
return str.length; // str could be null!
}
// With strictNullChecks — you must handle the null case
function getLength(str: string | null): number {
if (str === null) return 0;
return str.length; // TypeScript knows str is string here
}
If you are adding TypeScript to an existing JavaScript project, strict mode might produce hundreds of errors. In that case, enable strict checks one at a time, starting with noImplicitAny.
Discriminated Unions Over Optional Properties
One of the most powerful TypeScript patterns is the discriminated union. Instead of having an object with many optional properties where the valid combinations are unclear, use a union of specific types with a shared discriminant property.
// Avoid: unclear which combinations are valid
interface ApiResponse {
status: "loading" | "success" | "error";
data?: User[];
error?: string;
retryAfter?: number;
}
// Prefer: each state is explicit and self-documenting
type ApiResponse =
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: string; retryAfter?: number };
With the discriminated union approach, TypeScript can narrow the type based on the status field:
function handleResponse(response: ApiResponse) {
switch (response.status) {
case "loading":
return <Spinner />;
case "success":
return <UserList users={response.data} />; // data is guaranteed to exist
case "error":
return <Error message={response.error} />; // error is guaranteed to exist
}
}
This pattern is incredibly useful for state management, API responses, form states, and anywhere you have distinct modes or phases.
Type Narrowing: Let TypeScript Work for You
TypeScript's control flow analysis is sophisticated. When you check a condition, TypeScript narrows the type within that branch. Learn to use this instead of fighting it with type assertions.
// Type guards with typeof
function processValue(value: string | number) {
if (typeof value === "string") {
return value.toUpperCase(); // TypeScript knows it is a string
}
return value.toFixed(2); // TypeScript knows it is a number
}
// Custom type guards for complex types
interface Dog {
kind: "dog";
bark(): void;
}
interface Cat {
kind: "cat";
purr(): void;
}
type Animal = Dog | Cat;
function isDog(animal: Animal): animal is Dog {
return animal.kind === "dog";
}
function handleAnimal(animal: Animal) {
if (isDog(animal)) {
animal.bark(); // TypeScript knows it is a Dog
} else {
animal.purr(); // TypeScript knows it is a Cat
}
}
The in operator also works as a type guard:
function handleResponse(response: SuccessResponse | ErrorResponse) {
if ("data" in response) {
// TypeScript narrows to SuccessResponse
console.log(response.data);
}
}
Avoid any Like the Plague
Using any turns off TypeScript's type checking for that value. It is the escape hatch that makes TypeScript pointless. Every any in your codebase is a potential runtime error waiting to happen.
When you are tempted to use any, reach for these alternatives:
unknown — the type-safe counterpart to any. You can assign anything to unknown, but you cannot use it without narrowing first.
// Bad: any lets you do anything without checks
function processInput(input: any) {
return input.name.toUpperCase(); // no error, but crashes if input has no name
}
// Good: unknown forces you to check first
function processInput(input: unknown) {
if (typeof input === "object" && input !== null && "name" in input) {
return (input as { name: string }).name.toUpperCase();
}
throw new Error("Invalid input");
}
Generic types — when you want flexibility without losing type information.
// Bad: loses type information
function firstElement(arr: any[]): any {
return arr[0];
}
// Good: preserves type information
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
Record<string, unknown> — when you need a generic object type.
If you absolutely must use any (for example, when dealing with a poorly typed third-party library), isolate it behind a well-typed wrapper function so the any does not leak into the rest of your codebase.
Interface vs Type: When to Use Each
This is one of the most common TypeScript debates. Here is my practical guideline:
Use interface when defining the shape of an object, especially if it might be extended or implemented by classes. Interfaces support declaration merging, which is useful for extending third-party types.
interface User {
id: string;
name: string;
email: string;
}
interface AdminUser extends User {
permissions: string[];
}
Use type for everything else: unions, intersections, mapped types, conditional types, and simple aliases.
type Status = "active" | "inactive" | "pending";
type Result<T> = { success: true; data: T } | { success: false; error: string };
type Nullable<T> = T | null;
In practice, the difference is small for most use cases. Pick one convention for your project and be consistent.
Enums vs Const Objects
TypeScript enums have some quirks that make me prefer const objects in most cases:
// TypeScript enum — generates runtime JavaScript
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT",
}
// Const object — no runtime overhead, better type inference
const Direction = {
Up: "UP",
Down: "DOWN",
Left: "LEFT",
Right: "RIGHT",
} as const;
type Direction = (typeof Direction)[keyof typeof Direction];
// type Direction = "UP" | "DOWN" | "LEFT" | "RIGHT"
The const object approach has several advantages: it produces cleaner JavaScript output, works naturally with Object.values() and Object.keys(), and does not create a separate TypeScript-specific construct that behaves differently from regular objects.
The one case where I still use enums is in code that interacts with databases or APIs where you want a named constant that maps to a numeric value and benefits from reverse mapping.
Utility Types You Should Know
TypeScript ships with powerful utility types. Here are the ones I use most often:
interface User {
id: string;
name: string;
email: string;
avatar: string;
createdAt: Date;
}
// Partial — all properties optional (great for update functions)
function updateUser(id: string, updates: Partial<User>) { ... }
// Pick — select specific properties
type UserPreview = Pick<User, "id" | "name" | "avatar">;
// Omit — exclude specific properties
type CreateUserInput = Omit<User, "id" | "createdAt">;
// Required — make all properties required
type CompleteUser = Required<User>;
// Record — define object shapes
type UserRoles = Record<string, "admin" | "user" | "guest">;
// ReturnType — extract a function's return type
type ApiResult = ReturnType<typeof fetchUsers>;
Combining utility types with generics creates reusable patterns:
type ApiResponse<T> = {
data: T;
meta: {
page: number;
total: number;
};
};
type PaginatedUsers = ApiResponse<User[]>;
Generic Constraints
Generics without constraints are too permissive. Use the extends keyword to restrict what types can be passed:
// Too broad — T could be anything
function getProperty<T>(obj: T, key: string) {
return obj[key]; // error: no index signature
}
// Properly constrained
function getProperty<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // type-safe!
}
const user = { name: "Bitnara", age: 30 };
getProperty(user, "name"); // returns string
getProperty(user, "foo"); // compile error — "foo" is not a key of user
A practical pattern I use frequently is constraining generics to ensure they have certain properties:
interface HasId {
id: string;
}
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find((item) => item.id === id);
}
Error Handling Patterns
TypeScript does not have a built-in Result type like Rust, but you can build one:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseJson<T>(json: string): Result<T> {
try {
return { ok: true, value: JSON.parse(json) };
} catch (e) {
return { ok: false, error: e instanceof Error ? e : new Error(String(e)) };
}
}
const result = parseJson<User>(rawData);
if (result.ok) {
console.log(result.value.name); // type-safe access
} else {
console.error(result.error.message);
}
This pattern makes error handling explicit and forces callers to handle both success and failure cases. It is especially useful for functions that interact with external systems (APIs, file system, databases).
Type-Safe API Calls
When your frontend communicates with an API, type safety across the boundary prevents a huge category of bugs. Here is a pattern I use:
// Define your API schema
interface ApiEndpoints {
"/users": {
GET: { response: User[]; query: { page: number; limit: number } };
POST: { response: User; body: CreateUserInput };
};
"/users/:id": {
GET: { response: User };
DELETE: { response: void };
};
}
// Type-safe fetch wrapper
async function apiCall<
Path extends keyof ApiEndpoints,
Method extends keyof ApiEndpoints[Path]
>(
path: Path,
method: Method,
options?: { body?: unknown; query?: unknown }
): Promise<ApiEndpoints[Path][Method] extends { response: infer R } ? R : never> {
// implementation
}
// Usage — fully type-checked
const users = await apiCall("/users", "GET"); // returns User[]
const newUser = await apiCall("/users", "POST", { body: userData }); // returns User
For projects using tRPC or GraphQL with code generation, you get this type safety automatically. But for REST APIs, building a typed wrapper like this is worth the upfront effort.
Practical Tips
Use satisfies for type checking without widening:
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
} satisfies Record<string, string | number>;
// config.apiUrl is still typed as string (not string | number)
Use template literal types for string patterns:
type EventName = `on${Capitalize<string>}`;
type CssUnit = `${number}${"px" | "rem" | "em" | "%"}`;
Use as const assertions to preserve literal types:
const ROUTES = {
home: "/",
about: "/about",
blog: "/blog",
} as const;
// typeof ROUTES.home is "/" (not string)
The Mindset Shift
The biggest TypeScript best practice is not a specific technique — it is a mindset. Think of types as documentation that the compiler enforces. When you write a type, you are not just helping TypeScript catch errors — you are communicating to future developers (including yourself) what this code expects and what it returns.
Good TypeScript code reads like a specification. The types tell you what is possible, what is required, and what the edges cases are. When you find yourself writing a comment to explain what a function accepts, consider whether a better type definition could convey the same information in a way that the compiler can verify.
TypeScript is not about adding types to JavaScript. It is about designing your data flow so precisely that entire categories of bugs become impossible. That is the goal worth striving for.