Blog & Insights

Thoughts, tutorials, and explorations at the intersection of mathematics, visualization, and web development.

TypeScript Best Practices for Modern Web Development

Published: March 20, 2025Category: Web Development

TypeScript Best Practices for Modern Web Development

TypeScript has become the language of choice for many web developers, offering static typing, enhanced tooling, and improved developer experience over plain JavaScript. In this article, we'll explore best practices and patterns that will help you write more maintainable, robust, and scalable TypeScript code.

Why TypeScript?

Before diving into best practices, let's briefly recap why TypeScript has gained such popularity:

  • Static Type Checking: Catch errors at compile time rather than runtime
  • Enhanced IDE Support: Better autocompletion, navigation, and refactoring
  • Self-Documenting Code: Types serve as documentation
  • Safer Refactoring: Types help ensure changes don't break existing functionality
  • Improved Team Collaboration: Clear interfaces between components

TypeScript was created by Microsoft and released in 2012. It's a superset of JavaScript, meaning any valid JavaScript code is also valid TypeScript code.

Setting Up a TypeScript Project

Configuring tsconfig.json

A well-configured tsconfig.json is the foundation of any TypeScript project:

1{
2  "compilerOptions": {
3    "target": "ES2020",
4    "module": "ESNext",
5    "moduleResolution": "node",
6    "esModuleInterop": true,
7    "strict": true,
8    "skipLibCheck": true,
9    "forceConsistentCasingInFileNames": true,
10    "outDir": "./dist",
11    "rootDir": "./src",
12    "declaration": true,
13    "sourceMap": true
14  },
15  "include": ["src/**/*"],
16  "exclude": ["node_modules", "**/*.test.ts"]
17}
18

Key options to understand:

  • strict: Enables all strict type checking options
  • target: Specifies the ECMAScript target version
  • moduleResolution: Determines how modules are resolved
  • esModuleInterop: Enables interoperability between CommonJS and ES Modules

Linting with ESLint

ESLint with TypeScript support helps maintain code quality:

1// .eslintrc.json
2{
3  "parser": "@typescript-eslint/parser",
4  "plugins": ["@typescript-eslint"],
5  "extends": [
6    "eslint:recommended",
7    "plugin:@typescript-eslint/recommended"
8  ],
9  "rules": {
10    "@typescript-eslint/explicit-function-return-type": "warn",
11    "@typescript-eslint/no-unused-vars": "error",
12    "@typescript-eslint/no-explicit-any": "warn"
13  }
14}
15

Type System Best Practices

Use Specific Types

Avoid using any whenever possible. Instead, use more specific types:

1// ❌ Avoid
2function processData(data: any): any {
3  // ...
4}
5
6// ✅ Better
7interface UserData {
8  id: number;
9  name: string;
10  email: string;
11}
12
13function processUserData(userData: UserData): UserData {
14  // ...
15  return userData;
16}
17

Leverage Type Inference

TypeScript's type inference is powerful. Use it when it makes your code cleaner:

1// ❌ Unnecessary type annotation
2const name: string = "John";
3
4// ✅ Let TypeScript infer the type
5const name = "John";
6
7// ✅ Use type annotations for complex types or when inference isn't clear
8const user: User = {
9  id: 1,
10  name: "John",
11  email: "john@example.com"
12};
13

Union Types for Flexibility

Union types allow a variable to be one of several types:

1// Function that accepts different types of IDs
2function getUserById(id: string | number): User {
3  // ...
4}
5
6// Discriminated unions for type-safe handling of different shapes
7type Success = { status: "success"; data: User };
8type Error = { status: "error"; message: string };
9type Response = Success | Error;
10
11function handleResponse(response: Response): void {
12  if (response.status === "success") {
13    // TypeScript knows response.data exists here
14    console.log(response.data.name);
15  } else {
16    // TypeScript knows response.message exists here
17    console.error(response.message);
18  }
19}
20

Type Guards for Runtime Type Checking

Type guards help TypeScript narrow down types at runtime:

1// User-defined type guard
2function isUser(obj: any): obj is User {
3  return (
4    obj &&
5    typeof obj === "object" &&
6    "id" in obj &&
7    "name" in obj &&
8    "email" in obj
9  );
10}
11
12function processEntity(entity: unknown): void {
13  if (isUser(entity)) {
14    // TypeScript knows entity is a User here
15    console.log(entity.name);
16  }
17}
18
19// Built-in type guards
20function processValue(value: string | number): void {
21  if (typeof value === "string") {
22    // TypeScript knows value is a string here
23    console.log(value.toUpperCase());
24  } else {
25    // TypeScript knows value is a number here
26    console.log(value.toFixed(2));
27  }
28}
29

Generics for Reusable Code

Generics allow you to create reusable components with different types:

1// Generic function
2function identity<T>(arg: T): T {
3  return arg;
4}
5
6const num = identity(42);        // num is number
7const str = identity("hello");   // str is string
8
9// Generic interface
10interface Repository<T> {
11  getById(id: string): Promise<T>;
12  getAll(): Promise<T[]>;
13  create(item: T): Promise<T>;
14  update(id: string, item: T): Promise<T>;
15  delete(id: string): Promise<void>;
16}
17
18// Implementation for a specific type
19class UserRepository implements Repository<User> {
20  async getById(id: string): Promise<User> {
21    // ...
22  }
23  
24  // ... other methods
25}
26

