Why TypeScript?
The bug TypeScript is designed to catch
Section titled “The bug TypeScript is designed to catch”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 TS2561function 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)"]
- 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 TS1005function 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.
Type erasure, concretely
Section titled “Type erasure, concretely”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.
The honest costs
Section titled “The honest costs”TypeScript is not free, and pretending otherwise does you a disservice:
- A build step. You need
tscor a bundler that strips types. Plain.tsdoesn’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.
TypeScript 7 and the native compiler
Section titled “TypeScript 7 and the native compiler”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:
strictis on by default. Since 6.0 a baretsconfig.jsonalready meansnoImplicitAny,strictNullChecksand friends are enabled — every example in this course assumes that.- Old escape hatches are gone.
target: "es5",moduleResolution: "node",baseUrlandesModuleInterop: falsewere 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.
as and ! are assertions, not conversions
Section titled “as and ! are assertions, not conversions”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
// @runfunction 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 checkconsole.log(user.name.toUpperCase()); 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.
@ts-ignore vs @ts-expect-error
Section titled “@ts-ignore vs @ts-expect-error”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-ignoreconst total: number = "not a number"; // a real, live TS2322 — now silenced foreverconsole.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-errorconst 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.
Mental Model
Section titled “Mental Model”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"]
Trade-offs
Section titled “Trade-offs”| Tool | Benefit | Cost |
|---|---|---|
as / as unknown as | Escapes a type error immediately, no runtime cost | No 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 certain | Identical risk to as, specifically for null/undefined — and API responses are rarely as certain as they look |
@ts-ignore | Silences any error on the next line, forever | Also silences a different, real error introduced later on that same line — you’ll never be told |
@ts-expect-error | Fails the build the moment the suppressed error stops being true | Slightly more ceremony; you must know the exact intent (an error is expected here) |
declare | Lets you use a value TypeScript can’t see the definition of | Zero verification that the runtime value actually matches what you declared |
Common Misconceptions
Section titled “Common Misconceptions”asconverts a value to the target type, like a cast in other languages. It does not run any code —raw as unknown as numberleavesrawexactly 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 asas— “pretty sure” that turns out wrong crashes exactly like an uncheckednullaccess would have, except the compiler’s warning is gone.@ts-ignoreand@ts-expect-errorare interchangeable ways to silence an error. They fail in opposite directions when the code changes:@ts-expect-errordemands the error still exist and fails loudly the moment it doesn’t;@ts-ignoresays nothing either way, forever.declarecreates 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.
Reading AI-generated code
Section titled “Reading AI-generated code”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
nullcheck, a shape validation — that the generated code skipped instead of writing? - Is
@ts-ignoresitting 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
declarereference 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.