TypeScript is JavaScript with a structural type system bolted on top — and that combination is its
superpower: one language for the server, the edge, and the browser, with types you can share across all
three. This track moves from tsc --init to discriminated unions, runtime validation with zod, and a Hono
service deployed to Cloudflare’s edge — tagged by level so you can read only as deep as you need.
Install TypeScript and initialise a project
BeginnerMake a folder, run npm init -y, then add TypeScript and create a tsconfig.json with tsc --init.
What tsc and tsconfig.json actually are
TypeScript is a compiler (tsc) plus a type checker. It reads .ts files, checks the types, and emits
plain JavaScript — the types themselves are erased and never exist at runtime. tsconfig.json is the
project’s control panel: it tells tsc which files to include, which JavaScript version to target, and how
strict to be. Installing it as a dev dependency (not globally) pins the version per project, so every
contributor compiles with the same compiler.
Create the project
mkdir tidal-api && cd tidal-api
npm init -y
# TypeScript is a dev dependency — it compiles, it doesn't ship
npm install -D typescript
npx tsc --init
npx tsc --versionTurn on strict mode
BeginnerOpen tsconfig.json and make sure "strict": true is set. This is the single most important setting.
Why strict mode is non-negotiable
strict is an umbrella flag that switches on the checks that make TypeScript actually catch bugs —
including strictNullChecks (so null and undefined are not silently assignable everywhere) and
noImplicitAny (so untyped values are flagged instead of becoming the escape-hatch any). Without it,
TypeScript degrades into “JavaScript with optional hints” and stops protecting you. New projects from
tsc --init enable it by default; never turn it off to make red squiggles go away — that’s silencing the
tool that’s doing its job.
The settings that matter
// tsconfig.json (the keys worth checking)
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"noUncheckedIndexedAccess": true, // arr[i] is T | undefined — honest indexing
"outDir": "dist"
}
}npx tsc --noEmit # type-check the whole project without writing filesWrite your first typed function and run it
BeginnerCreate src/index.ts with a typed function, compile it with tsc, and run the emitted JavaScript with Node.
Annotations, inference, and the compile step
You annotate parameters and return types; TypeScript infers almost everything else, so idiomatic code
is far less verbose than it looks. The flow is always: write .ts, run tsc to emit .js into outDir,
then run that .js with a runtime. During development a runner like tsx skips the manual compile step and
runs .ts directly, but the mental model — types are checked, then erased — never changes.
Type, compile, run
// src/index.ts
function greet(name: string): string {
return `Hello, ${name}`;
}
console.log(greet("Tidal"));npx tsc # emits dist/index.js
node dist/index.js # -> Hello, Tidal
# faster dev loop: run the .ts directly, no manual build
npm install -D tsx
npx tsx src/index.tsAgent prompt — paste into an agent with repo access
Role: Senior TypeScript engineer working in this repo.
Context: Fresh npm project tidal-api with TypeScript installed and "strict": true in tsconfig.json. tsx is available as a dev dependency.
Task: Create src/money.ts exporting a function formatCents(cents: number, currency: string): string that renders integer cents as a currency string using Intl.NumberFormat.
Requirements:
- Parameters and return type are explicitly annotated; no `any`.
- 1999 with "USD" formats as "$19.99"; 500 with "EUR" formats with a euro sign.
- Throw a RangeError if cents is not a non-negative integer.
- Add a tiny scripts/check-money.ts that calls it and console.logs three examples.
Tests / acceptance:
- `npx tsc --noEmit` reports no errors.
- `npx tsx scripts/check-money.ts` prints the three formatted strings.
Output: a unified diff plus a one-paragraph summary.Model your domain with interfaces and unions
IntermediateDescribe your data with interface and type, and use a string literal union to constrain a field to a fixed set of values.
Structural typing: shape is what matters
TypeScript is structurally typed: a value fits a type if it has the right shape, regardless of what it was
“declared” as. Two interfaces with identical fields are interchangeable. This is what lets types flow freely
across module and package boundaries. String-literal unions ("draft" | "published") turn a stringly-typed
field into a closed set the compiler enforces — a typo like "publishd" becomes a compile error, not a
production incident.
An interface with a literal union
// src/post.ts
export type PostStatus = "draft" | "published" | "archived";
export interface Post {
id: string;
title: string;
body: string;
status: PostStatus;
likes: number;
}
// "publishd" would be a compile error — the union is closed
const draft: Post = {
id: "p1",
title: "Hello",
body: "...",
status: "draft",
likes: 0,
};Make impossible states unrepresentable with discriminated unions
IntermediateModel a value that can be one of several shapes as a union of objects that share a kind (the discriminant), then switch on it.
Why discriminated unions are TypeScript at its best
A discriminated union is a set of object types sharing one literal field. When you switch on that field,
TypeScript narrows the type inside each branch — so in the error branch you can read .message, and in
the ok branch you can read .data, with no casts. Add a default branch that assigns the value to
never and the compiler will fail the build if you ever add a new variant and forget to handle it. This is
how you make illegal states (a “loading” result that also has data) literally impossible to construct.
A result type that the compiler checks for exhaustiveness
// src/result.ts
export type Result<T> =
| { kind: "ok"; data: T }
| { kind: "error"; message: string };
export function render<T>(r: Result<T>): string {
switch (r.kind) {
case "ok":
return `ok: ${JSON.stringify(r.data)}`;
case "error":
return `error: ${r.message}`;
default: {
// if a new variant is added, this line stops compiling
const _exhaustive: never = r;
return _exhaustive;
}
}
}Agent prompt — paste into an agent with repo access
Role: Senior TypeScript engineer in this repo, fluent in advanced types.
Context: src/result.ts exports Result<T> = { kind: "ok"; data: T } | { kind: "error"; message: string }. Project is strict; vitest is available as a dev dependency.
Task: Add a fetchPost(id: string) that returns Promise<Result<Post>> against an in-memory map, plus exhaustive handling.
Requirements:
- Never throw for "not found" — return { kind: "error", message }. Reserve throws for programmer errors.
- A handle(r) function that switches on r.kind with a `never`-typed default branch proving exhaustiveness.
- Adding a third variant to Result must cause a compile error in handle() until it is handled.
Tests / acceptance:
- `npx tsc --noEmit` passes.
- `npx vitest run` passes tests asserting: known id -> kind "ok"; unknown id -> kind "error".
Output: a unified diff plus a one-paragraph note on why the `never` branch matters.Write generic, reusable functions
IntermediateWrite a function with a type parameter <T> so it works for any type while preserving that type through the call.
Generics keep type information flowing
A generic captures a type the caller supplies and threads it through the signature, so first<T>(arr: T[]): T | undefined
returns a string when given string[] and a Post when given Post[] — no any, no casts. Constraints
(<T extends { id: string }>) let you require a shape while staying generic. The payoff is one
well-typed utility instead of a copy per type, with autocomplete that still knows the concrete type at every
call site.
A constrained generic
// src/collection.ts
// indexBy turns a list into a lookup keyed by each item's id
export function indexBy<T extends { id: string }>(
items: readonly T[],
): Record<string, T> {
const out: Record<string, T> = {};
for (const item of items) {
out[item.id] = item;
}
return out;
}
// inferred as Record<string, Post> — no annotation needed at the call site
const byId = indexBy(posts);Chat prompt — paste into a chat to get the code
Role: TypeScript teacher. The reader has no repo access — return complete, self-contained code.
Task: Show a generic groupBy<T, K extends string>(items: readonly T[], key: (item: T) => K): Record<K, T[]> with a tiny usage example.
Requirements:
- Strict mode assumed; no `any`, no non-null assertions (!).
- The return type must be Record<K, T[]>, and grouping must initialise empty arrays lazily.
- Include a 3-line usage example grouping posts by their `status` field and the expected console output as a comment.
Tests / acceptance (describe, since no repo):
- groupBy(posts, p => p.status) yields keys matching the distinct statuses present.
- Each bucket contains exactly the items with that status.
Output: the complete TypeScript file, no commentary.Validate untrusted input at runtime with zod
IntermediateAdd zod, define a schema, and parse incoming data so that runtime values are checked, not just compile-time types.
Types are erased — runtime data needs a real guard
This is the most important idea in the whole track: TypeScript types do not exist at runtime. An
interface Post gives you zero protection against a malformed JSON body, a bad env var, or an API that
changed shape. zod is a schema library that validates actual values at runtime and — crucially —
infers a static type from the schema with z.infer, so one definition gives you both the runtime check
and the compile-time type. Parse at every trust boundary (request bodies, env, third-party responses); trust
the types only after a successful parse.
One schema, both a runtime check and a static type
npm install zod// src/schema.ts
import { z } from "zod";
export const PostInput = z.object({
title: z.string().min(1),
body: z.string().min(1),
status: z.enum(["draft", "published", "archived"]),
});
// the static type is derived from the schema — single source of truth
export type PostInput = z.infer<typeof PostInput>;
// throws ZodError on bad input; returns a typed value on success
export function parsePost(data: unknown): PostInput {
return PostInput.parse(data);
}Agent prompt — paste into an agent with repo access
Role: Senior TypeScript engineer in this repo.
Context: zod is installed. The project is strict; vitest is available. Untrusted input arrives as `unknown`.
Task: Create src/schema.ts with a CreatePost zod schema and a safeParsePost(data: unknown) that returns a Result<PostInput> (reuse src/result.ts), never throwing.
Requirements:
- title and body are non-empty strings; status is z.enum(["draft","published","archived"]); likes defaults to 0 via .default(0).
- Export the inferred type as `type PostInput = z.infer<typeof CreatePost>`.
- safeParsePost uses schema.safeParse and maps failure to { kind: "error", message } using the ZodError's message.
Tests / acceptance:
- `npx tsc --noEmit` passes.
- `npx vitest run` passes: a valid object -> kind "ok"; { title: "" } -> kind "error"; missing likes -> defaults to 0.
Output: a unified diff plus a one-paragraph note on where in a request lifecycle to call this.Build an edge-ready HTTP API with Hono
AdvancedAdd hono, define routes on a Hono app, and validate the request body with your zod schema before handling it.
Why Hono, and what 'edge-ready' means
Hono is a tiny, dependency-light web framework whose router runs on any standards-based runtime — Node, Bun,
Deno, and edge runtimes like Cloudflare Workers. It speaks the Web Request/Response API rather than
Node-specific objects, which is exactly why it ports to the edge unchanged. Pair it with zod at the boundary
(via Hono’s validator) and every handler receives data that is already shape-checked. Keep handlers pure of
Node APIs (fs, net) so the same code runs locally and at the edge.
A validated route
npm install hono// src/app.ts
import { Hono } from "hono";
import { PostInput } from "./schema";
export const app = new Hono();
app.get("/healthz", (c) => c.text("ok"));
app.post("/posts", async (c) => {
const body = await c.req.json();
const parsed = PostInput.safeParse(body);
if (!parsed.success) {
return c.json({ error: parsed.error.message }, 422);
}
// parsed.data is fully typed here
return c.json({ id: "p1", ...parsed.data }, 201);
});Agent prompt — paste into an agent with repo access
Role: Senior TypeScript engineer in this repo building an edge-portable API.
Context: hono and zod are installed; src/schema.ts exports the CreatePost schema and PostInput type. The project is strict. vitest is available.
Task: Build src/app.ts exporting a Hono app with GET /healthz and POST /posts, then test it via app.request().
Requirements:
- GET /healthz returns 200 with body "ok".
- POST /posts validates the JSON body with the zod schema; invalid input returns 422 with a JSON { error } body.
- Valid input returns 201 with the created post (a generated id plus the parsed fields).
- No Node-only APIs (no fs/net/process beyond env) so the app stays edge-portable.
Tests / acceptance:
- `npx vitest run` passes using app.request("/posts", { method: "POST", body: JSON.stringify(...) }) — asserting 201 on valid and 422 on { title: "" }.
- `npx tsc --noEmit` passes.
Output: a unified diff plus a one-paragraph note on what keeps this app edge-portable.Run it on Node, then deploy the same code to the edge
AdvancedRun the Hono app under Node locally, then deploy the identical app to Cloudflare Workers with Wrangler.
Same handlers, two runtimes — and what differs
The payoff of a Web-standards framework: one app.ts, two entrypoints. Locally you mount it with
@hono/node-server; for the edge you export the app as a Worker and deploy with Wrangler, Cloudflare’s
CLI. Cloudflare Workers run on V8 isolates, not a full Node.js process — there is no fs, no long-lived
TCP, and (by default) only a Web-standard API surface, though Workers offer Node-compatibility for some
modules behind a flag. Because Hono handlers already avoid Node-only APIs, they run unchanged. Bindings (KV,
D1, R2) are injected via the environment rather than imported.
Local Node server, then a Worker
# local: a real Node HTTP server around the same app
npm install @hono/node-server// src/server.ts — Node entrypoint
import { serve } from "@hono/node-server";
import { app } from "./app";
serve({ fetch: app.fetch, port: 8080 });
console.log("listening on http://localhost:8080");// src/worker.ts — Cloudflare Workers entrypoint (same app)
import { app } from "./app";
export default app; // Hono apps are valid Worker fetch handlersnpx tsx src/server.ts # Node: curl localhost:8080/healthz
npm install -D wrangler
npx wrangler dev src/worker.ts # edge: V8 isolate, local emulator
# npx wrangler deploy src/worker.ts # ship it to Cloudflare's networkMake impossible APIs unrepresentable with advanced types
AdvancedTighten a public function signature with a generic plus a conditional or mapped type so misuse fails to compile.
Push errors from runtime to compile time
TypeScript’s type system is itself a small functional language: conditional types (T extends U ? X : Y),
mapped types ({ [K in keyof T]: ... }), and helpers like keyof, Pick, Omit, and Awaited let you
derive new types from existing ones. The goal is leverage: encode an API’s rules in the types so that wrong
usage is a red squiggle, not a 2 a.m. page. Reach for this when a function’s correct shape depends on its
arguments — for example, a config loader whose return type is keyed to the keys you asked for.
A type-safe pick driven by a mapped type
// src/select.ts
// selectFields<T, K> returns an object containing exactly the requested keys
export function selectFields<T, K extends keyof T>(
source: T,
keys: readonly K[],
): { [P in K]: T[P] } {
const out = {} as { [P in K]: T[P] };
for (const key of keys) {
out[key] = source[key];
}
return out;
}
// inferred as { id: string; status: PostStatus } — asking for a missing key won't compile
const summary = selectFields(post, ["id", "status"] as const);Agent prompt — paste into an agent with repo access
Role: Senior TypeScript engineer in this repo, fluent in conditional and mapped types.
Context: The project is strict; vitest is available. We want a type-safe environment loader.
Task: Create src/env.ts exporting loadEnv<K extends string>(keys: readonly K[]): Record<K, string> that reads process.env and validates presence.
Requirements:
- Return type is Record<K, string>, so the result is keyed exactly to the requested names with no `string | undefined`.
- Throw an Error listing every missing key (not just the first) when any is absent.
- No `any`, no non-null assertions; rely on the generic K to keep the result precisely typed.
Tests / acceptance:
- `npx tsc --noEmit` passes, and `loadEnv(["A","B"]).A` type-checks as string while `.C` is a compile error.
- `npx vitest run` passes: all keys present -> typed object; a missing key -> Error whose message names it.
Output: a unified diff plus a one-paragraph note on why the generic beats returning Record<string, string>.Lock the build with tsc, ESLint, and vitest
AdvancedWire npm scripts that type-check with tsc --noEmit, lint with typescript-eslint, and run tests with vitest — the gates a CI run must pass.
Three gates, three different jobs
These tools do not overlap. tsc --noEmit proves the types are sound without emitting files. typescript-eslint
(ESLint + the TypeScript plugin) catches style and likely-bug patterns the type system permits — floating
promises, unsafe any usage, unused vars. vitest runs your behavioural tests fast with native TypeScript
support. A green CI means all three pass; wire them as separate scripts so a failure tells you which gate
broke. This is the operability upgrade that keeps a TypeScript codebase trustworthy as it grows.
The CI gates as npm scripts
npm install -D vitest eslint typescript-eslint// package.json
{
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"check": "npm run typecheck && npm run lint && npm run test"
}
}npm run check # the single command CI runsAgent prompt — paste into an agent with repo access
Role: Senior TypeScript engineer in this repo setting up CI gates.
Context: Source lives in src/. vitest, eslint, and typescript-eslint are installed. The project is strict.
Task: Add an eslint.config.js using typescript-eslint's recommended config, plus the npm scripts typecheck/lint/test/check, and one vitest test for src/result.ts.
Requirements:
- eslint.config.js is flat config and extends typescript-eslint recommended; it must flag floating promises.
- The "check" script runs typecheck, lint, and test in sequence and fails if any fails.
- The vitest test asserts render({ kind: "ok", data: 1 }) and render({ kind: "error", message: "x" }) produce the expected strings.
Tests / acceptance:
- `npm run check` exits 0 on the current tree.
- Introducing an unawaited promise makes `npm run lint` fail (state how you verified).
Output: a unified diff plus a one-paragraph summary of what each gate guarantees.Where to take it next
- Put this stack to work in Ticker, where a TypeScript edge server fans realtime changes out to a live dashboard.
- Want a simpler runtime and true concurrency for the same kind of service? Compare with Go.
- Deploying those Hono handlers globally? The edge runtime they target lives in Cloudflare.