Free Handbook · Runs in your browser

TypeScript + Tools

Eight mini-labs on TypeScript as it is actually used at work: Node.js, npm, Express, React, PostgreSQL, Docker, Jest, and Git — each with the real typed code and why it is written that way.

0 / 105 lessons🔥 0 day streak
ShareXLinkedIn

Module 12 · what you'll be able to do

  • Type a Node.js CLI entry point and a callback-based fs call correctly
  • Ship or consume a package's own .d.ts through package.json's types field
  • Type an Express request, response and route handler end to end
  • Type React props, useState and a form event handler with no any
  • Type a PostgreSQL query result row and a generic Repository
  • Run tsc inside a multi-stage Dockerfile so a broken build never ships
  • Type a Jest test file, including a typed mock
  • Write a TypeScript .gitignore and know what a published package commits that an app does not
01

TypeScript + Node.js

Node itself ships no types — @types/node (Module 09) supplies them, and without it process, Buffer and the rest of the standard library are compile errors even though the code runs fine. process.argv is always string[]: command-line arguments have no other type, whatever they look like.

TypeScript + Node.js

Typing process.argv and a callback-based fs read

Two Node habits worth typing correctly from day one: destructuring argv with a typed fallback, and a Node-style error-first callback, where the error parameter is always NodeJS.ErrnoException | null — never a bare Error, because null is how Node signals success.

typescript
// npm i -D @types/node
import { readFile } from "node:fs"

// argv is ALWAYS string[] — argv[0] is the node binary, argv[1] the script
const [, , command, ...rest] = process.argv
const target: string = rest[0] ?? "."

function loadConfig(
  path: string,
  cb: (err: NodeJS.ErrnoException | null, data?: string) => void,
): void {
  readFile(path, "utf8", (err, data) => {
    if (err) return cb(err)
    cb(null, data)
  })
}

loadConfig(`${target}/config.json`, (err, data) => {
  if (err) {
    console.error("failed to read config:", err.message)
    process.exitCode = 1
    return
  }
  console.log("config:", data)
})
process.argv has no other shape to infer
There is no generic version of process.argv — every element is a plain string, whether it looks like a number, a flag, or a path. Parsing (Number(rest[0]), a real flag parser) is always your responsibility, not something a type can do for you.
JuniorWhy is the error parameter in a Node-style callback typed NodeJS.ErrnoException | null instead of just Error?

Node's callback convention signals success by passing null as the error argument, not by omitting it — so the parameter has to include null in its type or every successful call would be a type error. NodeJS.ErrnoException extends Error with the extra fields Node's own APIs actually set, like code and errno, which a plain Error type would not expose without a cast.

02

TypeScript + npm

A package tells TypeScript where its own types live with a "types" field in package.json, pointing at a .d.ts generated by tsc --declaration when the package is built. A consumer installing that package gets full types automatically — no separate @types/ install, no configuration.

TypeScript + npm

Shipping types with your own published package

The "types" field is read the same way "main" is, just for the type checker instead of the runtime. "files": ["dist"] keeps the published tarball to only what consumers need — never the TypeScript source itself.

typescript
// package.json
{
  "name": "slugify-lite",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "prepublishOnly": "npm run build"
  },
  "devDependencies": { "typescript": "^5" }
}

// dist/index.d.ts — generated by tsc --declaration, never hand-edited
export declare function slugify(input: string): string

// a consumer's code — zero extra install, TypeScript sees this automatically
import { slugify } from "slugify-lite"
slugify("Hello World")
03

TypeScript + Express

Express's Request and Response are generic: Request<Params, ResBody, ReqBody> lets you type the route params, the response body, and the request body separately, so req.params.id and req.body are both checked instead of both being any.

TypeScript + Express

A typed route handler, params and body included

Note the empty object {} for params on the POST route — there is no :id in that path, so there is nothing to type there, and leaving it as any instead would silently lose checking on the other two generic slots too.

typescript
// npm i express && npm i -D @types/express
import express, { Request, Response } from "express"

interface Task {
  id: number
  text: string
  done: boolean
}

const app = express()
app.use(express.json())

const tasks: Task[] = [{ id: 1, text: "learn typescript", done: false }]

