TypeScript Crash Course for Beginners (11 Min + Real App)

0 views

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 strict from the first commit. It catches lookup misses like 'user' is possibly 'undefined'. – Use discriminated unions plus a never check so the compiler finds the case you forgot. – Derive types with Omit, Partial, Pick, Readonly and Record instead of copying them. – Treat fetch results as unknown and narrow them with a type guard. Don’t use as. – 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

bash
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.

ts
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.

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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.

ts
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

json
{
  "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 with npm i -D @types/node.
  • Relative import paths need explicit file extensions under NodeNext: import ./tasks.js even though the source file is tasks.ts.
  • Object is possibly 'undefined' everywhere: that’s strict mode doing its job. Narrow with if, 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": true so CI fails.

Best practices

  • Annotate function boundaries; let inference handle locals.
  • Prefer unknown to any. any turns off checking for everything it touches.
  • Keep one source of truth per entity and derive variants from it.
  • Run tsc --noEmit in 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:

ts
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 never check for exhaustive handling.
  • Constrain generics to what the function actually needs.
  • Derive types with utility types instead of duplicating them.
  • Treat external data as unknown and 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.

Open the playlist on YouTube · CheatCoders on YouTube

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.

Subscribe free →


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.