TypeScript NotesRohit’s interview study guide
Chapter 05

Assertions, as const & satisfies

Telling the compiler what you know — and the safer tools (as const, satisfies) that keep it checking.

8 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Type assertions with as

A type assertion tells the compiler "treat this expression as type T — I know more than you do here." It's written value as T (or the older <T>value, which doesn't work in .tsx files because it looks like JSX). The compiler then uses T for that expression instead of what it inferred.

There are real situations where you do know more than the checker. document.getElementById("email") can only promise HTMLElement | null; you wrote the HTML and know it's an <input>. JSON.parse returns any; you know which shape the endpoint sends. An assertion is how you pass that knowledge on.

The mental model: an assertion is a signed statement, not a check. The compiler doesn't verify it against reality — there's nothing at runtime to verify against, because types are erased. It only does a sanity check that the claim is plausible: the two types must overlap, meaning one of them is assignable to the other. If you're wrong, the compiler won't find out, and neither will you until something breaks at runtime.

TypeScript
const el = document.getElementById("email"); // const el: HTMLElement | null
const input = document.getElementById("email") as HTMLInputElement; // const input: HTMLInputElement
console.log(input.value);

const label = <HTMLLabelElement>document.querySelector("label"); // older syntax, same meaning

const n = "42" as number; // ❌ TS2352: Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
What’s happening
  1. Without an assertion, getElementById returns HTMLElement | null. el.value wouldn't compile: a plain HTMLElement has no value, and it might be null.
  2. as HTMLInputElement asks the checker to use HTMLInputElement instead. It first checks the claim is plausible: is HTMLInputElement assignable to HTMLElement | null? Yes — it's a subtype. So the assertion is accepted, and input.value is a string.
  3. Notice the assertion also quietly removed null. If there's no #email element on the page, input is null at runtime and input.value throws — the assertion covered that up.
  4. <HTMLLabelElement>… is the angle-bracket form. It's identical in .ts files and a syntax error in .tsx, so most code uses as everywhere.
  5. "42" as number is rejected with TS2352: string isn't assignable to number and number isn't assignable to string, so the types don't "sufficiently overlap". This is the only protection an assertion gives you — it stops claims that are obviously impossible, not claims that are merely wrong.
AdvancedAssertions in both directions

The overlap rule works in both directions, which is what makes assertions useful and dangerous at the same time:

TypeScript
interface User {
  id: number;
  name: string;
}

const widened = "hello" as string; // up: a literal to its wider type
const narrowed = ({ id: 1, name: "Ana" } as object) as User; // down: a wider type to a narrower one
const incomplete = { id: 1 } as User; // compiles — but name is missing
const fromApi = JSON.parse('{"id":1}') as User; // any → anything is always allowed
What’s happening
  1. "hello" as string goes up the type hierarchy (literal "hello" to string). That's always safe — it's the same as an annotation would allow.
  2. (… as object) as User goes down: object is wider than User, and User is assignable to object, so the claim "this object is actually a User" is plausible and accepted.
  3. { id: 1 } as User is also a downcast: User is assignable to { id: number } (it has id and more), so the types overlap and the compiler accepts it — even though name is missing. Compare an annotation, const u: User = { id: 1 }, which would be an error (see Annotations vs assertions).
  4. JSON.parse returns any, and any overlaps with everything, so as User is always accepted and never checked. The object at runtime has no name at all.
  5. So the rule is: an assertion may widen (safe) or narrow (unchecked). The compiler can't tell a correct narrowing from a wrong one.
AdvancedWhen assertions are reasonable

When assertions are reasonable: DOM lookups of elements you control, narrowing something the checker can't follow (but prefer a type guard if you can), test doubles, and the inside of small, well-tested helpers. When to avoid them: anything that came from outside your program — API responses, localStorage, user input. For those, validate at runtime (see Validating runtime data).

#An assertion is not a conversion

This one deserves its own heading because the syntax is misleading. In other languages (int) x or x as Int may actually convert a value. In TypeScript, x as number changes nothing at runtime. It's erased completely: the compiled JavaScript for raw as number is just raw. If raw was the string "42" before, it's still the string "42" afterwards — but now the compiler believes it's a number, and stops warning you.