app.get(
  "/api/tasks/:id",
  (req: Request<{ id: string }>, res: Response<Task | { error: string }>) => {
    const task = tasks.find(t => t.id === Number(req.params.id))
    if (!task) return res.status(404).json({ error: "task not found" })
    res.json(task)
  },
)

app.post(
  "/api/tasks",
  (req: Request<{}, Task, { text: string }>, res: Response<Task>) => {
    const task: Task = { id: tasks.length + 1, text: req.body.text, done: false }
    tasks.push(task)
    res.status(201).json(task)
  },
)

app.listen(3000)
Mid-levelWhat do the three type parameters on Express's Request control, and why bother typing all three?

P types req.params — the route's dynamic segments, like :id; ResBody types what res.json() is allowed to send back; ReqBody types req.body, the parsed request payload. Typing only one and leaving the others as their any defaults quietly loses checking everywhere else in the handler — a typo in req.body.txet instead of req.body.text would otherwise compile silently, which is exactly the class of bug typing the handler was meant to catch.

04

TypeScript + React

Three shapes cover most day-to-day React typing: an interface for a component's props, a type argument on useState<T> when the initial value does not make the type obvious, and React's own event types (React.ChangeEvent<HTMLInputElement>, React.FormEvent<HTMLFormElement>) for handlers.

TypeScript + React

Typed props, typed state, and a typed form event

useState("") alone would already infer string correctly — the explicit <string> below is shown for clarity, and becomes necessary the moment the initial value does not reveal the full type, such as useState<Task[]>([]).

typescript
import { useState } from "react"

interface TaskItemProps {
  text: string
  done: boolean
  onToggle: () => void
}

function TaskItem({ text, done, onToggle }: TaskItemProps) {
  return (
    <li onClick={onToggle} style={{ textDecoration: done ? "line-through" : "none" }}>
      {text}
    </li>
  )
}

function TaskForm({ onAdd }: { onAdd: (text: string) => void }) {
  const [text, setText] = useState<string>("")

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setText(e.target.value)
  }

  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    if (!text.trim()) return
    onAdd(text.trim())
    setText("")
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={handleChange} />
      <button type="submit">Add</button>
    </form>
  )
}
05

TypeScript + PostgreSQL

The pg driver's query method is generic: pool.query<TaskRow>(sql, params) types result.rows as TaskRow[] instead of any[]. A Repository<T> takes that one step further — one generic class that works for any row shape, as long as it has an id.

TypeScript + PostgreSQL

A typed query result and a generic Repository<T>

T extends { id: number } is a generic constraint — it says "T can be anything, as long as it at least has a numeric id", which is what findById needs to be meaningful, and is why Omit<T, "id"> works for insert: every T is guaranteed to have that field to omit.

typescript
import { Pool, QueryResult } from "pg"

interface TaskRow {
  id: number
  text: string
  done: boolean
}

const pool = new Pool({ connectionString: process.env.DATABASE_URL })

async function getTask(id: number): Promise<TaskRow | undefined> {
  const result: QueryResult<TaskRow> = await pool.query(
    "SELECT id, text, done FROM tasks WHERE id = $1",
    [id],
  )
  return result.rows[0]
}

class Repository<T extends { id: number }> {
  constructor(private pool: Pool, private table: string) {}

  async findById(id: number): Promise<T | undefined> {
    const { rows } = await this.pool.query<T>(
      `SELECT * FROM ${this.table} WHERE id = $1`,
      [id],
    )
    return rows[0]
  }

  async insert(row: Omit<T, "id">): Promise<T> {
    const cols = Object.keys(row)
    const values = Object.values(row)
    const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ")
    const { rows } = await this.pool.query<T>(
      `INSERT INTO ${this.table} (${cols.join(", ")}) VALUES (${placeholders}) RETURNING *`,
      values,
    )
    return rows[0]
  }
}

const tasks = new Repository<TaskRow>(pool, "tasks")
SeniorWhy constrain Repository with "T extends { id: number }" instead of leaving T unconstrained?

An unconstrained T could be anything, including a type with no id field at all, which would make findById and the RETURNING * shape meaningless and break at the point of use rather than at the point of instantiation. The constraint moves that failure to compile time: Repository only type-checks if SomeType actually has a numeric id, so a misuse is caught the moment the class is instantiated with the wrong row type, not buried inside a method body.

