TypeScript Best Practices for Enterprise Applications in 2026
TypeScript has become the default choice for serious JavaScript development. But using TypeScript and using it well are different things. After maintaining codebases with 500K+ lines of TypeScript, here are the patterns that pay dividends at scale.
1. Enable Strict Mode — All of It
Half the value of TypeScript comes from its strict checks. Enable everything in your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true
}
}
noUncheckedIndexedAccess is particularly valuable — it makes array and object index access return T | undefined instead of T, preventing a whole class of runtime errors.
2. Use Discriminated Unions for State Management
Discriminated unions are one of TypeScript's most powerful patterns for modeling states that have different shapes:
// ❌ Bad: Boolean flags and optional fields
interface ApiState {
loading: boolean;
error?: string;
data?: User[];
}
// Problem: Can be in impossible states (loading: true AND error: "failed")
// ✅ Good: Discriminated union - each state is explicit
type ApiState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
// TypeScript narrows the type based on status
function renderUsers(state: ApiState) {
switch (state.status) {
case 'idle':
return <p>Ready to load</p>;
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={state.data} />; // data is guaranteed
case 'error':
return <ErrorMessage message={state.error} />; // error is guaranteed
}
}
3. Branded Types for Semantic Safety
Prevent mixing up values that have the same primitive type but different semantic meaning:
// Without branded types, these are interchangeable (dangerous!)
type UserId = string;
type OrderId = string;
// With branded types, they're incompatible
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function createUserId(id: string): UserId {
return id as UserId;
}
function getUser(id: UserId): Promise<User> { ... }
function getOrder(id: OrderId): Promise<Order> { ... }
const userId = createUserId('abc-123');
const orderId = createOrderId('ord-456');
getUser(userId); // ✅ Compiles
getUser(orderId); // ❌ Type error! Can't pass OrderId as UserId
This prevents a class of bugs that no amount of testing can reliably catch — accidentally passing the wrong ID to the wrong function.
4. Result Types for Error Handling
Instead of throwing exceptions for expected error cases, use a Result type pattern:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
// Usage
async function parseCSV(file: File): Promise<Result<Row[], ParseError>> {
try {
const rows = await processFile(file);
if (rows.length === 0) {
return { ok: false, error: new ParseError('File is empty') };
}
return { ok: true, value: rows };
} catch (e) {
return { ok: false, error: new ParseError('Invalid CSV format') };
}
}
// Caller is forced to handle both cases
const result = await parseCSV(uploadedFile);
if (!result.ok) {
showError(result.error.message);
return;
}
// result.value is typed as Row[] here
renderTable(result.value);
5. Utility Types for DRY Schemas
Leverage TypeScript's built-in utility types and create custom ones to avoid duplication:
// Derive types from a source of truth
interface User {
id: string;
email: string;
name: string;
password: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: Date;
updatedAt: Date;
}
// API response (no password, no internal dates)
type UserResponse = Omit<User, 'password' | 'updatedAt'>;
// Creation input (no auto-generated fields)
type CreateUserInput = Pick<User, 'email' | 'name' | 'password' | 'role'>;
// Update input (all fields optional except id)
type UpdateUserInput = Partial<CreateUserInput> & Pick<User, 'id'>;
6. Const Assertions and Enums
Prefer as const objects over enums for better tree-shaking and type inference:
// ✅ Preferred: const assertion
const HttpStatus = {
OK: 200,
Created: 201,
BadRequest: 400,
Unauthorized: 401,
NotFound: 404,
InternalError: 500,
} as const;
type HttpStatus = (typeof HttpStatus)[keyof typeof HttpStatus];
// type HttpStatus = 200 | 201 | 400 | 401 | 404 | 500
These patterns have significantly reduced bug counts and improved developer velocity across our enterprise projects. TypeScript is most powerful when you lean into its type system rather than fighting it with any casts and type assertions.