Week 1: Node.js & TypeScript Fundamentals

Every later week in this course assumes you're comfortable with how Node actually executes your code and with a TypeScript project you can compile and run without fighting the tooling. This week builds both from scratch — the event loop and non-blocking I/O model that makes Node's concurrency story different from a traditional threaded server, how modules and npm hang together, and a working TypeScript setup you'll reuse for the rest of the course.

Module 1 of 22 Week 1 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain how the event loop lets Node handle concurrent I/O on a single thread
  • Structure a Node project with npm/pnpm, semantic versioning and a sane folder layout
  • Configure and run a TypeScript project targeting Node, end to end

1. The Event Loop & Non-Blocking I/O

Node.js runs your JavaScript on a single thread, which sounds like a limitation until you understand what it doesn't block on. When your code calls something I/O-bound — reading a file, querying a database, making an HTTP request — Node hands that work off to the underlying system (via libuv's thread pool or the OS's async I/O) and keeps executing other code. When the I/O finishes, its callback is queued to run on the main thread. That queue-and-resume mechanism is the event loop.

event-loop-demo.js
console.log("1: start");

setTimeout(() => console.log("2: timeout"), 0);

fs.readFile(__filename, () => console.log("3: file read done"));

console.log("4: end");

// Output order: 1, 4, 3 (or 2), 2 (or 3)
// "1" and "4" run synchronously, back to back -- the event loop
// never gets a chance to run anything else until the synchronous
// code finishes. "2" and "3" are both async callbacks whose exact
// relative order depends on I/O timing, but BOTH always run after
// every synchronous line above them, never interleaved with it.

The practical consequence: any CPU-bound work you do synchronously — a huge JSON parse, a tight loop, a heavy computation — blocks that single thread completely, and every other request your server is handling stalls until it finishes. This is the single most important mental model in this course: Node's concurrency model is excellent for I/O-bound work (which is most of what a typical REST API does — waiting on a database, waiting on another service) and actively bad for CPU-bound work done on the main thread.

Why this matters for Express

Every route handler you write in Weeks 2 onward runs on this same single thread. A route that does JSON.parse() on a huge payload synchronously, or runs a heavy synchronous loop, blocks every other concurrent request your server is handling — not just the one that triggered it. Keeping route handlers non-blocking is a running theme for the rest of this course, not just a Week 1 detail.

2. Modules, npm/pnpm & Project Structure

Modern Node projects use ES modules (import/ export) rather than the older CommonJS require/ module.exports — this course uses ES modules throughout, since that's also what TypeScript and modern tooling default to:

math.ts
export function add(a: number, b: number): number {
  return a + b;
}

export const PI = 3.14159;
main.ts
import { add, PI } from "./math.js"; // note the .js extension, even in a .ts file

console.log(add(2, 3), PI);

package.json is every Node project's manifest — dependencies, scripts, and metadata npm uses to install and run your project:

package.json
{
  "name": "week-01-fundamentals",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/main.js",
    "dev": "tsx watch src/main.ts"
  },
  "dependencies": {},
  "devDependencies": {
    "typescript": "^5.5.0",
    "tsx": "^4.16.0",
    "@types/node": "^20.14.0"
  }
}

"type": "module" tells Node to treat .js files as ES modules by default, which matters once TypeScript compiles down to plain JavaScript. Dependency versions follow semantic versioning (MAJOR.MINOR.PATCH): a caret like ^5.5.0 allows npm to install any 5.x.x release but never 6.0.0, since a major version bump signals a breaking change by convention.

a sane project layout for this course
my-api/
├── src/
│   ├── main.ts          # entry point
│   ├── routes/           # Express routers (Week 2+)
│   └── lib/              # shared utilities
├── dist/                 # compiled output (gitignored)
├── package.json
├── tsconfig.json
└── .gitignore
npm vs. pnpm