06

TypeScript + Docker

A multi-stage Dockerfile runs tsc as its own build stage, separate from the final image — so the shipped container never contains the TypeScript compiler, the source .ts files, or dev dependencies, only the compiled dist/ output and what it needs to run.

TypeScript + Docker

A multi-stage Dockerfile that fails the build on a type error

The build stage runs tsc without --noEmit, so it both type-checks and produces dist/ in one step — if tsc exits non-zero, docker build stops there, and a type error can never reach production.

dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npx tsc -p tsconfig.json

FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
07

TypeScript + Jest

A test file is typed exactly like the code it tests — the value in typing it shows up in the mocks: jest.Mock<ReturnType, Args> types a mock function's return value and arguments, so mockResolvedValue is checked against the same shape the real function would return.

TypeScript + Jest

A typed test file with a typed mock

jest.Mock<Promise<{ rows: TaskRow[] }>, [string, unknown[]]> reads as: a mock function returning that promise, called with a SQL string and a params array — matching pool.query's real signature, so a mismatched mockResolvedValue is caught by the type checker before the test even runs.

typescript
import { describe, test, expect, jest } from "@jest/globals"
import { Pool } from "pg"
import { getTask } from "../src/tasks"

jest.mock("pg")

interface TaskRow {
  id: number
  text: string
}

describe("getTask", () => {
  test("returns the row for a matching id", async () => {
    const mockQuery: jest.Mock<Promise<{ rows: TaskRow[] }>, [string, unknown[]]> = jest.fn()
    mockQuery.mockResolvedValue({ rows: [{ id: 1, text: "ship it" }] })
    ;(Pool as jest.Mock).mockImplementation(() => ({ query: mockQuery }))

    const task = await getTask(1)

    expect(task).toEqual({ id: 1, text: "ship it" })
    expect(mockQuery).toHaveBeenCalledWith(
      "SELECT id, text, done FROM tasks WHERE id = $1",
      [1],
    )
  })
})
Vitest is the same idea, faster
Everything here applies equally to Vitest's vi.fn<Args, Return> — the API is a near-exact match, and most new projects reach for Vitest over Jest specifically for native ESM and TypeScript support with no extra transform config. Both are worth being able to read; Jest is still what most existing codebases run.
08

TypeScript + Git

What a TypeScript project ignores follows from what tsc produces: compiled output and its incremental-build cache never belong in history, because they are always regenerable from the source that is committed.

TypeScript + Git

A TypeScript .gitignore, and the one exception that flips it

An app you deploy yourself builds in CI or Docker, so dist/ has no reason to live in git — nobody ever runs it straight from a clone. A published package is different: npm install gives consumers your compiled output directly, so either dist/ (including its .d.ts files) is committed, or a prepublishOnly script builds it fresh at publish time and package.json's "files" field includes it in the tarball regardless of what git tracks.

bash
# .gitignore — a TypeScript app
node_modules/
dist/
*.tsbuildinfo
.env
coverage/

# .gitignore — a PUBLISHED PACKAGE keeps dist/ out of GIT the same way,
# but package.json ships it anyway via "files", built fresh at publish time:
#
#   { "files": ["dist"], "scripts": { "prepublishOnly": "tsc -p tsconfig.build.json" } }
#
# The rule either way: dist/ is either reproducible from source in CI (an app),
# or reproduced right before every publish (a package) — never hand-edited,
# never relied on as the source of truth, so committing it to GIT specifically
# is rarely the right call even for a package; "files" solves the same problem.
Mid-levelShould dist/ be committed to git for a TypeScript project?

For an app you build and deploy yourself — in CI, in a Docker build stage — no: dist/ is always reproducible from the committed .ts source, and committing generated output just creates merge conflicts and drift from what actually built it. A published npm package is the one case where consumers need the compiled output directly, but even then the usual answer is not to commit dist/ to git — instead, package.json's "files" field ships it in the published tarball, built fresh by a prepublishOnly script at publish time, keeping the git history free of generated files either way.

Finish the TypeScript handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.