So an assertion that's wrong doesn't fail where you wrote it. It fails later, somewhere else, often with a misleading error or — worse — no error and a wrong result.

TypeScript
function readAge(raw: unknown): number {
  return raw as number;
}

const age = readAge("42"); // const age: number — but it's the string "42"

console.log(age + 1); // "421" — string concatenation, not 43
console.log(age.toFixed(1)); // compiles; at runtime: TypeError: age.toFixed is not a function
What’s happening
  1. readAge receives "42" as an unknown. raw as number is allowed because unknown overlaps with everything.
  2. The emitted JavaScript is function readAge(raw) { return raw; }. No conversion happens; the string goes straight through.
  3. The checker types age as number, so age + 1 type-checks as numeric addition. At runtime age is "42", and + with a string concatenates: "42" + 1 → "421". No error — just a wrong answer.
  4. age.toFixed(1) type-checks because numbers have toFixed. At runtime "42".toFixed is undefined, and calling it throws TypeError: age.toFixed is not a function.
  5. The bug is in readAge, but the symptoms appear at the call sites. That distance between cause and symptom is exactly what makes wrong assertions expensive.
AdvancedConvert and check instead of asserting

The fix is to convert and check, which produces a real number and lets the compiler narrow from what you actually tested:

TypeScript
function readAge(raw: unknown): number {
  const age = typeof raw === "string" ? Number(raw) : raw;
  if (typeof age !== "number" || Number.isNaN(age)) {
    throw new TypeError(`Not a valid age: ${String(raw)}`);
  }
  return age;
}

