TSer/Finale · Thinking in types
CHAPTER · satisfies, as const, unknown

Finale: thinking in types

Eleven chapters have covered the syntax. This one is about how to think. TypeScript is JavaScript plus a layer of types that exists only while you write and compile: the compiler checks the types, then removes them, and the JavaScript that runs is the JavaScript you wrote. Five ideas, one map of the course, and a final quiz.

§01

Idea 1 — three forms: annotation, as, and satisfies

The same config object, written three ways. The difference is what gets checked and what gets inferred.

Three ways to tell the compiler what a value is

Giving a value a type is a message to the compiler, and these three forms send three different messages. An annotation (: Config) means "check this": the compiler checks the value, then replaces the inferred type with the type you declared. An assertion (as Config) means "take my word for it": the check is skipped. satisfies (added in TypeScript 4.9) means "check it, but do not rewrite it": the shape is checked and the inferred type is left alone. Compare them below.

Three forms, one object literal
config.ts
1interface Config {
2 shop: string;
3 theme: "light" | "dark";
4 maxSugar: number;
5}
6
7const config: Config = {
8 shop: "Sunrise Tea",
9 theme: "dark",
10 maxSugar: 7,
11};
Annotation — "please check this". The compiler checks the object, then replaces the inferred type with the declared one.

An object literal assigned straight to a typed variable gets the excess property check: Object literal may only specify known properties, but 'thema' does not exist in type 'Config'. Did you mean to write 'theme'? (TS2561). That check is from chapter 04.

Summary: annotation checks ✓ infers ✕ · as checks ✕ infers ✕ · satisfies checks ✓ infers ✓. When you want both, use satisfies.

One more keyword often appears next to these three: as const. It does not check a shape. It does something else: it stops widening. Literal values keep their exact literal types, and every property becomes readonly.

as const: keep the literals exactly as written
1const SIZES = ["small", "medium", "large"] as const;
2// type: readonly ["small", "medium", "large"]
3// not string[] — nothing was widened
4
5type Size = (typeof SIZES)[number];
6// "small" | "medium" | "large"
7// typeof and indexed access, from chapter 07, working together:
8// the values are written once and the type comes out of them
A common combination: satisfies checks the shape, and as const keeps the literals. They can be used together: {…} as const satisfies Config.

Annotating everything is a beginner habit

Inference already does most of the work. Writing const total: number = 22 adds nothing the compiler did not already know, and it makes the code longer and harder to change.

Two places are worth annotating. Function parameters, because a parameter comes from outside and inference has nothing to read. Public boundaries — exported functions, module APIs, shared data — because there the type is a promise to other code, and writing it down means the compiler checks the promise instead of copying whatever you happened to return. Everywhere else, let inference do it.

§02

Idea 2 — what an assertion costs

Every as says: I know more about this value than the compiler does. Sometimes that is true. The question is where your information comes from.

as is not a bad keyword. It exists for the cases where you really do know more than the compiler. There is one test: where does your information come from? If it comes from something you checked yourself, the assertion is reasonable. If it comes from not wanting to handle the other case, it is not.

Reasonable: you really do know more
1// You wrote the page. You know #pay is a button.
2const btn = document
3 .querySelector("#pay") as HTMLButtonElement;
4btn.disabled = true;
5
6// A test stub: only the fields the test touches
7const stub = {
8 id: "T-1", total: 30,
9} as Order; // the test reads these two only
querySelector returns Element | null. The compiler has not read your HTML and you have. That gap in information is what makes the assertion reasonable. It is still a promise you are making, so if the markup changes, this line becomes wrong silently.
Not reasonable: you just skipped the work
1// You have no idea what the other side sent.
2const res = await fetch("/api/order/1");
3const order = (await res.json()) as Order;
4
5order.total.toFixed(2);
6// compiles: yes
7// the day the backend renames the field:
8// TypeError: Cannot read properties of undefined
JSON from a request, localStorage, form input: the shape of that data is not controlled by your codebase. Here as is not knowledge, it is a guess written down as a fact. Section 03 shows what to do instead.

