TypeScript — a typed language that pays
The most-employable language in 2026. Types, generics, narrowing, the React/Node ecosystem — without the cargo cult of every Twitter thread.
Prerequisites
01.1
Stack
Node 22 LTS (via fnm)TypeScript 5.xtsx for running .ts directlyvitest for testspnpm or bun
By the end of this module
- Set up a real TypeScript project (strict tsconfig, vitest, pnpm) without copying ten Stack Overflow answers.
- Read and write idiomatic TS: types vs interfaces, generics, narrowing, utility types, discriminated unions.
- Validate runtime data with zod and bridge it to compile-time types.
- Ship a small typed CLI tool that reads JSON, validates it, and does something useful.
Pick one language to be employable in for the next decade and TypeScript is the safe bet. It runs on the server, in the browser, in serverless functions, on the edge. Every job listing for a frontend or full-stack role assumes it. Most fast-moving AI startups in 2026 are TS-on-the-frontend, TS-or-Python on the backend. If you’re going to learn one typed language well as your first job-grade language, this is the one.
The catch is that TypeScript has accumulated a lot of cargo cult around it. Twitter is full of conditional-type wizards proving they can encode chess in the type system. You will not need any of that. The version of TS that actually pays — the version every working engineer uses daily — is a small, useful subset that gives you most of the benefits of static typing without the type-Olympics.
This module is that subset. We will skip the Olympics. The opinionated take: you are not paid to write the cleverest type. You are paid to ship code that fails fast at the compiler and runs correctly in production. Most of the time, plain types and a few utility helpers do the job.
Set up
# Make sure you have Node 22 LTS via fnm (from 00.2)
fnm install 22 && fnm default 22
node --version # v22.x
# Install pnpm (faster, saner than npm)
npm install -g pnpm
# or, if you want bleeding-edge: npm install -g bun
# New project
mkdir ts-deep && cd ts-deep
pnpm init
pnpm add -D typescript tsx vitest @types/node
pnpm exec tsc --init
Now edit tsconfig.json to the strict settings that actually matter. Ignore the 60 default options.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
strict: true turns on the seven flags that catch the most bugs. noUncheckedIndexedAccess is the one most people forget — without it, arr[0] is typed as T instead of T | undefined, which is a lie.
Add a script and a tiny first file:
# package.json scripts
pnpm pkg set scripts.dev="tsx watch src/main.ts"
pnpm pkg set scripts.test="vitest"
pnpm pkg set scripts.build="tsc"
mkdir src
echo 'console.log("hello typescript")' > src/main.ts
pnpm dev
If that prints hello typescript, you’re set up.
Read these first
Four sources, in this order, then stop.
- Matt Pocock — Total TypeScript Beginner’s Tutorial. link · 3 hrs · the single best modern starting point. Free.
- TypeScript handbook — Everyday Types + Narrowing + Object Types + Generics. docs · 90 min · the official material, surprisingly readable.
- Dan Abramov — The Magical Story of How TypeScript Compiles. post · 30 min · context for why TS works the way it does.
- Effective TypeScript — Dan Vanderkam. book · the second-pass book once you have the basics. Read selectively.
Skip “TypeScript Tips” Twitter threads. Most of them are showing off, not teaching.
Types vs interfaces
The first decision people overthink. Use this rule:
// Use `type` by default — it's more flexible.
type User = {
id: string;
name: string;
};
// Use `interface` when you specifically want declaration merging
// (e.g., extending a global like the Express Request).
interface Request {
user?: User;
}
type can do unions, intersections, mapped types, conditional types — interface cannot. interface allows declaration merging — type does not. In 95% of code, use type. Don’t lose hours arguing about it.
The basics that matter
You’ll use these every day. Master them, defer everything else.
Primitives, arrays, objects, functions
const name: string = "ada";
const age: number = 30;
const isAdmin: boolean = false;
const tags: string[] = ["a", "b"]; // or Array<string>
const user: { id: string; age: number } = { id: "1", age: 30 };
function greet(name: string, formal = false): string {
return formal ? `Hello, ${name}` : `hi ${name}`;
}
// Arrow with explicit types
const add: (a: number, b: number) => number = (a, b) => a + b;
Unions and narrowing
This is the single most distinctive TypeScript pattern. A value can be one of several types, and you narrow with typeof, in, instanceof, or a type predicate.
function format(input: string | number): string {
if (typeof input === "string") {
return input.toUpperCase(); // narrowed to string
}
return input.toFixed(2); // narrowed to number
}
Discriminated unions
The pattern that replaces 90% of OOP inheritance hierarchies. A field shared across variants distinguishes them.
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function unwrap<T>(r: Result<T>): T {
if (r.ok) return r.value; // TS knows .value exists here
throw new Error(r.error); // and .error exists here
}
This is how modern TypeScript codebases model state, API responses, parser outputs, anything with multiple shapes.
Generics
A function that works for many types without losing type info.
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const n = first([1, 2, 3]); // number | undefined
const s = first(["a", "b"]); // string | undefined
You will write generics constantly once you start writing utility code. Don’t avoid them; they’re not advanced.
Utility types — the four you need
TypeScript ships with a couple dozen utility types. These four cover ~80% of real use:
type User = { id: string; name: string; email: string; age: number };
type PublicUser = Omit<User, "email">; // drop a field
type Patch = Partial<User>; // make all fields optional
type IdAndName = Pick<User, "id" | "name">; // keep only some
type ById = Record<string, User>; // dict-like map
You’ll see Pick, Omit, Partial, Required, Record, ReturnType, Awaited, NonNullable in real code. The rest you can look up when you need them.
What NOT to obsess over
A short list of TypeScript features that consume a lot of energy and pay back almost none of it for working engineers in their first two years.
- Conditional types and
infer. You will read them in library code; you do not need to write them. - Mapped types beyond
Partial/Pick/Record. Same. - Template literal types. Cute parlor trick. Almost never load-bearing.
- The
nevertype beyond exhaustiveness checks. Useful in one specific pattern, otherwise fancy. - Branded types via intersection tricks. A real tool, but not until your codebase is big enough to need them.
If you find yourself two hours into a Twitter thread about infer T extends ... — close the tab. You don’t need it.
async/await, the Node way
Same model as Python’s async, different surface. Promises are values that will resolve.
async function fetchUser(id: string): Promise<User> {
const r = await fetch(`https://api.example.com/users/${id}`);
if (!r.ok) throw new Error(`http ${r.status}`);
return r.json() as Promise<User>; // we'll do better than `as` in a sec
}
// Run several in parallel
async function main() {
const ids = ["1", "2", "3"];
const users = await Promise.all(ids.map(fetchUser));
console.log(users);
}
Three rules:
- Always handle errors.
.catch()on the boundary, ortry/catcharoundawait. Promise.allfor parallel I/O. Sequentialawaitin a loop is almost always a bug.- Never type API responses with
as— that’s a lie to the compiler. Use a runtime validator.
zod: bridging runtime to compile-time
The biggest TypeScript trap is assuming the network gives you the shape you typed. It does not. zod (or valibot, or arktype) is the common modern fix: define the shape once, get a runtime validator and a compile-time type from the same definition.
pnpm add zod
import { z } from "zod";
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
age: z.number().int().nonnegative(),
});
type User = z.infer<typeof UserSchema>; // type derived from schema
async function fetchUser(id: string): Promise<User> {
const r = await fetch(`https://api.example.com/users/${id}`);
const raw = await r.json();
return UserSchema.parse(raw); // throws on shape mismatch
}
If the API ever lies — sends a number instead of a string, drops a field — you’ll get a clear runtime error at the boundary instead of a mysterious crash three frames in. Use zod (or one of its competitors) for every external boundary: HTTP responses, env variables, file inputs.
React, briefly
If you’re going to use React (which most TS jobs assume), the type surface you actually need is small.
import { useState } from "react";
type Todo = { id: string; text: string; done: boolean };
type Props = {
initialTodos?: Todo[];
onChange?: (todos: Todo[]) => void;
};
export function TodoList({ initialTodos = [], onChange }: Props) {
const [todos, setTodos] = useState<Todo[]>(initialTodos);
// ...
return null;
}
That’s the shape: type props, type state with a generic useState, type events as React.MouseEvent parameterized by the element when you need them. Defer forwardRef, polymorphic components, and the rest until a specific PR forces you to learn them.
The build
Build a typed CLI that reads a JSON file describing some inputs, validates it with zod, and does something. Spec:
A CLI called
validate-ordersthat reads a JSON array of orders, validates each one, computes the total revenue grouped by user, and writes the result toout.json. Use zod for validation. Use vitest for tests.tsc --noEmitshould pass with strict mode.
Skeleton:
// src/main.ts
import { readFileSync, writeFileSync } from "node:fs";
import { z } from "zod";
const OrderSchema = z.object({
id: z.string(),
user: z.string(),
items: z.array(z.object({ sku: z.string(), price: z.number(), qty: z.number().int() })),
});
type Order = z.infer<typeof OrderSchema>;
function totalByUser(orders: Order[]): Record<string, number> {
const out: Record<string, number> = {};
for (const o of orders) {
const subtotal = o.items.reduce((s, i) => s + i.price * i.qty, 0);
out[o.user] = (out[o.user] ?? 0) + subtotal;
}
return out;
}
const inPath = process.argv[2] ?? "orders.json";
const outPath = process.argv[3] ?? "out.json";
const raw = JSON.parse(readFileSync(inPath, "utf8"));
const orders = z.array(OrderSchema).parse(raw);
writeFileSync(outPath, JSON.stringify(totalByUser(orders), null, 2));
console.log(`wrote ${outPath}`);
Test:
// src/main.test.ts
import { describe, expect, it } from "vitest";
import { totalByUser } from "./main";
describe("totalByUser", () => {
it("sums per user", () => {
expect(
totalByUser([
{ id: "1", user: "ada", items: [{ sku: "a", price: 10, qty: 2 }] },
{ id: "2", user: "ada", items: [{ sku: "b", price: 5, qty: 1 }] },
{ id: "3", user: "bob", items: [{ sku: "c", price: 7, qty: 3 }] },
]),
).toEqual({ ada: 25, bob: 21 });
});
});
Run:
pnpm test
pnpm exec tsc --noEmit
echo '[{"id":"1","user":"ada","items":[{"sku":"a","price":10,"qty":2}]}]' > orders.json
pnpm tsx src/main.ts orders.json
cat out.json
If all four work, you’ve held a typed pipeline together end-to-end.
Going deeper
When you have specific questions, in this order:
- Total TypeScript — Matt Pocock. link · the most current curriculum, beginner through advanced.
- TypeScript handbook (full). docs · once you have a project, the rest of the handbook starts paying off.
- type-challenges. repo · puzzles, useful in moderation. Don’t grind these instead of shipping.
- Effective TypeScript — Dan Vanderkam. book · the patterns book.
Checkpoints
If any one wobbles, the corresponding section above is what to reread.
- Why
strict: trueplusnoUncheckedIndexedAccessinstead of the default tsconfig? - When do you reach for
typeand when forinterface? State the rule and one exception. - Walk through a discriminated union and explain why it replaces most OOP inheritance hierarchies.
- What does zod give you that “just typing the response” doesn’t?
- Show the validate-orders CLI working with
tsc --noEmitandpnpm testboth clean.
If all five hold and the build passes, you’ve earned 01.4. Next: 01.5 — C or Rust, closer to the metal. You only need a low-level language once. But you need it once.