
Building Scalable Web Applications with TypeScript
TypeScript is a statically typed superset of JavaScript that compiles to plain JavaScript. It adds compile-time type checking and better tooling on top of standard JS, which is why most large-scale web projects use it by default now.
Why TypeScript?
JavaScript's dynamic typing is flexible, but it lets bugs slip through that only show up at runtime. TypeScript closes that gap:
- Catches type errors at compile time instead of in production
- Makes code easier to read since the types document expected shapes
- Improves IDE support — autocomplete, jump-to-definition, safer renames
- Makes large-scale refactors less risky
- Gives teams a clearer contract between modules
Setting Up TypeScript in a New Project
With Next.js (recommended)
| 1 | npx create-next-app@latest my-app --typescript |
| 2 | cd my-app |
| 3 | npm run dev |
In an existing JavaScript project
| 1 | npm install --save-dev typescript @types/node @types/react |
| 2 | npx tsc --init |
Your tsconfig.json should include:
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "target": "ES2020", |
| 4 | "strict": true, |
| 5 | "moduleResolution": "bundler", |
| 6 | "jsx": "preserve", |
| 7 | "paths": { |
| 8 | "@/*": ["./src/*"] |
| 9 | } |
| 10 | } |
| 11 | } |
Core TypeScript Concepts
1. Interfaces and Types
Use interfaces for object shapes, and type aliases for unions, primitives, or computed shapes:
| 1 | // Interface: best for object shapes |
| 2 | interface 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 |
| 11 | type ApiResponse<T> = |
| 12 | | { status: 'success'; data: T } |
| 13 | | { status: 'error'; message: string }; |
2. Generics
Generics let you write functions and components that work across types without losing type safety:
| 1 | async 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 |
| 11 | const users = await fetchData<User[]>('/api/users'); |
3. Utility Types
TypeScript ships with built-in utility types that cover most common transformations:
| 1 | // Partial makes all fields optional (useful for update payloads) |
| 2 | type UserUpdate = Partial<User>; |
| 3 | |
| 4 | // Pick selects specific fields |
| 5 | type UserPreview = Pick<User, 'id' | 'name'>; |
| 6 | |
| 7 | // Omit removes specific fields |
| 8 | type CreateUserPayload = Omit<User, 'id' | 'createdAt'>; |
| 9 | |
| 10 | // Record maps keys to values |
| 11 | type RolePermissions = Record<User['role'], string[]>; |
4. Type Guards
Type guards narrow a type at runtime, so TypeScript can trust it afterward:
| 1 | function 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 | |
| 10 | function handleResponse(data: unknown) { |
| 11 | if (isUser(data)) { |
| 12 | console.log(data.email); // TypeScript knows this is a User here |
| 13 | } |
| 14 | } |
Structuring a Scalable TypeScript Application
Project structure matters as much as the types themselves:
| 1 | src/ |
| 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 |
Keep types centralized in src/types/ and import them across the project so there's one source of truth.
Practical Patterns
Typed API Service
| 1 | // src/services/userService.ts |
| 2 | import type { User, CreateUserPayload } from '@/types/models'; |
| 3 | |
| 4 | const BASE_URL = process.env.NEXT_PUBLIC_API_URL; |
| 5 | |
| 6 | export 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 | }; |
Typed React Component with Props
| 1 | // src/components/UserCard.tsx |
| 2 | import type { User } from '@/types/models'; |
| 3 | |
| 4 | interface UserCardProps { |
| 5 | user: User; |
| 6 | onEdit?: (id: string) => void; |
| 7 | compact?: boolean; |
| 8 | } |
| 9 | |
| 10 | export 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 | } |
TypeScript Best Practices
- Enable
"strict": truein your tsconfig. It turns on the checks that actually catch bugs. - Avoid
any. Useunknownand narrow it with a type guard instead. - Use
constassertions for immutable data:const roles = ['admin', 'user'] as const. - Prefer interfaces for public APIs, type aliases for internal or union types.
- Keep types close to where they're used. Only promote a type to a shared file once more than one module needs it.
- Don't reach for generics on everything; plain types are fine for most cases.
- Don't use
asto silence a type error. Fix the underlying mismatch instead.
Summary
A TypeScript codebase that's structured well stays easier to read, easier to refactor, and harder to break by accident as the team and the app grow. Turn on strict mode, put your data models in a shared types/ directory, and let the compiler catch what manual review would otherwise miss.