Building Scalable Web Applications with TypeScript

β€’4 min read

Building Scalable Web Applications with TypeScript

TypeScript is a statically typed superset of JavaScript that compiles to plain JavaScript. It brings compile-time type checking, improved tooling, and better maintainability to JavaScript projects β€” making it the go-to choice for large-scale web applications.

Why TypeScript?

JavaScript's dynamic nature is powerful but can lead to subtle bugs that only surface at runtime. TypeScript addresses this by introducing a type system that:

  • Catches errors at compile time, not in production
  • Makes code self-documenting through explicit types
  • Powers superior IDE support (autocomplete, refactoring, navigation)
  • Enables safer refactoring across large codebases
  • Facilitates team collaboration with clearer contracts between modules

πŸ’‘ According to a study by Airbnb, TypeScript could have prevented 38% of their production bugs.


Setting Up TypeScript in a New Project

With Next.js (recommended)

bash
1npx create-next-app@latest my-app --typescript
2cd my-app
3npm run dev
UTF-8 3 Lines

In an existing JavaScript project

bash
1npm install --save-dev typescript @types/node @types/react
2npx tsc --init
UTF-8 2 Lines

Your tsconfig.json should include:

json
1{
2 "compilerOptions": {
3 "target": "ES2020",
4 "strict": true,
5 "moduleResolution": "bundler",
6 "jsx": "preserve",
7 "paths": {
8 "@/*": ["./src/*"]
9 }
10 }
11}
UTF-8 11 Lines

Core TypeScript Concepts

1. Interfaces and Types

Use interfaces for object shapes and types for unions, primitives, or computed shapes:

typescript
1// Interface: best for object shapes
2interface User {
3 id: string;
4 name: string;
5 email: string;
6 role: 'admin' | 'editor' | 'viewer';
7 createdAt: Date;
8}
9
10// Type alias: good for unions and complex types
11type ApiResponse<T> =
12 | { status: 'success'; data: T }
13 | { status: 'error'; message: string };
UTF-8 13 Lines

2. Generics

Generics make functions and components reusable across types:

typescript
1async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
2 const res = await fetch(url);
3 if (!res.ok) {
4 return { status: 'error', message: `HTTP ${res.status}` };
5 }
6 const data: T = await res.json();
7 return { status: 'success', data };
8}
9
10// Usage
11const users = await fetchData<User[]>('/api/users');
UTF-8 11 Lines

3. Utility Types

TypeScript ships with powerful built-in utility types:

typescript
1// Partial makes all fields optional (useful for update payloads)
2type UserUpdate = Partial<User>;
3
4// Pick selects specific fields
5type UserPreview = Pick<User, 'id' | 'name'>;
6
7// Omit removes specific fields
8type CreateUserPayload = Omit<User, 'id' | 'createdAt'>;
9
10// Record maps keys to values
11type RolePermissions = Record<User['role'], string[]>;
UTF-8 11 Lines

4. Type Guards

Type guards let you narrow types at runtime:

typescript
1function isUser(obj: unknown): obj is User {
2 return (
3 typeof obj === 'object' &&
4 obj !== null &&
5 typeof (obj as User).id === 'string' &&
6 typeof (obj as User).email === 'string'
7 );
8}
9
10function handleResponse(data: unknown) {
11 if (isUser(data)) {
12 console.log(data.email); // TypeScript knows this is a User here
13 }
14}
UTF-8 14 Lines

Structuring a Scalable TypeScript Application

A well-organised project structure is just as important as type safety:

javascript
1src/
2β”œβ”€β”€ app/ # Next.js App Router pages
3β”œβ”€β”€ components/ # Reusable UI components
4β”‚ β”œβ”€β”€ ui/ # Primitive components (Button, Input, etc.)
5β”‚ └── features/ # Feature-specific components
6β”œβ”€β”€ lib/ # Utilities and helpers
7β”œβ”€β”€ hooks/ # Custom React hooks
8β”œβ”€β”€ types/ # Shared type definitions
9β”‚ β”œβ”€β”€ api.ts
10β”‚ └── models.ts
11└── services/ # API calls and data fetching
UTF-8 11 Lines

Centralise your types in src/types/ and import them across the project β€” this ensures a single source of truth.

Practical Patterns

Typed API Service

typescript
1// src/services/userService.ts
2import type { User, CreateUserPayload } from '@/types/models';
3
4const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
5
6export const userService = {
7 async getAll(): Promise<User[]> {
8 const res = await fetch(`${BASE_URL}/users`);
9 if (!res.ok) throw new Error('Failed to fetch users');
10 return res.json();
11 },
12
13 async create(payload: CreateUserPayload): Promise<User> {
14 const res = await fetch(`${BASE_URL}/users`, {
15 method: 'POST',
16 headers: { 'Content-Type': 'application/json' },
17 body: JSON.stringify(payload),
18 });
19 if (!res.ok) throw new Error('Failed to create user');
20 return res.json();
21 },
22};
UTF-8 22 Lines

Typed React Component with Props

typescript
1// src/components/UserCard.tsx
2import type { User } from '@/types/models';
3
4interface UserCardProps {
5 user: User;
6 onEdit?: (id: string) => void;
7 compact?: boolean;
8}
9
10export function UserCard({ user, onEdit, compact = false }: UserCardProps) {
11 return (
12 <div className={compact ? 'p-2' : 'p-4'}>
13 <h3>{user.name}</h3>
14 <p>{user.email}</p>
15 {onEdit && (
16 <button onClick={() => onEdit(user.id)}>Edit</button>
17 )}
18 </div>
19 );
20}
UTF-8 20 Lines

TypeScript Best Practices

  • βœ… Enable "strict": true in your tsconfig β€” it enables the most valuable checks
  • βœ… Avoid any β€” use unknown and narrow with type guards instead
  • βœ… Use const assertions for immutable data: const roles = ['admin', 'user'] as const
  • βœ… Prefer interfaces for public APIs and types for internal/union types
  • βœ… Colocate types close to where they're used; share only truly global types
  • ❌ Don't over-engineer β€” not everything needs a generic type
  • ❌ Don't use type assertions (as) to silence errors; fix the root cause

Summary

TypeScript transforms JavaScript development by making the impossible β€” discovering an entire class of bugs before your code runs β€” completely routine. When structured well, a TypeScript codebase becomes self-documenting, easier to refactor, and far more resilient as your team and product grow. Start with strict mode, define your data models in a shared types/ directory, and let TypeScript guide you to more intentional, robust code.