TypeScript Best Practices for Modern Web Development
TypeScript Best Practices for Modern Web Development
TypeScript has become an essential tool in modern web development, offering static typing, enhanced IDE support, and improved code quality. However, simply using TypeScript doesn't automatically make your code better. This guide explores best practices for leveraging TypeScript effectively in your web projects, from basic typing to advanced patterns.
Why TypeScript Matters
Before diving into best practices, let's understand why TypeScript has become so popular:
- Type Safety: Catches type-related errors at compile time rather than runtime
- Developer Experience: Provides better autocompletion, navigation, and refactoring tools
- Documentation: Types serve as living documentation for your codebase
- Scalability: Makes large codebases more maintainable and easier to understand
- Ecosystem Support: Excellent integration with popular frameworks and libraries
Setting Up TypeScript Properly
Configuring tsconfig.json
A well-configured tsconfig.json
is the foundation of any TypeScript project. Here's a recommended configuration with explanations:
{
"compilerOptions": {
"target": "ES2020", /* Modern JavaScript features */
"module": "ESNext", /* Use ES modules */
"moduleResolution": "node", /* How to resolve modules */
"esModuleInterop": true, /* Better interop with CommonJS modules */
"isolatedModules": true, /* Ensure each file can be safely transpiled */
"strict": true, /* Enable all strict type checking options */
"noImplicitAny": true, /* Raise error on expressions with implied 'any' type */
"strictNullChecks": true, /* Enable strict null checks */
"noUncheckedIndexedAccess": true, /* Add undefined to indexed access results */
"forceConsistentCasingInFileNames": true, /* Ensure consistent casing in imports */
"skipLibCheck": true, /* Skip type checking of declaration files */
"jsx": "react-jsx", /* For React projects */
"lib": ["DOM", "DOM.Iterable", "ESNext"], /* Standard libraries */
"allowJs": true, /* Allow JavaScript files to be compiled */
"resolveJsonModule": true, /* Allow importing JSON modules */
"incremental": true, /* Enable incremental compilation */
"baseUrl": ".", /* Base directory for resolving non-relative module names */
"paths": { /* Path mapping for module aliases */
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"], /* Files to include */
"exclude": ["node_modules", "build", "dist"] /* Files to exclude */
}
Strict Mode
Always enable strict: true
in your TypeScript configuration. This enables a suite of type checking options that help catch more errors:
noImplicitAny
: Prevents variables from having an impliedany
typestrictNullChecks
: Makes handling ofnull
andundefined
more explicitstrictFunctionTypes
: Enables more accurate function parameter type checkingstrictBindCallApply
: Ensures correct typing ofbind
,call
, andapply
methodsstrictPropertyInitialization
: Ensures class properties are initialized
While strict mode might seem challenging at first, it significantly improves code quality and prevents many common bugs.
Type Definitions Best Practices
Use Specific Types
Avoid using any
whenever possible. It defeats the purpose of TypeScript by opting out of type checking.
// Bad
function processData(data: any) {
return data.length * 2; // No type safety
}
// Good
function processData(data: string | string[]) {
if (typeof data === 'string') {
return data.length * 2;
}
return data.reduce((sum, item) => sum + item.length, 0) * 2;
}
Type vs Interface
Both type
and interface
can define object shapes, but they have subtle differences:
// Interface
interface User {
id: number;
name: string;
}
// Type alias
type User = {
id: number;
name: string;
};
Guidelines for choosing between them:
- Use
interface
for public API definitions, object shapes that might be extended, and when you want to take advantage of declaration merging - Use
type
for unions, intersections, mapped types, and when you need to create complex types that aren't just object shapes
// Interface can be extended
interface BaseUser {
id: number;
name: string;
}
interface AdminUser extends BaseUser {
permissions: string[];
}
// Interface can be merged
interface User {
id: number;
name: string;
}
interface User {
email: string; // Adds to the existing interface
}
// Type is better for unions and complex types
type UserRole = 'admin' | 'editor' | 'viewer';
type Result<T> = {
data: T;
error: null;
} | {
data: null;
error: Error;
};
Use Union Types for Better Type Safety
Union types allow a value to be one of several types, providing better type safety than using more general types.
// Less specific
function getLength(value: any): number {
return value.length;
}
// More specific with union
function getLength(value: string | any[]): number {
return value.length;
}
// Even better with type guards
function getLength(value: string | any[]): number {
if (typeof value === 'string') {
return value.length;
}
return value.length;
}
Leverage Literal Types
Literal types allow you to specify exact values a variable can have, which is useful for function parameters, state machines, and configuration options.
// Using string literal types
type Direction = 'north' | 'south' | 'east' | 'west';
function move(direction: Direction, distance: number) {
// Implementation
}
// This works
move('north', 10);
// This will cause a type error
// move('up', 10);
Use Readonly for Immutability
The readonly
modifier and Readonly<T>
utility type help enforce immutability in your code.
// For properties
interface User {
readonly id: number;
name: string;
}
const user: User = { id: 1, name: 'John' };
// user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property
// For arrays
const numbers: ReadonlyArray<number> = [1, 2, 3];
// numbers.push(4); // Error: Property 'push' does not exist on type 'readonly number[]'
// For objects
const config: Readonly<{debug: boolean; mode: string}> = {
debug: true,
mode: 'development'
};
// config.debug = false; // Error: Cannot assign to 'debug' because it is a read-only property
Advanced Type Patterns
Discriminated Unions
Discriminated unions are a powerful pattern for modeling data that can be of several different shapes, each with its own set of properties.
type Success = {
status: 'success';
data: any;
};
type Error = {
status: 'error';
error: string;
};
type Loading = {
status: 'loading';
};
type ApiResponse = Success | Error | Loading;
function handleResponse(response: ApiResponse) {
switch (response.status) {
case 'success':
// TypeScript knows response has data property
console.log(response.data);
break;
case 'error':
// TypeScript knows response has error property
console.error(response.error);
break;
case 'loading':
// TypeScript knows response only has status property
console.log('Loading...');
break;
}
}
Utility Types
TypeScript provides several utility types that help transform existing types into new ones.
// Partial makes all properties optional
type User = {
id: number;
name: string;
email: string;
};
function updateUser(id: number, updates: Partial<User>) {
// Implementation
}
updateUser(1, { name: 'New Name' }); // Valid, don't need to provide all fields
// Pick selects a subset of properties
type UserCredentials = Pick<User, 'email' | 'id'>;
// Omit removes properties
type UserWithoutId = Omit<User, 'id'>;
// Record creates a type with specified properties
type UserRoles = Record<string, 'admin' | 'editor' | 'viewer'>;
// Required makes all properties required
type OptionalUser = {
id?: number;
name?: string;
};
type RequiredUser = Required<OptionalUser>; // { id: number; name: string; }
Mapped Types
Mapped types allow you to create new types based on existing ones by transforming properties.
// Make all properties nullable
type Nullable<T> = { [P in keyof T]: T[P] | null };
type User = {
id: number;
name: string;
};
type NullableUser = Nullable<User>; // { id: number | null; name: string | null; }
// Make all properties optional and readonly
type ReadonlyOptional<T> = { readonly [P in keyof T]?: T[P] };
type ConfigOptions = {
debug: boolean;
mode: 'development' | 'production';
};
type PartialConfig = ReadonlyOptional<ConfigOptions>;
// { readonly debug?: boolean; readonly mode?: 'development' | 'production'; }
Template Literal Types
Template literal types allow you to create string types based on concatenating other string types.
type EventName = 'click' | 'focus' | 'blur';
type ElementType = 'button' | 'input' | 'form';
// Creates: 'button:click' | 'button:focus' | 'button:blur' | 'input:click' | etc.
type ElementEvent = `${ElementType}:${EventName}`;
function handleEvent(elementEvent: ElementEvent, callback: () => void) {
const [element, event] = elementEvent.split(':');
// Implementation
}
handleEvent('button:click', () => console.log('Button clicked'));
// handleEvent('div:click', () => {}); // Error: Argument of type '"div:click"' is not assignable to parameter of type 'ElementEvent'
Type Guards and Type Narrowing
Type guards help TypeScript understand the type of a variable within a certain scope, allowing for more precise type checking.
User-Defined Type Guards
interface Fish {
swim(): void;
scales: boolean;
}
interface Bird {
fly(): void;
wings: boolean;
}
type Pet = Fish | Bird;
// User-defined type guard
function isFish(pet: Pet): pet is Fish {
return (pet as Fish).scales !== undefined;
}
function careForPet(pet: Pet) {
if (isFish(pet)) {
// TypeScript knows pet is Fish here
pet.swim();
} else {
// TypeScript knows pet is Bird here
pet.fly();
}
}
Using instanceof and typeof
class ApiError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
}
}
class ValidationError extends Error {
field: string;
constructor(message: string, field: string) {
super(message);
this.field = field;
}
}
function handleError(error: Error) {
if (error instanceof ApiError) {
// TypeScript knows error is ApiError
console.error(`API Error ${error.statusCode}: ${error.message}`);
} else if (error instanceof ValidationError) {
// TypeScript knows error is ValidationError
console.error(`Validation Error in ${error.field}: ${error.message}`);
} else {
// TypeScript knows error is just Error
console.error(`Generic Error: ${error.message}`);
}
}
// Using typeof
function processValue(value: string | number) {
if (typeof value === 'string') {
// TypeScript knows value is string
return value.toUpperCase();
} else {
// TypeScript knows value is number
return value.toFixed(2);
}
}
Exhaustiveness Checking
Exhaustiveness checking ensures you've handled all possible cases in a switch statement or conditional.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; sideLength: number }
| { kind: 'rectangle'; width: number; height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
// This ensures we've handled all cases
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
If you add a new shape type but forget to handle it in getArea
, TypeScript will show an error because the new shape type won't be assignable to never
.
Asynchronous TypeScript
Typing Promises
// Explicitly typing the Promise return type
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.statusText}`);
}
return response.json();
}
// Using with Promise.all
async function fetchUserAndPosts(userId: number): Promise<[User, Post[]]> {
const [user, posts] = await Promise.all([
fetchUser(userId),
fetchPosts(userId)
]);
return [user, posts];
}
Error Handling with Types
type Result<T> =
| { success: true; data: T }
| { success: false; error: Error };
async function fetchData<T>(url: string): Promise<Result<T>> {
try {
const response = await fetch(url);
if (!response.ok) {
return {
success: false,
error: new Error(`HTTP error ${response.status}`)
};
}
const data: T = await response.json();
return { success: true, data };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error(String(error))
};
}
}
// Usage
async function getUserData(userId: number) {
const result = await fetchData<User>(`/api/users/${userId}`);
if (result.success) {
// TypeScript knows result.data is User
console.log(`User name: ${result.data.name}`);
} else {
// TypeScript knows result.error is Error
console.error(`Failed to fetch user: ${result.error.message}`);
}
}
TypeScript with React
Typing Component Props
// Using interface
interface ButtonProps {
text: string;
onClick: () => void;
disabled?: boolean;
variant?: 'primary' | 'secondary' | 'danger';
}
const Button: React.FC<ButtonProps> = ({
text,
onClick,
disabled = false,
variant = 'primary'
}) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{text}
</button>
);
};
// Usage
<Button
text="Click me"
onClick={() => console.log('Clicked')}
variant="primary"
/>
Typing Component State
interface User {
id: number;
name: string;
email: string;
}
interface UserState {
user: User | null;
loading: boolean;
error: string | null;
}
const UserProfile: React.FC = () => {
const [state, setState] = useState<UserState>({
user: null,
loading: true,
error: null
});
useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch('/api/user');
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const user: User = await response.json();
setState({ user, loading: false, error: null });
} catch (error) {
setState({
user: null,
loading: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
};
fetchUser();
}, []);
if (state.loading) {
return <div>Loading...</div>;
}
if (state.error) {
return <div>Error: {state.error}</div>;
}
return state.user ? (
<div>
<h1>{state.user.name}</h1>
<p>{state.user.email}</p>
</div>
) : null;
};
Typing Event Handlers
const Form: React.FC = () => {
const [value, setValue] = useState('');
// Typing the event parameter
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log('Submitted:', value);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={value}
onChange={handleChange}
placeholder="Type something"
/>
<button type="submit">Submit</button>
</form>
);
};
Typing Refs
const InputWithFocus: React.FC = () => {
// Typing the ref
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
// Need to check if current exists
if (inputRef.current) {
inputRef.current.focus();
}
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus Input</button>
</div>
);
};
Typing Custom Hooks
// Custom hook with TypeScript
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
// Get from local storage then
// parse stored json or return initialValue
const readValue = (): T => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
};
// State to store our value
const [storedValue, setStoredValue] = useState<T>(readValue);
// Return a wrapped version of useState's setter function that
// persists the new value to localStorage
const setValue = (value: T) => {
try {
// Save state
setStoredValue(value);
// Save to local storage
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(value));
}
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue];
}
// Usage
function App() {
const [name, setName] = useLocalStorage<string>('name', 'Bob');
const [items, setItems] = useLocalStorage<string[]>('items', []);
return (
<div>
<input
type="text"
placeholder="Enter your name"
value={name}
onChange={e => setName(e.target.value)}
/>
{/* ... */}
</div>
);
}
TypeScript with Next.js
Typing API Routes
// pages/api/user/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
interface User {
id: number;
name: string;
email: string;
}
interface ErrorResponse {
message: string;
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<User | ErrorResponse>
) {
const { id } = req.query;
if (req.method === 'GET') {
// Fetch user from database
const user: User = { id: Number(id), name: 'John Doe', email: 'john@example.com' };
res.status(200).json(user);
} else {
res.status(405).json({ message: 'Method not allowed' });
}
}
Typing getServerSideProps and getStaticProps
// pages/users/[id].tsx
import { GetServerSideProps, NextPage } from 'next';
interface User {
id: number;
name: string;
email: string;
}
interface UserPageProps {
user: User;
}
const UserPage: NextPage<UserPageProps> = ({ user }) => {
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
};
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
const { id } = context.params as { id: string };
try {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
return {
notFound: true
};
}
const user: User = await response.json();
return {
props: {
user
}
};
} catch (error) {
return {
redirect: {
destination: '/error',
permanent: false
}
};
}
};
export default UserPage;
Typing Dynamic Routes
// pages/posts/[...slug].tsx
import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
interface PostParams {
slug: string[];
}
interface PostPageProps {
slug: string[];
content: string;
}
const PostPage: NextPage<PostPageProps> = ({ slug, content }) => {
return (
<div>
<h1>Post: {slug.join('/')}</h1>
<div>{content}</div>
</div>
);
};
export const getStaticPaths: GetStaticPaths<PostParams> = async () => {
return {
paths: [
{ params: { slug: ['2023', '01', 'hello-world'] } },
{ params: { slug: ['2023', '02', 'typescript-tips'] } }
],
fallback: 'blocking'
};
};
export const getStaticProps: GetStaticProps<PostPageProps, PostParams> = async (context) => {
const { slug } = context.params as PostParams;
// Fetch post content based on slug
const content = `This is the content for ${slug.join('/')}`;
return {
props: {
slug,
content
},
revalidate: 60 // ISR: regenerate page every 60 seconds if requested
};
};
export default PostPage;
Testing with TypeScript
Typing Jest Tests
// user.ts
export interface User {
id: number;
name: string;
email: string;
}
export function formatUserName(user: User): string {
return user.name.toUpperCase();
}
// user.test.ts
import { User, formatUserName } from './user';
describe('User utilities', () => {
test('formatUserName should uppercase the name', () => {
const user: User = {
id: 1,
name: 'John Doe',
email: 'john@example.com'
};
expect(formatUserName(user)).toBe('JOHN DOE');
});
});
Mocking with TypeScript
// api.ts
export interface User {
id: number;
name: string;
email: string;
}
export interface UserService {
getUser(id: number): Promise<User>;
createUser(user: Omit<User, 'id'>): Promise<User>;
}
// userService.test.ts
import { UserService, User } from './api';
// Create a mock implementation of UserService
const mockUserService: jest.Mocked<UserService> = {
getUser: jest.fn(),
createUser: jest.fn()
};
describe('UserService', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('getUser should return a user', async () => {
const mockUser: User = {
id: 1,
name: 'John Doe',
email: 'john@example.com'
};
mockUserService.getUser.mockResolvedValue(mockUser);
const result = await mockUserService.getUser(1);
expect(mockUserService.getUser).toHaveBeenCalledWith(1);
expect(result).toEqual(mockUser);
});
});
Performance Optimization
Type-Only Imports
Use type-only imports to avoid importing runtime code when you only need types.
// Without type-only imports
import { User } from './user';
// With type-only imports
import type { User } from './user';
function processUser(user: User) {
// Implementation
}
const Assertions
Use as const
to create readonly tuple types and literal types.
// Without const assertion
const config = {
api: {
url: 'https://api.example.com',
version: 'v1'
},
features: ['auth', 'profiles', 'messaging']
};
// Type: { api: { url: string; version: string; }; features: string[]; }
// With const assertion
const config = {
api: {
url: 'https://api.example.com',
version: 'v1'
},
features: ['auth', 'profiles', 'messaging']
} as const;
// Type: { readonly api: { readonly url: "https://api.example.com"; readonly version: "v1"; }; readonly features: readonly ["auth", "profiles", "messaging"]; }
// Now we can do this
type Feature = typeof config.features[number]; // "auth" | "profiles" | "messaging"
Optimizing Build Times
- Use Project References: For large projects, split your codebase into smaller projects with references between them.
// tsconfig.json
{
"references": [
{ "path": "./packages/common" },
{ "path": "./packages/client" },
{ "path": "./packages/server" }
]
}
- Incremental Compilation: Enable incremental compilation to speed up subsequent builds.
// tsconfig.json
{
"compilerOptions": {
"incremental": true
}
}
- Skip Type Checking of Declaration Files: Use
skipLibCheck
to avoid checking all declaration files.
// tsconfig.json
{
"compilerOptions": {
"skipLibCheck": true
}
}
Common Pitfalls and How to Avoid Them
Type Assertions
Avoid using type assertions (as
) unless absolutely necessary, as they bypass TypeScript's type checking.
// Bad: Using type assertion
const user = { } as User;
// Good: Initialize with all required properties
const user: User = {
id: 1,
name: 'John',
email: 'john@example.com'
};
// If you must use assertion, consider a safer approach
function assertIsUser(obj: any): asserts obj is User {
if (typeof obj !== 'object' || obj === null) {
throw new Error('Not an object');
}
if (!('id' in obj) || typeof obj.id !== 'number') {
throw new Error('Missing or invalid id');
}
// Check other properties...
}
const data = fetchDataFromAPI();
assertIsUser(data);
// Now TypeScript knows data is User
Non-Null Assertion Operator
Avoid using the non-null assertion operator (!
) as it can lead to runtime errors.
// Bad: Using non-null assertion
function getLength(str: string | null) {
return str!.length; // Can cause runtime error if str is null
}
// Good: Use proper null checking
function getLength(str: string | null) {
if (str === null) {
return 0;
}
return str.length;
}
// Or use nullish coalescing
function getLength(str: string | null) {
return str?.length ?? 0;
}
Type Widening
Be aware of type widening, which can make your types less specific than intended.
// Type widens to string
let name = 'John';
// name = 42; // Error: Type 'number' is not assignable to type 'string'
// Type widens to number
let age = 30;
// age = 'old'; // Error: Type 'string' is not assignable to type 'number'
// But with let, the type can be any string or any number
name = 'Jane'; // OK
age = 31; // OK
// To be more specific, use const with as const
const role = 'admin' as const; // Type is 'admin', not string
// role = 'user'; // Error: Type '"user"' is not assignable to type '"admin"'
Forgetting to Check for Undefined in Optional Properties
interface User {
name: string;
address?: {
street: string;
city: string;
};
}
// Bad: Not checking for undefined
function getCity(user: User) {
return user.address.city; // Error: Object is possibly 'undefined'
}
// Good: Proper checking
function getCity(user: User) {
return user.address?.city; // Using optional chaining
}
// Or with a default value
function getCity(user: User) {
return user.address?.city ?? 'Unknown';
}
Conclusion
TypeScript is a powerful tool that can significantly improve the quality and maintainability of your web applications. By following these best practices, you can leverage TypeScript's type system to catch errors early, write self-documenting code, and create more robust applications.
Remember that TypeScript is designed to help you, not to get in your way. When you encounter challenges, look for type-safe solutions rather than bypassing the type system. With practice, you'll find that TypeScript becomes an invaluable part of your development workflow, helping you write better code with fewer bugs.
As you continue your TypeScript journey, keep exploring advanced type features and patterns. The TypeScript ecosystem is constantly evolving, with new features and improvements in each release. Stay up to date with the latest developments and continue refining your TypeScript skills to build better web applications.