Both work fine for this course; pnpm is worth knowing about because it stores dependencies once on disk and links them into each project (rather than copying them into every project's node_modules), which matters once you're running many Node projects side by side. Commands are nearly identical: npm install vs. pnpm install.

3. TypeScript Setup for Node

TypeScript adds a compile-time type system on top of JavaScript — it catches an entire class of bugs (calling a function with the wrong argument type, accessing a property that doesn't exist) before your code ever runs, at the cost of a compile step. tsconfig.json is where you tell the compiler how to do that compilation:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}

"strict": true is the single most important setting in this file — it enables the full set of strict type-checking rules (no implicit any, strict null checks, and more), and every lesson in this course assumes it's on. Turning it off makes TypeScript significantly less useful; you'd be paying the compile-step cost without most of the safety benefit. "module": "NodeNext" paired with "moduleResolution": "NodeNext" tells TypeScript to compile using the same module resolution rules Node itself uses at runtime, which is why the import in Section 2's main.ts needed an explicit .js extension even though the source file is .ts — the compiler emits that path unchanged into the compiled output, where the file really is .js.

terminal
npm install
npm run build   # runs tsc, compiles src/ -> dist/
npm start       # runs the compiled output with node

# or, for local development without a manual compile step:
npm run dev     # tsx watch src/main.ts -- recompiles and restarts on save

tsx compiles and runs TypeScript directly in development without a separate build step — you'll use npm run dev constantly through this course, and reserve the full tsc build for what actually ships (Week 14's Docker image runs the compiled dist/ output, never tsx itself).

4. Writing & Running Your First Script

Put the event loop and TypeScript setup together in one small script that demonstrates non-blocking I/O concretely:

src/main.ts
import { readFile } from "node:fs/promises";

async function main(): Promise<void> {
  console.log("Starting two reads at the same time...");

  const start = Date.now();

  // Both reads are kicked off before either has to finish --
  // this is non-blocking I/O in practice, not just in theory.
  const [pkg, tsconfig] = await Promise.all([
    readFile("package.json", "utf-8"),
    readFile("tsconfig.json", "utf-8"),
  ]);

  console.log(`Both files read in ${Date.now() - start}ms`);
  console.log(`package.json is ${pkg.length} bytes`);
  console.log(`tsconfig.json is ${tsconfig.length} bytes`);
}

main().catch((err: unknown) => {
  console.error("Failed:", err);
  process.exitCode = 1;
});

Promise.all starts both reads concurrently rather than sequentially — the total time is roughly the time of the slower read, not the sum of both, because neither read blocks the thread while waiting on disk I/O. main().catch(...) at the top level is a pattern you'll use constantly: an unhandled rejection in an async function doesn't crash Node the way a thrown synchronous error does, so catching it explicitly and setting process.exitCode is what makes a script fail loudly and with the correct exit code, instead of silently.

5. Hands-on Exercise

Hands-on

Set up a TypeScript project and prove non-blocking I/O to yourself

Build the project skeleton this whole course will run on, and confirm the event loop model with your own timing measurements.

Requirements:

  1. Create a new project with the folder layout from Section 2, initialize it with npm init -y, and install typescript, tsx and @types/node as dev dependencies.
  2. Add the tsconfig.json from Section 3 with strict: true, and confirm npm run build && npm start works end to end.
  3. Write a script that reads 5 files concurrently with Promise.all and logs the total elapsed time, then rewrite it to read the same 5 files sequentially with a for...of loop and await — log both times side by side.
  4. Write a third version that does a deliberately expensive synchronous loop (e.g. counting to 500 million) between two console.log timestamps, and explain in a comment why this blocks differently than the file reads did.
  5. Set up an npm script called dev using tsx watch and confirm editing and saving the file triggers an automatic rerun.
Hint

Use console.time("label") and console.timeEnd("label") instead of manually computing timestamps — it's the standard, less error-prone way to measure elapsed time in Node, and you'll see the concurrent version finish in roughly the time of the single slowest read rather than the sum of all five.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why can Node handle thousands of concurrent connections on a single thread, when a heavy synchronous loop still blocks everything?

I/O operations are handed off to the OS or a background thread pool while the main thread moves on to other work, resuming each request via a callback only once its I/O actually completes — this is what lets many concurrent I/O-bound requests share one thread efficiently. A synchronous CPU-bound loop, by contrast, runs entirely on that same main thread with nothing to hand off, so it occupies the only thread available and every other request has to wait for it to finish.

Q2

Why does "strict": true in tsconfig.json matter so much that every lesson in this course assumes it's on?

Without strict mode, TypeScript allows implicit any types and doesn't enforce null/undefined checks, which silently disables most of the compile-time safety the type system exists to provide — you'd pay the cost of a build step without getting the corresponding benefit. With it on, the compiler catches an entire class of bugs (calling a method on a possibly-undefined value, passing the wrong shape of object) before the code ever runs.

Q3

Why does the import in a TypeScript file need a .js extension (./math.js) even though the source file is math.ts?

With "module": "NodeNext", TypeScript emits import paths into the compiled output unchanged, and at runtime Node resolves modules against the compiled dist/ directory where the file genuinely is .js, not .ts. Writing the extension the compiled output will actually need, rather than the source extension, is what keeps the emitted JavaScript's imports resolvable by Node.

Q4

Why attach .catch(...) to a top-level async function call instead of leaving a rejected promise unhandled?

An unhandled promise rejection doesn't stop a Node script the way an uncaught synchronous throw does — it can log a warning and let the process keep running (or exit with a generic, unhelpful code, depending on the Node version), which makes real failures easy to miss silently. Explicitly catching it and setting process.exitCode guarantees the script fails loudly, with a clear error message and a correct non-zero exit code a CI pipeline can actually detect.