TypeScript Best Practices for Large Applications
As applications grow in complexity, TypeScript’s type system becomes invaluable for maintaining code quality, preventing bugs, and enabling confident refactoring. This guide explores battle-tested patterns and practices for building large-scale TypeScript applications that remain maintainable and performant over time.
Project Structure and Organization
Modular Architecture
Organize code by feature rather than by file type:
src/
├── shared/
│ ├── types/
│ │ ├── api.types.ts
│ │ ├── common.types.ts
│ │ └── index.ts
│ ├── utils/
│ │ ├── validation.util.ts
│ │ ├── formatting.util.ts
│ │ └── index.ts
│ ├── constants/
│ │ ├── api.constants.ts
│ │ └── index.ts
│ └── services/
│ ├── http.service.ts
│ ├── storage.service.ts
│ └── index.ts
├── features/
│ ├── user-management/
│ │ ├── types/
│ │ │ └── user.types.ts
│ │ ├── services/
│ │ │ └── user.service.ts
│ │ ├── components/
│ │ │ ├── UserList.tsx
│ │ │ └── UserProfile.tsx
│ │ ├── hooks/
│ │ │ └── useUser.ts
│ │ └── index.ts
│ └── order-processing/
│ ├── types/
│ ├── services/
│ ├── components/
│ └── index.ts
└── app/
├── store/
├── routing/
└── main.tsx
Barrel Exports
Use index files to create clean import paths:
// features/user-management/index.ts
export * from './types/user.types';
export * from './services/user.service';
export { default as UserList } from './components/UserList';
export { default as UserProfile } from './components/UserProfile';
export * from './hooks/useUser';
// Usage elsewhere
import { User, UserService, UserList, useUser } from '@/features/user-management';
TypeScript Configuration
Strict Configuration
Start with the strictest possible configuration for new projects:
// tsconfig.json
{
"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,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
/* Path Mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/shared/*": ["./src/shared/*"],
"@/features/*": ["./src/features/*"],
"@/types/*": ["./src/shared/types/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Environment-Specific Types
Create type-safe environment variables:
// shared/types/env.types.ts
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_API_KEY: string;
readonly VITE_ENVIRONMENT: 'development' | 'staging' | 'production';
readonly VITE_SENTRY_DSN?: string;
readonly VITE_FEATURE_FLAGS?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
// shared/config/env.ts
class EnvironmentConfig {
private static instance: EnvironmentConfig;
private constructor(
public readonly apiBaseUrl: string,
public readonly apiKey: string,
public readonly environment: ImportMetaEnv['VITE_ENVIRONMENT'],
public readonly sentryDsn?: string,
public readonly featureFlags: Record<string, boolean> = {}
) {}
public static getInstance(): EnvironmentConfig {
if (!EnvironmentConfig.instance) {
const env = import.meta.env;
EnvironmentConfig.instance = new EnvironmentConfig(
env.VITE_API_BASE_URL,
env.VITE_API_KEY,
env.VITE_ENVIRONMENT,
env.VITE_SENTRY_DSN,
env.VITE_FEATURE_FLAGS ? JSON.parse(env.VITE_FEATURE_FLAGS) : {}
);
}
return EnvironmentConfig.instance;
}
public isProduction(): boolean {
return this.environment === 'production';
}
public isFeatureEnabled(feature: string): boolean {
return this.featureFlags[feature] === true;
}
}
export const config = EnvironmentConfig.getInstance();
Advanced Type Patterns
Generic Utilities
Create reusable generic utilities for common patterns:
// shared/types/utility.types.ts
// Deep readonly for immutable data structures
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends (infer U)[]
? readonly DeepReadonly<U>[]
: T[P] extends object
? DeepReadonly<T[P]>
: T[P];
};
// Strict omit that prevents typos
type StrictOmit<T, K extends keyof T> = Omit<T, K>;
// Extract keys by value type
type KeysOfType<T, U> = {
[K in keyof T]: T[K] extends U ? K : never;
}[keyof T];
// Non-empty array type
type NonEmptyArray<T> = [T, ...T[]];
// Required properties only
type RequiredProperties<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];
// Optional properties only
type OptionalProperties<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];
// Split required and optional properties
type SplitRequired<T> = {
required: Pick<T, RequiredProperties<T>>;
optional: Pick<T, OptionalProperties<T>>;
};
// Usage examples
interface User {
id: string;
name: string;
email?: string;
avatar?: string;
}
type UserKeys = KeysOfType<User, string>; // 'id' | 'name' | 'email' | 'avatar'
type UserStringKeys = KeysOfType<User, string | undefined>; // 'id' | 'name' | 'email' | 'avatar'
type UserRequiredKeys = RequiredProperties<User>; // 'id' | 'name'
type UserOptionalKeys = OptionalProperties<User>; // 'email' | 'avatar'
API Response Types
Create type-safe API response handling:
// shared/types/api.types.ts
// Base API response structure
interface ApiResponse<T = unknown> {
data: T;
message?: string;
status: 'success' | 'error';
meta?: {
page?: number;
limit?: number;
total?: number;
hasNext?: boolean;
hasPrevious?: boolean;
};
}
// Error response structure
interface ApiError {
code: string;
message: string;
details?: Record<string, unknown>;
field?: string;
}
interface ApiErrorResponse {
errors: ApiError[];
status: 'error';
message: string;
}
// Result type for API calls
type ApiResult<T> = ApiResponse<T> | ApiErrorResponse;
// Type guards for API responses
export const isApiError = (response: ApiResult<unknown>): response is ApiErrorResponse => {
return response.status === 'error' && 'errors' in response;
};
export const isApiSuccess = <T>(response: ApiResult<T>): response is ApiResponse<T> => {
return response.status === 'success' && 'data' in response;
};
// Paginated response helper
interface PaginatedResponse<T> {
items: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrevious: boolean;
};
}
// API endpoint type generator
type ApiEndpoint<
TParams = Record<string, never>,
TQuery = Record<string, never>,
TBody = Record<string, never>,
TResponse = unknown
> = {
params?: TParams;
query?: TQuery;
body?: TBody;
response: TResponse;
};
// Usage example
type GetUsersEndpoint = ApiEndpoint<
{ organizationId: string },
{ page?: number; limit?: number; search?: string },
never,
PaginatedResponse<User>
>;
State Management Types
Create type-safe state management patterns:
// shared/types/state.types.ts
// Async state pattern
interface AsyncState<T, E = string> {
data: T | null;
loading: boolean;
error: E | null;
lastFetch?: Date;
}
// Create initial async state
export const createAsyncState = <T, E = string>(): AsyncState<T, E> => ({
data: null,
loading: false,
error: null,
});
// Action types for async operations
type AsyncActions<T, E = string> =
| { type: 'LOADING' }
| { type: 'SUCCESS'; payload: T }
| { type: 'ERROR'; payload: E }
| { type: 'RESET' };
// Async state reducer
export const asyncStateReducer = <T, E = string>(
state: AsyncState<T, E>,
action: AsyncActions<T, E>
): AsyncState<T, E> => {
switch (action.type) {
case 'LOADING':
return { ...state, loading: true, error: null };
case 'SUCCESS':
return {
data: action.payload,
loading: false,
error: null,
lastFetch: new Date(),
};
case 'ERROR':
return { ...state, loading: false, error: action.payload };
case 'RESET':
return createAsyncState<T, E>();
default:
return state;
}
};
// Form state pattern
interface FormState<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
isSubmitting: boolean;
isValid: boolean;
isDirty: boolean;
}
// Form field validation
type ValidationRule<T> = (value: T) => string | null;
// Validation schema
type ValidationSchema<T> = {
[K in keyof T]?: ValidationRule<T[K]>[];
};
// Form hook type
interface UseFormResult<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
setValue: <K extends keyof T>(field: K, value: T[K]) => void;
setError: (field: keyof T, error: string) => void;
setTouched: (field: keyof T, touched: boolean) => void;
validate: () => boolean;
reset: (values?: Partial<T>) => void;
handleSubmit: (onSubmit: (values: T) => void | Promise<void>) => (e: React.FormEvent) => void;
isValid: boolean;
isDirty: boolean;
isSubmitting: boolean;
}
Component Architecture
Generic Component Patterns
Create flexible, reusable components:
// shared/components/DataTable.tsx
interface Column<T> {
key: keyof T;
title: string;
render?: (value: T[keyof T], record: T, index: number) => React.ReactNode;
sortable?: boolean;
width?: string | number;
align?: 'left' | 'center' | 'right';
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
loading?: boolean;
pagination?: {
current: number;
pageSize: number;
total: number;
onChange: (page: number, pageSize: number) => void;
};
rowKey: keyof T | ((record: T) => string);
onRowClick?: (record: T, index: number) => void;
emptyText?: string;
className?: string;
}
export function DataTable<T extends Record<string, unknown>>({
data,
columns,
loading = false,
pagination,
rowKey,
onRowClick,
emptyText = 'No data',
className,
}: DataTableProps<T>) {
const getRowKey = (record: T, index: number): string => {
if (typeof rowKey === 'function') {
return rowKey(record);
}
return String(record[rowKey]) || String(index);
};
if (loading) {
return <div className="data-table-loading">Loading...</div>;
}
if (data.length === 0) {
return <div className="data-table-empty">{emptyText}</div>;
}
return (
<div className={`data-table ${className || ''}`}>
<table>
<thead>
<tr>
{columns.map((column) => (
<th
key={String(column.key)}
style={{ width: column.width, textAlign: column.align }}
>
{column.title}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((record, index) => (
<tr
key={getRowKey(record, index)}
onClick={() => onRowClick?.(record, index)}
className={onRowClick ? 'clickable' : ''}
>
{columns.map((column) => (
<td
key={String(column.key)}
style={{ textAlign: column.align }}
>
{column.render
? column.render(record[column.key], record, index)
: String(record[column.key] || '')}
</td>
))}
</tr>
))}
</tbody>
</table>
{pagination && (
<div className="data-table-pagination">
{/* Pagination component */}
</div>
)}
</div>
);
}
// Usage example
const UserTable: React.FC<{ users: User[] }> = ({ users }) => {
const columns: Column<User>[] = [
{
key: 'name',
title: 'Name',
sortable: true,
},
{
key: 'email',
title: 'Email',
render: (email) => email || 'No email',
},
{
key: 'id',
title: 'Actions',
render: (_, user) => (
<button onClick={() => editUser(user.id)}>
Edit
</button>
),
},
];
return (
<DataTable
data={users}
columns={columns}
rowKey="id"
onRowClick={(user) => viewUser(user.id)}
/>
);
};
Higher-Order Component Types
Create type-safe HOCs:
// shared/hoc/withAuth.tsx
interface WithAuthProps {
user: User | null;
isAuthenticated: boolean;
login: () => Promise<void>;
logout: () => void;
}
type WithAuthComponent<P = {}> = React.ComponentType<P & WithAuthProps>;
export function withAuth<P extends object>(
Component: React.ComponentType<P & WithAuthProps>
): React.ComponentType<P> {
return function AuthenticatedComponent(props: P) {
const { user, isAuthenticated, login, logout } = useAuth();
if (!isAuthenticated) {
return <LoginRequired onLogin={login} />;
}
return (
<Component
{...props}
user={user}
isAuthenticated={isAuthenticated}
login={login}
logout={logout}
/>
);
};
}
// Usage
interface DashboardProps {
title: string;
}
const Dashboard: React.FC<DashboardProps & WithAuthProps> = ({
title,
user,
logout
}) => {
return (
<div>
<h1>{title}</h1>
<p>Welcome, {user?.name}</p>
<button onClick={logout}>Logout</button>
</div>
);
};
export default withAuth(Dashboard);
Performance Optimization
Memoization Patterns
Use TypeScript to ensure correct memoization:
// shared/hooks/useMemoizedCallback.ts
import { useCallback, useMemo, useRef } from 'react';
// Type-safe useCallback with dependencies tracking
export function useMemoizedCallback<T extends (...args: any[]) => any>(
callback: T,
deps: React.DependencyList
): T {
return useCallback(callback, deps);
}
// Stable reference for object dependencies
export function useStableReference<T extends Record<string, unknown>>(
obj: T
): T {
const ref = useRef<T>(obj);
return useMemo(() => {
const hasChanged = Object.keys(obj).some(
key => obj[key] !== ref.current[key]
);
if (hasChanged) {
ref.current = obj;
}
return ref.current;
}, [obj]);
}
// Memoized selector
export function useMemoizedSelector<T, R>(
selector: (state: T) => R,
state: T,
equalityFn?: (a: R, b: R) => boolean
): R {
const previousValueRef = useRef<R>();
const previousStateRef = useRef<T>();
return useMemo(() => {
if (state !== previousStateRef.current) {
const newValue = selector(state);
if (
previousValueRef.current !== undefined &&
equalityFn &&
equalityFn(previousValueRef.current, newValue)
) {
return previousValueRef.current;
}
previousValueRef.current = newValue;
previousStateRef.current = state;
return newValue;
}
return previousValueRef.current!;
}, [selector, state, equalityFn]);
}
Code Splitting Types
Type-safe lazy loading:
// shared/components/LazyLoader.tsx
interface LazyComponentProps<T> {
loader: () => Promise<{ default: React.ComponentType<T> }>;
fallback?: React.ComponentType | React.ReactElement;
onError?: (error: Error) => void;
}
export function createLazyComponent<T extends object>(
loader: () => Promise<{ default: React.ComponentType<T> }>
): React.ComponentType<T> {
return React.lazy(loader);
}
// Route-based code splitting
interface RouteModule<T = {}> {
default: React.ComponentType<T>;
loader?: () => Promise<unknown>;
meta?: {
title?: string;
requiresAuth?: boolean;
roles?: string[];
};
}
type LazyRoute<T = {}> = () => Promise<RouteModule<T>>;
// Usage
const Dashboard = createLazyComponent(
() => import('@/features/dashboard/components/Dashboard')
);
const UserManagement = createLazyComponent(
() => import('@/features/user-management')
);
// Route configuration with types
interface Route {
path: string;
component: React.ComponentType<any>;
requiresAuth?: boolean;
roles?: string[];
}
export const routes: Route[] = [
{
path: '/dashboard',
component: Dashboard,
requiresAuth: true,
},
{
path: '/users',
component: UserManagement,
requiresAuth: true,
roles: ['admin'],
},
];
Testing Strategies
Type-Safe Test Utilities
Create utilities for consistent testing:
// shared/testing/test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from '@/shared/contexts/AuthContext';
// Custom render with providers
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
initialEntries?: string[];
user?: User | null;
}
export const renderWithProviders = (
ui: ReactElement,
{
initialEntries = ['/'],
user = null,
...renderOptions
}: CustomRenderOptions = {}
) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider initialUser={user}>
{children}
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);
}
return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
queryClient,
};
};
// Mock factories with TypeScript
export const createMockUser = (overrides: Partial<User> = {}): User => ({
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
role: 'user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
});
export const createMockApiResponse = <T>(
data: T,
overrides: Partial<Omit<ApiResponse<T>, 'data'>> = {}
): ApiResponse<T> => ({
data,
status: 'success' as const,
message: 'Success',
...overrides,
});
// Type-safe mock functions
export const createMockService = <T extends Record<string, (...args: any[]) => any>>(
service: T
): { [K in keyof T]: jest.MockedFunction<T[K]> } => {
const mockService = {} as { [K in keyof T]: jest.MockedFunction<T[K]> };
Object.keys(service).forEach((key) => {
mockService[key as keyof T] = jest.fn() as jest.MockedFunction<T[keyof T]>;
});
return mockService;
};
Component Testing Examples
// features/user-management/__tests__/UserList.test.tsx
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, createMockUser, createMockApiResponse } from '@/shared/testing/test-utils';
import { UserList } from '../components/UserList';
import { userService } from '../services/user.service';
// Mock the service
jest.mock('../services/user.service');
const mockUserService = userService as jest.Mocked<typeof userService>;
describe('UserList', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('displays users correctly', async () => {
const users = [
createMockUser({ id: '1', name: 'John Doe' }),
createMockUser({ id: '2', name: 'Jane Smith' }),
];
mockUserService.getUsers.mockResolvedValue(
createMockApiResponse({
items: users,
pagination: {
page: 1,
limit: 10,
total: 2,
totalPages: 1,
hasNext: false,
hasPrevious: false,
},
})
);
renderWithProviders(<UserList />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
it('handles user search correctly', async () => {
const user = userEvent.setup();
mockUserService.getUsers.mockResolvedValue(
createMockApiResponse({
items: [createMockUser({ name: 'John Doe' })],
pagination: {
page: 1,
limit: 10,
total: 1,
totalPages: 1,
hasNext: false,
hasPrevious: false,
},
})
);
renderWithProviders(<UserList />);
const searchInput = screen.getByPlaceholderText('Search users...');
await user.type(searchInput, 'John');
await waitFor(() => {
expect(mockUserService.getUsers).toHaveBeenCalledWith({
search: 'John',
page: 1,
limit: 10,
});
});
});
});
Error Handling
Type-Safe Error Handling
// shared/types/error.types.ts
export class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number = 500,
public context?: Record<string, unknown>
) {
super(message);
this.name = 'AppError';
}
}
export class ValidationError extends AppError {
constructor(
message: string,
public field: string,
context?: Record<string, unknown>
) {
super(message, 'VALIDATION_ERROR', 400, context);
this.name = 'ValidationError';
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} with id ${id} not found`, 'NOT_FOUND', 404, { resource, id });
this.name = 'NotFoundError';
}
}
// Error boundary with types
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
export class TypedErrorBoundary extends React.Component<
React.PropsWithChildren<{
fallback: (error: Error) => React.ReactElement;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}>,
ErrorBoundaryState
> {
constructor(props: React.PropsWithChildren<{
fallback: (error: Error) => React.ReactElement;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}>) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.hasError && this.state.error) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}
Conclusion
Building large-scale TypeScript applications requires discipline, good architecture, and leveraging TypeScript’s powerful type system. Key principles include:
- Start strict: Use the strictest TypeScript configuration possible
- Design for scale: Organize code by feature, not file type
- Leverage types: Use advanced TypeScript patterns for better DX
- Type everything: APIs, state, components, and tests
- Performance matters: Use memoization and code splitting effectively
- Test with types: Ensure your tests are as type-safe as your code
By following these patterns and practices, you’ll build applications that are not only robust and maintainable but also provide excellent developer experience through TypeScript’s powerful type system.
References
- Microsoft Corporation. “TypeScript Handbook.” TypeScript Documentation, 2024. https://www.typescriptlang.org/docs/
- Bierman, Gavin, et al. “Understanding TypeScript’s Type System.” ACM Transactions on Programming Languages and Systems, 2014.
- Hejlsberg, Anders. “TypeScript: Language Design and Implementation.” Microsoft Research, 2012.
- Pankaj, Basarat Ali Syed. TypeScript Deep Dive. GitBook, 2023.
- Chernev, Boris. “Advanced TypeScript Patterns.” Dev.to, 2023.
- React Team. “TypeScript Support.” React Documentation, 2024. https://react.dev/learn/typescript