Skip to content

Why TypeScript?

Here is plain JavaScript that looks fine and fails at runtime:

function greet(user) {
return "Hello, " + user.name.toUpperCase();
}
greet({ nam: "Ada" }); // typo: `nam` not `name`
// 💥 TypeError: Cannot read properties of undefined (reading 'toUpperCase')

The typo isn’t found until that line executes — maybe in production, maybe for one unlucky user. TypeScript moves that discovery to compile time:

// @expect-error TS2561
function greet(user: { name: string }) {
return "Hello, " + user.name.toUpperCase();
}
greet({ nam: "Ada" });
// ❌ Error: Object literal may only specify known properties,
// and 'nam' does not exist in type '{ name: string }'.

You find out the instant you write it, in the editor, before anything runs. That shift — from “fails later, somewhere” to “fails now, right here” — is the whole value proposition.

Compile time vs runtime: the crucial split

Section titled “Compile time vs runtime: the crucial split”

TypeScript has two completely separate phases, and confusing them is the source of most beginner mistakes.

flowchart LR
  ts[".ts source
(types + logic)"] -->|tsc: type-check| check["Type checking
(errors reported here)"]
  ts -->|tsc: emit| js[".js output
(types ERASED)"]
  js --> run["Runtime
(no types exist)"]
Types are checked, then erased
  • Compile time — the type checker reads your annotations and inferred types and reports errors. This is where all the safety happens.
  • Runtime — the emitted JavaScript runs. The types are gone. They were erased during compilation.

This is why you cannot do this:

// @expect-error TS1005
function isString(x: unknown) {
return x is string; // ❌ not a thing — types don't exist at runtime
}

At runtime there is no string type to check against — only the JavaScript value. To narrow types at runtime you use real JavaScript operations (typeof x === "string") that the compiler understands. More on that in the narrowing lesson.

Compile this:

interface User { name: string; age: number; }
const u: User = { name: "Ada", age: 36 };
function older(u: User): number { return u.age + 1; }

The emitted JavaScript is:

const u = { name: "Ada", age: 36 };
function older(u) { return u.age + 1; }

The interface vanished entirely; the annotations are stripped. Types add zero runtime cost and zero runtime behavior — they are a conversation between you and the compiler that ends before the program runs.

TypeScript is not free, and pretending otherwise does you a disservice:

  • A build step. You need tsc or a bundler that strips types. Plain .ts doesn’t run in Node or the browser directly (though runtimes are increasingly adding type-stripping).
  • A learning curve. The type system is a language of its own — this whole course exists because of that.
  • Type/runtime gap. The compiler can be wrong about runtime reality — an any, a bad cast, or unvalidated external data can lie to it. Types are a model, not a guarantee about the outside world (the Practical Mastery module is largely about this).

The trade is almost always worth it for anything beyond a throwaway script: you pay a fixed tax and get a large, compounding return in caught bugs, safe refactoring, and editor intelligence.

This course is written against TypeScript 7 (July 2026). The compiler was ported from TypeScript to Go: same language, same tsc command, but type-checking now runs natively and in parallel (--checkers 4 by default), which makes full builds roughly 8–12× faster and cuts the editor’s “time to first error” by roughly 10× on large codebases — VS Code dropped from 17.5s to 1.3s, Canva from 58s to 4.8s. Two consequences matter while you learn:

  • strict is on by default. Since 6.0 a bare tsconfig.json already means noImplicitAny, strictNullChecks and friends are enabled — every example in this course assumes that.
  • Old escape hatches are gone. target: "es5", moduleResolution: "node", baseUrl and esModuleInterop: false were deprecated in 6.0 and removed in 7.0. The Compiler & Tooling module covers the migration.

If you learned TypeScript on 4.x or 5.x, the type system you know is unchanged; what changed is speed and defaults. Lessons tag newer features with the version that introduced them (for example “5.5”) so you can tell what is recent.

Every value in this course so far has been checked honestly — the compiler derived a type from real evidence (a literal, a return type, a declared parameter). as and ! are different: they are you telling the compiler something it did not derive, with no runtime check behind the claim. The compiler trusts you completely, and “trust” here means “stops checking,” not “verifies and confirms.”

const raw: string = "42";
const n = raw as unknown as number; // "trust me, this is a number"
console.log(n.toFixed(2));

This compiles with zero errors. raw is still, at runtime, the string "42" — as doesn’t run any code, it only changes what the compiler believes about the expression from that point on. Click Run:

A non-null assertion on a value that actually is null

// @run
function find(id: string): { id: string; name: string } | undefined {
return undefined; // pretend this looked it up and found nothing
}
const user = find("1")!; // "trust me, this exists" — !, not a check
console.log(user.name.toUpperCase());
Open in TS Playground

find("1") really does return undefined here — the ! doesn’t change that, it just tells the compiler to stop worrying about it. The program compiles clean and then throws TypeError: Cannot read properties of undefined (reading 'name') the instant it runs, exactly the crash strictNullChecks exists to prevent. Same story as as: an assertion is a claim about the value, never a conversion of it.

Both comments suppress the error on the next line — but they fail in opposite directions when the code around them changes later, which is the only property that matters in a real codebase.

// @ts-ignore
const total: number = "not a number"; // a real, live TS2322 — now silenced forever
console.log(total.toFixed(2));

This compiles with zero errors reported — @ts-ignore swallows whatever is on the next line unconditionally, so the genuine bug (total is a string) disappears from the compiler’s output and crashes at runtime instead: TypeError: total.toFixed is not a function.

// @expect-error TS2578
// @ts-expect-error
const total: number = 1 + 1; // no error here — the suppression is now stale

@ts-expect-error makes the opposite promise: “the next line has an error, and if it ever stops having one, fail the build.” Here the line it guards is fine (1 + 1 really is a number), so the directive itself becomes an error: TS2578: Unused '@ts-expect-error' directive. That is the entire point — if a bug this comment was hiding gets fixed elsewhere (the value stops being wrong), @ts-expect-error tells you loudly instead of staying silent forever the way @ts-ignore would.

declare: types with no implementation, and zero emitted code

Section titled “declare: types with no implementation, and zero emitted code”

Sometimes a value genuinely exists at runtime through some path the compiler can’t see — a script tag loaded before your bundle, a global injected by a test runner, a module written in plain JavaScript with no types. declare describes that value’s type without providing (or checking) an implementation:

declare const BUILD_VERSION: string;
declare function legacyAlert(msg: string): void;
declare module "my-untyped-lib" {
export function doThing(): void;
}
console.log(BUILD_VERSION);
legacyAlert("hi");

This compiles with zero errors, and — because declare only describes, it never defines — it emits nothing. Compiling the snippet above with tsc --outDir produces exactly:

"use strict";
console.log(BUILD_VERSION);
legacyAlert("hi");

Every declare statement vanished; only the code that uses those names survived. This is the same erasure this lesson opened with, just at the level of an entire declaration instead of a type annotation — and it’s why .d.ts files exist at all: a whole file of nothing but declare-shaped statements compiles to nothing, so it can ship next to a library’s real JavaScript purely to describe its shape.

Everything in this lesson is one idea applied twice: the compiler only knows what it can derive, and erases everything it knows before the program runs. as/! are you injecting a fact it didn’t derive (and can’t verify); declare is you injecting a whole declaration it didn’t derive (and can’t check an implementation against). Both compile to nothing extra and both trust you completely — which is exactly why both are the first place to look when generated or unfamiliar code type-checks but still crashes.

flowchart LR
  a["as / !
(a claim about ONE value)"] --> trust["Compiler trusts it,
checks nothing further"]
  d["declare
(a claim about a whole binding)"] --> trust
  trust --> risk["Wrong claim survives
to runtime unchanged"]
Two ways to hand the compiler an unverified fact
ToolBenefitCost
as / as unknown asEscapes a type error immediately, no runtime costNo verification at all — a wrong claim compiles clean and crashes later, often far from the cast
! (non-null assertion)Shorter than a real null check when you’re certainIdentical risk to as, specifically for null/undefined — and API responses are rarely as certain as they look
@ts-ignoreSilences any error on the next line, foreverAlso silences a different, real error introduced later on that same line — you’ll never be told
@ts-expect-errorFails the build the moment the suppressed error stops being trueSlightly more ceremony; you must know the exact intent (an error is expected here)
declareLets you use a value TypeScript can’t see the definition ofZero verification that the runtime value actually matches what you declared
  • as converts a value to the target type, like a cast in other languages. It does not run any code — raw as unknown as number leaves raw exactly as it was; it only changes what the compiler believes about the expression from that point forward.
  • ! is a safe shortcut once you’re “pretty sure” a value isn’t null. It has the exact same zero-runtime-check behavior as as — “pretty sure” that turns out wrong crashes exactly like an unchecked null access would have, except the compiler’s warning is gone.
  • @ts-ignore and @ts-expect-error are interchangeable ways to silence an error. They fail in opposite directions when the code changes: @ts-expect-error demands the error still exist and fails loudly the moment it doesn’t; @ts-ignore says nothing either way, forever.
  • declare creates the value it describes. It does the opposite — it asserts a type for a value that must already exist some other way. declare-only code emits nothing and creates nothing.

Generated code reaches for as, !, @ts-ignore, and sometimes a stray declare the moment a type error is inconvenient, because all four make the red squiggle disappear immediately. When you see one, check:

  • Is there a real runtime check the assertion is standing in for — a null check, a shape validation — that the generated code skipped instead of writing?
  • Is @ts-ignore sitting on a line where the actual error message (visible if you temporarily remove the comment) describes a real bug, not a false positive?
  • Does a declare reference a global or module that genuinely exists at runtime in this project, or was it invented to make an import error go away?

🐛 Spot the bug

function getConfigValue(key: string): string {
const config: Record<string, string | undefined> = { host: "localhost" };
return config[key]!;
}
const port = getConfigValue("port");
console.log(port.toUpperCase());
Show the answer

This compiles with zero errors and crashes at runtime. config[key] is genuinely string | undefined — the compiler is correctly warning that a lookup might miss. The ! doesn’t check anything; it just asserts “this key exists,” which is false for "port" in this config object. getConfigValue("port") returns undefined at runtime despite its : string return annotation (the annotation is never verified against reality — see the Type Inference lesson on why annotating public return types matters), and port.toUpperCase() throws TypeError: Cannot read properties of undefined (reading 'toUpperCase'). The fix is a real check — if (config[key] === undefined) throw new Error(...), or making the return type honestly string | undefined and handling it at the call site — not a ! that asserts the check away.

What happens to TypeScript types when the code is compiled to JavaScript?
What is the core value proposition of TypeScript?
Why can't you check a TypeScript type directly at runtime?
What happens when this runs?
const raw: string = "42";
const n = raw as unknown as number;
console.log(n.toFixed(2));
What is the key difference between @ts-ignore and @ts-expect-error?
Does this compile, and what happens when it runs?
function getConfigValue(key: string): string {
  const config: Record<string, string | undefined> = { host: "localhost" };
  return config[key]!;
}
const port = getConfigValue("port");
console.log(port.toUpperCase());
What does declare const BUILD_VERSION: string; compile to in the emitted JavaScript?