Foundations reference

TypeScript Reference

Inference, unions, narrowing, generics and typing untrusted data, with the compiler messages translated into what they actually mean.

The idea

What TypeScript is

JavaScript with type annotations, checked before the code runs and then removed. Nothing survives into the output but the JavaScript you already knew how to write.

// what you write
function total(prices: number[]): number {
  return prices.reduce((a, b) => a + b, 0);
}

// what actually ships
function total(prices) {
  return prices.reduce((a, b) => a + b, 0);
}
  • Types are erased at build time. They cost nothing at runtime and provide nothing at runtime.
  • That second half matters: a value arriving from an API is not checked by TypeScript. It only checked the code you wrote about it.
  • Every valid JavaScript file is a valid TypeScript file, which is what makes gradual adoption possible.

What it buys you

Mistakes found while typing rather than while a user is on the page.

  • A misspelled property, a function called with the wrong arguments, a value that might be null: all caught before running.
  • Editor autocomplete that is actually accurate, which changes how quickly unfamiliar code can be read.
  • Refactoring with the compiler listing everything you have not updated yet.
  • It does not catch logic errors. A function that returns the wrong number returns a wrong number of the right type.

Types and inference

Inference does most of the work

TypeScript works out the type from the value. Annotating what it already knows is noise.

let count = 5;          // number
const name = "Amara";   // "Amara", a literal type
let items = [1, 2, 3];  // number[]

// unnecessary
let count: number = 5;

// necessary: nothing to infer from
let found: string | null = null;
function parse(input: string): number { ... }
  • Annotate the boundaries: function parameters, return types where they are not obvious, and empty containers.
  • const infers a literal type, let infers the wider one. That difference matters for unions.
  • If you annotate everything, the annotations rot and start lying. Let inference carry what it can.

The primitive types

The ones you use constantly.

string  number  boolean
null    undefined
string[]            // array
[string, number]    // tuple, fixed length and order
"a" | "b"           // literal union
any                 // switches checking off
unknown             // must be narrowed before use
never               // cannot happen
void                // returns nothing useful
  • One number type, as in JavaScript. No int or float.
  • any is an escape hatch that disables checking for that value and everything it touches.
  • unknown is the safe version of any: you can hold it, but you must narrow it before doing anything with it.

any is contagious

A value typed any spreads. Everything derived from it is unchecked too, and the compiler goes quiet exactly where you needed it loudest.

const data: any = await res.json();
const name = data.user.name;   // no error, no checking
name.toUpperCase();            // no error, may explode
  • Reaching for any to silence an error usually moves the error to runtime rather than removing it.
  • Prefer unknown and narrow it. It takes three more lines and keeps the checking.
  • noImplicitAny in tsconfig stops any appearing without you asking for it.

Describing shapes

Object types, interfaces and aliases

Three spellings, largely interchangeable in practice.

type Book = {
  title: string;
  year: number;
  subtitle?: string;      // optional
  readonly id: string;    // cannot be reassigned
};

interface Book {
  title: string;
  year: number;
}
  • Use type for unions, intersections and anything that is not an object. Use either for object shapes.
  • interface can be reopened and added to, which is useful for extending library types and surprising otherwise.
  • A ? makes the property optional, which means its type also includes undefined.
  • Pick one convention per codebase and stop thinking about it.

Structural typing

Types match by shape, not by name. Anything with the right properties fits, whether or not it was declared as that type.

type Named = { name: string };

const author = { name: "Butler", born: 1947 };
const n: Named = author;      // fine: it has a name

// but an object literal is checked strictly
const m: Named = { name: "x", born: 1947 };
// error: object literal may only specify known properties
  • This is why a function taking { name: string } accepts a much larger object.
  • Object literals are the exception: extra properties in a literal are an error, because it is almost always a typo.
  • Assigning through a variable first is the standard way around that, and it is worth asking why you want to.

Functions

Annotate the parameters; let the return type be inferred unless stating it adds something.

function total(prices: number[]): number {
  return prices.reduce((a, b) => a + b, 0);
}

const greet = (name: string, loud = false) =>
  loud ? name.toUpperCase() : name;

// a function type
type Formatter = (value: number) => string;
  • A default value gives the parameter its type, so loud is boolean without saying so.
  • Optional parameters use ?, and must come after required ones.
  • Stating the return type makes the compiler check the function against your intention rather than infer your mistake.

Unions and narrowing

This is where TypeScript stops being annotation and starts being useful. Most real values are one of several things, and narrowing is how you prove which.

Union types

A value that is one of several types.

type Status = "loading" | "error" | "ready";

let id: string | number;
let found: Book | null;
  • A literal union is a far better enum than a set of loose strings: a typo becomes a compile error.
  • You can only use members that exist on EVERY branch until you narrow.

Narrowing

Proving to the compiler which branch you are in. Ordinary JavaScript checks do this; there is no special syntax to learn.

function show(value: string | number) {
  if (typeof value === "string") {
    return value.toUpperCase();   // string here
  }
  return value.toFixed(2);        // number here
}

