TypeScript is easiest to learn when you watch the compiler argue with you. This crash course runs every example through the real compiler (tsc 5.9) on Node.js 22, and every error on screen is a genuine compiler error. This article follows the same path: basic types, interfaces versus type aliases, unions and narrowing, discriminated unions, generics, utility types, strict mode, and a small typed app, ending with a bug TypeScript did not catch.
⚡ TL;DR – Turn on
strictfrom the first commit. It catches lookup misses like'user' is possibly 'undefined'. – Use discriminated unions plus anevercheck so the compiler finds the case you forgot. – Derive types withOmit,Partial,Pick,ReadonlyandRecordinstead of copying them. – Treatfetchresults asunknownand narrow them with a type guard. Don’t useas. – TypeScript checks shapes, not logic. Keep your tests.
Before you start
- Node.js 22 or newer and npm.
- Comfortable with modern JavaScript (arrow functions, modules, async/await).
- An editor with TypeScript support (VS Code works out of the box), so errors show while you type.
Step 1: Set up the project
npm init -y
npm install --save-dev typescript
npx tsc --init --strict
npx tsc && node dist/index.js
Turn strict on from day one. Most of the value of TypeScript lives behind that flag, and retrofitting it later is painful.
Step 2: Learn types and inference
You rarely need to annotate local variables. TypeScript infers let count = 0 as number, and assigning a string later is an error. Annotate function parameters and public return types, because those are the contracts other code depends on.
function average(values: number[]): number {
return values.reduce((a, b) => a + b, 0) / values.length;
}
Step 3: Choose interface vs type alias
Both describe object shapes. Interfaces can be reopened (declaration merging) and extended with extends; type aliases can describe anything, including unions, tuples and mapped types. A practical rule: use interface for object shapes you expect others to extend, and type for unions and compositions.
interface User { id: number; name: string; email?: string }
type Status = 'todo' | 'doing' | 'done';
Step 4: Use unions and narrowing
A union says a value can be one of several types. You can only use members common to all of them until you narrow:
function format(id: string | number) {
if (typeof id === 'number') return id.toFixed(0); // number here
return id.toUpperCase(); // string here
}
Step 5: Model states with discriminated unions
This is the feature that pays for TypeScript on its own. Give each variant a literal kind field and switch on it. Add a never check in the default branch, and the compiler tells you when you forget a case:
type Command =
| { kind: 'add'; title: string }
| { kind: 'done'; id: number }
| { kind: 'list' };
function run(cmd: Command) {
switch (cmd.kind) {
case 'add': return add(cmd.title);
case 'done': return complete(cmd.id);
case 'list': return list();
default: {
const _exhaustive: never = cmd; // error if a case is missing
return _exhaustive;
}
}
}
Add a fourth command, remove, without handling it, and tsc points to the exact line. That is the “how tsc finds the case you forgot” moment in the video.
Step 6: Write generics with a constraint
Generics let a function keep type information instead of collapsing to any. A constraint states what the function needs:
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
const u = findById(users, 1); // u: User | undefined
Step 7: Derive types with utility types
Omit<User, 'id'>: input for a create endpoint.Partial<User>: a patch where every field is optional.Pick<User, 'id' | 'name'>: a slim view for lists.Readonly<User>: prevents accidental mutation.Record<Status, Task[]>: a board grouped by status, with every key required.
These keep derived types in sync with the source type, so renaming a field in User updates every variant automatically.
Step 8: Let strict mode catch a real bug
With strict on, users.find(...) returns User | undefined, and accessing .name directly produces the error shown in the video: 'user' is possibly 'undefined'. That is not pedantry; it is the lookup-miss crash you would otherwise discover in production. Handle it explicitly:
const user = findById(users, id);
if (!user) throw new Error(`No user ${id}`);
console.log(user.name);
Step 9: Type a fetch call with unknown and a guard
response.json() returns data the compiler cannot verify. Treat it as unknown and prove its shape with a type guard rather than casting:
function isTodo(x: unknown): x is { id: number; title: string; completed: boolean } {
return typeof x === 'object' && x !== null
&& typeof (x as any).id === 'number'
&& typeof (x as any).title === 'string';
}
const data: unknown = await (await fetch('https://jsonplaceholder.typicode.com/todos/1')).json();
if (!isTodo(data)) throw new Error('Unexpected response');
console.log(data.title);
Casting with as Todo compiles fine and lies to you at runtime. A guard (or a schema library such as zod) actually checks.
Step 10: Build the task CLI
The small real app in the video is a task CLI whose commands are the discriminated union shown above. Parse process.argv into a Command, then pass it to run. Because parsing produces a typed value, every downstream function gets correct autocomplete and exhaustiveness checks for free.
function parse(argv: string[]): Command {
const [cmd, ...rest] = argv;
switch (cmd) {
case 'add': {
const title = rest.join(' ').trim();
if (!title) throw new Error('Usage: add <title>');
return { kind: 'add', title };
}
case 'done': {
const id = Number(rest[0]);
if (!Number.isInteger(id)) throw new Error('Usage: done <id>');
return { kind: 'done', id };
}
case 'list': return { kind: 'list' };
default: throw new Error(`Unknown command: ${cmd}`);
}
}
run(parse(process.argv.slice(2)));
Parsing at the boundary is the pattern to remember. Everything coming from outside (argv, HTTP bodies, files, environment variables) is untyped, so turn it into a typed value once, at the edge, and the rest of the program can trust it.
A bug TypeScript did not catch
Types describe shapes, not intent. The video ends with a bug that compiled cleanly and was still wrong, the kind of logic mistake (an off-by-one, a wrong comparison, a value that is the right type but the wrong meaning) that only tests or careful reading find. The lesson is to pair TypeScript with tests, not to replace them.
Recommended tsconfig settings
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"outDir": "dist",
"rootDir": "src"
}
}
noUncheckedIndexedAccess makes arr[i] return T | undefined, which catches the same class of bug as find. It’s noisy at first, but it pays off.
Troubleshooting
Cannot find name 'process': install Node types withnpm i -D @types/node.Relative import paths need explicit file extensionsunder NodeNext: import./tasks.jseven though the source file istasks.ts.Object is possibly 'undefined'everywhere: that’s strict mode doing its job. Narrow withif, use??for defaults, and only use!when you can prove the value exists.- Type errors but the code runs: tsc still emits JavaScript by default. Add
"noEmitOnError": trueso CI fails.
Best practices
- Annotate function boundaries; let inference handle locals.
- Prefer
unknowntoany.anyturns off checking for everything it touches. - Keep one source of truth per entity and derive variants from it.
- Run
tsc --noEmitin CI on every pull request.
Edge cases worth knowing
Exhaustiveness checks with never. When you switch over a discriminated union, add a default branch that assigns to never. If someone adds a new variant later, the compiler flags every switch that forgot it:
function label(s: TaskState): string {
switch (s.status) {
case 'todo': return 'To do';
case 'done': return `Done ${s.completedAt}`;
default: {
const unreachable: never = s; // error if a variant is unhandled
return unreachable;
}
}
}
as is not a conversion. JSON.parse(text) as Task[] silences the compiler but checks nothing at runtime. Keep data from files, APIs and user input as unknown and validate it with a type guard, as in Step 9, or with a schema library like zod.
satisfies for config objects. const cfg = { port: 3000 } satisfies Config checks the shape but keeps the narrow literal types, so cfg.port stays 3000 instead of widening to number.
Index access can be undefined. With noUncheckedIndexedAccess enabled, tasks[0] is typed Task | undefined, which forces you to handle empty arrays. That’s exactly the class of bug that shows up in production.
FAQ
Should I migrate an existing JavaScript project all at once?
No. Turn on allowJs and checkJs, rename files one at a time, and add strict per folder as you go.
Do I need a runtime validation library?
For external data, yes in practice. A type guard works for small shapes. zod or valibot give you one schema for both the runtime check and the static type.
interface or type for React props?
Either works. Pick one convention for the codebase and stick with it.
Official documentation
Key takeaways
- Enable
strict; most of TypeScript’s value depends on it. - Use discriminated unions with a
nevercheck for exhaustive handling. - Constrain generics to what the function actually needs.
- Derive types with utility types instead of duplicating them.
- Treat external data as
unknownand narrow it with guards. - Types catch shape errors, not logic errors; keep writing tests.
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
PDF for this post is being prepared. Subscribe now and we’ll unlock downloads sitewide as packs go live.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.