as unknown as T: the loudest signal in the type system

When two types have nothing in common, a direct as is rejected, so people write x as unknown as T to get past it in two steps. Read what that actually means: "discard everything the compiler knows about this value, then let me relabel it". In test code it is sometimes a reasonable escape hatch. In application code it usually means the types are modelled wrongly, and the model is what should change.

TypeScript is deliberately not fully sound

A type system is sound when a program that passes the check cannot fail in a way the types said was impossible. TypeScript is not sound, and this is a design decision, not a bug. A fully sound system would reject a great deal of ordinary JavaScript, so TypeScript trades some guarantees for being usable on real code.

It helps to know the specific holes, because these are the places where a green compile still means nothing.

Four holes, all of which compile without an error
1// 1. any switches off every check on the values it touches
2const raw: any = JSON.parse(input);
3raw.total.toFixed(2); // accepted; may fail at runtime
4
5// 2. an assertion is believed, not verified
6const order = raw as Order; // accepted; nothing was checked
7
8// 3. array types are covariant, so this is accepted
9declare const rex: Dog;
10declare const cat: Cat;
11const dogs: Dog[] = [rex];
12const animals: Animal[] = dogs;
13animals.push(cat); // accepted; dogs now holds a Cat
14
15// 4. indexing is not checked unless you ask for it
16const names: string[] = [];
17const first: string = names[0];
18// first has type string, and at runtime it is undefined.
19// noUncheckedIndexedAccess (chapter 10) makes it string | undefined
None of these lines is an error today, and all four can fail at runtime. This is not a reason to distrust TypeScript. It is the reason the rest of this chapter exists: keep any and as rare, check data at the boundary yourself, and turn on noUncheckedIndexedAccess when the project can take it.
§03

Idea 3 — unknown at the boundary

The compiler's authority ends when compilation ends. Data that arrives while the program runs has to be checked by code you wrote.

Types are erased during compilation. That has one cold consequence: every interface you wrote is gone in production, and data arriving from outside is not validated by anything. An interface that describes an API response is a claim about the data, not a guarantee. Step through what that looks like.

Types end at compile time. The boundary is yours to guard.
Your code
JSON.parse(raw) as Order
Compiler
A request comes back with some JSON. You write as Order, and the compiler accepts it without looking at the value. From this point on, the type of that value is Order as far as the compiler is concerned.
1 / 5

A type error is not a runtime error

Beginners assume a type error stops everything. It does not. By default tsc reports the error and still writes the JavaScript file. Set noEmitOnError if you want it to stop. Many build tools go further and remove types without checking them at all, so the type error never even appears during the build.

So a red underline is a message, not a wall. It is worth saying plainly, because it explains something people find confusing: code with type errors can still run, and can still be shipped. Making the check part of continuous integration is what turns the message into a wall.

The isOrder function standing at the boundary is the type predicate from chapter 03: a check that runs on the value, in exchange for narrowing at the type level.

A boundary check, written by hand
1interface Order {
2 id: string;
3 total: number;
4 size: "small" | "medium" | "large";
5}
6
7// Return type x is Order: if the check passes, unknown becomes Order here
8function isOrder(x: unknown): x is Order {
9 if (typeof x !== "object" || x === null) return false;
10 const o = x as Record<string, unknown>; // local assertion, to read fields
11 return (
12 typeof o.id === "string" &&
13 typeof o.total === "number" &&
14 (o.size === "small" || o.size === "medium" || o.size === "large")
15 );
16}
17
18const data: unknown = await res.json(); // start by admitting you do not know
19if (isOrder(data)) {
20 data.total.toFixed(2); // data is Order here, and it was actually checked
21} else {
22 throw new Error("Response body is not an Order");
23}
Look at the as on line 10. It lives inside the function, it exists only so the fields can be read one by one, and every field is then actually checked. That is an assertion helping a check, not an assertion replacing one.