if (book === null) return;
book.title;                        // not null after the guard

if ("area" in shape) shape.area();
if (Array.isArray(x)) x.length;
if (err instanceof Error) err.message;
  • typeof, ===, in, Array.isArray and instanceof all narrow.
  • An early return narrows everything after it, which is why guard clauses read so well in TypeScript.
  • Truthiness narrows too, and catches people out: an empty string is falsy, so a check for a truthy string also excludes "".

Discriminated unions

A shared literal field that tells the branches apart. The single most useful pattern in the language.

type Result =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "ready"; items: Book[] };

function render(r: Result) {
  switch (r.status) {
    case "loading": return "Loading";
    case "error":   return r.message;   // only here
    case "ready":   return r.items.length;
  }
}
  • The four states from the React course, made impossible to get wrong.
  • You cannot read message unless you have checked the status, so a loading state carrying an error message cannot be written.
  • Add a fourth case to the type and every switch that does not handle it becomes an error, which is refactoring that finds its own work.

Exhaustiveness

Making the compiler prove you handled every case.

function render(r: Result): string {
  switch (r.status) {
    case "loading": return "Loading";
    case "error":   return r.message;
    case "ready":   return String(r.items.length);
    default: {
      const impossible: never = r;
      return impossible;
    }
  }
}
  • never accepts nothing, so if any case is unhandled the assignment fails and names the type you forgot.
  • This is the trick that makes adding a case to a union safe across a large codebase.

Generics

The idea

A type parameter, so a function can work with many types without losing track of which one it was given.

function first<T>(items: T[]): T | undefined {
  return items[0];
}

first([1, 2, 3]);        // number | undefined
first(["a", "b"]);       // string | undefined
  • Without the generic you would return any and lose the type, or write the function once per type.
  • T is a convention, not a rule. Name it for what it holds when that helps: <Item>, <Row>.
  • You rarely need to pass it explicitly. Inference works it out from the arguments.

Constraints

Requiring the type parameter to have something.

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest("abc", "de");        // fine
longest([1, 2], [3]);        // fine
longest(1, 2);               // error: no length
  • extends here means 'at least this shape', not inheritance.
  • Without the constraint you cannot use .length inside, because T might be anything.

Generic types

Types can take parameters too, and most of the built-in ones do.

Array<string>            // same as string[]
Promise<Book>
Record<string, number>
Map<string, Book>

type ApiResponse<T> = {
  results: T[];
  count: number;
};
  • Promise<Book> is what an async function returns; await unwraps it.
  • Writing your own generic types is worth it once the same shape wraps several kinds of payload.

Utility types

The ones worth knowing

Built-in transformations that save writing a near-duplicate type.

Partial<Book>              // every property optional
Required<Book>            // every property required
Readonly<Book>            // every property readonly
Pick<Book, "title">       // only these properties
Omit<Book, "id">          // all but these
Record<string, number>    // an object with these keys and values
ReturnType<typeof fn>     // whatever fn returns
Awaited<Promise<Book>>    // Book
  • Partial is what an update function usually takes: some fields, not all.
  • Omit is how you type an object before it has an id assigned by the database.
  • Deriving a type from another means the two cannot drift apart, which is the real win.

typeof and keyof

Getting a type out of a value, and getting the keys out of a type.

const config = { host: "localhost", port: 5432 };
type Config = typeof config;      // { host: string; port: number }

type Key = keyof Config;          // "host" | "port"

function get<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
  • typeof in a type position is the type-level one, not the JavaScript operator. Same word, different world.
  • That get function returns exactly the right type per key, which is not expressible without keyof.

Untrusted data

The most important section here, and the one most tutorials skip. Types are erased at build time, so nothing checks what an API actually sent.

The lie at the boundary

Annotating a parsed response does not check it. It tells the compiler to assume, and the compiler believes you.

// this checks NOTHING at runtime
const book = await res.json() as Book;
book.title.toUpperCase();   // may explode

// json() returns any, which is worse: it spreads silently
  • as is an assertion, not a conversion. It means 'trust me', and it is where most TypeScript projects actually break.
  • The APIs & Datasets course covers what real responses look like: missing fields, nulls, a string where you expected a number. None of that is caught here.
  • The fix is a runtime check at the edge, once, and full type safety everywhere behind it.

unknown and type guards

Take the response as unknown, check it, and let a guard tell the compiler what you proved.

function isBook(v: unknown): v is Book {
  return (
    typeof v === "object" && v !== null &&
    "title" in v && typeof (v as { title: unknown }).title === "string" &&
    "year" in v && typeof (v as { year: unknown }).year === "number"
  );
}

const data: unknown = await res.json();
if (!isBook(data)) throw new Error("Unexpected response shape");
data.title;    // genuinely a Book from here on
  • The v is Book return type is a type predicate: it makes the check narrow the value for the compiler.
  • It is your job to make the predicate honest. TypeScript trusts it exactly as much as it trusts an as.
  • For anything beyond a couple of fields, a schema validation library removes the tedium and the risk of a wrong predicate.