const age = readAge("42");
console.log(age + 1); // 43
What’s happening
  1. If raw is a string, Number(raw) converts it: "42" becomes the number 42. Otherwise raw is passed through unchanged. At this point age is typed unknown (it's number | unknown, which collapses to unknown).
  2. typeof age !== "number" is a real runtime check, and the checker understands it: after the if throws for non-numbers, age is narrowed to number (see Narrowing with typeof).
  3. Number.isNaN(age) rejects strings like "abc" that converted to NaN.
  4. return age needs no assertion — the compiler proved it's a number. And age + 1 now prints 43.
AdvancedAsserting API data isn't validation

The same trap with API data:

TypeScript
interface User {
  id: number;
  name: string;
  email: string;
}

const user = JSON.parse('{"id":"7","name":"Ana"}') as User;

console.log(user.id + 1); // "71" — id is really a string
console.log(user.email.toLowerCase()); // compiles; at runtime: TypeError: Cannot read properties of undefined (reading 'toLowerCase')
What’s happening
  1. The JSON has id as a string and no email. JSON.parse returns any, and as User turns that into a User without looking at it.
  2. user.id + 1: the checker thinks id is a number; at runtime it's "7", so you get "71".
  3. user.email.toLowerCase(): the checker thinks email is a string; at runtime it's undefined, so the property access on undefined throws.
  4. The type said "this is a User", but nothing made it one. For data crossing a trust boundary, parse it with a validator (zod, valibot, class-validator) that checks at runtime and produces the type — see Validating runtime data.

#Double assertions

The overlap rule stops "42" as number. If you really want to make the claim anyway, you go through a type that overlaps with everything: value as unknown as T. Everything is assignable to unknown, and unknown overlaps with every type, so each of the two steps passes the plausibility check on its own. (Older code uses as any as T for the same effect.)

The compiler's own error message suggests it — "If this was intentional, convert the expression to 'unknown' first" — and that's the right framing: a double assertion is you explicitly switching off the one check assertions have. It should be rare, and it should look deliberate.

TypeScript
class Database {
  private pool = "pg://…";
  query(sql: string): Promise<string[]> {
    return Promise.resolve([]);
  }
  close() {}
}

const fake = {
  calls: [] as string[],
  async query(sql: string) {
    this.calls.push(sql);
    return ["row"];
  },
};

const db1 = fake as Database; // ❌ TS2352: Conversion of type '{ calls: string[]; query(sql: string): Promise<string[]>; }' to type 'Database' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
const db2 = fake as unknown as Database; // compiles

async function countUsers(db: Database) {
  const rows = await db.query("select * from users");
  return rows.length;
}

countUsers(db2).then((n) => console.log(n, fake.calls)); // 1 [ 'select * from users' ]
What’s happening
  1. fake is a test double: it has query and a calls array to record what was asked. It doesn't have close, and it can't have pool, which is private to the class.
  2. fake as Database fails the overlap check in both directions: fake is missing pool and close, so it isn't assignable to Database; and Database has no calls, so it isn't assignable to fake's type. TS2352.
  3. fake as unknown as Database: step one, fake as unknown, always succeeds (anything widens to unknown). Step two, unknown as Database, always succeeds (unknown can be narrowed to anything). The check is bypassed.
  4. countUsers(db2) only calls query, which fake does have, so it works at runtime: it records the SQL and resolves to ["row"], and the log shows 1 and the recorded call.
  5. If countUsers ever calls db.close(), the test will throw db.close is not a function and the compiler won't warn you. That's the trade you made: in a test with a narrow surface, acceptable; in application code, a red flag.
AdvancedAlternatives to double assertions

Better alternatives, in order of preference: type the dependency as the narrow interface you actually need (function countUsers(db: Pick<Database, "query">) — then fake fits without any assertion), use a mocking library that builds a full fake, or write a type guard. Many teams lint against double assertions outside tests.

#Annotations vs assertions

Both put a type next to a value, so they look interchangeable. They are opposites:

  • An annotation (const user: User = …) is a requirement: "this value must be a User — check it." The compiler verifies the value against the type and reports anything missing, mistyped or extra.
  • An assertion (const user = … as User) is a claim: "trust me, this is a User." The compiler only checks plausibility and then believes you.

Default to annotations. Reach for an assertion only when you have information the checker can't see.

Annotation — the compiler checks

TypeScript
interface User {
  id: number;
  name: string;
}

const a: User = { id: 1 }; // ❌ TS2741: Property 'name' is missing in type '{ id: number; }' but required in type 'User'.
const b: User = { id: 1, nmae: "Ana" }; // ❌ TS2353: Object literal may only specify known properties, and 'nmae' does not exist in type 'User'.
const c: User = { id: 1, name: "Ana" };
What’s happening
  1. a is missing name. The annotation is a requirement, so TS2741.
  2. b misspells name as nmae. That makes name missing and nmae an excess property; the excess-property check (see Excess property checking) reports the typo first.
  3. c is complete and passes.

Assertion — the compiler trusts you

TypeScript
interface User {
  id: number;
  name: string;
}

const a = { id: 1 } as User; // compiles — name is missing
const b = { id: 1, nmae: "Ana" } as User; // ❌ TS2352: Conversion of type '{ id: number; nmae: string; }' to type 'User' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
const c = {} as User; // compiles — everything is missing
What’s happening
  1. a passes: User is assignable to { id: number }, so the types overlap, and the assertion is accepted. a.name is undefined at runtime.
  2. b is caught only because the typo makes the types completely disjoint: { id; nmae } lacks name, and User lacks nmae. Assertions don't do excess-property checks.
  3. c passes: {} is wider than User, so it's a plausible narrowing. {} as User is the classic way to create a broken object.
AdvancedHow annotations catch later type changes

The difference compounds over time. Imagine adding a required email: string to User next month:

TypeScript
interface User {
  id: number;
  name: string;
  email: string; // new field
}

function makeUserChecked(): User {
  return { id: 1, name: "Ana" }; // ❌ TS2741: Property 'email' is missing in type '{ id: number; name: string; }' but required in type 'User'.
}

function makeUserAsserted() {
  return { id: 1, name: "Ana" } as User; // compiles — silently missing email
}
What’s happening
  1. makeUserChecked declares its return type, which works like an annotation on the returned value. When email becomes required, the literal no longer satisfies User, and the compiler points straight at the place to fix.
  2. makeUserAsserted uses an assertion. User (with email) is still assignable to { id: number; name: string }, so the types still overlap and the assertion still passes.
  3. Result: every assertion site becomes a hidden bug the day the type grows. Annotations turn type changes into a to-do list of compiler errors; assertions hide them.
AdvancedWhere an assertion is the right tool

A few places where an assertion is the normal tool, because an annotation can't express it:

  • An empty starting value that will be filled in: const byId = {} as Record<string, User> (though const byId: Record<string, User> = {} works here too, and is better).
  • Narrowing a DOM lookup you control: form.elements.namedItem("email") as HTMLInputElement.
  • Telling the checker about a literal type: as const (next-but-one topic) — the one assertion that makes types more precise rather than less checked.
  • When you want checking and the inferred type, neither fits — that's what satisfies is for.
Which lines compile?
TypeScript
interface Point { x: number; y: number }

const p1: Point = { x: 1 };
const p2 = { x: 1 } as Point;
const p3 = { x: 1, y: 2, z: 3 } as Point;
const p4: Point = { x: 1, y: 2, z: 3 };
const p5 = "origin" as Point;
Show answer

p2 and p3 compile; p1, p4 and p5 don't.

What’s happening
  1. p1 — annotation, y missing → TS2741.
  2. p2 — assertion; Point is assignable to { x: number }, so the types overlap and it's accepted. p2.y is undefined at runtime.
  3. p3 — assertion; { x; y; z } is assignable to Point (extra properties are fine structurally), and assertions skip excess-property checks. Accepted.
  4. p4 — annotation on a fresh object literal, so excess property checking runs: z doesn't exist in Point → TS2353.
  5. p5 — string and Point don't overlap in either direction → TS2352. You'd need "origin" as unknown as Point, which should make you stop and think.

#The non-null assertion operator

Under strictNullChecks (see strict and strictNullChecks), anything that might be null or undefined has to be handled before you use it. The postfix ! operator is a shortcut: value! tells the compiler "this isn't null or undefined, take my word for it." The type loses null | undefined — HTMLElement | null becomes HTMLElement — and that's all. Like every assertion, it's erased: the JavaScript output is just value.

It's tempting because it makes red squiggles go away with one character. The mental model: ! is as NonNullable<typeof x> with less typing — an unchecked claim. Compare ?., which looks similar but is a real runtime check that short-circuits to undefined.

TypeScript
const prices = new Map<string, number>([["apple", 1.5]]);

if (prices.has("apple")) {
  const p1 = prices.get("apple"); // const p1: number | undefined
  const p2 = prices.get("apple")!; // const p2: number
  console.log(p2.toFixed(2)); // "1.50"
}

const p3 = prices.get("mango")!; // const p3: number — a lie
console.log(p3.toFixed(2)); // compiles; at runtime: TypeError: Cannot read properties of undefined (reading 'toFixed')

const p4 = prices.get("mango")?.toFixed(2); // const p4: string | undefined — real check, no crash
What’s happening
  1. Map.prototype.get is typed to return number | undefined, because the key might be missing.
  2. Inside if (prices.has("apple")) you know the key exists, but the checker doesn't connect two separate method calls — has returning true tells it nothing about a later get. So p1 is still number | undefined.
  3. prices.get("apple")! removes undefined from the type: p2 is number. Here the claim is true, so p2.toFixed(2) prints "1.50".
  4. prices.get("mango")! makes the same claim, but it's false. The ! is erased, p3 is undefined at runtime, and .toFixed throws — far from where the lie was told.
  5. ?. is different in kind: it's real JavaScript that checks for null/undefined at runtime and returns undefined instead of throwing. Its type honestly includes undefined, so the compiler will make you handle it.
AdvancedDefinite assignment assertions

The same ! character has a second use, the definite assignment assertion, on declarations rather than expressions. It's for variables and class fields that are assigned in a way the checker can't see:

TypeScript
class Person {
  name!: string; // definite assignment assertion: "something will set this"
  age: number; // ❌ TS2564: Property 'age' has no initializer and is not definitely assigned in the constructor.

  init(data: { name: string; age: number }) {
    this.name = data.name;
    this.age = data.age;
  }
}

let config!: { port: number };
loadConfig();
console.log(config.port); // 8080

function loadConfig() {
  config = { port: 8080 };
}
What’s happening
  1. Under strictPropertyInitialization (part of strict), every class field must be assigned in the constructor or have an initialiser. age has neither, so TS2564.
  2. name!: string says "I promise this will be set before it's read" — here, by init. The checker accepts it and types name as string, not string | undefined. If someone reads name before calling init, they get undefined with no warning.
  3. let config!: … does the same for a variable. Without the !, config.port would be TS2454 ("Variable 'config' is used before being assigned"), because the checker doesn't look inside loadConfig to see that it assigns config.
  4. At runtime loadConfig() runs first (function declarations are hoisted), sets config, and the log prints 8080. Swap the two lines and it throws — the compiler won't notice.
  5. Frameworks that set fields for you (Angular's @Input(), ORMs, dependency injection) are the legitimate users of field!: T.
AdvancedAvoiding ! most of the time

How to avoid ! most of the time:

  • Narrow instead: const el = document.getElementById("app"); if (!el) throw new Error("#app missing"); — after the throw, el is HTMLElement, and if it's ever missing you get a clear error at the right place.
  • Use the value you already have: const p = prices.get(key); if (p !== undefined) { … } instead of has + get!.
  • Defaults: prices.get(key) ?? 0.
  • Optional chaining when "nothing" is a fine answer: user?.address?.city.
  • Lint with @typescript-eslint/no-non-null-assertion if your team wants ! to be a deliberate exception.

#as const

as const is a const assertion. Unlike other assertions, it doesn't make the compiler trust you about anything — it tells the compiler to infer the most specific type it can for a literal expression:

  • string, number and boolean literals keep their literal types ("GET", not string; 3, not number)
  • every property of an object literal becomes readonly, all the way down
  • array literals become readonly tuples (readonly ["a", "b"], not string[])

Why is that needed? Because by default TypeScript widens literals in places that can change. A let or an object property could be reassigned later, so { method: "GET" } is inferred as { method: string } (see Primitive and literal types). as const says "this will never change", which lets the compiler keep the exact values.

TypeScript
let m1 = "GET"; // let m1: string
let m2 = "GET" as const; // let m2: "GET"

const config = { method: "GET", retries: 3, tags: ["a", "b"] };
// const config: { method: string; retries: number; tags: string[] }

const frozen = { method: "GET", retries: 3, tags: ["a", "b"] } as const;
// const frozen: { readonly method: "GET"; readonly retries: 3; readonly tags: readonly ["a", "b"] }

frozen.retries = 5; // ❌ TS2540: Cannot assign to 'retries' because it is a read-only property.
frozen.tags.push("c"); // ❌ TS2339: Property 'push' does not exist on type 'readonly ["a", "b"]'.

const sum = (1 + 2) as const; // ❌ TS1355: A 'const' assertion can only be applied to references to enum members, or string, number, boolean, array, or object literals.
What’s happening
  1. let m1 = "GET" widens to string, because a let can be reassigned to any string. let m2 = "GET" as const keeps "GET": m2 can now only ever hold "GET".
  2. config is a const, but const only stops you rebinding the variable — the object's properties can still change. So the checker widens each property: method: string, retries: number, tags: string[].
  3. frozen with as const keeps every literal ("GET", 3), marks every property readonly, and turns the array into the tuple readonly ["a", "b"]. The readonly-ness is deep: it applies inside nested objects and arrays too.
  4. Because of that, assigning frozen.retries is TS2540, and push doesn't exist on a readonly tuple (readonly arrays have no mutating methods).
  5. (1 + 2) as const is rejected with TS1355: as const only works on literal expressions (and enum member references), because "the most specific type of a computed value" isn't something it can promise.
AdvancedWhere as const earns its keep

Where it earns its keep:

TypeScript
type Method = "GET" | "POST";
declare function send(method: Method, url: string): void;

const req = { method: "GET", url: "/users" };
send(req.method, req.url); // ❌ TS2345: Argument of type 'string' is not assignable to parameter of type 'Method'.

const reqC = { method: "GET", url: "/users" } as const;
send(reqC.method, reqC.url); // fine: method is "GET"

const Role = { Admin: "admin", Editor: "editor", Viewer: "viewer" } as const;
type Role = (typeof Role)[keyof typeof Role]; // type Role = "admin" | "editor" | "viewer"

function useToggle(initial: boolean) {
  let on = initial;
  const toggle = () => {
    on = !on;
  };
  return [on, toggle] as const; // readonly [boolean, () => void]
}

const [isOpen, toggleOpen] = useToggle(false);
What’s happening
  1. req.method was widened to string, and string isn't assignable to "GET" | "POST", so send rejects it with TS2345. This is the most common way people meet the problem.
  2. With as const, reqC.method is "GET", which is a member of Method, so the call compiles.
  3. Role is the enum replacement pattern: a plain object with as const gives you runtime values (Role.Admin) and, via typeof Role[keyof typeof Role], a union of its values as a type. keyof typeof Role is "Admin" | "Editor" | "Viewer"; indexing with it gives the value types "admin" | "editor" | "viewer" (see keyof typeof and Enums vs literal unions).
  4. useToggle returns a pair. Without as const the return type is (boolean | (() => void))[] — an array where every element could be either — so isOpen couldn't be used as a boolean. With as const it's the tuple readonly [boolean, () => void], and destructuring gives isOpen: boolean and toggleOpen: () => void. That's how hooks like React's useState return typed pairs.
AdvancedPitfall: as const at runtime
AdvancedUnions from as const arrays

For pulling a union out of an as const array (typeof roles[number]), see [Element types with T[number]](#element-types-with-t-number).

#satisfies

My notes say "satisfies — important" and "little hard to understand", so here's the one-sentence version: expr satisfies T checks that expr is a valid T, but the variable keeps the type inferred from expr, not T. It was added in TypeScript 4.9.

It exists because annotations and assertions each give you only half of what you want:

  • An annotation const x: T = expr checks expr against T — good — but then x is a T. Everything specific the compiler could have inferred about expr is thrown away and replaced by the (usually wider) T.
  • An assertion expr as T doesn't really check — bad — and x is still a T.
  • satisfies checks like an annotation (missing properties, wrong types, excess properties, typos) and then leaves the inferred type alone.

Mental model: an annotation puts the value in a box labelled T; you only see the label from then on. satisfies holds the value up against a T-shaped stencil, confirms it fits, and hands it back unchanged.

TypeScript
type Color = "red" | "green" | "blue";
type RGB = [number, number, number];

const annotated: Record<Color, string | RGB> = {
  red: [255, 0, 0],
  green: "#00ff00",
  blue: [0, 0, 255],
};
annotated.green.toUpperCase(); // ❌ TS2339: Property 'toUpperCase' does not exist on type 'string | RGB'.

const palette = {
  red: [255, 0, 0],
  green: "#00ff00",
  blue: [0, 0, 255],
} satisfies Record<Color, string | RGB>;
// const palette: { red: [number, number, number]; green: string; blue: [number, number, number] }

palette.green.toUpperCase(); // fine: green is a string
palette.red.map((channel) => channel / 255); // fine: red is a tuple of numbers
What’s happening
  1. With the annotation, the checker verifies the literal fits Record<Color, string | RGB> — it does — and then types annotated as exactly that. Every property is now string | RGB, even though you can see green is a string.
  2. So annotated.green.toUpperCase() fails with TS2339: on the RGB half of the union there's no toUpperCase. You'd need a typeof check for a value you wrote yourself two lines up.
  3. With satisfies, the same check runs: every Color key must be present and every value must be a string or an RGB.
  4. But palette keeps its inferred type. green is string; red and blue are [number, number, number] — note: tuples, not number[]. The satisfies type is used as a contextual type during inference, so the array literals were inferred as tuples to fit RGB.
  5. So palette.green.toUpperCase() and palette.red.map(…) both compile with no narrowing. You got the check and the precise type.
AdvancedMistakes satisfies catches

It catches the same mistakes an annotation would — and an assertion wouldn't:

TypeScript
type Color = "red" | "green" | "blue";
type RGB = [number, number, number];

const typo = {
  red: [255, 0, 0],
  green: "#00ff00",
  bleu: [0, 0, 255], // ❌ TS2353: Object literal may only specify known properties, and 'bleu' does not exist in type 'Record<Color, string | RGB>'.
} satisfies Record<Color, string | RGB>;

const missing = { red: [255, 0, 0], green: "#00ff00" } satisfies Record<Color, string | RGB>; // ❌ TS2741: Property 'blue' is missing in type '{ red: [number, number, number]; green: string; }' but required in type 'Record<Color, string | RGB>'.

const asserted = { red: [255, 0, 0], green: "#00ff00" } as Record<Color, string | RGB>; // compiles — blue is missing
What’s happening
  1. typo misspells blue. satisfies runs excess property checking on the literal, so bleu is flagged right on its line.
  2. missing leaves blue out, and satisfies reports it, like an annotation would. The message shows the inferred type of the literal, including the tuple types.
  3. asserted makes the same mistake with as, and it compiles: Record<Color, …> is assignable to { red; green }, so the types overlap and the assertion is accepted. asserted.blue is undefined at runtime but typed string | RGB.
Advancedas const satisfies T together

The most useful combination in real code is as const satisfies T: freeze the literal types and validate the shape. Config objects and route tables are the classic case:

TypeScript
type Route = { path: string; auth: boolean };

const routes = {
  home: { path: "/", auth: false },
  settings: { path: "/settings", auth: true },
} as const satisfies Record<string, Route>;

type RouteName = keyof typeof routes; // type RouteName = "home" | "settings"
const settingsPath = routes.settings.path; // const settingsPath: "/settings"

const annotatedRoutes: Record<string, Route> = {
  home: { path: "/", auth: false },
};
type LostNames = keyof typeof annotatedRoutes; // type LostNames = string
What’s happening
  1. as const applies first: the literal's type becomes { readonly home: { readonly path: "/"; readonly auth: false }; readonly settings: { … } }.
  2. satisfies Record<string, Route> then checks every entry has a string path and a boolean auth. A typo like auht: true or a missing path would be an error on that entry.
  3. routes keeps the as const type, so keyof typeof routes is the exact union "home" | "settings" — you can use it to type a navigate(to: RouteName) function, and adding a route automatically extends the union.
  4. With an annotation instead, annotatedRoutes is a Record<string, Route>, so its keys are just string: the route names are lost, and annotatedRoutes.nope would type-check (as Route, thanks to the index signature).
What is the type of mode on each of these?
TypeScript
type Theme = { mode: "light" | "dark"; accent: string };

const t1: Theme = { mode: "dark", accent: "#09f" };
const t2 = { mode: "dark", accent: "#09f" } satisfies Theme;
const t3 = { mode: "dark", accent: "#09f" } as const satisfies Theme;
const t4 = { mode: "dark", accent: "#09f" } as Theme;
Show answer

t1.mode is "light" | "dark", t2.mode is "dark", t3.mode is "dark" (and readonly), t4.mode is "light" | "dark".

What’s happening
  1. t1 — annotation: the variable is a Theme, so mode is whatever Theme says: "light" | "dark".
  2. t2 — satisfies: the variable keeps the inferred type. Normally mode: "dark" in an object literal would widen to string, but the satisfies type is used as a contextual type, and because Theme["mode"] is a literal union, the checker keeps the literal "dark". accent is string.
  3. t3 — as const keeps "dark" regardless of context, makes both properties readonly, and keeps accent as the literal "#09f". satisfies then confirms it's a valid Theme.
  4. t4 — assertion: the variable is a Theme again, so mode is "light" | "dark", and nothing was really checked.

#unique symbol

Advanced

At runtime every call to Symbol() produces a value that's different from every other symbol — that's the whole point of symbols. But the general type symbol doesn't capture that: two variables of type symbol look interchangeable to the checker. unique symbol is a subtype of symbol that stands for one specific symbol value, tied to one declaration. My notes put it as "unique at type level", which is exactly right.

You rarely write unique symbol yourself, because a const declaration initialised with Symbol() gets it automatically — the type shows up as typeof NAME. You need it when you want a symbol to work as a known property key in a type, the way a string literal does, and when you want the checker to tell two symbols apart.

TypeScript
const A = Symbol("id"); // const A: typeof A  — a unique symbol
const B = Symbol("id"); // const B: typeof B  — a different unique symbol
let C = Symbol("id"); // let C: symbol

if (A === B) { // ❌ TS2367: This comparison appears to be unintentional because the types 'typeof A' and 'typeof B' have no overlap.
  console.log("never");
}

let D: unique symbol = Symbol("id"); // ❌ TS1332: A variable whose type is a 'unique symbol' type must be 'const'.

console.log(A.description === B.description); // true — same description, still different symbols
What’s happening
  1. const A = Symbol("id"): because A is const and initialised with a Symbol() call, the checker gives it a fresh unique symbol type, displayed as typeof A. B gets its own, different one.
  2. let C is reassignable — it could hold any symbol later — so it only gets the general type symbol.
  3. A === B is TS2367: two different unique symbol types have no values in common, so the checker knows the comparison can only be false. With plain symbol it would allow the comparison.
  4. let D: unique symbol is TS1332: a unique symbol type has to be bound to exactly one value forever, so only const declarations (and static readonly class properties) may have it.
  5. The description "id" is just a label for debugging. Both symbols have the same description and are still different values — and the types reflect that.

The main use is symbol property keys with precise types:

TypeScript
const ID = Symbol("id");

interface Entity {
  [ID]: string;
  name: string;
}

const e: Entity = { [ID]: "u_1", name: "Ana" };
const id = e[ID]; // const id: string

console.log(Object.keys(e)); // [ 'name' ] — symbol keys are skipped
console.log(JSON.stringify(e)); // {"name":"Ana"}
What’s happening
  1. ID is a unique symbol, so [ID]: string in the interface declares one specific property, the same way name: string does. The checker knows Entity has exactly two members.
  2. Creating e requires both [ID] and name; e[ID] reads back as string.
  3. At runtime, symbol-keyed properties are hidden from Object.keys, for…in and JSON.stringify, which is why libraries use them for metadata that shouldn't collide with user keys or leak into serialised output.
  4. If the key were a plain symbol (for example from a let), the checker couldn't know which symbol it is, so it can't declare one property. Current TypeScript treats a computed key of type symbol in an interface as a symbol index signature, [x: symbol]: string — any symbol key at all, which is far less precise.

The built-in well-known symbols are typed the same way — SymbolConstructor declares readonly iterator: unique symbol — which is why [Symbol.iterator]() { … } in a class is checked as a specific, known method. And unique symbols power the branded type pattern, which gives structural TypeScript a taste of nominal typing:

TypeScript
declare const brand: unique symbol;

type UserId = string & { readonly [brand]: "UserId" };
type OrderId = string & { readonly [brand]: "OrderId" };

function toUserId(raw: string): UserId {
  if (!raw.startsWith("u_")) throw new Error(`Bad user id: ${raw}`);
  return raw as UserId;
}

function getUser(id: UserId) {
  return { id, name: "Ana" };
}

getUser(toUserId("u_1")); // fine
getUser("u_1"); // ❌ TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.

const orderId = "o_9" as OrderId;
getUser(orderId); // ❌ TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
What’s happening
  1. declare const brand: unique symbol creates a unique symbol type without creating any runtime value — declare emits nothing. It's only ever used as a key in types.
  2. UserId is "a string that also has a [brand] property of type "UserId"". No real string has that property, so a plain string isn't assignable to UserId — that's the error on getUser("u_1").
  3. toUserId is the one sanctioned way in: it validates the string and then uses an assertion (raw as UserId) to apply the brand. This is a good use of as: contained in one small function that actually checked the value.
  4. OrderId has the same shape except the brand's value is "OrderId". Comparing [brand] property types, "OrderId" isn't assignable to "UserId", so passing an order id where a user id is expected is caught.
  5. Because brand is a unique symbol that's never exported, no other code can accidentally construct something with the same key. At runtime, UserId values are just ordinary strings — the brand costs nothing.

symbol

  • The type of any symbol value.
  • What let s = Symbol() and function parameters get.
  • Two symbols can be compared; the checker can't tell them apart.
  • As a computed key in an interface, it can only describe "some symbol key" (an index signature).

unique symbol

  • The type of one specific symbol, written typeof NAME.
  • Inferred automatically for const NAME = Symbol(); only allowed on const and static readonly.
  • Two different unique symbols don't overlap, so comparing them is an error.
  • Works as a precise property key in interfaces and types, like a string literal.

Built from Rohit’s “Typescript Interview” study doc. Examples are checked with the TypeScript compiler in strict mode.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.