Writing every validator by hand gets tiring

In a real project the shapes get large, and a hand-written validator gets long and easy to get wrong. Runtime validation libraries such as zod solve this: you describe the shape once, and both the validation function and the TypeScript type are generated from that one description.

This course does not cover them. One sentence is enough: they exist because types are erased, so something has to do the checking at runtime. The idea is the same as the isOrder you just read.

§04

Idea 4 — any is a tool, not a habit

Criticising any is easy. Using it well is harder. The rule is about how far it can spread.

Eleven chapters have said "avoid any". Here is the fair version. any has legitimate uses: old code during a migration (the transitional state from chapter 10), and a genuinely dynamic boundary, such as the result of eval or a third-party callback whose shape is not documented anywhere. The discipline is one line: keep it in the smallest scope that works.

Out of control: any reaches an exported signature
1// utils.ts
2export function parseOrder(raw: any): any {
3 return JSON.parse(raw);
4}
5
6// three months later, in another file:
7const o = parseOrder(raw); // o: any
8o.tatol; // misspelled, nobody reports it —
9// any spread along the call chain into the whole project
any spreads: anything read out of an any value is any as well. Once it reaches an exported signature, every caller in the project loses its checks too.
Contained: the messy part stays inside
1// utils.ts
2export function parseOrder(raw: string): Order {
3 // inside, do whatever you need — even a local any:
4 const data: unknown = JSON.parse(raw);
5 if (!isOrder(data)) throw new Error("Bad order payload");
6 return data; // what leaves the function is a checked Order
7}
8
9const o = parseOrder(raw); // o: Order
The exported signature is precise, so every caller is protected. Messy inside is fine. The boundary is not.

Reach for unknown first, and for any only if you must

Both accept any value. The difference is what you may then do with it. unknown must be narrowed before use, so the compiler forces you to add the check. any allows every use, so no check is required and none is performed.

So when you do not know the type, start with unknown. It is the type-safe way of saying "I do not know yet". Drop to any only when unknown genuinely makes the code impossible to write, and keep it in the smallest scope you can.

§05

Idea 5 — types as documentation: make illegal states unrepresentable

A comment can go out of date and a document can go unread. A type cannot be quietly ignored, because code that contradicts it does not compile.

The same order status, modelled two ways. On the left every field is optional, so impossible combinations can be built freely. On the right the discriminated union from chapter 03 writes down which fields exist in which state.

One big object, everything optional
1interface Order {
2 status: string; // a typo here is not an error
3 paidAt?: Date;
4 deliveredAt?: Date;
5 cancelReason?: string;
6}
7
8// All of these impossible orders type check:
9// delivered, but never paid:
10// { status: "delivered" }
11// canceled, and delivered anyway:
12// { status: "canceled",
13// deliveredAt: yesterday }
You could write "paidAt only exists when status is paid" in a document. A document does not report errors, and code does not read documents. The agreement lives only in someone's memory.
Discriminated union: illegal states cannot be written
1type Order =
2 | { status: "unpaid"; items: Item[] }
3 | { status: "paid"; items: Item[]; paidAt: Date }
4 | { status: "delivered"; items: Item[]; paidAt: Date;
5 deliveredAt: Date }
6 | { status: "canceled"; reason: string };
7
8// "delivered but never paid"?
9// You cannot write it down. The compiler says:
10// Property 'paidAt' is missing in type
11// '{ status: "delivered"; items: never[];
12// deliveredAt: Date; }' but required in type
13// '{ status: "delivered"; items: Item[];
14// paidAt: Date; deliveredAt: Date; }'.
After switch (order.status) each branch has exactly the fields that state has, and never catches a state you forgot. Chapter 03 gave you the technique. This chapter gives the reason to use it: put the agreement in the type instead of in a comment beside it.

Why this idea comes last

