Architecting for Scale and Safety
From Dynamic Freedom to Static Safety
JavaScript's dynamic typing is a double-edged sword. It's fast for prototyping, but dangerous for large, complex backend applications.
What happens when you refactor a function?
function calculateTotal(items) {
// What if `items` is not an array?
// What if an item doesn't have a `price` property?
return items.reduce((acc, item) => acc + item.price, 0);
}
calculateTotal( { price: 100 } ); // 💣 TypeError: items.reduce is not a function
This error would only be discovered at runtime, potentially crashing your production server.
TypeScript is JavaScript, with an added layer: a static type system.
It turns runtime errors into compile-time errors.
With TypeScript, the error is caught instantly in your code editor.
interface Item {
price: number;
}
function calculateTotal(items: Item[]) {
return items.reduce((acc, item) => acc + item.price, 0);
}
// 🚨 ERROR! Argument of type '{ price: number; }'
// is not assignable to parameter of type 'Item[]'.
calculateTotal( { price: 100 } );
This is the core value proposition: safety, predictability, and maintainability for large-scale applications.
Defining the Shape of Your Data
You can explicitly annotate variables with their type.
let username: string = 'Alex';
let port: number = 3000;
let isRunning: boolean = true;
// Arrays can be typed in two ways:
let roles: string[] = ['admin', 'editor'];
let userIds: Array = [1, 2, 3];
// `any` is an escape hatch. It opts out of type checking.
// AVOID USING THIS WHENEVER POSSIBLE!
let data: any = 'could be anything';
TypeScript is smart. If you declare and initialize a variable at the same time, it will infer the type for you.
// No need to write `: string`. TS knows `port` is a number.
let port = 3000;
// port = '3000'; // 🚨 ERROR! Type 'string' is not assignable to type 'number'.
Best practice is to rely on type inference when possible to keep your code clean.
An `interface` is a way to define the shape of an object.
interface User {
id: number;
name: string;
email: string;
isActive: boolean;
role?: string; // The `?` makes this property optional
}
function printUser(user: User) {
console.log(`User ${user.name} (${user.email})`);
}
const myUser: User = {
id: 1,
name: 'Alex',
email: 'alex@example.com',
isActive: true
};
printUser(myUser); // Works!
The `type` keyword can also define object shapes, but it's more versatile. It's particularly useful for creating Union Types.
A union type allows a variable to be one of several types.
type Status = 'pending' | 'shipped' | 'delivered' | 'cancelled';
type UserId = string | number;
let currentStatus: Status = 'pending';
// currentStatus = 'processing'; // 🚨 ERROR!
let id: UserId = 123;
id = 'abc-456'; // This is also valid.
Generics allow you to write reusable components that can work over a variety of types rather than a single one.
Think of them as variables for types.
// A generic type for a standard API response
interface ApiResponse {
status: 'success' | 'error';
data: T; // The data can be of any type `T`
message?: string;
}
// We can now create specific response types
const userResponse: ApiResponse = {
status: 'success',
data: { id: 1, name: 'Alex', ... }
};
const productsResponse: ApiResponse = {
status: 'success',
data: [ { id: 101, name: 'Laptop' }, ... ]
};
Architecting a Type-Safe Server
We need to install TypeScript and some helper tools.
# Install TypeScript and ts-node as dev dependencies
npm install -D typescript ts-node
# Install type definition files for Node and Express
npm install -D @types/node @types/express
This file is the blueprint for your TypeScript project. It tells the compiler how to behave.
# This command creates a default tsconfig.json file
npx tsc --init
Key options to set:
{
"compilerOptions": {
"target": "ES2020", // Compile to a modern JS version
"module": "CommonJS", // The module system to use
"outDir": "./dist", // Where to put compiled JS files
"rootDir": "./src", // Where our TS source files are
"strict": true, // Enable all strict type-checking options
"esModuleInterop": true
}
}
We update our scripts to use `ts-node` for development and `tsc` for a production build.
"scripts": {
"start": "node dist/app.js",
"build": "tsc",
"dev": "nodemon src/app.ts"
}
Now, `npm run dev` will run our app with `ts-node`, and `npm run build` will compile our TypeScript to JavaScript in the `dist` folder.
Now we rename our `.js` files to `.ts` and start adding types!
`src/app.ts`
import express, { Request, Response, NextFunction } from 'express';
// Use ES Module `import` syntax now!
const app = express();
const PORT: number = 3000;
app.get('/', (req: Request, res: Response) => {
res.send('Hello from TypeScript!');
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Notice we import types like `Request`, `Response`, and `NextFunction` directly from `express`.
Let's convert our `getUserById` controller.
import { Request, Response } from 'express';
import { User } from '../entities/User'; // Assuming we created an interface
// Type the request parameters
interface GetUserRequest extends Request {
params: {
id: string;
}
}
export const getUserById = (req: GetUserRequest, res: Response) => {
const id = parseInt(req.params.id);
// ... logic to find user ...
const user: User | undefined = users.find(u => u.id === id);
if (!user) {
return res.status(404).send('User not found');
}
res.json(user);
};
You will be given the complete JavaScript Express API from last week. Your task is to set up the TypeScript environment and convert the `createUser` controller and route to be fully type-safe.
interface CreateUserRequest extends Request {
body: { name: string; email: string; };
}