Appearance
Page 0: JS & TS Foundations β
Overview β
Every piece of code in SI 679 is written in TypeScript running on Node.js. TypeScript is JavaScript with a static type checker on top. You write JavaScript, add type information, and the compiler (tsc) checks it before the code runs. The types are then erased, and plain JavaScript is what actually executes.
That matters more on the backend than almost anywhere else. A server receives data it didn't create (request bodies, query strings, database documents) and sends data other programs rely on (JSON responses). Types let you write down what those shapes are supposed to be, so the compiler catches mismatches between layers (router β service β database) before a user does.
This page covers:
- A JavaScript refresher: the JS features the course code uses constantly.
- TypeScript basics: annotations, inference, unions,
anyvsunknown, optional properties, generics. typevsinterface: the biggest section, with a decision guide.- Backend-flavored examples: typing request bodies, responses, database models, and route handlers.
Sources: the practice/ files from the first TS session, the TS basics book-catalog exercise, and the two TS Resources readings. Examples also borrow from later weeks' code, so you can see how the course actually uses each feature.
How to read the code on this page β
The examples come from real course files, so even the JavaScript section contains a little TypeScript syntax. Here's just enough to read it. Each item gets a full explanation later, at the link.
| You'll see | It means | Full explanation |
|---|---|---|
let age: number = 25 | a type annotation: : number says what kind of value age holds | Part 2 |
function f(x: string): void | the parameter x is a string, and the function returns nothing (void) | Part 2 |
interface Book { β¦ } or type Book = { β¦ } | gives a name to an object shape ("a Book has a title, an author, β¦") | Part 3 |
Book[] | an array of Book objects | Part 2 |
Promise<Product[]>, Array<string> | angle brackets fill in a type inside another type: "a Promise of a Product array" | Generics |
string | number | a union: either a string or a number | Part 2 |
tsconfig.json | the settings file for the TypeScript compiler (how strict to be, where the output goes, β¦) | Week 2 |
The parts are in order: each one only relies on what came before it, or on this table.
Part 1: JavaScript refresher β
let and const β
let and const are the two keywords for declaring a variable (creating a named slot that holds a value). const means the variable can't be reassigned. let means it can. Neither one is about whether the value can change: a const array can still be pushed to.
ts
// week02-router/src/books-service.ts
let catalog: Book[] = []; // let: removeBook reassigns it below
export function addBook(book: Book): void {
catalog.push(book); // mutating is fine even for const
}
export function removeBook(id: number): void {
catalog = catalog.filter(book => book.id !== id); // reassignment β needs let
}Compare the TS basics exercise, where const catalog: Book[] = [] works because that code only ever pushes to it.
π₯ Real life: a gym locker
const is the number on your locker: it always points to the same locker. You can still swap what's inside (push, pop, change properties). let is a locker you're allowed to switch for a different one, which is what removeBook does when it replaces catalog with a new filtered array.
Rule of thumb: default to const, and switch to let only when you truly reassign. (var is the old, function-scoped version. The course never uses it, and you shouldn't either.)
Functions and arrow functions β
A function is a reusable block of code that takes inputs (parameters) and can give back an output (its return value). JavaScript has two ways to write one: the classic function name(β¦) { β¦ } declaration, and the shorter arrow function (β¦) => { β¦ }, which is usually stored in a variable or passed directly to another function. The course uses both forms, often in the same project:
ts
// Function declaration (HW1 middleware/auth.ts)
export function parseBearerToken(header: string | undefined): string | null { /* β¦ */ }
// Arrow function stored in a const (Week 3 db.ts)
export const connect = async (uri: string, dbName: string) => { /* β¦ */ };
// Arrow function passed inline as a route handler (Week 1 server.js)
app.get('/about', (req, res) => {
res.send('Welcome to the About page!');
});There are two practical differences:
- Hoisting: a
functiondeclaration can be called before the line where it's defined. An arrow function in aconstcan't. this: inside a method,thisrefers to the object the method was called on. Arrow functions don't get their ownthis. That's why object methods that usethisare written as methods, likegetDiscount(percent) { return this.price * β¦ }inpractice/src/05-interface.ts.
Objects and arrays β
An object is a collection of named values (properties), written { title: "Dune", year: 1965 } and read with book.title. An array is an ordered list, written [1, 2, 3] and read by position with numbers[0]. JSON, the format APIs send and receive, is built from exactly these two structures.
Backend code is mostly moving objects and arrays around. Arrays come with built-in methods that take a function (a callback) and run it on each element, and the course code leans on them constantly:
| Method | Returns | Course example |
|---|---|---|
find | first match or undefined | catalog.find((book) => book.title === title) (TS basics) |
filter | new array of matches | orders.filter((order) => order.status === status) (HW1) |
map | new array, transformed | productDocs.map((pDoc) => productFromDocument(pDoc)) (Week 4) |
some | true if any match | menuItem.sizes.some((option) => option.name === size) (HW1) |
includes | true if value present | menuItem.crusts.includes(crust) (HW1) |
Shorthand properties: when a variable has the same name as the key, you can write it once:
ts
// week02-router/src/books-router.ts
const bookToAdd: Book = { id, title, author, year, status };
// same as { id: id, title: title, author: author, β¦ }Loops and array methods β
A loop runs the same block of code over and over. Iterating over an array means visiting each element in turn. JavaScript gives you two styles for this:
- Loop statements (
for,for...of,while): you write the steps, and you can stop early. - Array methods (
map,filter,reduce, β¦): you pass a callback, and the method does the looping for you and usually returns a new array.
Backend code uses both. Validation code often loops so it can collect errors item by item, while repositories and services use map and filter to reshape data.
The classic for loop β
ts
for (let i = 0; i < items.length; i++) {
console.log(i, items[i]);
}The three parts are: start (let i = 0), keep going while (i < items.length) and after each pass (i++, which adds 1). Use this form when you need the index (the position number), for example to build an error message like items[2].quantity must be β¦. It has to be let, not const, because i++ reassigns i.
for...of: loop over the values β
for...of hands you each value directly, with no index to manage. It's the most common loop in the course code:
ts
// hw1/src/validation/validate-order.ts
for (const item of body.items) {
// check each item in the orderβ¦
}
// week05-nyt/src/__tests__/products.e2e.test.ts
for (const product of res.body) {
expect(product.id).toMatch(/^[0-9a-f]{24}$/);
}Here const is fine: each pass gets a brand-new item variable. If you need the index too, use for (const [i, item] of items.entries()). It uses array destructuring, covered in the next section.
for...in: loop over the keys (avoid it for arrays) β
for...in walks an object's keys (property names). On an array the keys are the indexes, but as strings ("0", "1", β¦), not numbers, which causes subtle bugs. Use for...of for arrays. For objects, the clearer option is the Object helpers:
ts
const sizePrices = { small: 1000, large: 1600 };
Object.keys(sizePrices); // ["small", "large"]
Object.values(sizePrices); // [1000, 1600]
Object.entries(sizePrices); // [["small", 1000], ["large", 1600]]
for (const [size, price] of Object.entries(sizePrices)) {
console.log(`${size}: ${price}`);
}while, break and continue β
while (condition) { β¦ } repeats as long as the condition is true. It's useful when you don't know the number of passes ahead of time, but it's rare in this course. Inside any loop statement:
breakstops the whole loop immediately.continueskips to the next pass.
These only work in loop statements. You can't break out of forEach or map.
Array methods β
Each method below takes a callback (usually an arrow function) and calls it once per element. The callback gets the element first, then optionally its index: (item, i) => β¦.
forEach runs the callback for its side effect (something it does, like printing, rather than a value it returns). It returns undefined, so there's nothing to save:
ts
// ts-basics/src/index.ts
catalog.forEach((book) => console.log(`- ${book.title} (${book.year})`));map transforms each element and returns a new array of the same length. This is how the repositories turn raw MongoDB documents into the app's own type:
ts
// week05-nyt/src/db/products-repository.ts
return docs.map((doc) => toProduct(doc)); // Document[] β Product[]filter keeps the elements whose callback returns true, and returns a new array that is the same length or shorter:
ts
// week05-nyt/src/services/products-service.ts
return products.filter((product) => product.quantity < threshold);find, some, every, includes answer questions about the array instead of building a new one:
| Method | Question it answers | Returns |
|---|---|---|
find(cb) | which element is the first match? | the element, or undefined |
findIndex(cb) | where is the first match? | its index, or -1 |
some(cb) | does at least one match? | true / false |
every(cb) | do all match? | true / false (and true for an empty array) |
includes(value) | is this exact value in it? | true / false |
These stop as soon as they know the answer, so they're also how you "break early" with methods. HW1's tests use every to check a whole response: res.body.every((i) => i.category === "pizza").
reduce combines every element into one value, like a total. The callback gets a running result (the accumulator) and the current element, and whatever it returns becomes the accumulator for the next element. The second argument is the starting value:
ts
// illustrative, not from course code
const items = [
{ name: "Margherita", price: 1200, quantity: 2 },
{ name: "Garlic Knots", price: 500, quantity: 1 },
];
const totalCents = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// 0 β 2400 β 2900Always pass the starting value (the 0). Without it, reduce on an empty array throws a TypeError.
Chaining: each method that returns an array can feed straight into the next one:
ts
const lowStockNames = products
.filter((p) => p.quantity < 5)
.map((p) => p.modelName);π₯ Real life: sorting the mail
forEach is reading every letter aloud: you get nothing back. map is stamping every envelope: same pile, every item changed. filter is tossing the junk mail: a smaller pile of the same letters. find is "hand me the first one from the bank." reduce is adding up all the bills into one number.
Which one should I use? β
| You want to⦠| Use |
|---|---|
| change every element into something else | map |
| keep only some elements | filter |
| get one element | find |
| get a yes/no answer | some / every / includes |
| boil the array down to one value | reduce |
| do something with each element (print, push to another array) | for...of (or forEach) |
stop early, or await inside the loop | for...of (see Loops with await) |
| use the index | classic for, or for (const [i, x] of arr.entries()) |
Types in loops β
TypeScript usually infers the callback's parameter type from the array (fully explained in Part 2): in products.filter((product) => β¦), product is already a Product, so you don't annotate it. The exception is an array typed any, like a Supertest res.body. That's why the tests write the type out:
ts
// week05-nyt/src/__tests__/products.e2e.test.ts
const manufacturers = res.body.map((product: Product) => product.manufacturer);sort changes the original array
Unlike map and filter, sort mutates the array in place. Its default order also compares values as strings, so [10, 9, 1].sort() gives [1, 10, 9]. For numbers, pass a compare function: .sort((a, b) => a - b). To keep the original intact, copy it first with [...arr].sort(β¦), or use arr.toSorted(β¦) (Node 20+).
Destructuring β
Destructuring pulls properties out of an object into variables. This is how nearly every Express handler in the course reads its input:
js
// week01-nyt/server.js
const { itemid } = req.query; // ?itemid=42
const { artistName, songName } = req.params; // /play/artist/:artistName/song/:songName
const { artist, title, album, year } = req.body;Extra context
Destructuring can also rename and set defaults: const { page = 1, limit: pageSize = 20 } = req.query. Array destructuring works by position: const [scheme, token] = header.split(' ') would be a neat way to write HW1's parseBearerToken.
Spread (...) β
Spread (...) copies the contents of an object or array into a new one. It's the standard way to "change" data without mutating (modifying in place) the original:
ts
const hobbit = { title: 'The Hobbit', author: 'J.R.R. Tolkien', year: 1937 };
const blank = { ...hobbit, title: '' }; // a copy of hobbit, with title overridden
// hobbit is unchangedHW1's tests use the same idea to build a fake version of a module:
ts
// hw1/src/__tests__/errors.test.ts
return { ...real, addOrder: () => { throw new Error("boom"); } };
// = everything the real module exports, but with addOrder replacedExtra context
The same idea shows up in API code: const updated = { ...existingProduct, ...changes } merges a PATCH body over the stored record, with later keys winning. Arrays work too: [...catalog, newBook].
Modules: import / export β
A module is a file whose variables and functions are private by default. You export what other files may use and import it where you need it. A named export (export const app = β¦) is imported by its exact name in braces: import { app } from './app.js'. A default export is a module's single main value, imported without braces under any name you choose: import express from 'express'.
ts
// week04-nyt/src/services/product-service.ts
import { db } from '../db/db.js'; // named import
import { productFromDocument } from '../models/products.js';
import type { Product } from '../models/products.js'; // type-only import
export const productService = { getAll }; // named exportts
import express from 'express'; // default import (a package's main export)Three details from the course code trip people up:
.jsin import paths. Week 2β4 projects have"type": "module"inpackage.jsonand"module": "NodeNext"intsconfig.json. In that setup you import./types.jseven though the file on disk istypes.ts, because the path has to be correct for the compiled JavaScript. (week2-router/src/books-service.tseven has the comment//note we import from .js.) HW1 has no"type": "module", so Node treats it as CommonJS, Node's older module system (the one withrequire()andmodule.exports). That's why HW1's imports ("../types") have no extension.import typeimports something that exists only as a type and is erased at compile time. The Week 3/4 tsconfigs turn on theverbatimModuleSyntaxoption, which requires you to mark type-only imports this way.- You can mix both:
import { BookStatus, type Book } from './types.js'(Week 2) imports a real value (BookStatus, an enum, which exists at runtime) and a type in one line.
Promises and async / await β
Some operations take time, like a database query or a network call. Instead of freezing the whole server while it waits, JavaScript runs them asynchronously: the operation starts, and the program keeps going. Such an operation immediately returns a Promise, a placeholder for a value that arrives later. A Promise ends up fulfilled (the value arrived) or rejected (an error happened).
You mark a function async to be allowed to use await inside it. await pauses that function until the Promise settles, then gives you the value (or throws the error).
ts
// week03-nyt/src/db.ts
export const addProduct = async (product: Product) => {
const result = await productsCollection.insertOne(product); // wait for MongoDB
return result.insertedId;
};- An
asyncfunction always returns a Promise. Here the return type is inferred asPromise<ObjectId>, notObjectId(MongoDB's id type, covered in Week 3). - You can only use
awaitinside anasyncfunction, or at the top level of an ES module (a file in a"type": "module"project). Week 4'sindex.tsdoesawait db.init();at the top level before callingapp.listen. - Forgetting
awaitis a classic bug: you get a pending Promise instead of the data.
π₯ Real life: the restaurant buzzer
At a busy counter you order, and they hand you a buzzer, not the food. The buzzer is the Promise. await is you standing by until it buzzes, then picking up the tray. If you walk to your table with just the buzzer (forgot await), you try to eat a plastic pager. Meanwhile the kitchen keeps taking other orders, the way Node keeps serving other requests while one waits on MongoDB.
Extra context
Express 5 (which the course uses) automatically sends a rejected promise from an async route handler to your error handler (the special function that turns errors into responses, covered in Week 2). In Express 4 you had to wrap handlers in try/catch and call next(err) yourself.
Loops with await β
Once a function inside a loop becomes async (for example, because it now looks something up in the database), the loop has to wait for it. Only some loop styles can do that:
ts
// illustrative: checkItem is async and pushes to errors
async function validate(ids: string[]): Promise<string[]> {
const errors: string[] = [];
for (const id of ids) {
checkItem(id, errors); // β no await: returns errors before any check finishes
}
ids.forEach(async (id) => {
await checkItem(id, errors); // β forEach ignores the Promise the callback returns
});
for (const id of ids) {
await checkItem(id, errors); // β
one at a time, in order
}
await Promise.all(ids.map((id) => checkItem(id, errors))); // β
all at once, wait for every one
return errors;
}In the two broken versions, validate returns an empty errors array, and the errors are pushed later, after nobody is looking. Nothing crashes, so the bug is easy to miss. In tests it looks like "validation never rejects anything."
for...of+awaitruns the checks one after another. It's simple, and the order is guaranteed.Promise.all(array.map(β¦))starts all of them at once.mapproduces an array of Promises, andPromise.allturns it into one Promise that settles when they're all done. It's faster when the checks don't depend on each other.
π₯ Real life: the restaurant buzzer, again
A forEach with async is handing out a buzzer for each order and then leaving the restaurant before any of them goes off. for...of + await is waiting for each buzzer before ordering the next dish. Promise.all is ordering everything at once and waiting until the last buzzer goes off.
Part 2: TypeScript basics β
A type describes what kind of value something is, and therefore what you're allowed to do with it. A string has .toUpperCase(), and a number can be added. TypeScript's job is to check, before the code runs, that you only do things the types allow.
Type annotations and inference β
An annotation is : Type written after a variable, parameter, or function to state its type explicitly. It's straight from practice/src/02-basic.ts:
ts
let username: string = "jesiluv";
let age: number = 25;
let isAdmin: boolean = true;
let subscribe = (message: string): void => { // param type + return type
console.log(message);
};Inference means TypeScript works out the type from the value, so you often don't need the annotation. let age = 25 is already number. The TS in 5 Minutes reading stresses this: TypeScript knows JavaScript well enough to infer most types for you.
When to annotate explicitly:
- Function parameters. These are always needed; TS can't infer them.
- Function return types on exported/public functions. This documents intent and catches mistakes inside the function body. Week 4 does this everywhere (
Promise<Product[]>,Promise<void>). - Empty collections.
const catalog = []has no useful type, so writeconst catalog: Book[] = [](TODO 2 in the TS basics exercise). - Everywhere else, let inference do the work.
Primitive types β
Primitives are the basic, single-value types that everything else is built from:
| Type | Values | Notes |
|---|---|---|
string | "hello" | |
number | 25, 5.99 | one type for ints and floats |
boolean | true / false | |
null, undefined | themselves | with strict: true, not assignable to other types unless you say so |
void | (nothing) | return type of functions that don't return a value |
Every course tsconfig.json has "strict": true. It turns on two important checks: values that might be null/undefined must be handled before use, and a parameter with no type is an error instead of silently becoming any (the "anything goes" type, see any vs unknown). You want it on.
Arrays and tuples β
An array type says what every element of an array is. A tuple is a special array with a fixed length, where each position has its own type.
ts
let numbers: number[] = [1, 2, 3]; // array of numbers
let names: Array<string> = ["a", "b"]; // same thing, written with generics (angle brackets, see below)
let person: [string, number] = ["Piyush", 25]; // tuple: position 0 is a string, position 1 a numberArrays are "any number of the same kind of thing". A tuple is "exactly these types in this order". You'll mostly meet tuples as return values, like [error, result] pairs, and when destructuring.
Union and literal types β
A union type, written A | B, means "a value of type A or type B". A literal type is a type with exactly one allowed value, like "north" or 6. Put them together and you get a precise list of allowed values:
ts
// practice/src/07-union-inter.ts
type Status = "pending" | "approved" | "rejected";
let diceRoll: 1 | 2 | 3 | 4 | 5 | 6;
// hw1/src/types.ts: the same idea for real data
export type Category = "pizza" | "appetizer" | "side" | "beverage";Narrowing means using a runtime check to shrink a union down to one member. When a value could be one of several types, you narrow it before using type-specific features. TS follows the if and knows which branch has which type:
ts
// ts-basics/src/index.ts (TODO 5)
type MyFilterType = string | number;
function findBooksBy(catalog: Book[], filter: MyFilterType) {
if (typeof filter === "number") {
return catalog.filter((book) => book.year === filter); // filter: number here
}
return catalog.filter((book) => book.author === filter); // filter: string here
}The narrowing checks you'll see in the course are:
| Check | Asks |
|---|---|
typeof x === "string" | is it this primitive type? |
Array.isArray(x) | is it an array? |
x instanceof SyntaxError | was it created by this class? (HW1's error handler) |
"body" in err | does this object have this property? (HW1's error handler) |
x !== undefined | is it actually there? |
Enums β
An enum (short for enumeration) is a named, fixed set of related constants. Instead of scattering magic strings like "checked-out" around your code, where a typo like "checkedout" goes unnoticed, you list the allowed values once, give the group a name, and refer to each one through that name: BookStatus.STATUS_AVAILABLE. The compiler then rejects any value that isn't in the set, and your editor autocompletes the options.
Unlike most TypeScript features, an enum is not erased. It becomes a real object in the compiled JavaScript, so it exists at runtime (that's why Week 2 imports it as a value, not with import type).
The TS basics exercise (TODO 3) replaced three loose string constants with a string enum, where each member is given a string value:
ts
// ts-basics/src/index.ts
// Before: const STATUS_AVAILABLE = "available"; (Γ3, unrelated constants)
enum BookStatus {
STATUS_AVAILABLE = "available",
STATUS_CHECKED_OUT = "checked-out",
STATUS_LOST = "lost",
}
book.status = BookStatus.STATUS_CHECKED_OUT; // the value stored is "checked-out"Week 2 used a numeric enum instead. When you don't assign values, TypeScript numbers the members 0, 1, 2 in order:
ts
// week2-router/src/types.ts
export const enum BookStatus { STATUS_AVAILABLE, STATUS_CHECKED_OUT, STATUS_LOST }
// = 0 = 1 = 2(const enum is a variant whose members are copied into the code as plain values at compile time, instead of being looked up on an enum object.)
The difference shows up in API responses: res.json(book) sends "status": 0 for the numeric enum, and "status": "available" for the string one. A client reading the JSON has no idea what 0 means.
π₯ Real life: the dropdown menu
An enum is a dropdown on a form instead of a free-text box. With free text, people type "Checked out", "checked-out", and "chkd out", and your code has to cope with all of them. A dropdown only offers the valid choices. A string enum shows the choice's name in the JSON; a numeric enum sends "option #1" and leaves the reader to guess.
Since you now know literal unions, compare the alternative HW1 uses:
ts
type OrderStatus = "pending" | "in-progress" | "completed"; // a literal unionExtra context
Many TypeScript codebases prefer a union of string literals like this over an enum. It gives the same checking and autocomplete, the JSON is readable, and it's erased at compile time like other types. An enum is useful when you want a runtime object you can loop over, or to rename the underlying values in one place.
any vs unknown β
any and unknown are the two types for "a value we don't know the type of". Both can hold anything, but they behave in opposite ways:
any | unknown | |
|---|---|---|
| Can hold any value? | yes | yes |
Can you use it (.foo, call it, pass it on) without checking? | yes, and the compiler stops checking | no, you must narrow first |
| Safety | turns type checking off | forces you to check |
To use an unknown value you either narrow it (above), or use a type assertion: value as string tells the compiler "treat this as a string, I'm sure". An assertion performs no runtime check. If you're wrong, the code fails later, so narrowing is safer.
ts
// practice/src/09-assertion-guards.ts
let someValue: unknown = "subscribe to me";
someValue.length; // β error: 'someValue' is of type 'unknown'
let strLength: number = (someValue as string).length; // β
after an assertionHW1's validateOrder(body: any) is a real example of where the choice matters. With any, a typo like item.menuItem (instead of item.menuItemId) compiles fine and prints undefined is not on the menu. With unknown, the compiler would make you check each field's type before touching it. That's more typing, but it's exactly what a validator is for.
π₯ Real life: airport security
any is a security line where every bag gets waved through, so nothing is stopped, including the dangerous stuff. unknown is the line where every bag goes through the scanner before it can board. It's slower, but once a bag is cleared (narrowed), you know what's in it.
Rule: use unknown for data you haven't validated yet (request bodies, parsed JSON). Treat any as an escape hatch, as the practice notes say: "ANY, avoid when possible. Unknown (safer than any)."
Optional properties and ? β
? after a property name means the property may be missing. Its type becomes T | undefined:
ts
// practice/src/05-interface.ts
interface User {
name: string;
age: number;
email?: string; // may be absent
}
// hw1/src/types.ts: optional fields that only apply to some menu items
export interface MenuItem {
id: string;
name: string;
category: Category;
price?: number;
sizes?: SizeOption[];
}π₯ Real life: the pizza order form
On SliceDrop's order form, customer name and items are required boxes, and crust, sauce, and toppings are the "if applicable" boxes. A soda has no crust. That's exactly OrderItem: menuItemId and quantity are required, and size?, crust?, sauce?, and toppings? are optional.
Because an optional property might be undefined, TS makes you check before using it. That's why HW1's helper functions start with if (menuItem.sizes === undefined) return false;.
The same ? works on parameters: listOrders(status?: string) in HW1 can be called with or without an argument.
Related operators:
a?.bis optional chaining: it givesundefinedinstead of crashing ifais null/undefined.a!is the non-null assertion: "trust me, this isn't null/undefined". The TS basics demo ends withfindBookByTitle(catalog, "Dune")!.status. It compiles, but if you're wrong it crashes at runtime, so use it sparingly.
A stricter setting in practice/tsconfig.json
exactOptionalPropertyTypes: true separates "property missing" from "property present but set to undefined". With it on, { name: "A", age: 1, email: undefined } is an error for the User above. noUncheckedIndexedAccess: true makes arr[0] have type T | undefined, since the array might be empty.
Generics β
A generic is a type parameter: a placeholder filled in when the function or type is used. You've already been using generics: Array<string>, Promise<Product[]>, Partial<Product>.
ts
// ts-basics/src/index.ts (TODO 6)
function getFirstItem<Type>(items: Array<Type>): Type {
return items[0];
}
getFirstItem(catalog).title; // Type is inferred as Book, so .title autocompletes
getFirstItem([1, 2, 3]); // Type is inferred as numberπ₯ Real life: a labeled storage bin
A generic is a clear bin with a label slot. Array<Book> is a bin labeled "Books": you know that whatever you pull out is a book. getFirstItem is "hand me the top thing from this bin". It works on any bin, and the label tells you what you'll get back. any is a bin with the label torn off.
Without generics, you'd have to either write one function per type or use any and lose all type information. The generic keeps the link between what goes in and what comes out.
The TS in 5 Minutes reading shows generics on types too, e.g. interface Backpack<Type> { add: (obj: Type) => void; get: () => Type; }. You'll use the same pattern for API responses in Part 4.
Classes (briefly) β
A class is a blueprint for creating objects that share the same properties and methods: new Employee(101, "Jesi", "Engineering") builds one. Access modifiers control who can touch each member: public (anyone), private (only code inside the class), and protected (the class and classes that extend it).
practice/src/10-classes.ts covers all three, plus the parameter properties shorthand:
ts
class Employee {
constructor(
private id: number, // declares AND assigns this.id
public name: string,
protected department: string,
) {}
}The course's backend code so far uses plain objects and functions (e.g. export const productService = { getAll }) rather than classes, so this is useful background more than a daily tool.
Part 3: type vs interface β
This is the question that comes up constantly: both type and interface can describe the shape of an object, so which one should you use? The course code uses both. Week 3 wrote interface Product and Week 4 wrote type Product, so it's worth understanding exactly how they differ.
What they are β
- An
interfacedeclares a named object shape: which properties an object has, and their types. It can only ever describe objects. - A
typealias gives a name to any type at all: an object shape, but also a union, a primitive, a tuple, a function type, or a type computed from another type.
Neither one exists at runtime. Both are erased when TypeScript compiles to JavaScript.
The syntax β
ts
// interface: a declaration, no "="
// week03-nyt/src/db.ts
export interface Product {
name: string;
price: number;
quantity: number;
}
// type alias: gives a name to ANY type expression, uses "="
// week04-nyt/src/models/products.ts
export type Product = {
id: string;
modelName: string;
modelNumber: string;
manufacturer: string;
color: string;
price: number;
quantity: number;
};For a plain object shape like this, the two are interchangeable. Anything that accepts one accepts the other, because TypeScript is structurally typed: it compares shapes, not names. If an object has the right properties with the right types, it fits, whatever the type is called (this idea comes from the TS in 5 Minutes reading).
What both can do β
Two terms in the table: a readonly property can be set when the object is created but never changed afterward. implements is how a class promises to match a shape: class Laptop implements Product { β¦ } makes the compiler check that the class has every Product property.
| Feature | interface | type |
|---|---|---|
| Object shapes | β | β |
Optional (?) and readonly properties | β | β |
Methods (e.g. getDiscount(percent: number): number) | β | β |
Generics (Backpack<Type>) | β | β |
Used by a class with implements | β | β (if it's an object type) |
| Be extended / combined | β
with extends | β
with & |
Extending: extends vs intersections (&) β
Extending means building a new shape from an existing one: "an Order is everything in a NewOrder, plus an id, a status, and a timestamp". Interfaces do this with the extends keyword. Type aliases do it with an intersection (A & B), meaning "a value that is an A and a B at the same time", so it has all the properties of both.
A correction to my practice notes
practice/src/06-type-aliases.ts has the comment "interfaces can be extended, types cannot". That's not quite right. Types can be combined, just with a different mechanism: the intersection operator &. What types can't do is use the extends keyword in their own declaration, or merge (see below).
interface β¦ extends: HW1 uses this to model "what the client sends" vs. "what the server stores":
ts
// hw1/src/types.ts
/** What a customer sends to POST /orders. */
export interface NewOrder {
customerName: string;
items: OrderItem[];
}
/** What the server stores and returns. */
export interface Order extends NewOrder {
id: string;
status: OrderStatus;
createdAt: string;
}Intersection &: practice/src/07-union-inter.ts combines two interfaces into a type:
ts
interface Colorful { color: string; }
interface Circle { radius: number; }
type ColorfulCircle = Colorful & Circle; // must have BOTH color and radiusThe same Order could be written as a type:
ts
type Order = NewOrder & {
id: string;
status: OrderStatus;
createdAt: string;
};They're mostly equivalent, and they can be mixed: an interface can extend a type alias, and a type can & an interface. The key difference is conflicts. To read the example below, you need one more type: never, the type with no possible values. TypeScript produces it when you ask for something impossible, like a value that is a string and a number at the same time.
ts
interface A { id: string; }
interface B extends A { id: number; }
// β Error right here: Interface 'B' incorrectly extends interface 'A'.
type C = A & { id: number };
// β
No error here... but C['id'] is string & number, which is `never`.
// You'll get a confusing error later, when you try to create a C.π₯ Real life: renovation plans
interface β¦ extends is filing an addition with the building inspector: if the new plan contradicts the original (a wall that's both load-bearing and removed), you're told before construction. & is stapling two blueprints together and handing them to the contractor, so the contradiction only surfaces when someone tries to build it.
extends checks compatibility at the declaration and gives a clear error. & silently produces an impossible type. For inheritance-style hierarchies ("an Order is a NewOrder plus more"), extends gives better errors.
Extra context
The TypeScript team also notes that interfaces with extends are slightly faster for the compiler to check than large intersections, because the relationships between interfaces are cached. You won't notice this in a course project, but it's one reason the handbook leans toward interface.
Declaration merging (interfaces only) β
Declaration merging means that if you declare the same interface name twice, TypeScript doesn't complain. It combines both declarations into one interface with all the properties:
ts
interface User { name: string; }
interface User { email: string; }
// User is now { name: string; email: string }
type Point = { x: number };
type Point = { y: number }; // β Error: Duplicate identifier 'Point'.In your own code, merging is usually an accident, which is a small argument for type. But it's essential for augmenting library types, which is where you'll meet it on the backend:
Extra context: adding a property to Express's Request
In Week 2 and HW1, auth middleware (a function that runs before your route handler, see Week 2) checks a token and then calls next(). A common next step is for the middleware to attach the logged-in user to the request, so later handlers can read req.user. Express's Request type doesn't have a user property, but because Express declares Request as an interface, you can merge one in:
ts
// src/types/express.d.ts
declare global {
namespace Express {
interface Request {
user?: { id: string; role: 'customer' | 'staff' };
}
}
}
export {};A .d.ts file holds only type declarations, and declare global { namespace Express { β¦ } } says "add to the Express types that the library already defined". This only works because Request is an interface. A type alias can't be reopened.
What only type can do β
These are the cases where you have to use type, because an interface can only describe an object shape.
1. Unions (including literal unions). This is the most common one in the course:
ts
// hw1/src/types.ts
export type Category = "pizza" | "appetizer" | "side" | "beverage";
export type OrderStatus = "pending" | "in-progress" | "completed";
// week2-router/src/types.ts
export type BookFilter = string | number;2. Aliases for primitives, tuples, and functions:
ts
type ProductId = string;
type Coordinates = [number, number]; // tuple
type Validator = (body: unknown) => string[]; // function type3. Mapped and utility types. A mapped type builds a new object type by going through every property of an existing one and transforming it (for example, making each property optional). TypeScript ships ready-made generic mapped types called utility types. The three you'll use most:
| Utility type | Produces |
|---|---|
Partial<T> | T with every property optional |
Omit<T, 'a' | 'b'> | T without the listed properties |
Pick<T, 'a' | 'b'> | T with only the listed properties |
The Week 3 code has a comment pointing right at this:
ts
// week03-nyt/src/db.ts
export interface ProductUpdate {
name?: string;
price?: number;
quantity?: number;
}
// same as export type ProductUpdate = Partial<Product>;Partial<Product> goes through every key of Product and makes it optional. You can only give that result a name with type. The one-liner is also better than the hand-written interface: if someone adds a field to Product, ProductUpdate picks it up automatically.
One more operator appears below: keyof T gives a union of T's property names, so keyof { a: 1; b: 2 } is "a" | "b".
ts
// How Partial works under the hood: for each key K of T, make it optional (?)
// and keep its original type (T[K])
type MyPartial<T> = { [K in keyof T]?: T[K] };
// Useful combinations for Week 4's Product (which has an id):
type NewProduct = Omit<Product, 'id'>; // POST body: everything but id
type ProductUpdate = Partial<Omit<Product, 'id'>>; // PATCH body: any subset, never id
type ProductField = keyof Product; // 'id' | 'modelName' | 'price' | β¦4. Types computed from values or other types. Besides keyof, there's typeof someValue (in a type position: "the type of this variable") and conditional types (T extends string ? A : B, an if/else that picks a type). These are more advanced, but they all require type.
Comparison table β
interface | type | |
|---|---|---|
| Object shapes | β | β |
Unions ("a" | "b", string | number) | β | β |
Primitive aliases (type Id = string) | β | β |
Tuples ([number, number]) | β (awkwardly) | β |
| Function types | β (call signature) | β (cleaner) |
Mapped / utility types (Partial<T>, Omit<T, K>) | β | β |
| Extend another shape | extends (checks conflicts) | & (conflicts become never) |
| Declaration merging | β | β (duplicate identifier error) |
Augment a library's types (e.g. Express Request) | β | β |
implements in a class | β | β (object types only) |
| Error messages | shows the interface name | may expand the whole type |
| Official handbook recommendation | "prefer interface" | "when you need specific features" |
Decision guide β
Ask these in order and stop at the first "yes":
- Is it a union, a literal set, a tuple, a primitive alias, or a function type? β use
type. (OrderStatus,Category,BookFilter) - Is it derived from another type (
Partial,Omit,Pick,keyof, a mapped type)? β usetype. (ProductUpdate = Partial<Product>) - Do you need to add to a type you don't own, like a library's
Request? β useinterface(declaration merging). - Is it an object shape that other shapes build on (
NewOrderβOrder)? βinterface+extendsgives clearer errors. - Is it a plain object shape (a request body, a model, a response)? β Either works. The TS in 5 Minutes reading says to prefer
interface. Whichever you pick, be consistent within a project.
How the course code uses them β
| File | Declaration | Why it fits |
|---|---|---|
ts-basics/src/index.ts | interface Book | plain object shape |
ts-basics/src/index.ts | type MyFilterType = string | number | union, so it has to be type |
practice/src/07-union-inter.ts | type ColorfulCircle = Colorful & Circle | combining shapes with & |
week2-router/src/types.ts | interface Book, type BookFilter | shape vs union |
hw1/src/types.ts | interface Order extends NewOrder | hierarchy: stored order = new order + server fields |
hw1/src/types.ts | type Category, type OrderStatus | literal unions |
week03-nyt/src/db.ts | interface Product, interface ProductUpdate | shapes (with a hint that Partial<Product> would do) |
week04-nyt/src/models/products.ts | type Product = { β¦ } | plain shape; type is an equally valid choice |
Part 4: Backend-flavored examples β
These pull the pieces together in the shapes you'll write every week. They use a few backend terms that the week pages teach properly. If you haven't reached those weeks yet, here's what they mean:
| Term | Meaning | Taught in |
|---|---|---|
| request / response | what the client sends to the server, and what the server sends back | Week 1 |
| request body | the data a client sends inside a request (usually JSON), available as req.body | Week 1 |
| route handler | the function Express runs for a particular method + path, e.g. GET /products | Week 1 |
| middleware | a function that runs before the route handler (to log, check a token, parse JSON, β¦) and then passes control on with next() | Week 2 |
| status code | the number that says how a request went: 200 OK, 201 Created, 400 Bad Request, 404 Not Found, β¦ | Week 2 |
| document | one record stored in MongoDB, a JSON-like object with an automatic _id of type ObjectId | Week 3 |
A request body β
Types don't exist at runtime. Writing const order: NewOrder = req.body doesn't check anything. It just tells the compiler to trust that it's a NewOrder. In Express's type definitions, req.body is typed as any by default, so this assignment always compiles, whatever the client actually sent.
π₯ Real life: the packing slip
A delivery box has a packing slip saying "12 mugs". That's your type. The slip doesn't make the mugs appear. At the receiving dock someone still opens the box and counts (validation) before the stock goes on the shelf. A type annotation on req.body is only the slip.
That's why HW1 separates the type (what a valid body looks like) from the validator (the runtime code that checks it):
ts
// hw1/src/types.ts: the shape
export interface NewOrder {
customerName: string;
items: OrderItem[];
}
// hw1/src/routers/orders.ts: check first, then trust
ordersRouter.post("/", requireCustomerToken, (req: Request, res: Response) => {
const problems = validateOrder(req.body); // runtime check
if (problems.length > 0) {
res.status(400).json({ errors: problems });
} else {
const order = addOrder(req.body); // only now treated as a NewOrder
res.status(201).json(order);
}
});Extra context: typing req.body directly
Express's Request type is generic: Request<Params, ResBody, ReqBody, Query>. Its four type parameters describe the route params, the response body, the request body, and the query string. You can declare the expected body so req.body is no longer any:
ts
import type { Request, Response } from 'express';
type ProductParams = { id: string };
productRouter.patch(
'/:id',
async (req: Request<ProductParams, unknown, ProductUpdate>, res: Response) => {
req.params.id; // string
req.body.price; // number | undefined
},
);This still doesn't validate anything. It's a promise you make to the compiler, so pair it with a runtime check like validateOrder.
An API response β
HW1's responses follow a consistent pattern: data on success, and { error: "β¦" } or { errors: [...] } on failure. You can describe that with a union type (a job for type).
A useful pattern here is the discriminated union: a union of object types that all share one property (the discriminant, here ok) whose literal value tells you which member you have. Checking that one property narrows the whole object:
ts
type ApiError = { error: string };
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
// A route handler's response, e.g. GET /orders
type ListOrdersResponse = Order[] | ApiError;Checking if (result.ok) narrows the type, so TS knows data exists in the true branch and error exists in the false branch.
A database model β
A model is the type that describes one kind of stored record: what a product is in your app. Weeks 3 and 4 show two ways to model a MongoDB document:
ts
// Week 3: the shape you INSERT (Mongo adds _id itself)
export interface Product {
name: string;
price: number;
quantity: number;
}
export const addProduct = async (product: Product) => {
const result = await productsCollection.insertOne(product);
return result.insertedId;
};ts
// Week 4: the shape the REST OF THE APP sees, plus a converter
export type Product = {
id: string; // a string, not Mongo's ObjectId
modelName: string;
/* β¦ */
price: number;
quantity: number;
};
export const productFromDocument = (productDocument: Document): Product => ({
id: productDocument._id.toString(),
modelName: productDocument.modelName,
/* β¦ */
});π₯ Real life: the warehouse SKU
A warehouse tracks stock by an internal bin code like A7-04-221. The online store shows customers a clean product page, not the bin code. productFromDocument is the person who translates between the two, so shoppers (and your controllers) never deal with warehouse internals like ObjectId.
Week 4's approach keeps MongoDB details (Document, ObjectId, _id) inside the model/db layer. Services, controllers, and routes only ever see a clean Product. The code comment says it directly: "no layer above this one ever has to know what a Document or an ObjectId is."
A full set of model types for one resource often looks like this:
ts
export type Product = { id: string; modelName: string; price: number; quantity: number };
export type NewProduct = Omit<Product, 'id'>; // POST body
export type ProductUpdate = Partial<NewProduct>; // PATCH bodyRoute handler signatures β
A function's signature is its parameter types plus its return type: the "shape" of the function. Every Express function in the course has one of three signatures. Request, Response, and NextFunction are types that Express provides for the req object, the res object, and the next callback. Here they are from the code:
ts
import type { Request, Response, NextFunction } from 'express';
// 1. Route handler: receives the request, sends a response
// week04-nyt/src/controllers/product-controllers.ts
const getProducts = async (req: Request, res: Response): Promise<void> => {
const allProducts = await productService.getAll();
res.json(allProducts);
};
// 2. Middleware: does something, then calls next() or ends the response
// week2-router/src/books-router.ts
const checkAuth = (req: Request, res: Response, next: NextFunction): void => {
const { authorization } = req.headers;
if (authorization && secretKey === authorization.split(' ')[1]) {
next();
} else {
res.sendStatus(403);
}
};
// 3. Error handler: FOUR parameters, error first
// week04-nyt/src/middleware/error-handler.ts
export const errorHandler = (
err: Error, req: Request, res: Response, next: NextFunction
): void => {
res.status(500).send(`ERROR: ${err.message} encountered.`);
};Some things to notice:
- The return type is
void(orPromise<void>if it'sasync). The handler's job is to callres.json/res.send, not toreturndata. - Express recognizes an error handler only by its four parameters, so
nexthas to stay in the list even if you never call it (both HW1 and Week 4 point this out in comments). req.paramsvalues are typedstring | string[]in Express 5's type definitions. That's why Week 3 writesgetProduct(String(req.params.id))and HW1 checkstypeof id === "string"before using it.
Extra context
Express exports a RequestHandler type, so you can write const getProducts: RequestHandler = async (req, res) => { β¦ } and have req/res inferred instead of annotating each parameter.
Code walkthrough β
TS basics exercise: converting JS to TS β
Full source: src/index.ts (converted) Β· src/index.js (original JS) Β· README.md
The exercise starts from a working JavaScript book catalog and adds types one TODO at a time. npm run typecheck (tsc --noEmit) lists every place a type is missing, and you work through them until it's clean.
ts
// TODO 1: Interface. Describe the shape of a book.
interface Book {
title: string;
author: string;
year: number;
status: unknown; // π see note below
}
// TODO 2: Explicit type. An empty array needs one.
const catalog: Book[] = [];
// TODO 3: Enum. Replaces three loose string constants.
enum BookStatus {
STATUS_AVAILABLE = "available",
STATUS_CHECKED_OUT = "checked-out",
STATUS_LOST = "lost",
}
// TODO 4: Function signatures. `Book | undefined` because find() may miss.
function findBookByTitle(catalog: Book[], title: string): Book | undefined {
return catalog.find((book) => book.title === title);
}
// TODO 5: Type alias. A union has to be a `type`.
type MyFilterType = string | number;
// TODO 6: Generics. The return type follows the input type.
function getFirstItem<Type>(items: Array<Type>): Type {
return items[0];
}Why each piece matters:
Book | undefinedonfindBookByTitleforces every caller to handle "not found". Look atcheckoutBook, which starts withif (!book || β¦) return false;. That's the same "404 if missing" pattern the Week 3 router uses.status: unknownworks, but it throws away information: TS won't stop you from settingstatus = 42. Now thatBookStatusexists,status: BookStatusis the precise type, and comparisons likebook.status !== BookStatus.STATUS_AVAILABLEbecome fully checked.getFirstItemis honest only if the array is non-empty. WithnoUncheckedIndexedAccess(turned on inpractice/tsconfig.json),items[0]isType | undefined, and the function would need to returnType | undefined.
practice/: the first TS session β
Full source: all practice files
| File | What it shows |
|---|---|
02-basic.ts | primitive annotations, arrays, tuple [string, number], a numeric enum, void |
05-interface.ts | an interface with an optional field (email?) and a method (getDiscount) |
06-type-aliases.ts | type Point = {β¦}, and interface Dog extends Animal |
07-union-inter.ts | literal unions (Status, diceRoll) and an intersection (Colorful & Circle) |
09-assertion-guards.ts | unknown + as assertion, typeof type guard |
10-classes.ts | access modifiers, getters/setters, parameter properties |
tsconfig.json | strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, verbatimModuleSyntax |
A method inside an interface, and this inside an object literal:
ts
interface Product {
name: string;
price: number;
getDiscount(percent: number): number; // a method signature: params + return type
}
let laptop: Product = {
name: "macPro",
price: 2000, // β comma (the original file has a ';' here, a syntax error)
getDiscount(percent: number): number {
return this.price * (percent / 100); // method syntax so `this` is the object
},
};Readings summary β
TypeScript for JavaScript Programmers ("TS in 5 Minutes") β
A quick tour for people who already know JavaScript. Its main points: TypeScript infers most types from your code, so you don't annotate everything. When you do need to describe a shape, you declare an interface (or a type), and the handbook explicitly says to prefer interface and use type when you need its specific features. It then shows how to compose types with unions (including literal unions like "open" | "closed") and generics, and explains that TypeScript is structurally typed: two objects with the same shape are compatible, whatever they're named.
Connection to the course: this page is essentially the reading applied to backend code. Structural typing is why Week 3's interface Product and Week 4's type Product behave the same way, and the "prefer interface" advice is step 5 of the decision guide.
The TypeScript Handbook: introduction β
The intro page explains what the handbook is for. TypeScript is a static type checker that finds errors before code runs, which is most valuable as a codebase grows. The handbook is meant to be readable in a few hours, and it deliberately doesn't teach core JavaScript, tool setup, or every edge case. It also points readers to different starting points depending on background (new programmers, JS developers, Java/C# developers, functional programmers).
Connection to the course: it's the reference to reach for when a later week introduces a feature this page only touches on, like utility types, narrowing, or modules.
Gotchas & common mistakes β
Types disappear at runtime
const body: NewOrder = req.body checks nothing. req.body is any, so the compiler takes your word for it. Always validate incoming data at runtime (as HW1's validateOrder does) before trusting its type.
- Commas vs semicolons. Interfaces and type literals can separate members with
;or,, but object literals only accept,.price: 2000;inside an object (as in05-interface.ts) is a syntax error. - Missing
.jsin imports (ESM projects). With"type": "module"+NodeNext,import { app } from './app'fails. It has to be'./app.js'. - Forgetting
import type. WithverbatimModuleSyntax(Weeks 3β4), importing a type withouttypeis a compile error. - Numeric enums in JSON.
res.json(book)sends"status": 0. Use string enums or literal unions for anything that goes over the wire. anyspreads. A value typedanymakes everything derived from itanytoo, so typos in property names (like HW1'sitem.menuItem) go uncaught. Preferunknownand narrow it.&conflicts silently. Intersecting{ id: string }with{ id: number }givesid: never, with no error until you try to use it.interface extendsreports the conflict immediately.- Assuming a type alias can't be extended. It can, with
&. What it can't do is merge. - Overusing
!. The non-null assertion hides theundefinedcase from the compiler but not from the runtime. - Forgetting
await.const products = getAllProducts();gives you aPromise, not an array. The type (Promise<β¦>) is your hint. - Mismatched
@types/node. Week 3'sSETUP.mdwarns that plainnpm install @types/nodefetches a newer version than your Node runtime. Pin it with@types/node@^22.
Practice problems β
Hands-on exercises. Try each one in a scratch .ts file (in any course project, npx tsx scratch.ts runs it and npx tsc --noEmit type-checks it) before opening the solution.
Problem 1: Type the Week 1 /tracks endpoint β
In Week 1, POST /tracks receives { artist, title, album, year } and responds with { status: "success", trackAdded: {β¦}, timestamp }. Write TypeScript types for the request body and the response. The response's status should only ever be the string "success".
Hint
The response contains the request body, so reuse it instead of repeating the fields. A literal type ("success") restricts a string to one exact value.
Solution
ts
interface NewTrack {
artist: string;
title: string;
album: string;
year: string; // the lecture example sends "1985" as a string
}
interface TrackAddedResponse {
status: "success"; // literal type: no other string allowed
trackAdded: NewTrack; // reuse, don't repeat
timestamp: string; // new Date().toISOString()
}Either interface or type works here: they're plain object shapes (step 5 of the decision guide).
Problem 2: Replace the numeric enum β
Week 2's BookStatus is a numeric const enum, so res.json(book) sends "status": 0. Rewrite it as a string literal union and update the Book interface and the line in books-router.ts that sets the default status.
Solution
ts
// types.ts
export type BookStatus = "available" | "checked-out" | "lost";
export interface Book {
id: number;
title: string;
author: string;
year: number;
status: BookStatus;
}
// books-router.ts
const status: BookStatus = "available"; // JSON now shows "status": "available"BookStatus has to be a type, because it's a union. The import can become type-only too (import type { Book, BookStatus }), because a type alias doesn't exist at runtime, unlike an enum. Under verbatimModuleSyntax (Weeks 3β4) it has to be.
Problem 3: A validator that takes unknown β
Write validateTrack(body: unknown): string[] for Problem 1's NewTrack. Follow the HW1 pattern: return every problem found, and an empty array if the body is valid. The Week 1 notes want messages like artist must be a valid, non-empty string.
Hint
First narrow body to "an object that isn't null" with typeof body !== "object" || body === null. After that, TS still won't let you read body.artist. One way through is to treat it as a Record<string, unknown>, which says it's an object with unknown values, and then check each value's typeof.
Solution
ts
function validateTrack(body: unknown): string[] {
if (typeof body !== "object" || body === null) {
return ["body must be a JSON object"];
}
const fields = body as Record<string, unknown>; // still unknown VALUES
const problems: string[] = [];
for (const key of ["artist", "title", "album", "year"]) {
const value = fields[key];
if (typeof value !== "string" || value.trim() === "") {
problems.push(`${key} must be a valid, non-empty string`);
}
}
return problems;
}Compare this with HW1's validateOrder(body: any). Here the compiler would reject fields.artist.trim() without the typeof check, so a typo like fields.artst can't silently slip through either: it's just unknown and fails the check.
Problem 4: Derive the Week 4 body types β
Week 4's Product has id, modelName, modelNumber, manufacturer, color, price, quantity. Without retyping any field names, create:
NewProduct: thePOST /productsbody, which is everything exceptidProductUpdate: thePATCH /products/:idbody, which is any subset ofNewProduct, neveridProductField: a union of all ofProduct's key names
Solution
ts
import type { Product } from './models/products.js';
export type NewProduct = Omit<Product, 'id'>;
export type ProductUpdate = Partial<NewProduct>;
export type ProductField = keyof Product;
// 'id' | 'modelName' | 'modelNumber' | 'manufacturer' | 'color' | 'price' | 'quantity'All three must be type aliases: each one is computed from Product, and an interface can only be a literal shape. If you add a weight field to Product later, all three update automatically.
Problem 5: A generic findById β
Write one function findById that works for both HW1's Order[] and MenuItem[] (both have id: string), returns the right type (Order or MenuItem), and returns undefined if nothing matches.
Hint
A plain <T> doesn't know T has an id. You can constrain a type parameter with extends: <T extends { id: string }> means "any type, as long as it has a string id".
Solution
ts
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
return items.find((item) => item.id === id);
}
const order = findById(orders, "3"); // Order | undefined
const item = findById(menu, "hawaiian"); // MenuItem | undefined
findById([1, 2, 3], "1"); // β number has no `id`Extra context
Generic constraints (T extends β¦) aren't in the course code yet, but they're the natural next step after getFirstItem<Type>: they let a generic function use properties of T safely.
Problem 6: Spot the bug β
This Week 3-style handler always responds with {} and never returns a 404. Why? And what type would TypeScript show for product?
ts
productRouter.get('/:id', async (req: Request, res: Response) => {
const product = getProduct(String(req.params.id));
if (!product) {
res.sendStatus(404);
return;
}
res.json(product);
});Solution
await is missing. product is a Promise (TS shows Promise<WithId<Document> | null>), and a Promise object is always truthy, so !product is never true and the 404 branch never runs. res.json(promise) then serializes the Promise object, which has no own properties, so you get {}.
The fix is const product = await getProduct(String(req.params.id));.
Note that tsc doesn't report an error here, because !somePromise is legal code. The type check can't catch this one for you. Hovering over product to see Promise<β¦> is the fastest way to spot it, and a Supertest test for the 404 case would catch it automatically.
Review questions β
1. In Week 3, ProductUpdate is written as an interface with every field optional. Rewrite it as a one-line type, and explain why you must use type for that version.
Answer
export type ProductUpdate = Partial<Product>;
Partial<Product> is a mapped type: it computes a new type from Product. Only a type alias can give a name to a computed type expression, because an interface can only be written out as a literal object shape. It's also safer: if Product gets a new field, ProductUpdate picks it up automatically.
2. HW1 declares interface Order extends NewOrder. Write the equivalent using type, and describe one way the two behave differently.
Answer
ts
type Order = NewOrder & { id: string; status: OrderStatus; createdAt: string };The difference shows up with conflicts. If Order redeclared a property with an incompatible type (say customerName: number), extends gives an error at the declaration. With &, the property silently becomes string & number, which is never, and you only find out later when you can't create a valid Order.
3. HW1's validateOrder(body: any) compiles even though it reads item.menuItem (a property that doesn't exist). Why? What would change if the parameter were unknown?
Answer
any switches type checking off for that value and for everything derived from it, so item is any too and any property name is accepted. With unknown, TS refuses property access until you narrow the value (e.g. typeof body === "object" && body !== null && "items" in body, then Array.isArray(body.items)), so a validator written against unknown has to actually check each field before using it. That's exactly what a validator should do.
4. Which of these must be a type, which should probably be an interface, and which could be either? (a) "pending" | "in-progress" | "completed" (b) adding user to Express's Request (c) the shape of a Book (d) [number, number]
Answer
- (a) Must be
type: it's a union of literals. - (b) Must be
interface: only interfaces support declaration merging, which is how you add to a library's type. - (c) Either: it's a plain object shape. The handbook says to prefer
interface. Be consistent within the project. - (d) Must be
type: it's a tuple.
5. Why does Week 3's router call getProduct(String(req.params.id)) instead of just passing req.params.id? And why does Week 4's model convert _id to a string id?
Answer
In Express 5's type definitions, route params are typed string | string[], but getProduct expects a string. String(...) makes the value definitely a string, so it type-checks (HW1 does the same thing with a typeof id === "string" check).
Week 4's productFromDocument turns Mongo's ObjectId _id into a plain string id so that no layer above the model needs to know about MongoDB types. A string also serializes cleanly to JSON for API responses.