The first four ideas are about getting along with the compiler. This one is about what the type system is actually for. It is not a spell checker. It is a language for describing the rules of your problem. "An order cannot be delivered before it is paid" is such a rule. Written into the type, every future change that breaks it fails to compile.

Notice what the union discriminates on: status, a real property that exists at runtime. It cannot discriminate on the name of a type, because TypeScript compares the shape of a type, not its name, and the name is gone after compilation. Chapter 04 covers that comparison in detail.

§06

Building your own type helpers

The tools from chapter 06 and the parts from chapter 07, put together. Build two of them yourself.

First one: MyOmit, a rewrite of the Omit you have been using since chapter 06. All the parts come from chapter 07. A mapped type walks the keys, and as key remapping (TypeScript 4.1) removes the ones you do not want.

First one: MyOmit
1interface Order {
2 id: string;
3 total: number;
4 toppings: string[];
5}
6
7type MyOmit<T, K extends keyof T> = {
8 [P in keyof T as P extends K ? never : P]: T[P];
9};
10
11// Line 8, piece by piece:
12// [P in keyof T go through every key of T (mapped type)
13// as P extends K key remapping: send each key through a test
14// ? never : P] on the list K? rename it to never, which drops it
15// : T[P] for the keys that stay, copy the property type
16
17type Draft = MyOmit<Order, "toppings">;
18// { id: string; total: number }
never does the work again here. A key mapped to never is removed from the result, which makes never a general way to delete things in type-level code.

Second one, harder: DeepReadonly. The built-in Readonly only locks the top level, so a nested object can still be changed. To lock all of it, let the mapped type call itself.

Second one: DeepReadonly
1type DeepReadonly<T> = {
2 readonly [K in keyof T]: T[K] extends object
3 ? DeepReadonly<T[K]> // an object? go one level down (recursion)
4 : T[K]; // a primitive? nothing left to lock
5};
6
7const cfg: DeepReadonly<{
8 shop: string;
9 hours: { open: number; close: number };
10}> = { shop: "Sunrise Tea", hours: { open: 9, close: 22 } };
11
12cfg.hours.open = 8;
13// Cannot assign to 'open' because it is a read-only property.
Conditional types (chapter 07) plus mapped types (chapter 07) plus recursion gives you a new tool. A careful version also handles functions and arrays: a function is an object too, and there is no point recursing into it. That is a medium-level problem on type-challenges, and it is worth doing yourself.
Practice · problem set
type-challenges

github.com/type-challenges/type-challenges is a community-maintained set of type-level problems, from easy to extreme. Each one runs in the Playground and ships with test cases, so you get an answer immediately. The MyOmit and DeepReadonly you just wrote are both problems from this set.

Reference · official docs
TypeScript Handbook

The Handbook under typescriptlang.org/docs is the primary source. Every concept in this course has an authoritative version there. At your current level it is readable, which is the main thing this course was for.

Workbench · check anything
TS Playground

typescriptlang.org/play needs no account, produces a shareable link, lets you switch TypeScript versions, and shows the compiled output. When you are unsure how something behaves, do not guess. Paste it in and read the answer. That habit is worth more than any single fact in this course.

§07

The whole course on one map

Twelve chapters, five stages, one sentence each. If a sentence does not feel solid, open that chapter again.

§08

Practice

An idea only counts once you have used it: the three forms bench, a validator written by hand, and two type-level exercises.

§09

Final quiz

Twelve questions across the whole course: inference, narrowing, structural typing, generics, utility types, type operators, and tsconfig.

QUESTION 01 / 12

What does TypeScript infer for let a = "Oolong Tea"; and const b = "Oolong Tea";?

QUESTION 02 / 12

An order status is modelled as a discriminated union, and the default branch of the switch contains const x: never = order;. Three months later someone adds a new status, "refunded". What happens?

QUESTION 03 / 12

order({ size: "large", ice: "none" }) reports that ice does not exist, but storing the same object in a variable first and passing the variable compiles. Why?

