You already know JavaScript β TypeScript IS JavaScript, plus a layer that reads your code before it runs and points at mistakes. Every JS line you've written is valid TS. You're not learning a new language; you're hiring a proofreader.
Every one of these is a real bug you wrote in the Library app. JavaScript ran them silently. TypeScript refuses to compile them:
| Your actual bug | What JS did | What TS says instead |
|---|---|---|
books.lenght (typo) | silent undefined, NaN later | Property 'lenght' does not exist. Did you mean 'length'? |
Math.max(books.map(...)) β no spread | silent NaN id | Argument of type 'number[]' is not assignable to parameter of type 'number' |
books[books.find(...)] β object as index | silent undefined | Type 'Book' cannot be used as an index type |
"5" + 3 stored where a number belongs | silent "53" | Type 'string' is not assignable to type 'number' |
book.publisher (key that doesn't exist) | silent undefined | Property 'publisher' does not exist on type 'Book' |
undefined/NaN that explodes three steps later, far from the
real cause. TS kills the bug at its birthplace.
tsc (TypeScript Compiler). Big bonus: the check happens during
translation, and the output is ordinary JS with the type notes stripped out.
Types exist only at compile time β zero cost in the browser.
tsc is a program on your computer (like Node), not something the page
runs. The browser never sees TypeScript at all. In VS Code: Terminal β New Terminal
(shortcut Ctrl+`) and type the commands there. The flow is:
you write app.ts β run tsc in the terminal β it produces app.js β the browser runs app.js.
The browser stays as JS-only as ever; tsc does its checking before the browser is
ever involved.
# one-time install (you have Node, so you have npm):
npm install -g typescript
# check it worked:
tsc --version
# compile one file: app.ts β app.js
tsc app.ts
# create tsconfig.json (project settings) once:
tsc --init
# with a tsconfig, just: tsc (compiles the project)
# or better: tsc --watch (recompiles on every save)
-g mean? Global vs local installs
-g = global: npm installs TypeScript once for your whole machine
(on Windows it lands in C:\Users\<you>\AppData\Roaming\npm), not
into the folder you're standing in. That's exactly why the tsc command then works from
any terminal, in any folder.
-g) = tools you use across many projects (tsc, later maybe nodemon). Install once, use everywhere. Like installing VS Code itself.-g) = a project's own dependencies, listed in its package.json, installed into that project's node_modules. You'll meet this in the Node phase.npm install -g β doesn't care.
tsc --init β cares a lot: it creates tsconfig.json in the
folder you're standing in, so run it inside the project (LibraryManagementSystem).
tsc --version β 7.0.2 (the new fast native compiler) Β·
tsc --init run inside LibraryManagementSystem/ Β·
tsconfig.json generated with "strict": true.
tsc --init generates a big file; almost everything can stay default. Know these:
"strict": true β ALL the safety checks on. Non-negotiable for us. Learning with strict off is learning half of TS."target" β which JS version to output (e.g. "es2020" is fine; modern browsers are happy)."outDir" β where compiled .js files land (e.g. "./dist") so they don't mix with your .ts sources.<script> tag must point at the compiled .js
file. The .ts file never goes in the HTML. Classic first-day confusion: "I fixed the .ts but the page
didn't change" β did you recompile? (--watch exists to end that pain.)
exports is not defined crash
App compiled with 0 errors, but the browser console exploded:
Uncaught ReferenceError: exports is not defined, then a cascade of
Cannot access 'books' before initialization. The cause was tsconfig, not your
code. The generated tsconfig had "module": "nodenext" +
"moduleDetection": "force" β which told tsc "treat every file as a Node.js
module" β so the emitted app.js contained Node-style plumbing (exports) that
browsers don't have.
<script src="app.js"> is a
script β no import/export machinery. Modules (with import/export)
need different loading. We're script-world until the Node phase."module": "esnext" + "moduleDetection": "auto"
β auto = "it's a module only if the file actually uses import/export." Yours doesn't, so tsc
emits plain browser JS again.An annotation is a colon + type after a name: "this box holds only this kind."
let title: string = "Harry Potter";
let copies: number = 5;
let isAvailable: boolean = true;
copies = "five"; // β Type 'string' is not assignable to type 'number'
Arrays: the item type + []:
let titles: string[] = ["Harry Potter", "The Alchemist"];
let ids: number[] = [1, 2, 3, 4];
ids.push("5"); // β string can't enter a number[] β the "53" bug, blocked at the door
let copies = 5; and TS infers number from the value β same
protection, no annotation. Hover any variable in VS Code to see its inferred type.
Rule: annotate where TS can't guess β function parameters, empty arrays
(let loans: Loan[] = []) β and let inference do the rest. Beginner code drowning in
unnecessary annotations is a smell.
Parameters get annotations; the return type goes after the ( ):
function lateFine(daysLate: number): number {
return daysLate * 2;
}
lateFine(5); // 10
lateFine("5"); // β caught β remember .value is always a string!
lateFine(); // β Expected 1 argument, but got 0
(b: Book) => b.copies <= 2.
void β "returns nothing"
Your renderBooks, saveBooks, borrowBook return nothing β their
return type is void: function saveBooks(): void { ... }. TS usually infers
it; writing it is documentation.
interface β the shape of a Book, written downYour whole app runs on objects shaped like a book β but in JS that shape lived only in your head. An interface writes it down, and TS enforces it everywhere:
interface Book {
id: number;
title: string;
author: string;
category: string;
copies: number;
}
let books: Book[] = [
{ id: 1, title: "Harry Potter", author: "J.K. Rowling", category: "Fantasy", copies: 5 }
];
books.push({ id: 5, title: "New Book" });
// β missing: author, category, copies β no more half-built books EVER entering the array
book.titel; // β typo caught β plus autocomplete now knows every field
?
publisher?: string means "may or may not exist." Then TS forces you to handle the
missing case before using it β Β§11's "missing key = undefined" surprise, now supervised.
undefined β your find is about to argueA union means "one of these types," written with | (read it as "or"):
let memberId: number | null = null; // "a number, or nothing yet"
let category: "Fantasy" | "Fiction" | "Autobiography"; // only these exact strings!
Book | undefined
What does books.find(b => b.id === id) return when nothing matches? undefined
(you know this from Β§9). So TS says its return type is Book | undefined β and
refuses book.copies until you've handled the undefined case:
let book = books.find(b => b.id === id);
book.copies; // β 'book' is possibly 'undefined'
if (!book) return; // handle the miss (guard clause β your addBook pattern!)
book.copies; // β
TS watched the if β inside here it KNOWS it's a Book
This isn't TS being annoying β it's the findIndex β -1 β splice deletes the wrong book
hole I mentioned, found automatically. TS forces the boundary thinking you've been
practicing all along.
if (!book) return; the type narrows from
Book | undefined to Book. Same with typeof x === "string"
branches. Your guard-clause habit is exactly how narrowing is done β you were writing TS-shaped code
before you knew TS.
any β the type that turns TypeScript offany means "don't check this." Every safety net in this page β gone, for that variable,
and for everything it touches.
let data: any = "hello";
data.lenght; // no error. data() β no error. data.foo.bar β no error. All bombs at runtime.
any (the plan literally says so)
any spreads: one any assigned into other variables silently makes them unchecked too.
With "strict": true, TS at least flags implicit anys (untyped parameters).
When a type is genuinely unknown (e.g. JSON.parse output), the honest tool is
unknown: TS then forces you to CHECK before using β vs any which lets you
do anything blind. See a lazy tutorial slap any on things? That's the tutorial
giving up, not a technique.
Converting the Library, you'll hit these immediately β they're features, not obstacles:
getElementById might find nothing
Its return type is HTMLElement | null β because a typo'd id really does return null
(remember the script-before-tbody crash in Β§14? Same hole). Guard it, or assert when you're certain:
let tbody = document.getElementById("bookRows");
if (tbody) { tbody.innerHTML = rowsHTML; } // narrowed, safe
// or, when you KNOW the element exists in your HTML:
let tbody = document.getElementById("bookRows")!; // ! = "trust me, not null"
.value doesn't exist on HTMLElement
getElementById can't know it found an input, and plain HTMLElements have no
.value. Tell TS which element it is with as (a type assertion):
let input = document.getElementById("newTitle") as HTMLInputElement;
input.value; // β
now TS knows .value exists β and that it's a string
And because TS knows .value is a string, forgetting Number(...)
on copies becomes a compile error the instant you try to put it in a number slot.
The "+ trap" from Β§5 β extinct.
<T> you keep seeing (light intro)How can one find work on arrays of books AND arrays of numbers, yet stay type-safe?
Generics: a type placeholder, filled in per use.
// Book[] is shorthand for:
let books: Array<Book> = [];
// on Book[], TS reads find as: find(callback): Book | undefined
// on number[], the SAME find is: find(callback): number | undefined
<T> as "of ___"
Array<Book> = "array of Books." T is a blank the user of the function fills;
TS then threads that type through parameters and return. That's the whole trick β a type-level
parameter, exactly like function parameters but for types. For now just read them;
writing your own generics comes later.
This is the actual Phase 3 project, in order:
<script> body into app.ts; point the HTML at the compiled app.js.tsc, watch it list errors. Don't panic: the errors are a TODO list, and each one is a real hole JS was hiding.interface Book β then let books: Book[] = [...]. Most errors organize themselves around this.borrowBook(id: number): void, etc.as HTMLInputElement, handle Book | undefined.any, app works identically β that's the Phase 3 finish line.JSON.parse(saved) returns any β TS can't know what was in storage (this is
where your stale-id migration bug lived, Β§27!). The honest pattern:
books = JSON.parse(saved) as Book[]; β an assertion, i.e. a promise you make.
TS can't verify what's on disk; you can at least pin the expected shape.
| JavaScript | TypeScript |
|---|---|
| Types exist, but only in your head | Types written down, checked by machine |
| Bugs surface at run time, far from the cause | Bugs surface at compile time, at the cause |
Silent undefined / NaN | Loud red squiggle, before you even save |
| Shape of a Book: tribal knowledge | Shape of a Book: interface, enforced + autocompleted |
| Runs in the browser | Compiles TO the JS that runs in the browser |
Your real Phase 3 run β 18 errors to zero, with what each step taught:
| Step | Errors | What happened |
|---|---|---|
First tsc on raw JS | 18 | 4 buckets: untyped params (TS7006 Γ4), possibly-null DOM (TS2531 Γ6), .value missing on HTMLElement (TS2339 Γ5), find-may-be-undefined (TS18048 Γ3). The errors were a TODO list, exactly as promised. |
interface Book + books: Book[] | 18 β 14 | One interface killed 4 errors at once β inference flowed through Book[] into every callback's (book) param. Types at the source spread to the users. |
if (!book) return; guard | 14 β 11 | Narrowing: after the guard, Book | undefined becomes Book. The id-99 story β a find that misses returns undefined, and TS refuses to let you touch it unguarded. |
| First DOM pass | 11 β 12 β οΈ | Errors went UP: a leftover experiment line + only a partial fix. Lesson: read the compiler output as evidence, not as a score. The list tells you exactly which lines β believe it. |
as HTMLInputElement Γ5 + ! | 12 β 0 π | Zero errors, zero any. Then the Β§2 exports-crash detour (tsconfig, above) β and finally: all 5 features verified working, behavior identical. |
Everything so far was converting code you'd already written. The capstone is different: designing a feature in TypeScript from the start β types first, code second. Read-aheads: JS notes Β§29 (Dates) and Β§30 (linking by id) are the raw material.
borrowedBy/dueDate fields onto Book. That breaks the
moment two copies of the same book are borrowed by different people. Right design: a
separate Loan object per borrow event, pointing at its book by
bookId β the same way your Delete buttons already send ${book.id}.
One book β many loans. This is exactly a foreign key, the idea Phase 5 (SQL) is built on.
interface Loan {
id: number; // the loan's own id (same Math.max trick as books)
bookId: number; // WHICH book β the link, like a foreign key
memberName: string;
borrowedDate: string; // ISO string β see the why box below
dueDate: string;
returned: boolean;
}
string dates and not Date?
Loans live in localStorage via JSON.stringify β and JSON has no Date type.
A Date object silently becomes a string on save, and JSON.parse gives you back a plain
string, NOT a Date (JS notes Β§29). If the interface claimed Date, it would be lying after
every reload. Honest rule: store ISO strings, wrap in new Date(...) only at the
moment you need date math (comparing, adding days).
new Date() = now Β· d.toISOString().slice(0, 10) β "2026-08-28" Β·
add 14 days: d.setDate(d.getDate() + 14) Β· compare: ISO strings sort/compare correctly
as plain strings ("2026-09-01" > "2026-08-28" is true) β that's WHY ISO format
puts year first.
copies - 1. In the capstone it becomes a transaction touching
two arrays: decrement the book's copies and push a new Loan (who, which book, due
when). Return does the reverse: mark the loan returned, increment copies. Fine = if today > dueDate,
days late Γ rate. Two sources of truth (books, loans) that must stay
consistent β the exact problem real databases solve with transactions. You're meeting it small first.
Built in one session: interface Loan + persistent loans array β Borrow
creates a Loan (name via prompt, due = today + 14 days) β loans table with idβtitle
lookup β Return button (copy goes back) β fines at βΉ5/day overdue. Verified end-to-end: back-dated
loan showed βΉ50 (10 days late), Return flipped it to βΉ0 and restored the copy, everything survived
refresh. These are the bugs the build actually hit β each one is a permanent lesson:
tsc generates. One more compile and the bulldozer would have erased it all.
The same trap bit three times in one session from both sides: editing .ts and forgetting
tsc (browser runs stale code), and editing .js directly (work gets overwritten).
The chain is law: edit app.ts β tsc β refresh. app.js is disposable output β
never open it to edit, only to read what the compiler produced.
localStorage.clear() (last session's unsolved mystery)
A leftover debugging line β localStorage.clear();, uncommented, at the bottom of the
script β ran on every page load and wiped everything that had been saved. Symptom:
deleted books kept reappearing after reload. This was also the real cause of the previous session's
"clear()/delete not working" confusion. Lesson: scratch lines that destroy state are not
harmless graveyard comments β they run. Delete debug lines the moment the experiment ends.
if (!index) return; blocked deleting the FIRST book
findIndex answers with a position: 0 for the first item,
-1 for "not found". But !0 is true β so the guard treated the
first book as "not found" and returned. Worse, !(-1) is false, so a real
miss would sail through to splice(-1, 1) β which deletes the LAST item. The guard was
wrong in both directions. Truthiness cannot tell "nothing" from "position 0" β
compare against the actual sentinel: if (index === -1) return;
= vs === bug RETURNED (twice, in returnBook)
loans.find(l => l.id = loanId) and books.find(b => b.id = loan.bookId) β
the exact bug from the JS phase (notes Β§27), reborn. One = assigns: the "search"
overwrites every id it touches. And here's the sober part: TypeScript did not catch it β
assignment inside an arrow is legal code that returns a number, and find accepts it.
Some bugs only discipline catches. When typing a condition, hear it in your head: "double-equals-strict
compares, single assigns."
loan.returned = "Returned" (data vs display, again)
The interface says returned: boolean β the data stores true/false,
never prose. The word "Returned" is presentation, produced at render time by
loan.returned ? "Returned" : "Out". Same split as the very first render lesson:
data stays clean and machine-shaped; the screen translates it for humans. (And this one TS
did refuse β a string is not assignable to boolean. That's the conversion paying rent.)
book.copies = book.copies - 1; copied straight from borrowBook into returnBook.
Borrow takes a copy OUT (β1); Return brings it BACK (+1). As written, one borrow + return cost the
library two copies. Lesson: when copying a line, re-ask what the story says the line should
do here β the syntax travels, the intent doesn't.
books (copiesβ1) AND loans (push) β
saveBooks(); saveLoans(); renderBooks(books); renderLoans();. returnBook likewise.
Forgetting saveLoans() made loans vanish on refresh (in memory β on disk β the oldest
lesson in the app); forgetting renderLoans() made new loans invisible until reload
(data β screen). Two sources of truth that must stay consistent is exactly the problem SQL
transactions solve β Phase 5 will feel familiar.
fine field exists on Loan. Every render recomputes it fresh from the dates:
returned β βΉ0; not yet due β βΉ0; else Math.floor((today β due) in ms Γ· 86,400,000)
days late Γ βΉ5. Subtracting two Dates gives milliseconds, so divide by
1000Β·60Β·60Β·24 to get days. Why not store the fine? A stored fine is stale the next day;
a computed one is always right. Rule of thumb: store facts (dates), derive opinions
(amounts).
<meta charset="UTF-8">
Files are bytes, not characters. βΉ is three bytes in UTF-8; with no charset declared, the
browser guessed an old one-byte-per-character encoding and read them as three separate letters:
Γ’ β ΒΉ. The first line inside <head> β
<meta charset="UTF-8"> β tells the browser how to decode the bytes.
Every real webpage carries it; now the Library does too.
borrowBook(1) pops the prompt.
Fake test data the same way: loans[loans.length - 1].dueDate = "2026-08-20T00:00:00.000Z";
saveLoans() β refresh β the fine math runs against a 10-day-late loan. Predict the number
BEFORE looking (10 Γ βΉ5 = βΉ50) β a test you didn't predict is just watching, not testing.
β Prev: JavaScript Β· Back to contents Β· Next: Node.js β