TypeScript Best Practices for Large Projects
Essential TypeScript patterns and practices for maintaining type safety in large codebases.
Harshit Shrivastav
Contributor
TypeScript Best Practices for Large Projects
TypeScript is powerful, but it's easy to write TypeScript that's hard to maintain. Here's how to keep your types manageable as your project grows.
Type vs Interface
Both work, but be consistent:
// Use interface for objects
interface User {
id: number;
name: string;
email: string;
}
// Use type for unions, intersections, tuples
type Status = 'pending' | 'approved' | 'rejected';
type Point = [number, number];
type UserWithProfile = User & { profile: Profile };
Strict Configuration
Always enable strict mode:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true
}
}
Utility Types
Use built-in utilities:
interface User {
id: number;
name: string;
email: string;
password: string;
}
// Pick specific properties
type PublicUser = Pick<User, 'id' | 'name'>;
// Omit specific properties
type UserWithoutPassword = Omit<User, 'password'>;
// Make all properties optional
type PartialUser = Partial<User>;
// Make all properties required
type RequiredUser = Required<User>;
// Make all properties readonly
type ImmutableUser = Readonly<User>;
Generic Functions
Write reusable functions:
// Bad: Specific to one type
function getFirstUser(array: User[]): User | undefined {
return array[0];
}
// Good: Works with any type
function getFirst<T>(array: T[]): T | undefined {
return array[0];
}
// Usage
const firstUser = getFirst(users);
const firstPost = getFirst(posts);
Discriminated Unions
Type-safe state management:
type ApiResponse<T> =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; data: T };
function handleResponse<T>(response: ApiResponse<T>) {
switch (response.status) {
case 'loading':
return 'Loading...';
case 'error':
return `Error: ${response.error}`;
case 'success':
return response.data; // TypeScript knows data exists
}
}
Type Guards
Runtime type checking:
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value
);
}
// Usage
if (isUser(data)) {
console.log(data.name); // TypeScript knows it's a User
}
Avoid Any
// Bad
function process(data: any) {
return data.something;
}
// Better: Use unknown
function process(data: unknown) {
if (isValidData(data)) {
return data.something;
}
}
// Best: Use generic
function process<T extends { something: string }>(data: T) {
return data.something;
}
Const Assertions
Create readonly types:
// Without const
const colors = ['red', 'green', 'blue'];
// Type: string[]
// With const
const colors = ['red', 'green', 'blue'] as const;
// Type: readonly ['red', 'green', 'blue']
type Color = typeof colors[number];
// Type: 'red' | 'green' | 'blue'
Branded Types
Prevent mixing similar types:
type UserId = string & { readonly brand: unique symbol };
type PostId = string & { readonly brand: unique symbol };
function getUserId(id: string): UserId {
return id as UserId;
}
function getUser(userId: UserId) {
// ...
}
const userId = getUserId('123');
const postId = '456' as PostId;
getUser(userId); // โ
OK
getUser(postId); // โ Error: PostId is not assignable to UserId
Template Literal Types
Type-safe string patterns:
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Route = `/api/${string}`;
type APIEndpoint = `${HTTPMethod} ${Route}`;
// Valid
const endpoint1: APIEndpoint = 'GET /api/users';
const endpoint2: APIEndpoint = 'POST /api/posts';
// Invalid
const endpoint3: APIEndpoint = 'GET /users'; // Error
Conditional Types
Dynamic type logic:
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>; // false
// Practical example
type Flatten<T> = T extends Array<infer U> ? U : T;
type Str = Flatten<string[]>; // string
type Num = Flatten<number>; // number
Mapped Types
Transform existing types:
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
interface User {
id: number;
name: string;
}
type NullableUser = Nullable<User>;
// { id: number | null; name: string | null }
Function Overloads
Multiple function signatures:
function createElement(tag: 'div'): HTMLDivElement;
function createElement(tag: 'span'): HTMLSpanElement;
function createElement(tag: 'button'): HTMLButtonElement;
function createElement(tag: string): HTMLElement {
return document.createElement(tag);
}
const div = createElement('div'); // Type: HTMLDivElement
const span = createElement('span'); // Type: HTMLSpanElement
Namespace Pattern
Organize related types:
namespace API {
export interface Request {
method: string;
url: string;
}
export interface Response<T> {
status: number;
data: T;
}
export type Error = {
message: string;
code: number;
};
}
// Usage
function handleRequest(req: API.Request): Promise<API.Response<User>> {
// ...
}
Error Handling
Type-safe errors:
class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number
) {
super(message);
this.name = 'AppError';
}
}
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
type Result<T, E = Error> =
| { success: true; value: T }
| { success: false; error: E };
async function fetchUser(id: string): Promise<Result<User>> {
try {
const user = await db.users.findOne(id);
return { success: true, value: user };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error('Unknown error')
};
}
}
Testing Types
Test your types:
// Type test utilities
type Equals<T, U> =
(<G>() => G extends T ? 1 : 2) extends
(<G>() => G extends U ? 1 : 2)
? true
: false;
// Usage
type Test1 = Equals<string, string>; // true
type Test2 = Equals<string, number>; // false
Common Pitfalls
1. Type Assertions
// Avoid
const user = data as User;
// Better
const user = isUser(data) ? data : null;
2. Non-null Assertions
// Avoid
const name = user!.name;
// Better
const name = user?.name ?? 'Unknown';
3. Index Signatures
// Avoid
interface Config {
[key: string]: any;
}
// Better
interface Config {
port: number;
host: string;
[key: string]: string | number;
}
Conclusion
Good TypeScript is about:
- Leveraging the type system
- Avoiding escape hatches (any, as)
- Using utility types
- Writing type-safe code
- Making illegal states unrepresentable
Type safety is a journey, not a destination. Improve gradually.
Comments (0)
Loading comments...