Web Development / TypeScript / Best Practices / JavaScript / Development
TypeScript Best Practices for Large-Scale Applications
Learn essential TypeScript patterns, advanced types, and best practices for building maintainable and scalable applications.
On this page
- Setting Up a Robust TypeScript Configuration
- Advanced Type Patterns
- Utility Types for API Responses
- Discriminated Unions for State Management
- Generic Constraints and Conditional Types
- Type-Safe Event Handling
- Advanced React TypeScript Patterns
- Generic Components
- Higher-Order Component Types
- Error Handling with Types
- Performance Optimization with Types
- Branded Types for Performance
- Testing with TypeScript
- Conclusion
TypeScript has become the go-to choice for building large-scale JavaScript applications. Its static type system helps catch errors early, improves code maintainability, and enhances developer productivity. In this guide, we'll explore advanced TypeScript patterns and best practices.
Setting Up a Robust TypeScript Configuration
A well-configured tsconfig.json is the foundation of any TypeScript project:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
// Type Checking
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Path Mapping
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/utils/*": ["./src/utils/*"],
"@/types/*": ["./src/types/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Advanced Type Patterns
Utility Types for API Responses
// Base API response structure
interface ApiResponse<T> {
data: T;
status: 'success' | 'error';
message?: string;
timestamp: string;
}
// User entity
interface User {
id: string;
email: string;
name: string;
avatar?: string;
createdAt: string;
updatedAt: string;
}
// Create different variations using utility types
type CreateUserRequest = Pick<User, 'email' | 'name'> & {
password: string;
};
type UpdateUserRequest = Partial<Pick<User, 'name' | 'avatar'>>;
type UserResponse = ApiResponse<User>;
type UsersResponse = ApiResponse<User[]>;
// Public user info (excluding sensitive data)
type PublicUser = Omit<User, 'email' | 'createdAt' | 'updatedAt'>;Discriminated Unions for State Management
// Loading states with discriminated unions
type LoadingState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
// Usage in React component
function UserProfile({ userId }: { userId: string }) {
const [userState, setUserState] = useState<LoadingState<User>>({
status: 'idle',
});
const handleUserState = (state: LoadingState<User>) => {
switch (state.status) {
case 'idle':
return <div>Click to load user</div>;
case 'loading':
return <div>Loading...</div>;
case 'success':
// TypeScript knows `data` exists here
return <div>Welcome, {state.data.name}!</div>;
case 'error':
// TypeScript knows `error` exists here
return <div>Error: {state.error}</div>;
default:
// Exhaustive check - TypeScript will error if we miss a case
const _exhaustive: never = state;
return _exhaustive;
}
};
return handleUserState(userState);
}Generic Constraints and Conditional Types
// Generic constraint for objects with an id
interface HasId {
id: string | number;
}
// Generic function that works with any object that has an id
function updateEntity<T extends HasId>(
entities: T[],
id: T['id'],
updates: Partial<Omit<T, 'id'>>
): T[] {
return entities.map(entity =>
entity.id === id ? { ...entity, ...updates } : entity
);
}
// Conditional types for API endpoints
type ApiEndpoint<T> = T extends 'users'
? '/api/users'
: T extends 'posts'
? '/api/posts'
: T extends 'comments'
? '/api/comments'
: never;
// Usage
type UserEndpoint = ApiEndpoint<'users'>; // '/api/users'
type PostEndpoint = ApiEndpoint<'posts'>; // '/api/posts'
type InvalidEndpoint = ApiEndpoint<'invalid'>; // never
// Generic API client
class ApiClient {
async get<T extends 'users' | 'posts' | 'comments'>(
endpoint: T
): Promise<ApiResponse<any>> {
const url: ApiEndpoint<T> = this.getEndpointUrl(endpoint);
const response = await fetch(url);
return response.json();
}
private getEndpointUrl<T extends 'users' | 'posts' | 'comments'>(
endpoint: T
): ApiEndpoint<T> {
const endpoints = {
users: '/api/users',
posts: '/api/posts',
comments: '/api/comments',
} as const;
return endpoints[endpoint] as ApiEndpoint<T>;
}
}Type-Safe Event Handling
// Define event types
interface AppEvents {
'user:login': { userId: string; timestamp: Date };
'user:logout': { userId: string };
'post:created': { postId: string; authorId: string };
'post:updated': { postId: string; changes: string[] };
}
// Type-safe event emitter
class TypedEventEmitter<T extends Record<string, any>> {
private listeners: {
[K in keyof T]?: Array<(data: T[K]) => void>;
} = {};
on<K extends keyof T>(event: K, listener: (data: T[K]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
const eventListeners = this.listeners[event];
if (eventListeners) {
eventListeners.forEach(listener => listener(data));
}
}
off<K extends keyof T>(event: K, listener: (data: T[K]) => void): void {
const eventListeners = this.listeners[event];
if (eventListeners) {
const index = eventListeners.indexOf(listener);
if (index > -1) {
eventListeners.splice(index, 1);
}
}
}
}
// Usage
const eventEmitter = new TypedEventEmitter<AppEvents>();
// Type-safe event listening
eventEmitter.on('user:login', data => {
// TypeScript knows data has userId and timestamp
console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});
// Type-safe event emission
eventEmitter.emit('user:login', {
userId: '123',
timestamp: new Date(),
});Advanced React TypeScript Patterns
Generic Components
interface SelectOption<T> {
value: T;
label: string;
disabled?: boolean;
}
interface SelectProps<T> {
options: SelectOption<T>[];
value?: T;
onChange: (value: T) => void;
placeholder?: string;
multiple?: boolean;
}
function Select<T extends string | number>({
options,
value,
onChange,
placeholder = 'Select an option...',
multiple = false,
}: SelectProps<T>) {
const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const selectedValue = event.target.value as T;
onChange(selectedValue);
};
return (
<select value={value} onChange={handleChange} multiple={multiple}>
{placeholder && <option value="">{placeholder}</option>}
{options.map(option => (
<option
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</option>
))}
</select>
);
}
// Usage with type safety
const statusOptions: SelectOption<'active' | 'inactive' | 'pending'>[] = [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'pending', label: 'Pending' },
];
function UserStatusSelect() {
const [status, setStatus] = useState<'active' | 'inactive' | 'pending'>(
'active'
);
return (
<Select
options={statusOptions}
value={status}
onChange={setStatus} // Type-safe!
/>
);
}Higher-Order Component Types
// HOC that adds loading state
interface WithLoadingProps {
isLoading: boolean;
}
function withLoading<P extends object>(
Component: React.ComponentType<P>
): React.ComponentType<P & WithLoadingProps> {
return function WithLoadingComponent(props: P & WithLoadingProps) {
const { isLoading, ...restProps } = props;
if (isLoading) {
return <div>Loading...</div>;
}
return <Component {...(restProps as P)} />;
};
}
// Usage
interface UserListProps {
users: User[];
onUserClick: (user: User) => void;
}
const UserList: React.FC<UserListProps> = ({ users, onUserClick }) => (
<div>
{users.map(user => (
<div key={user.id} onClick={() => onUserClick(user)}>
{user.name}
</div>
))}
</div>
);
const UserListWithLoading = withLoading(UserList);
// Now UserListWithLoading requires both UserListProps and WithLoadingProps
function App() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
return (
<UserListWithLoading
users={users}
isLoading={isLoading}
onUserClick={user => console.log(user)}
/>
);
}Error Handling with Types
// Result type for error handling
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
// API error types
interface ApiError {
code: string;
message: string;
details?: Record<string, any>;
}
// Type-safe API client with error handling
class SafeApiClient {
async getUser(id: string): Promise<Result<User, ApiError>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
const error: ApiError = await response.json();
return { success: false, error };
}
const user: User = await response.json();
return { success: true, data: user };
} catch (error) {
return {
success: false,
error: {
code: 'NETWORK_ERROR',
message: 'Failed to fetch user',
},
};
}
}
}
// Usage with proper error handling
async function handleGetUser(id: string) {
const client = new SafeApiClient();
const result = await client.getUser(id);
if (result.success) {
// TypeScript knows result.data is User
console.log(`User: ${result.data.name}`);
} else {
// TypeScript knows result.error is ApiError
console.error(`Error ${result.error.code}: ${result.error.message}`);
}
}Performance Optimization with Types
Branded Types for Performance
// Branded types to prevent mixing different ID types
type UserId = string & { readonly brand: unique symbol };
type PostId = string & { readonly brand: unique symbol };
// Type guards for branded types
function isUserId(id: string): id is UserId {
return /^user_/.test(id);
}
function isPostId(id: string): id is PostId {
return /^post_/.test(id);
}
// Factory functions
function createUserId(id: string): UserId {
if (!isUserId(id)) {
throw new Error('Invalid user ID format');
}
return id;
}
function createPostId(id: string): PostId {
if (!isPostId(id)) {
throw new Error('Invalid post ID format');
}
return id;
}
// Usage prevents mixing different ID types
function getUser(id: UserId): Promise<User> {
// Implementation
return Promise.resolve({} as User);
}
function getPost(id: PostId): Promise<Post> {
// Implementation
return Promise.resolve({} as Post);
}
// This would cause a TypeScript error:
// getUser(createPostId('post_123')); // Error!
// This is correct:
getUser(createUserId('user_123')); // ✓Testing with TypeScript
// Type-safe test utilities
interface TestUser {
id: string;
name: string;
email: string;
}
// Factory function for test data
function createTestUser(overrides: Partial<TestUser> = {}): TestUser {
return {
id: 'test-user-1',
name: 'Test User',
email: 'test@example.com',
...overrides,
};
}
// Mock function with proper typing
interface UserService {
getUser(id: string): Promise<User>;
updateUser(id: string, updates: Partial<User>): Promise<User>;
}
const mockUserService: jest.Mocked<UserService> = {
getUser: jest.fn(),
updateUser: jest.fn(),
};
// Type-safe test
describe('UserComponent', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should display user name', async () => {
const testUser = createTestUser({ name: 'John Doe' });
mockUserService.getUser.mockResolvedValue(testUser);
// Test implementation
// TypeScript ensures we're using the correct types
});
});Conclusion
TypeScript's type system is incredibly powerful when used correctly. Key takeaways:
- Configure strictly: Use strict TypeScript settings for better type safety
- Leverage utility types: Use built-in and custom utility types for cleaner code
- Design with types: Think about types as part of your API design
- Handle errors safely: Use Result types and discriminated unions for error handling
- Test with types: Ensure your tests are type-safe too
By following these patterns and best practices, you'll build more maintainable, scalable, and robust TypeScript applications. The initial investment in proper typing pays dividends in reduced bugs, better developer experience, and easier refactoring.
Remember: TypeScript is not just about adding types to JavaScript—it's about designing better APIs and creating more predictable code.