TypeScript Essentials
TypeScript
is not a different language from JavaScript — it's JavaScript plus a
checker that reads your code before it runs and asks, "are you sure?" Every valid JavaScript program is
close to valid TypeScript, not always identical to it: renaming a .js file to
.ts and turning on strict mode (covered later in this chapter) can surface
real errors in code that ran without complaint before — that's the checker doing its job, not the
file being broken. You're opting into extra questions, not learning new syntax from scratch.
What a type checker actually does #
In plain JavaScript, calling a function with the wrong shape of data fails at runtime — sometimes with a clear error, sometimes by quietly producing undefined and a bug two
screens away. A type
is a description of the shape of a value. The checker compares the types you declare against how you
actually use them, and it does this entirely before your program runs.
That last point is worth a pause: types are completely erased at build time. They cost nothing at runtime and provide no runtime safety by themselves — they're a development-time tool for you that catches mistakes before your code ever executes.
Basic types and interfaces #
Simple values can be annotated directly:
basic annotationslet title: string = "Buy milk";
let count: number = 3;
let done: boolean = false;
let tags: string[] = ["errands", "urgent"];
Most of the time you won't write these annotations at all — TypeScript infers the type from
the value you assigned, a feature called
type inference.
let count = 3 is already known to be a number; adding : number
there is redundant.
let count = "3"; // inferred as string -- it's quoted
let count2 = 3; // inferred as number
let count3 = 3.0; // also inferred as number -- JavaScript has no separate float type,
// so 3 and 3.0 are the exact same value and the exact same type
Inference reads what's on the right of =, not what you meant. This matters most once
the value isn't a literal you typed, but something arriving from outside your program: form input,
a URL query parameter, a value parsed out of JSON. Those all arrive as string, even ones
that look numeric — "3" and 3 are different types, and inference will
never silently fix that mismatch for you.
function daysUntilDue(daysRemaining: number) { /* ... */ }
const raw = new URLSearchParams(location.search).get("days"); // raw: string | null
daysUntilDue(raw); // error: string | null is not assignable to number
When you genuinely need a number but can only be sure you have a string,
inference can't bridge that gap — you have to convert explicitly, and check that the conversion
actually worked, since not every string is a valid number:
const parsed = Number(raw); // parsed: number (but maybe NaN)
if (Number.isNaN(parsed)) {
throw new Error(`"${raw}" is not a valid number of days`);
}
daysUntilDue(parsed); // OK -- parsed is a number, and you've confirmed it's a real one
For shaped objects, an interface gives the shape a name you can reuse:
an interface and a function that uses itinterface Todo {
id: number;
title: string;
done: boolean;
notes?: string; // optional -- may be omitted entirely
}
function summarize(todo: Todo): string {
return `${todo.title} (${todo.done ? "done" : "pending"})`;
}
const t: Todo = { id: 1, title: "Buy milk", done: false };
summarize(t); // OK
summarize({ id: 2 }); // error: missing 'title' and 'done'
The ? after notes marks it optional — callers may leave it out, and
inside the function its type is string | undefined, not just string. This is
a common source of real bugs: forgetting that an optional field might not be there.
An interface is a contract, not an implementation: it says what shape a value must
have, and says nothing about how that value came to exist. This is how summarize behaves
in the above example. It accepts any object with the right fields — a plain literal, something loaded from a
database, something built by an entirely different part of your code — without caring which. Two
completely unrelated objects can both satisfy Todo:
const fromForm: Todo = { id: 1, title: "Buy milk", done: false };
const fromApi = JSON.parse('{"id":2,"title":"Write chapter 3","done":true}') as Todo;
summarize(fromForm); // works
summarize(fromApi); // also works -- same interface, completely different origin
The ability to write one function that works with several different concrete shapes, as long as
each satisfies the same contract, is
polymorphism.
Some languages require you to
explicitly declare that a class implements an interface before the compiler will allow this. TypeScript
doesn't: it uses
structural typing,
meaning any value with the right shape satisfies
an interface automatically, whether or not it was ever declared as one. Nothing above ever wrote
: Todo on fromApi at its point of creation — the as Todo
is just telling the checker to trust the shape, not registering it with the interface somewhere.
TypeScript also has a type alias, which overlaps heavily with interface:
type TodoId = number;
type Status = "pending" | "done" | "archived"; // a union of exact string values
Everywhere so far, string and number have been the types, and
"pending" or 3 have been values of those types. Status above
looks like it breaks that rule — but "pending" written inside a type
is a string literal type: a type so narrow it accepts exactly one value. string
means "any string at all"; "pending" as a type means "the string pending,
and nothing else." Status is a union of three of these narrow types, so a
Status can be any one of those three exact strings — and nothing else, not even
another perfectly valid string like "cancelled". The same value can be a
type in one position and a value in another; context tells TypeScript which is meant.
Use interface for object shapes you might extend later, and type for
unions, aliases of primitives, or combinations of other types. In practice most teams settle on one as
a default for plain object shapes and reach for the other only when they need something an interface
can't express, like a union.
Union types and generics #
A union type, written with |, says "this value is one of these
specific types":
function setStatus(status: "pending" | "done" | "archived") {
// ...
}
setStatus("done"); // OK
setStatus("cancelled"); // error: not one of the allowed values
This union-of-literals pattern is doing a job other languages give to a dedicated enum construct, and TypeScript has one of those too:
enum StatusEnum {
Pending,
Done,
Archived,
}
setStatusEnum(StatusEnum.Done);
The two look similar but behave differently. type Status = "pending" | "done" | "archived"
is type-only: it's erased completely at compile time (recall the diagram earlier in this
chapter), and the values flowing through your program at runtime are just plain strings — easy to
log, easy to send over JSON, easy to compare with ===. TypeScript's enum is
different: it actually generates a real JavaScript object at runtime that exists after compilation,
which lets you do things a type alone can't, like iterate over every member. That extra capability
isn't free — it's runtime code you're now shipping, and numeric enums have reverse-mapping
behavior that surprises people the first time they see it in the compiled output.
Default to a union of string literals for anything that's just "one of these known values" —
it's simpler, has zero runtime cost, and round-trips cleanly through JSON. Reach for enum
only when you specifically need the runtime object it provides.
A generic
lets a function, interface, or class work with more than one type while keeping the relationships
between its inputs and outputs precise. The built-in array type is itself generic — string[] is shorthand for Array<string>. You can write your own:
interface ApiResult<T> {
data: T;
fetchedAt: number;
}
function wrap<T>(data: T): ApiResult<T> {
return { data, fetchedAt: Date.now() };
}
const wrapped = wrap<Todo>(t);
wrapped.data.title; // TypeScript knows this is a string, because T was Todo
T is a placeholder that gets filled in at the call site. Without generics you'd either
lose type information (falling back to a catch-all type) or write a near-duplicate
ApiResult for every kind of data your API returns. Generics let one definition cover all of
them without losing precision.
Strict mode and shared configuration #
TypeScript's checking behavior is controlled by a config file, and by default many of its strictest, most useful checks are turned off for backward compatibility. Turning on strict mode enables the checks that catch the most real bugs:
- noImplicitAny — refuses to silently treat an unannotated, un-inferable
value as "anything goes." You must give it a real type, or opt out explicitly by writing
unknowninstead ofany—unknownstill requires you to narrow the type before using it, so you get the "I don't know this type yet" flexibility without losing the checker's protection entirely. - strictNullChecks — treats
nullandundefinedas distinct from every other type, so you can't accidentally call a method on something that might not exist without the checker flagging it.
{
"compilerOptions": {
"target": "ES2022",
"strict": true,
"module": "ESNext",
"moduleResolution": "bundler",
"skipLibCheck": true
}
}
target controls which JavaScript version the checker assumes your runtime supports, and
therefore how modern the emitted syntax is allowed to be. strict: true is a single switch
that turns on noImplicitAny, strictNullChecks, and several related flags at
once. moduleResolution: "bundler" tells the checker to resolve imports the way a modern
bundler like Vite does (Chapter 5), rather than mimicking older Node.js resolution rules — the
setting this course's own project setup actually uses.
In a repository with several related packages (a shared types package, a server, a web client — the subject of the next chapter), it's common to define one base tsconfig with the shared rules, and have each package's own config extend it, adding only what's specific to that package:
a package extending a shared base config{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
This keeps strictness and target settings consistent everywhere, while letting each package control its own output directory and included files.
Recall Todo's optional notes?: string field from earlier in this
chapter. Without strictNullChecks, the following compiles cleanly and crashes at runtime
the moment a real Todo shows up with no notes:
function noteLength(todo: Todo): number {
return todo.notes.length; // crashes if notes is undefined
}
With strictNullChecks enabled, that line is a compile-time error instead — the
checker forces you to handle the missing case, typically with todo.notes?.length ?? 0,
before you're ever allowed to run the code at all. Strict mode moves that entire class of bug from
"production incident" to "red squiggle in your editor."
The hands-on exercise for this Todo/summarize example lives in
Chapter 4's Try It, once you've got a real project running
to try it in.
Why this matters for everything that follows #
Nearly all example code from here forward is TypeScript, not plain JavaScript. Component props
(Chapter 7), API request and response shapes (Chapter 9), and database row types (Chapter 10) are all
just interfaces like Todo above. The types don't add new runtime behavior — they add
a safety net that catches mismatches between the pieces you're about to learn how to connect.