● David R. Longnecker
Chapter 3 · Foundations

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.

TypeScript source is checked for type errors, then stripped to plain JavaScript todo.ts source + types Type checker compares usage against declarations todo.js plain JavaScript types erased errors reported here, before anything runs
Types exist only at check time. The emitted JavaScript has no trace of them — the browser or Node.js never sees a type.

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 annotations
let 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 it
interface 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
Wait — aren't "pending" and "done" values, not types?

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:

a generic wrapper type
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:

a project's compiler options
{
  "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.

Why teams enable it

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.