Where the checking belongs

Once, at the edge. Never in the middle.

  • Validate at the boundary: the API response, the form submission, the file you parsed, the URL parameter.
  • Behind that boundary, trust the types completely. That is what you paid for.
  • Sprinkling defensive checks through code that is already typed is a sign the boundary is in the wrong place.

TypeScript with React

Props

A type for the props object. That is nearly all of it.

type CardProps = {
  title: string;
  year?: number;
  onSelect: (id: string) => void;
  children?: React.ReactNode;
};

function Card({ title, year, onSelect, children }: CardProps) {
  return <article>{title}</article>;
}
  • React.ReactNode is the type for anything renderable, which is what children usually is.
  • Typing the callback prop is where most real bugs get caught: the wrong argument to onSelect becomes a compile error.
  • React.FC is no longer recommended. Type the props parameter directly.

State and events

State is usually inferred. Annotate it when the initial value is not representative.

const [count, setCount] = useState(0);              // number
const [book, setBook] = useState<Book | null>(null); // needs the annotation

function onChange(e: React.ChangeEvent<HTMLInputElement>) {
  setQuery(e.target.value);
}

function onSubmit(e: React.FormEvent) {
  e.preventDefault();
}

const inputRef = useRef<HTMLInputElement>(null);
  • useState(null) infers null and nothing else, which is why the annotation is needed there.
  • An inline handler infers its event type from where it is used, so the annotation is only needed on a separate function.
  • Typing the ref element is what makes inputRef.current.focus() check properly.

The four states, typed

A discriminated union makes the impossible combinations unwritable.

type State =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "ready"; items: Book[] };

// loading with an error message cannot be constructed
// items cannot be read without checking status first
  • Three separate useState calls allow loading and error to both be true. This does not.
  • The same argument as useReducer in the React course, enforced by the compiler rather than by discipline.

Configuration and adoption

The settings that matter

A short list; the rest can wait.

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "noEmit": true,
    "skipLibCheck": true
  }
}
  • strict: true turns on the checks that make TypeScript worth using, including null checking. Start with it on.
  • Turning strict off to make errors go away removes most of the value and is very hard to turn back on later.
  • noEmit when a bundler does the compiling and tsc only checks.
  • skipLibCheck avoids type-checking your dependencies' definitions, which is almost always what you want.

Adding it to an existing project

Gradually. All at once is how migrations fail.

  • Rename one file to .ts and fix what appears. Repeat.
  • allowJs lets typed and untyped files coexist during the migration.
  • // @ts-expect-error marks a known problem and, unlike @ts-ignore, errors if the problem is fixed. Prefer it.
  • Type the boundaries first: API responses, shared utilities, anything used everywhere. That is where the payoff is.

Third-party types

Most libraries ship their own. Some need a companion package.

npm i -D @types/node
npm i -D @types/react
  • If an import errors with 'could not find a declaration file', try the @types package for it.
  • A library with no types at all can be declared minimally in a .d.ts file rather than left as any.

Common errors, and what they mean

TypeScript's messages are precise and read as hostile until you know the pattern. Almost all of them say the same thing: the shape you have is not the shape you promised.

Type 'X' is not assignable to type 'Y'

The most common message. You gave it something of the wrong shape.

  • Read the last line of the message first: it names the specific property that does not fit.
  • For a union, it means the value does not fit ANY branch.
  • Often the fix is narrowing rather than changing the type.

Object is possibly 'null' / 'undefined'

Strict null checking doing its job. You have not proved it exists.

if (book === null) return;
book.title;

book?.title;              // optional chaining
book?.title ?? "Untitled";
  • The ! operator silences it and switches the check off. Reach for it last, and never on data from outside.

Property 'x' does not exist on type 'y'

A typo, or the type is narrower than you think.

  • On a union, it means the property is not on every branch. Narrow first.
  • On an object literal assigned to a type, extra properties are rejected deliberately.

'x' is of type 'unknown'

You must narrow it before using it. Working exactly as intended.

if (typeof u === "string") u.toUpperCase();

Parameter 'x' implicitly has an 'any' type

noImplicitAny asking you to say what it is.

  • Usually a callback parameter TypeScript could not infer. Annotate it, or fix the surrounding type so it can.

Argument of type 'X' is not assignable to parameter of type 'Y'

The same message as the first one, at a call site.

  • Check the argument order before the types. Two parameters of the same type swapped is a common cause.

Type instantiation is excessively deep

A generic type that recurses too far. Rare, and usually from a library.

  • Simplify the type, or annotate the value explicitly to stop the compiler working it out.

It compiles and still crashes at runtime

The most important error in this list, because it is not an error at all.

  • Types are erased. Something from outside your code was not the shape you told the compiler it was.
  • Look for an as, an any, or a non-null !, and check the boundary where the data entered.
  • That is the whole reason the untrusted data section exists.

Want to learn how to build with this?

A reference tells you what exists. The course teaches you when to reach for it, and has you build something real while you learn.