Readonly Properties for Immutability

Use readonly to prevent properties from being modified:

1interface User {
2  readonly id: number;
3  name: string;
4  email: string;
5}
6
7const user: User = {
8  id: 1,
9  name: "John",
10  email: "john@example.com"
11};
12
13// ❌ Error: Cannot assign to 'id' because it is a read-only property
14user.id = 2;
15
16// ✅ This is allowed
17user.name = "John Doe";
18

Advanced TypeScript Patterns

Utility Types

TypeScript provides built-in utility types that help manipulate types:

1interface User {
2  id: number;
3  name: string;
4  email: string;
5  age: number;
6  address: string;
7}
8
9// Partial: Make all properties optional
10type PartialUser = Partial<User>;
11// Equivalent to: { id?: number; name?: string; ... }
12
13// Pick: Select specific properties
14type UserBasicInfo = Pick<User, "id" | "name" | "email">;
15// Equivalent to: { id: number; name: string; email: string; }
16
17// Omit: Remove specific properties
18type UserWithoutAddress = Omit<User, "address">;
19// Equivalent to: { id: number; name: string; email: string; age: number; }
20
21// Record: Create a type with specified keys and values
22type UserRoles = Record<string, "admin" | "editor" | "viewer">;
23// Equivalent to: { [key: string]: "admin" | "editor" | "viewer" }
24
25// Required: Make all properties required
26type RequiredUser = Required<PartialUser>;
27// Equivalent to: User
28

Mapped Types

Mapped types allow you to transform each property in a type:

1// Make all properties in User optional
2type Optional<T> = {
3  [P in keyof T]?: T[P];
4};
5
6// Make all properties in User readonly
7type Readonly<T> = {
8  readonly [P in keyof T]: T[P];
9};
10
11// Convert all properties to string
12type Stringify<T> = {
13  [P in keyof T]: string;
14};
15
16const stringifiedUser: Stringify<User> = {
17  id: "1",
18  name: "John",
19  email: "john@example.com",
20  age: "30",
21  address: "123 Main St"
22};
23

Conditional Types

Conditional types select a type based on a condition:

1// T extends U ? X : Y
2type IsString<T> = T extends string ? true : false;
3
4type A = IsString<"hello">;  // true
5type B = IsString<42>;       // false
6
7// Extract return type of a function
8type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
9
10function fetchUser(): Promise<User> {
11  // ...
12}
13
14type FetchUserReturn = ReturnType<typeof fetchUser>;  // Promise<User>
15

Template Literal Types

Template literal types allow you to manipulate string types:

1type EventName = "click" | "focus" | "blur";
2type EventHandler = `on${Capitalize<EventName>}`;
3
4// EventHandler = "onClick" | "onFocus" | "onBlur"
5
6// Create a type for CSS properties
7type CSSProperty = "margin" | "padding" | "border";
8type CSSDirection = "top" | "right" | "bottom" | "left";
9type CSSPropertyWithDirection = `${CSSProperty}-${CSSDirection}`;
10
11// CSSPropertyWithDirection = "margin-top" | "margin-right" | ... | "border-left"
12

Organizing TypeScript Code

File and Folder Structure

A well-organized project structure improves maintainability:

1src/
2├── components/       # UI components
3├── hooks/            # React hooks
4├── services/         # API services
5├── types/            # Type definitions
6│   ├── index.ts      # Re-exports all types
7│   ├── user.ts       # User-related types
8│   └── ...
9├── utils/            # Utility functions
10└── index.ts          # Main entry point
11

Barrel Files for Clean Imports

Use barrel files (index.ts) to simplify imports:

1// src/types/index.ts
2export * from './user';
3export * from './product';
4export * from './order';
5
6// Usage in another file
7import { User, Product, Order } from './types';
8

Namespace vs. Module

Prefer ES modules over namespaces:

1// ❌ Avoid namespaces
2namespace Models {
3  export interface User {
4    id: number;
5    name: string;
6  }
7}
8
9// ✅ Use ES modules
10// user.ts
11export interface User {
12  id: number;
13  name: string;
14}
15
16// Usage
17import { User } from './user';
18

TypeScript with React

Function Components with TypeScript

1import React from 'react';
2
3interface UserProfileProps {
4  name: string;
5  email: string;
6  age?: number;  // Optional prop
7  onUpdate: (name: string) => void;
8}
9
10// Function component with typed props
11const UserProfile: React.FC<UserProfileProps> = ({ 
12  name, 
13  email, 
14  age, 
15  onUpdate 
16}) => {
17  return (
18    <div>
19      <h2>{name}</h2>
20      <p>Email: {email}</p>
21      {age && <p>Age: {age}</p>}
22      <button onClick={() => onUpdate(name)}>Update</button>
23    </div>
24  );
25};
26
27export default UserProfile;
28

