TypeScript in Practice
The best way to consolidate everything from this guide is to see TypeScript working in real codebases. This chapter covers three practical scenarios: building a typed Node.js HTTP server, writing React components with full type safety, and migrating an existing JavaScript project to TypeScript.
TypeScript with Node.js
Project setup
mkdir ts-api && cd ts-api
npm init -y
npm install express
npm install --save-dev typescript @types/node @types/express tsx
npx tsc --init
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"declaration": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
A typed Express server
// src/types.ts
export interface User {
id: number;
name: string;
email: string;
role: "admin" | "user";
createdAt: string;
}
export interface CreateUserBody {
name: string;
email: string;
role?: "admin" | "user";
}
export interface ApiError {
message: string;
code?: string;
statusCode: number;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// src/middleware/error-handler.ts
import type { Request, Response, NextFunction, ErrorRequestHandler } from "express";
import type { ApiError } from "../types.js";
export class AppError extends Error {
constructor(
public readonly message: string,
public readonly statusCode: number = 500,
public readonly code?: string,
) {
super(message);
this.name = "AppError";
}
}
export const errorHandler: ErrorRequestHandler = (
err: unknown,
_req: Request,
res: Response,
_next: NextFunction,
): void => {
if (err instanceof AppError) {
const response: ApiError = {
message: err.message,
code: err.code,
statusCode: err.statusCode,
};
res.status(err.statusCode).json(response);
return;
}
console.error("Unhandled error:", err);
res.status(500).json({ message: "Internal server error", statusCode: 500 });
};
// src/routes/users.ts
import { Router } from "express";
import type { Request, Response, NextFunction } from "express";
import type { CreateUserBody, User, PaginatedResult } from "../types.js";
import { AppError } from "../middleware/error-handler.js";
const router = Router();
// In-memory store (use a real DB in production)
const users: User[] = [
{ id: 1, name: "Alice", email: "alice@example.com", role: "admin", createdAt: new Date().toISOString() },
{ id: 2, name: "Bob", email: "bob@example.com", role: "user", createdAt: new Date().toISOString() },
];
let nextId = 3;
// GET /users?page=1&pageSize=10
router.get("/", (req: Request, res: Response) => {
const page = Number(req.query.page) || 1;
const pageSize = Math.min(Number(req.query.pageSize) || 10, 100);
const start = (page - 1) * pageSize;
const result: PaginatedResult<User> = {
data: users.slice(start, start + pageSize),
total: users.length,
page,
pageSize,
totalPages: Math.ceil(users.length / pageSize),
};
res.json(result);
});
// GET /users/:id
router.get("/:id", (req: Request, res: Response, next: NextFunction) => {
const id = Number(req.params.id);
const user = users.find(u => u.id === id);
if (!user) {
next(new AppError(`User ${id} not found`, 404, "USER_NOT_FOUND"));
return;
}
res.json(user);
});
// POST /users
router.post("/", (req: Request<{}, User, CreateUserBody>, res: Response, next: NextFunction) => {
const { name, email, role = "user" } = req.body;
if (!name || !email) {
next(new AppError("Name and email are required", 400, "VALIDATION_ERROR"));
return;
}
if (users.some(u => u.email === email)) {
next(new AppError("Email already registered", 409, "DUPLICATE_EMAIL"));
return;
}
const newUser: User = {
id: nextId++,
name,
email,
role,
createdAt: new Date().toISOString(),
};
users.push(newUser);
res.status(201).json(newUser);
});
export default router;
// src/server.ts
import express from "express";
import usersRouter from "./routes/users.js";
import { errorHandler } from "./middleware/error-handler.js";
const app = express();
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
app.use(express.json());
app.get("/health", (_req, res) => {
res.json({ status: "ok", uptime: process.uptime() });
});
app.use("/users", usersRouter);
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
export default app;
Typing environment variables
Accessing process.env without checks gives you string | undefined. Create a typed config module:
// src/config.ts
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export const config = {
port: Number(process.env.PORT ?? 3000),
nodeEnv: (process.env.NODE_ENV ?? "development") as "development" | "production" | "test",
databaseUrl: requireEnv("DATABASE_URL"),
jwtSecret: requireEnv("JWT_SECRET"),
corsOrigin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
} as const;
TypeScript with React
Setup with Vite
npm create vite@latest my-react-app -- --template react-ts
cd my-react-app
npm install
Vite's React-TS template configures everything out of the box.
Typing component props
The most fundamental React + TypeScript pattern: define props as an interface:
// src/components/UserCard.tsx
interface UserCardProps {
id: number;
name: string;
email: string;
avatarUrl?: string;
role: "admin" | "user";
onDelete?: (id: number) => void;
onEdit?: (id: number) => void;
}
export function UserCard({ id, name, email, avatarUrl, role, onDelete, onEdit }: UserCardProps) {
return (
<div className="user-card">
{avatarUrl && <img src={avatarUrl} alt={`${name}'s avatar`} />}
<h3>{name}</h3>
<p>{email}</p>
<span className={`badge badge-${role}`}>{role}</span>
{onEdit && (
<button onClick={() => onEdit(id)}>Edit</button>
)}
{onDelete && (
<button onClick={() => onDelete(id)}>Delete</button>
)}
</div>
);
}
Note: Avoid
React.FC/React.FunctionComponentfor new code. The direct function signature shown above is cleaner and avoids the implicitchildrenprop thatReact.FCused to add (this was removed in React 18 types).
Typing children
When a component accepts children, use React.ReactNode:
import type { ReactNode } from "react";
interface CardProps {
title: string;
children: ReactNode;
className?: string;
footer?: ReactNode;
}
export function Card({ title, children, className = "", footer }: CardProps) {
return (
<div className={`card ${className}`}>
<div className="card-header">
<h2>{title}</h2>
</div>
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
Typing event handlers
import { useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react";
export function SearchForm({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState("");
// ChangeEvent<HTMLInputElement> for input changes
const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
setQuery(event.target.value);
};
// FormEvent<HTMLFormElement> for form submission
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
if (query.trim()) {
onSearch(query.trim());
}
};
// KeyboardEvent for keyboard interactions
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
if (event.key === "Escape") {
setQuery("");
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="search"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
);
}
Common event types:
| Event | Element types |
|---|---|
ChangeEvent<HTMLInputElement> | <input>, <textarea> |
ChangeEvent<HTMLSelectElement> | <select> |
FormEvent<HTMLFormElement> | <form> |
MouseEvent<HTMLButtonElement> | <button>, <div>, <a> |
KeyboardEvent<HTMLInputElement> | Any focusable element |
FocusEvent<HTMLInputElement> | Any focusable element |
DragEvent<HTMLDivElement> | Drag-and-drop targets |
Typing hooks
useState infers from the initial value, but sometimes you need to be explicit:
import { useState } from "react";
interface User {
id: number;
name: string;
}
// Inferred: useState<number>
const [count, setCount] = useState(0);
// Explicit type needed when initial value is null/undefined
const [user, setUser] = useState<User | null>(null);
// Array state
const [items, setItems] = useState<string[]>([]);
// Object state
const [form, setForm] = useState<{ email: string; password: string }>({
email: "",
password: "",
});
useRef for DOM elements:
import { useRef, useEffect } from "react";
function AutoFocusInput() {
// Typing useRef for DOM elements: HTMLInputElement | null
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // optional chaining -- ref may not be attached yet
}, []);
return <input ref={inputRef} type="text" />;
}
useReducer with discriminated unions:
import { useReducer } from "react";
interface State {
count: number;
step: number;
}
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset" }
| { type: "setStep"; payload: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { ...state, count: state.count + state.step };
case "decrement":
return { ...state, count: state.count - state.step };
case "reset":
return { count: 0, step: state.step };
case "setStep":
return { ...state, step: action.payload };
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+{state.step}</button>
<button onClick={() => dispatch({ type: "decrement" })}>-{state.step}</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
<input
type="number"
value={state.step}
onChange={e => dispatch({ type: "setStep", payload: Number(e.target.value) })}
/>
</div>
);
}
Custom hooks:
import { useState, useCallback } from "react";
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
function useFetch<T>(url: string): FetchState<T> & { refetch: () => void } {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: false,
error: null,
});
const fetch_ = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data: T = await response.json();
setState({ data, loading: false, error: null });
} catch (err) {
setState(prev => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : "Unknown error",
}));
}
}, [url]);
return { ...state, refetch: fetch_ };
}
// Usage -- T is inferred as User
interface User { id: number; name: string; }
function UserProfile({ userId }: { userId: number }) {
const { data: user, loading, error, refetch } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error} <button onClick={refetch}>Retry</button></p>;
if (!user) return null;
return <div><h1>{user.name}</h1></div>;
}
Typing context
import { createContext, useContext, useState, type ReactNode } from "react";
interface ThemeContextValue {
theme: "light" | "dark";
toggleTheme: () => void;
}
// createContext needs a default value or undefined + a guard
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggleTheme = () => setTheme(t => t === "light" ? "dark" : "light");
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
// Usage
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Switch to {theme === "light" ? "dark" : "light"} mode
</button>
);
}
Migrating a JavaScript project to TypeScript
Migrating incrementally is the recommended approach -- you do not have to convert everything at once.
Step 1: Add TypeScript to the project
npm install --save-dev typescript @types/node
npx tsc --init
Step 2: Configure a lenient tsconfig to start
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"allowJs": true, // Allow JavaScript files alongside TypeScript
"checkJs": false, // Don't type-check JS files yet
"outDir": "dist",
"rootDir": "src",
"strict": false, // Start lenient, tighten later
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
allowJs: true means you can have a mix of .ts and .js files. Rename and convert files one at a time.
Step 3: Rename files and fix errors
Rename .js files to .ts one at a time, fixing errors as they appear:
mv src/utils/format.js src/utils/format.ts
npx tsc --noEmit # See what broke
Common errors when converting:
| Error | Fix |
|---|---|
Parameter 'x' implicitly has an 'any' type | Add type annotations to function parameters |
Property 'x' does not exist on type '{}' | Define an interface or use a type assertion |
Cannot find module 'x' | Install @types/x or write a declaration file |
Object is possibly 'null' | Add null checks or use optional chaining |
Step 4: Add types progressively
Start with the most used files and work outward. Use // @ts-ignore or // @ts-expect-error as temporary escapes,
then remove them:
// Temporary escape hatch while migrating
// @ts-expect-error -- TODO: properly type this after the migration
const result = legacyFunction(data);
Step 5: Enable strict mode gradually
Enable strict flags one at a time rather than all at once:
{
"compilerOptions": {
"strict": false,
"strictNullChecks": true, // Week 1
"noImplicitAny": true, // Week 2
"strictFunctionTypes": true // Week 3
// ... add more each sprint
}
}
Step 6: Enable checkJs for remaining .js files
Once your TypeScript files are clean, you can enable basic checking for the remaining .js files:
{
"compilerOptions": {
"checkJs": true,
"strict": true // Full strict mode
}
}
Migration checklist
[ ] Install typescript and @types/node
[ ] Create tsconfig.json with allowJs: true, strict: false
[ ] Add "build": "tsc" and "typecheck": "tsc --noEmit" scripts
[ ] Convert utility files first (lowest coupling)
[ ] Convert service/business logic files
[ ] Convert route handlers and controllers
[ ] Convert entry point (server.ts, main.ts, index.ts)
[ ] Install @types/ packages for all dependencies
[ ] Write .d.ts files for dependencies without types
[ ] Enable strict: true and fix remaining errors
[ ] Add TypeScript to CI/CD: run npm run typecheck in pipeline
[ ] Remove all @ts-ignore comments
Common TypeScript patterns
The Builder pattern
class QueryBuilder {
private conditions: string[] = [];
private orderByClause: string | null = null;
private limitValue: number | null = null;
private table: string;
constructor(table: string) {
this.table = table;
}
where(condition: string): this {
this.conditions.push(condition);
return this;
}
orderBy(column: string, direction: "ASC" | "DESC" = "ASC"): this {
this.orderByClause = `ORDER BY ${column} ${direction}`;
return this;
}
limit(n: number): this {
this.limitValue = n;
return this;
}
build(): string {
const where = this.conditions.length > 0
? `WHERE ${this.conditions.join(" AND ")}`
: "";
const order = this.orderByClause ?? "";
const limit = this.limitValue !== null ? `LIMIT ${this.limitValue}` : "";
return `SELECT * FROM ${this.table} ${where} ${order} ${limit}`.trim().replace(/\s+/g, " ");
}
}
const query = new QueryBuilder("users")
.where("active = true")
.where("age > 18")
.orderBy("name")
.limit(20)
.build();
// "SELECT * FROM users WHERE active = true AND age > 18 ORDER BY name ASC LIMIT 20"
Type-safe event bus
type Listener<T> = (event: T) => void;
class TypedEventBus<Events extends Record<string, unknown>> {
private listeners = new Map<
keyof Events,
Set<Listener<Events[keyof Events]>>
>();
on<K extends keyof Events>(event: K, listener: Listener<Events[K]>): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener as Listener<Events[keyof Events]>);
// Return unsubscribe function
return () => this.off(event, listener);
}
off<K extends keyof Events>(event: K, listener: Listener<Events[K]>): void {
this.listeners.get(event)?.delete(listener as Listener<Events[keyof Events]>);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners.get(event)?.forEach(listener => listener(payload));
}
}
interface DomainEvents {
"user.created": { userId: number; name: string };
"order.placed": { orderId: number; amount: number };
"session.expired": { sessionId: string };
}
const bus = new TypedEventBus<DomainEvents>();
const unsub = bus.on("user.created", ({ userId, name }) => {
console.log(`New user: ${name} (${userId})`);
});
bus.emit("user.created", { userId: 1, name: "Alice" }); // OK
// bus.emit("user.created", { userId: "wrong" }); // Error
unsub(); // Unsubscribe
Summary
This final chapter demonstrated:
- A typed Express server with route handlers, error middleware, and a typed config module
- React component props defined as interfaces, with
children: ReactNode - Event handler types (
ChangeEvent,FormEvent,MouseEvent) fromreact - Typing hooks:
useState<T>,useRef<HTMLElement>,useReducerwith discriminated unions, typed custom hooks - Context typing with
createContext<T | undefined>and a guard hook - Migrating from JavaScript: incremental approach with
allowJs, gradual strict mode, and a migration checklist - Common patterns: Builder, typed event bus, and safe property access
Congratulations -- you have worked through the full TypeScript beginners guide. You now have a solid foundation in:
- The TypeScript type system: primitives, interfaces, type aliases, generics, enums, and utility types
- Classes, inheritance, abstract classes, and interface implementation
- Advanced type system features: conditional types, mapped types, template literal types
- Modules, declaration files, and working with third-party libraries
- tsconfig configuration, project references, and ESLint integration
- Real-world TypeScript in Node.js and React projects
- Migrating an existing JavaScript codebase
The best way to continue improving is to write TypeScript every day. Read the error messages -- they are excellent teachers. When you hit a type problem you cannot solve, the TypeScript Playground is an invaluable tool for experimenting without setting up a project.
Useful resources:
- TypeScript Handbook -- the official documentation
- TypeScript Playground -- experiment in your browser
- Total TypeScript -- free and paid courses by Matt Pocock
- Type Challenges -- practice type-level programming
- ts-reset -- improves TypeScript's built-in types