TypeScript Best Practices for Large-Scale Applications
Essential TypeScript best practices for building maintainable large-scale applications, including strict configuration, advanced type patterns, error handling, and team conventions.
TypeScript transforms JavaScript development by catching errors before they reach production, but its benefits scale dramatically with how well you use it. In small projects, any TypeScript is better than none. In large-scale applications with multiple teams and thousands of files, the difference between competent and expert TypeScript usage can mean the difference between a codebase that scales gracefully and one that drowns in any types and suppressed errors.
These are the practices we enforce on every large-scale TypeScript project to keep codebases healthy as they grow.
Start with a Strict Configuration
Your tsconfig.json is the foundation of your TypeScript experience. A permissive configuration undermines the entire point of using TypeScript. For any serious project, enable strict mode and additional safety checks.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"]
}
}The strict flag alone enables strictNullChecks, strictFunctionTypes, strictBindCallApply, noImplicitAny, and noImplicitThis. But the additional flags add critical protections. noUncheckedIndexedAccess is particularly important: it adds undefined to the type of any indexed access, forcing you to handle the case where an array or object lookup might not find a value.
const users: string[] = ["Alice", "Bob"];
// Without noUncheckedIndexedAccess: string (dangerous)
// With noUncheckedIndexedAccess: string | undefined (safe)
const user = users[5];
// Now you must handle the undefined case
if (user) {
console.log(user.toUpperCase()); // Safe
}Never use // @ts-ignore or // @ts-expect-error without a comment explaining why. Better yet, fix the underlying type issue instead of suppressing it.
Use Discriminated Unions for State Modeling
One of TypeScript's most powerful features for large applications is discriminated unions. They let you model complex states explicitly, making invalid states unrepresentable in your type system.
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function UserProfile({ state }: { state: AsyncState<User> }) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return <ProfileCard user={state.data} />;
case "error":
return <ErrorMessage error={state.error} />;
}
}TypeScript narrows the type within each case branch, so state.data is only accessible when status is "success", and state.error is only accessible when status is "error". This eliminates an entire class of bugs where code accesses data that has not loaded yet.
Apply this pattern to API responses, form states, modal states, and any entity that transitions through discrete phases.
Prefer Type Inference Over Explicit Annotations
TypeScript's type inference is excellent. Annotating every variable and return type adds noise without adding safety. Let TypeScript infer types where it can, and add explicit annotations where they provide documentation value or where inference would be too broad.
// Unnecessary - TypeScript infers the type perfectly
const name: string = "Alice";
const count: number = items.length;
const doubled: number[] = items.map((item: number): number => item * 2);
// Better - let inference work
const name = "Alice";
const count = items.length;
const doubled = items.map((item) => item * 2);
// Explicit return types ARE valuable on exported functions
// because they serve as documentation and catch unintended changes
export function calculateDiscount(price: number, tier: CustomerTier): number {
// ...
}The key rule is: annotate function parameters and exported function return types. Let TypeScript infer everything else. If you find inference producing an unexpectedly broad type, that is a signal to restructure rather than annotate.
Build Robust Error Handling Patterns
Large applications need consistent error handling. Using thrown exceptions as the primary error mechanism in TypeScript is problematic because the type system cannot track what a function might throw. A Result type pattern provides type-safe error handling.
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function fetchUser(id: string): Promise<Result<User, ApiError>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return {
success: false,
error: {
code: response.status,
message: `Failed to fetch user: ${response.statusText}`,
},
};
}
const data = await response.json();
return { success: true, data };
} catch (err) {
return {
success: false,
error: { code: 0, message: "Network error" },
};
}
}
// Caller is forced to handle both cases
const result = await fetchUser("123");
if (result.success) {
renderProfile(result.data);
} else {
showError(result.error.message);
}This pattern makes error handling explicit and composable. The caller cannot accidentally access data without first checking that the operation succeeded. For libraries like neverthrow or ts-results, consider adopting one if your team wants a more feature-rich Result type with methods like map, flatMap, and unwrapOr.
Use Branded Types for Domain Safety
In large applications, many values share the same primitive type but have different semantic meanings. A userId and an orderId are both strings, but passing one where the other is expected is always a bug. Branded types catch these mistakes at compile time.
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
function getUser(id: UserId): Promise<User> {
// ...
}
function getOrder(id: OrderId): Promise<Order> {
// ...
}
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
getUser(userId); // Compiles
getUser(orderId); // Type error - cannot pass OrderId as UserIdBranded types are particularly valuable for IDs, currency amounts, validated strings (like email addresses), and any domain concept where type confusion could lead to data corruption.
Organize Types Thoughtfully
As a codebase grows, type organization becomes critical. Avoid a single monolithic types.ts file. Instead, co-locate types with the code that uses them and create shared type modules only for types genuinely used across multiple domains.
src/
features/
users/
types.ts # User, UserRole, UserPreferences
api.ts # API functions using those types
components.tsx # Components using those types
orders/
types.ts # Order, OrderStatus, LineItem
api.ts
components.tsx
shared/
types/
api.ts # ApiError, PaginatedResponse, Result
common.ts # DateRange, Money, Address
Use interface for objects that might be extended and type for unions, intersections, and aliases. Be consistent within your codebase. Extract reusable utility types into a shared module rather than redefining them in multiple places.
// shared/types/api.ts
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
perPage: number;
total: number;
totalPages: number;
};
}
export type SortDirection = "asc" | "desc";
export interface SortConfig<T> {
field: keyof T;
direction: SortDirection;
}