Typing Hooks

1import React, { useState, useEffect } from 'react';
2
3interface User {
4  id: number;
5  name: string;
6  email: string;
7}
8
9const UserComponent: React.FC = () => {
10  // Typed state
11  const [user, setUser] = useState<User | null>(null);
12  const [loading, setLoading] = useState<boolean>(true);
13  const [error, setError] = useState<string | null>(null);
14  
15  useEffect(() => {
16    const fetchUser = async (): Promise<void> => {
17      try {
18        setLoading(true);
19        const response = await fetch('/api/user');
20        const data: User = await response.json();
21        setUser(data);
22        setError(null);
23      } catch (err) {
24        setError(err instanceof Error ? err.message : 'An unknown error occurred');
25        setUser(null);
26      } finally {
27        setLoading(false);
28      }
29    };
30    
31    fetchUser();
32  }, []);
33  
34  if (loading) return <div>Loading...</div>;
35  if (error) return <div>Error: {error}</div>;
36  if (!user) return <div>No user found</div>;
37  
38  return (
39    <div>
40      <h1>{user.name}</h1>
41      <p>{user.email}</p>
42    </div>
43  );
44};
45

Custom Hooks with TypeScript

1import { useState, useEffect } from 'react';
2
3// Generic custom hook
4function useFetch<T>(url: string) {
5  const [data, setData] = useState<T | null>(null);
6  const [loading, setLoading] = useState<boolean>(true);
7  const [error, setError] = useState<string | null>(null);
8  
9  useEffect(() => {
10    const fetchData = async (): Promise<void> => {
11      try {
12        setLoading(true);
13        const response = await fetch(url);
14        const result: T = await response.json();
15        setData(result);
16        setError(null);
17      } catch (err) {
18        setError(err instanceof Error ? err.message : 'An unknown error occurred');
19        setData(null);
20      } finally {
21        setLoading(false);
22      }
23    };
24    
25    fetchData();
26  }, [url]);
27  
28  return { data, loading, error };
29}
30
31// Usage
32interface User {
33  id: number;
34  name: string;
35  email: string;
36}
37
38function UserProfile({ userId }: { userId: number }) {
39  const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
40  
41  // ...
42}
43

Testing TypeScript Code

Unit Testing with Jest and TypeScript

1// user.service.ts
2export interface User {
3  id: number;
4  name: string;
5  email: string;
6}
7
8export class UserService {
9  async getUser(id: number): Promise<User> {
10    const response = await fetch(`/api/users/${id}`);
11    if (!response.ok) {
12      throw new Error(`Failed to fetch user: ${response.statusText}`);
13    }
14    return response.json();
15  }
16}
17
18// user.service.test.ts
19import { UserService } from './user.service';
20
21describe('UserService', () => {
22  let userService: UserService;
23  let fetchMock: jest.SpyInstance;
24  
25  beforeEach(() => {
26    userService = new UserService();
27    fetchMock = jest.spyOn(global, 'fetch').mockImplementation();
28  });
29  
30  afterEach(() => {
31    fetchMock.mockRestore();
32  });
33  
34  it('should fetch a user by id', async () => {
35    const mockUser = { id: 1, name: 'John', email: 'john@example.com' };
36    
37    fetchMock.mockResolvedValueOnce({
38      ok: true,
39      json: async () => mockUser
40    } as Response);
41    
42    const user = await userService.getUser(1);
43    
44    expect(fetchMock).toHaveBeenCalledWith('/api/users/1');
45    expect(user).toEqual(mockUser);
46  });
47  
48  it('should throw an error when fetch fails', async () => {
49    fetchMock.mockResolvedValueOnce({
50      ok: false,
51      statusText: 'Not Found'
52    } as Response);
53    
54    await expect(userService.getUser(1)).rejects.toThrow('Failed to fetch user: Not Found');
55  });
56});
57

Performance Considerations

Type-Only Imports

Use type-only imports to avoid importing runtime code:

1// ❌ Regular import (includes runtime code)
2import { User } from './models';
3
4// ✅ Type-only import (removed during compilation)
5import type { User } from './models';
6

const Assertions

Use as const to create more specific literal types:

1// Without const assertion
2const config = {
3  api: {
4    baseUrl: 'https://api.example.com',
5    timeout: 5000
6  }
7};
8// Type: { api: { baseUrl: string; timeout: number; } }
9
10// With const assertion
11const config = {
12  api: {
13    baseUrl: 'https://api.example.com',
14    timeout: 5000
15  }
16} as const;
17// Type: { readonly api: { readonly baseUrl: "https://api.example.com"; readonly timeout: 5000; } }
18

Conclusion

TypeScript offers powerful tools for building robust, maintainable applications. By following these best practices, you can leverage TypeScript's type system to catch errors early, improve code quality, and enhance developer productivity.

Remember that TypeScript is designed to help you, not hinder you. Start with basic typing and gradually adopt more advanced features as you become comfortable with them.

As your TypeScript skills grow, you'll find that the initial investment in learning the type system pays off in reduced bugs, improved code quality, and a better development experience.