QUESTION 04 / 12

Given function longest<T extends { length: number }>(a: T, b: T): T, which call is an error?

QUESTION 05 / 12

You are adding drafts: a half-filled Order must also be saveable, and every field may be empty for now. Which utility type is meant for this?

QUESTION 06 / 12

const MENU = { oolong: 12, mango: 22 } as const;
type Name = keyof typeof MENU; — what is Name? Join the members with |.

QUESTION 07 / 12

Under strict, what is the type of e in catch (e), and why?

QUESTION 08 / 12

You are writing a theme config object. You want the compiler to check that it matches Config, and you also want config.theme to keep the literal type "dark" so later checks stay precise. Which form do you use?

QUESTION 09 / 12

Which of these statements about any and unknown are correct? (Choose all that apply.)

QUESTION 10 / 12

With verbatimModuleSyntax on, you need to import Order, which is used only as a type. Which form is correct?

QUESTION 11 / 12

const config = { theme: "dark" } as const; — what is the type of config.theme?

QUESTION 12 / 12

Last question. In the compiled code that runs in production, can if (order instanceof Order) test whether a value matches an interface named Order?

§10

Where to go next

The course ends here. The part that makes it stick happens outside the course.

You have finished the course

In the prologue you were asking what types are for, since JavaScript already runs. Now you can read every word of a compiler error, design a validation boundary for data that comes back from a request, use a discriminated union so that illegal states cannot be written down, build Omit yourself, and plan a step-by-step migration for an old JavaScript project. That is twelve chapters of progress.

Route 1 · build it
Actually write the tea shop system

Start a project with Vite and TypeScript, and implement the ordering system this course kept coming back to: MenuItem, an Order as a discriminated union, a generic container, a Partial draft, and isOrder at the boundary. Turn on strict, and add noUncheckedIndexedAccess as well.

You will notice that all the types in this course were describing one world the whole time.

Route 2 · go deeper
One type problem a day

Work through type-challenges starting from the easy set, one problem a day. When you cannot solve one, read the discussion thread; the community posts some very clever solutions there. Once easy and medium are done, the type definitions in open-source libraries stop being intimidating.

Route 3 · read real code
Read types written by other people

Open the JavaScript library you use most and read its .d.ts inside node_modules. Then look at DefinitelyTyped to see how @types packages add types to libraries that have none. Reading other people's type design comes before doing your own.

A project you built and a pull request you sent say more than a certificate.

One last thing. What this course taught is not syntax. It is a way of looking at a program: every value has a shape, and a shape can be described, checked, and derived. The assumptions you carry in your head — "this will not be null here", "in this state that field must exist" — can be written down instead, and handed to a checker that never gets tired and never forgets.

It will not catch everything. Types are erased, the system is not fully sound, and the data arriving from outside is still your responsibility. What it does catch, it catches early, while you are still looking at the code that caused it.

Whatever language you write next, you will start by asking what the agreements are. That habit is what this course was really for.

What to take away from the whole course
  • A type is an agreement written down. The compiler keeps the agreements you wrote; the ones you only remembered depend on luck.
  • The three forms: an annotation checks but widens, as neither checks nor keeps the literal, satisfies does both. Add as const when you need the literals kept exactly.
  • Types are erased at compile time, so nothing is checked at runtime. Take external data in as unknown and narrow it with a check you wrote, so that a failure happens at the boundary rather than three files away.
  • The type system is deliberately not fully sound. any, assertions, array covariance, and unchecked indexing are real holes, so "it compiles" is not the same as "it is correct".
  • any is legitimate during a migration and at a genuinely dynamic boundary. The rule is containment: messy inside is fine, exported signatures must be precise. Reach for unknown first.
  • The most useful thing types do is modeling: a discriminated union makes illegal states impossible to write, so the agreement stops being a comment. And when you are unsure, check it in the Playground instead of guessing.