TypeScript NotesRohit’s interview study guide
Chapter 07

Classes & Enums

What TypeScript adds to classes, explicit resource management with using, and enums — plus why many teams prefer literal unions.

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

#Classes in TypeScript

A TypeScript class is a JavaScript class with types written on it. Everything that runs — the constructor, methods, fields, extends, super, #private, static — is plain JavaScript and behaves exactly as it does there. TypeScript adds two kinds of things on top: checks (every field must be declared and initialised, method calls are type-checked, overrides must be compatible) and a handful of TypeScript-only keywords (private, protected, public, readonly, abstract, override, implements, parameter properties). Most of those keywords are erased; a couple (parameter properties) generate code. The topics below go through each one and show what's left at runtime.

The one idea that makes classes "interesting" in TypeScript, as my notes put it: a class declaration creates two things with one name. class Point {} creates a value — the constructor function you call with new — and a type — the shape of the instances it creates. When you write Point after a colon, you mean the instance type; when you write new Point() or typeof Point, you mean the constructor.

And because TypeScript is structurally typed (see What TypeScript is (and isn't)), that instance type is just a shape. Any object with the same members is accepted where a Point is expected, whether or not it came from the class.

TypeScript
class Person {
  name: string;
  age = 0;                 // type inferred from the initialiser: number
  nickname?: string;       // optional: string | undefined, no initialiser needed

  constructor(name: string) {
    this.name = name;
  }

  greet(): string {
    return `Hi, I'm ${this.name}`;
  }
}

class Broken {
  title: string; // ❌ Property 'title' has no initializer and is not definitely assigned in the constructor.

  setUp() {
    this.label = "x"; // ❌ Property 'label' does not exist on type 'Broken'.
  }
}
What’s happening
  1. In JavaScript you can create a property anywhere by assigning this.x = …. TypeScript requires every instance property to be declared in the class body, so it knows the instance shape up front. name: string is a declaration; the constructor then assigns it.
  2. age = 0 declares and initialises in one go; the type number is inferred from 0. nickname?: string is optional, so its type is string | undefined and it's allowed to stay unset.
  3. In Broken, title: string is declared but never assigned. Under strict (strictPropertyInitialization) the checker looks at the constructor, sees no assignment on any path, and reports TS2564 "Property 'title' has no initializer and is not definitely assigned in the constructor." Otherwise new Broken().title.toUpperCase() would type-check and crash.
  4. this.label = "x" fails with TS2339 "Property 'label' does not exist on type 'Broken'." — assigning doesn't declare. This catches typos like this.lable that JavaScript would silently turn into a new property.
  5. If a property really is set somewhere the checker can't see (by a framework, or an init() method you always call), you can write title!: string — the definite assignment assertion. It switches off the check for that property, so use it sparingly.
AdvancedClass name as a type and a value

The class name as a type, and as a value:

TypeScript
class Point {
  x = 0;
  y = 0;
}

const p: Point = { x: 1, y: 2 }; // fine: it has the right shape
console.log(p instanceof Point);  // false — it never went through the constructor

function create(ctor: typeof Point): Point {
  return new ctor();
}
const q = create(Point); // Point { x: 0, y: 0 }
What’s happening
  1. const p: Point uses Point as a type: "an object with x: number and y: number". The object literal has exactly that shape, so it's accepted — the checker compares structure, not where the object came from.
  2. At runtime, p instanceof Point is false. instanceof checks the prototype chain, and a literal's prototype is Object.prototype. This is the classic structural-typing surprise: the type says Point, the value isn't one. Narrowing with instanceof (see Narrowing with instanceof) only works for objects really created with new.
  3. typeof Point in a type position means "the type of the value Point" — the constructor function itself, including new () => Point and any static members. That's why create can call new ctor().
  4. create(Point) passes the class itself (a value) and gets an instance back. If a class has private or #private members, a look-alike literal is no longer accepted — those members make the type behave nominally; see Access modifiers vs #private.
Advancedoverride and implements

Two keywords help when classes relate to other types. implements checks that a class matches an interface (it adds nothing — see Implementing interfaces in classes). override marks a method that's meant to replace a base-class method, so the compiler can tell you when it doesn't:

TypeScript
class Animal {
  move(): string {
    return "moving";
  }
}

class Dog extends Animal {
  override move(): string {
    return "running";
  }
  override mvoe(): string { // ❌ This member cannot have an 'override' modifier because it is not declared in the base class 'Animal'.
    return "typo";
  }
}
What’s happening
  1. override move() says "this replaces Animal.prototype.move". The checker confirms Animal has a move and that the new signature is compatible with it (an override that returned number would be an error).
  2. override mvoe() is a typo. Without override, it would compile as a brand-new method and dog.move() would quietly keep running the base version. With override, the checker reports TS4113 because there's no mvoe in Animal to override.
  3. It also protects you in the other direction: if someone renames Animal.move, every override move in subclasses becomes an error instead of silently turning into an unrelated method.
  4. Turn on noImplicitOverride in tsconfig.json to make the keyword required — then any method that shadows a base method without override is an error too. override is erased in the output.

#Access modifiers vs #private

Added

TypeScript has had public, private and protected since long before JavaScript had any privacy. JavaScript later added #private fields (ES2022). They look like they do the same job, but they live in different worlds: the TypeScript modifiers are compile-time only and are erased, while #private is enforced by the JavaScript engine at runtime.

  • public (the default) — accessible everywhere. Writing it is just documentation.
  • protected — accessible inside the class and its subclasses.
  • private — accessible only inside the class that declares it, as far as the type checker is concerned.
  • #name — accessible only inside the class body, enforced at runtime; nothing outside can read it, write it, or even see that it exists.

Mental model: private is a "Staff only" sign on a door — it stops people who read signs (the type checker), but the door isn't locked. #private is a locked door — it doesn't matter who you are or what tool you used to get there.

TypeScript
class BankAccount {
  public owner: string;
  private balance = 0;
  protected currency = "CAD";
  #pin: string;

  constructor(owner: string, pin: string) {
    this.owner = owner;
    this.#pin = pin;
  }

  deposit(amount: number): void {
    this.balance += amount;
  }

  checkPin(pin: string): boolean {
    return this.#pin === pin;
  }
}

const acc = new BankAccount("Ada", "1234");
acc.deposit(100);
acc.owner;      // fine
acc.balance;    // ❌ Property 'balance' is private and only accessible within class 'BankAccount'.
acc.currency;   // ❌ Property 'currency' is protected and only accessible within class 'BankAccount' and its subclasses.
acc.#pin;       // ❌ Property '#pin' is not accessible outside class 'BankAccount' because it has a private identifier.
acc["balance"]; // compiles! bracket access is an escape hatch for private
What’s happening
  1. Inside the class every member is usable: deposit updates this.balance, checkPin reads this.#pin. Access rules only bite outside the class body.
  2. acc.balance → TS2341 "Property 'balance' is private and only accessible within class 'BankAccount'." acc.currency → TS2445, because protected allows subclasses but not outside code.
  3. acc.#pin → TS18013. For #pin this is more than a type error: #pin outside the class body is a syntax error in JavaScript, so this line could never run even if you deleted the types.
  4. acc["balance"] compiles. TypeScript deliberately lets bracket access reach private members (an escape hatch for tests and debugging). There is no equivalent for #pin — acc["#pin"] is just a normal property called "#pin", which doesn't exist.
AdvancedEmitted JavaScript for private vs #private

What decides it is the emitted JavaScript (target ES2022):

BankAccount.js (emitted, with a few logs added)
class BankAccount {
    owner;
    balance = 0;
    currency = "CAD";
    #pin;
    constructor(owner, pin) {
        this.owner = owner;
        this.#pin = pin;
    }
    deposit(amount) {
        this.balance += amount;
    }
    checkPin(pin) {
        return this.#pin === pin;
    }
}
const acc = new BankAccount("Ada", "1234");
acc.deposit(100);
console.log(Object.keys(acc));    // [ 'owner', 'balance', 'currency' ]
console.log(JSON.stringify(acc)); // {"owner":"Ada","balance":100,"currency":"CAD"}
console.log(acc.balance);         // 100
What’s happening
  1. public, private and protected have vanished. balance and currency are emitted as ordinary public class fields — exactly like owner.
  2. #pin is still #pin. It's real JavaScript syntax, so the compiler leaves it alone and the engine enforces it.
  3. At runtime, Object.keys(acc) lists balance and currency but not #pin; JSON.stringify serialises "balance":100 — a "private" value leaking into an API response or a log line. #pin doesn't appear anywhere.
  4. Any plain JavaScript (a consumer of your library, a console.log, code using any) can read and change acc.balance. Nothing can reach #pin except code written inside the class body.
  5. If you compile for a target older than ES2022, TypeScript can't emit #pin as-is; it downlevels it to a module-level WeakMap (_BankAccount_pin = new WeakMap()) plus __classPrivateFieldGet/__classPrivateFieldSet helpers. Privacy is preserved, at some cost in speed and readability.
AdvancedPrivacy and type compatibility

Both kinds of privacy also change type compatibility. Normally two classes with the same shape are interchangeable; a private or #private member makes a class's type behave nominally — only instances of that class (or subclasses) are compatible:

TypeScript
class Celsius {
  private value: number;
  constructor(value: number) {
    this.value = value;
  }
}
class Fahrenheit {
  private value: number;
  constructor(value: number) {
    this.value = value;
  }
}
const t: Celsius = new Fahrenheit(70); // ❌ Type 'Fahrenheit' is not assignable to type 'Celsius'.

class Token {
  #brand = true;
  static isToken(value: unknown): value is Token {
    return typeof value === "object" && value !== null && #brand in value;
  }
}
const fake: Token = {}; // ❌ Property '#brand' is missing in type '{}' but required in type 'Token'.
What’s happening
  1. Celsius and Fahrenheit have identical shapes. With public value they'd be interchangeable. Because value is private, the checker treats each class's value as a different member — TS2322 with the detail "Types have separate declarations of a private property 'value'." That's handy for units and IDs you don't want mixed up.
  2. Token has a #brand field. {} can never satisfy it — no object literal can contain a private name — so TS2741 "Property '#brand' is missing in type '{}' but required in type 'Token'."
  3. #brand in value is the ergonomic brand check (ES2022): it's true only for objects created by Token's constructor, and it never throws. TypeScript narrows value to Token after it, which makes it a reliable runtime type guard — unlike instanceof, it isn't fooled by Object.setPrototypeOf and works across realms (iframes, vm contexts).
Advancedprivate vs #private reference table
private / protected#private
Enforced byTypeScript checker onlyJavaScript engine
In emitted JSOrdinary public property#field (or WeakMap for old targets)
Object.keys, JSON.stringify, spreadIncludedNot included
Escape hatchobj["name"], as anyNone
Subclass accessprotected: yesNo (not even subclasses)
Works with ProxyYesNo — methods throw when this is a proxy
Brand check—#field in obj

When to use which: for real encapsulation — library code, secrets, invariants you must protect from any caller — use #private. private is fine for application code where every caller is TypeScript, when you need protected (there's no #protected), when objects are wrapped in proxies (Vue's reactive(), MobX), or when tests need the bracket-access escape hatch. Many codebases just pick one style; the important thing is knowing that private is a compile-time convention.

What does this print?
TypeScript
class Session {
  private token = "secret-123";
  #userId = 42;
}

const s = new Session();
console.log(JSON.stringify(s));
console.log(Object.keys(s).length);
Show answer

It prints {"token":"secret-123"} and then 1.

What’s happening
  1. TypeScript erases private, so the emitted class has a normal public field token = "secret-123".
  2. #userId is a real private field. It's stored in a separate internal slot on the object, not as a property.
  3. JSON.stringify serialises own enumerable string-keyed properties: only token. The "private" token leaks into the output; #userId doesn't.
  4. Object.keys(s) is ["token"], so its length is 1. If the token had been #token, the output would be {} and 0.

#Parameter properties and readonly

Parameter properties are a TypeScript shorthand: put an access modifier (public, private, protected) or readonly in front of a constructor parameter, and TypeScript both declares a property of that name and assigns the argument to it. It's the most common piece of TypeScript syntax that generates real JavaScript code.

readonly marks a property as "assign once": it can be set where it's declared or in the constructor, and never again. Like the access modifiers it's compile-time only — and it's shallow: a readonly property holding an array stops you replacing the array, not pushing into it.

From my notes, a class implementing an interface with parameter properties:

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

class Person implements User {
  constructor(public id: number, public name: string, public email: string) {}
}

const ada = new Person(1, "Ada", "ada@example.com");
console.log(ada.name); // "Ada"
What’s happening
  1. public id: number in the constructor's parameter list does two jobs: it declares an instance property id: number, and it makes the constructor run this.id = id. Same for name and email. The constructor body is empty because the shorthand writes it for you.
  2. Without the shorthand you'd write each name three times: a field declaration, a constructor parameter, and an assignment.
  3. implements User then checks that Person's instance type has id, name and email of the right types. It does, so the class compiles. implements doesn't add anything — the parameter properties provide the members.
  4. new Person(1, "Ada", "ada@example.com") produces an object with three own properties; ada.name is "Ada".
Advancedreadonly with parameter properties

Here is readonly on its own and combined with parameter properties:

TypeScript
class Order {
  readonly createdAt = new Date();

  constructor(public readonly id: string, private readonly items: string[]) {}

  addItem(item: string): void {
    this.items.push(item);  // fine — the array itself isn't readonly
    this.items = [];        // ❌ Cannot assign to 'items' because it is a read-only property.
  }
}

const order = new Order("A1", []);
order.addItem("book");
order.id = "B2"; // ❌ Cannot assign to 'id' because it is a read-only property.
What’s happening
  1. readonly createdAt = new Date() is initialised at declaration. After that, any assignment outside the constructor is TS2540.
  2. public readonly id: string is a parameter property with two modifiers: public, and assign-once. private readonly items: string[] is private and assign-once.
  3. this.items.push(item) compiles: readonly protects the property items (which array it points to), not the array's contents. For an immutable array, type it readonly string[] — then push doesn't exist on it at all (see Arrays and readonly arrays).
  4. this.items = [] tries to repoint the property, which readonly forbids even inside the class: TS2540 "Cannot assign to 'items' because it is a read-only property."
  5. order.id = "B2" from outside is the same error. At runtime both assignments would succeed — nothing is frozen — the protection is purely in the checker.
AdvancedEmitted code for parameter properties

The emitted JavaScript shows what parameter properties really generate:

Order.js (emitted)
class Order {
    id;
    items;
    createdAt = new Date();
    constructor(id, items) {
        this.id = id;
        this.items = items;
    }
    addItem(item) {
        this.items.push(item);
    }
}
What’s happening
  1. readonly, public and private are all erased. createdAt, id and items are ordinary, writable fields.
  2. Each parameter property became two things: a field declaration (id;, items;) at the top of the class, and an assignment (this.id = id;) at the start of the constructor.
  3. Because this is code generation, not just type removal, parameter properties aren't "erasable" syntax. Node's built-in type stripping refuses them (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX: TypeScript parameter property is not supported in strip-only mode), and the erasableSyntaxOnly compiler option (TS 5.8) reports them as TS1294. See Running TypeScript: tsc, ts-node and tsx.
  4. Order matters: JavaScript runs field initialisers (createdAt = new Date()) before the constructor body, and the parameter-property assignments are in the constructor body. So a field initialiser can't use a parameter property — next example.
AdvancedInitialisation order with parameter properties
TypeScript
class Price {
  withTax = this.amount * 1.13; // ❌ Property 'amount' is used before its initialization.

  constructor(public amount: number) {}
}
What’s happening
  1. Reading the class top to bottom, it looks like amount is available: it's a constructor parameter, and withTax uses it.
  2. But the emitted code is amount; withTax = this.amount * 1.13; constructor(amount) { this.amount = amount; }. Field initialisers run first, while this.amount is still undefined, so withTax would be NaN.
  3. TypeScript knows that order and reports TS2729 "Property 'amount' is used before its initialization." The fix is to compute it in the constructor body, or make it a getter: get withTax() { return this.amount * 1.13; }.
AdvancedComparing three kinds of read-only

#Abstract classes

Added

An abstract class is a base class that is incomplete on purpose. It can't be instantiated with new; it exists to be extended. It can contain real, shared implementation and abstract members — methods or properties that are declared but not implemented, which every concrete subclass must provide.

It sits between an interface and a normal class. An interface is a pure contract with no code. A normal base class is complete and instantiable. An abstract class says "here's the shared skeleton and some ready-made behaviour; you fill in these specific holes." That's the template method pattern: the base class's describe() calls this.area(), and each subclass decides what area() means.

TypeScript
abstract class Shape {
  constructor(public readonly name: string) {}

  abstract area(): number;

  describe(): string {
    return `${this.name} with area ${this.area().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(private radius: number) {
    super("circle");
  }

  area(): number {
    return Math.PI * this.radius ** 2;
  }
}

console.log(new Circle(1).describe()); // "circle with area 3.14"

new Shape("blob");                 // ❌ Cannot create an instance of an abstract class.
class Square extends Shape {}      // ❌ Non-abstract class 'Square' does not implement inherited abstract member area from class 'Shape'.
What’s happening
  1. abstract class Shape marks the class as non-instantiable. abstract area(): number declares a method signature with no body — a hole each subclass must fill.
  2. describe() is a normal method with a body. It calls this.area(), which the checker allows because every concrete Shape is guaranteed to have one.
  3. Circle extends Shape, calls super("circle") (which runs the parameter property assignment this.name = "circle"), and implements area(). new Circle(1).describe() runs the inherited describe, which calls Circle's area → Math.PI * 1 → "circle with area 3.14".
  4. new Shape("blob") is TS2511 "Cannot create an instance of an abstract class." — the object would have no area method, and describe() would crash.
  5. class Square extends Shape {} forgets area, so TS2515. A subclass can avoid this only by being abstract itself.
AdvancedEmitted JavaScript for an abstract class

abstract is a TypeScript keyword, so it disappears from the output:

Shape.js (emitted)
class Shape {
    name;
    constructor(name) {
        this.name = name;
    }
    describe() {
        return `${this.name} with area ${this.area().toFixed(2)}`;
    }
}
class Circle extends Shape {
    radius;
    constructor(radius) {
        super("circle");
        this.radius = radius;
    }
    area() {
        return Math.PI * this.radius ** 2;
    }
}
What’s happening
  1. abstract class Shape became a plain class Shape. JavaScript has no abstract classes, so new Shape("blob") from plain JavaScript would succeed — and describe() would then throw TypeError: this.area is not a function.
  2. The abstract method area() emitted nothing. It was only a declaration; the real method lives on Circle.prototype.
  3. Note the order in Circle's constructor: super("circle") first, then this.radius = radius. In a derived class, this doesn't exist until super() returns, so TypeScript puts parameter-property assignments right after the super call.
  4. If you need runtime protection too, add a guard in the constructor: if (new.target === Shape) throw new TypeError("Shape is abstract").
AdvancedConstructor types for abstract classes

Because an abstract class can't be newed, its constructor type is special. A plain constructor type won't accept it; an abstract new signature will:

TypeScript
abstract class Repository {
  abstract find(id: string): string | undefined;
}

const make: new () => Repository = Repository; // ❌ Type 'typeof Repository' is not assignable to type 'new () => Repository'.
const base: abstract new () => Repository = Repository; // fine

function withLogging<T extends abstract new (...args: any[]) => object>(Base: T) {
  abstract class Logged extends Base {
    log(message: string) {
      console.log(`[${this.constructor.name}] ${message}`);
    }
  }
  return Logged;
}
What’s happening
  1. new () => Repository promises a constructor you can call with new. Repository is abstract, so assigning it would let someone write new make() and get an incomplete object. The checker refuses with TS2322 and the detail "Cannot assign an abstract constructor type to a non-abstract constructor type."
  2. abstract new () => Repository (TS 4.2+) describes "a constructor that can be extended but maybe not instantiated", so the abstract class fits.
  3. That's what makes mixins over abstract classes possible: withLogging accepts any constructor, abstract or not, and returns a new abstract subclass with an extra log method.
AdvancedWhen to use an abstract class

When to use an abstract class: when subclasses share real code (not just a shape) and there's a stable "is-a" relationship — a base Repository with shared caching, a base HttpError with formatting. If you only need a shape, use an interface (no runtime cost, a class can implement many). If the shared code can be passed in instead of inherited, composition is usually more flexible.

#using and explicit resource management

From my notes: "using — used to do automatic cleanup once the object goes out of scope." It's the JavaScript explicit resource management proposal: TypeScript 5.2 added the syntax and types, and engines now run it natively (V8 13.x; it works in Node 24+ and Chrome 134+). It's no longer experimental in the engines that matter, but for older runtimes TypeScript downlevels it.

The problem it solves is cleanup code that's easy to forget or get wrong: closing files, releasing locks, ending database transactions, removing listeners. The old answer is try/finally, which gets ugly with several resources (nested try blocks, cleanup in reverse order, what if a cleanup throws?). using x = value declares a constant whose [Symbol.dispose]() method is called automatically when the enclosing block exits — normally, by return, or by an exception. Think of it as C#'s using, Python's with, or Go's defer, attached to a variable.

The rules:

  • The value must have a [Symbol.dispose]() method (the Disposable interface), or be null/undefined (skipped).
  • Resources are disposed in reverse order of declaration — last acquired, first released, like a stack.
  • await using works with [Symbol.asyncDispose]() (the AsyncDisposable interface) and awaits each cleanup; it's only allowed in async functions and at the top level of modules.
TypeScript
class TempFile implements Disposable {
  constructor(public readonly path: string) {
    console.log(`open ${path}`);
  }
  [Symbol.dispose]() {
    console.log(`close ${this.path}`);
  }
}

function work() {
  using a = new TempFile("a.txt");
  using b = new TempFile("b.txt");
  console.log("working");
  return "done";
}
console.log(work());
// open a.txt
// open b.txt
// working
// close b.txt
// close a.txt
// done

function failing() {
  using c = new TempFile("c.txt");
  throw new Error("boom");
}
try {
  failing();
} catch (e) {
  console.log("caught", (e as Error).message);
}
// open c.txt
// close c.txt
// caught boom
What’s happening
  1. TempFile implements Disposable — the checker confirms it has a [Symbol.dispose](): void method. Disposable and Symbol.dispose come from the esnext.disposable lib (included in lib: ["esnext"]).
  2. In work, using a runs the constructor (open a.txt) and registers a for disposal; using b does the same (open b.txt). Then "working" is logged.
  3. return "done" evaluates the return value first, then — on the way out of the block — disposes in reverse order: b (close b.txt), then a (close a.txt). Only after that does the caller receive "done" and log it.
  4. In failing, throw leaves the block abnormally, but disposal still runs: close c.txt is printed before the catch sees the error. That's the finally guarantee without writing finally.
  5. If a dispose method itself throws while another error is propagating, the runtime wraps both in a SuppressedError (with .error and .suppressed) so neither is lost.
  6. The checker also verifies the value: using x = { close() {} } is rejected with TS2741 "Property '[Symbol.dispose]' is missing in type '{ close(): void; }' but required in type 'Disposable'." using x = null is allowed and simply skipped.
AdvancedDownlevelled using for ES2022

With target: "esnext", the emitted code is the same using syntax minus the types, and Node 26 runs it natively — that output above is from actually running it. For older targets, TypeScript rewrites each block into a try/catch/finally with helpers:

work() emitted for target ES2022 (helpers omitted)
function work() {
    const env_1 = { stack: [], error: void 0, hasError: false };
    try {
        const a = __addDisposableResource(env_1, new TempFile("a.txt"), false);
        const b = __addDisposableResource(env_1, new TempFile("b.txt"), false);
        console.log("working");
        return "done";
    }
    catch (e_1) {
        env_1.error = e_1;
        env_1.hasError = true;
    }
    finally {
        __disposeResources(env_1);
    }
}
What’s happening
  1. Each using becomes a const whose value is passed through __addDisposableResource, which checks the value has a Symbol.dispose method (throwing TypeError: Object not disposable. otherwise) and pushes it onto env_1.stack.
  2. The catch records any exception in env_1.error instead of letting it escape immediately.
  3. finally calls __disposeResources(env_1), which pops the stack — so the last resource added is disposed first — and then rethrows the recorded error (or a SuppressedError if a disposer also threw).
  4. The helpers need Symbol.dispose to exist at runtime. On engines without it, you need a polyfill (for example from core-js) that defines Symbol.dispose and Symbol.asyncDispose.
Advancedawait using and DisposableStack
TypeScript
class Connection implements AsyncDisposable {
  constructor(public name: string) {
    console.log(`connect ${name}`);
  }
  async [Symbol.asyncDispose]() {
    await new Promise((resolve) => setTimeout(resolve, 10));
    console.log(`disconnect ${this.name}`);
  }
}

async function main() {
  await using db = new Connection("db");
  {
    using stack = new DisposableStack();
    stack.defer(() => console.log("deferred 1"));
    stack.defer(() => console.log("deferred 2"));
    console.log("in block");
  }
  console.log("after block");
}

await main();
console.log("main finished");
// connect db
// in block
// deferred 2
// deferred 1
// after block
// disconnect db
// main finished
What’s happening
  1. await using db registers an async disposer. When main's body finishes, the runtime calls db[Symbol.asyncDispose]() and awaits it — so main()'s promise doesn't resolve until disconnect db has been logged.
  2. The inner { … } block is its own scope. DisposableStack is a built-in container of cleanups; stack.defer(fn) registers a callback, and using stack disposes the whole stack at the end of that block.
  3. Leaving the inner block runs the deferred callbacks in reverse: deferred 2, then deferred 1. Then after block is logged.
  4. Leaving main triggers db's async disposal: 10 ms later, disconnect db. Only then does await main() continue, and main finished is last.
  5. DisposableStack is useful when the number of resources is dynamic, or for adapting APIs that don't implement Symbol.dispose: stack.adopt(handle, (h) => h.close()), or stack.use(resource). stack.move() transfers everything to a new stack — the standard trick for "acquire several resources in a constructor and only keep them if all succeeded". AsyncDisposableStack is the async equivalent.

Where it shows up: Node's fs/promises FileHandle implements Symbol.asyncDispose (await using file = await open(path)), and many libraries now implement Symbol.dispose on locks, subscriptions, temp directories and test fixtures. When you write a class that owns a resource, implementing Disposable is a cheap way to let callers use using.

#Numeric enums

An enum is a named set of constants. enum Color { Red, Green, Blue } gives you Color.Red, Color.Green, Color.Blue instead of magic numbers, and a type Color that only accepts those members. It's one of the few TypeScript features that is not just types: an enum declaration generates a real JavaScript object.

In a numeric enum each member is a number. If you don't give a value, members are numbered from 0, each one more than the previous. If you set a member explicitly, counting continues from that value — the "notice the indexes" point in my notes.

TypeScript
enum Color {
  Red,         // 0
  Green,       // 1
  Yellow = 18, // 18 — set explicitly instead of 2
  Blue,        // 19 — continues from the last explicit value
}

console.log(Color.Red, Color.Green, Color.Yellow, Color.Blue); // 0 1 18 19

function paint(color: Color) {}
paint(Color.Green); // fine
paint(1);           // fine — 1 is the value of Color.Green
paint(2);           // ❌ Argument of type '2' is not assignable to parameter of type 'Color'.
What’s happening
  1. Red has no initialiser and is first, so it's 0. Green has none either, so it's previous + 1 = 1.
  2. Yellow = 18 sets its own value — without it, Yellow would have been 2.
  3. Blue has no initialiser, so it's "previous + 1", and the previous member is Yellow, so Blue is 19, not 3. Auto-increment always continues from the member just before.
  4. Color used as a type means "one of Color.Red | Color.Green | Color.Yellow | Color.Blue". Since TS 5.0, a numeric literal is accepted only if it matches a member's value: paint(1) passes (it's Green's value), paint(2) is TS2345 because no member is 2.
  5. The values printed at runtime are plain numbers: 0 1 18 19. A Color is just a number once compiled.
AdvancedEmitted IIFE for a numeric enum

The compiled output is an object built by an IIFE:

Color.js (emitted)
var Color;
(function (Color) {
    Color[Color["Red"] = 0] = "Red";
    Color[Color["Green"] = 1] = "Green";
    Color[Color["Yellow"] = 18] = "Yellow";
    Color[Color["Blue"] = 19] = "Blue";
})(Color || (Color = {}));
What’s happening
  1. var Color; declares the binding. (Color || (Color = {})) passes either the existing Color object or a fresh {} into the function. Reusing an existing object is what lets two enum Color declarations with the same name merge into one object.
  2. Read Color[Color["Red"] = 0] = "Red" from the inside out. Color["Red"] = 0 sets the forward mapping Color.Red = 0, and an assignment expression evaluates to the assigned value, 0.
  3. That 0 becomes the outer key: Color[0] = "Red" — the reverse mapping from value to name (next topic).
  4. Each line repeats this with the value TypeScript computed at compile time (18, 19). The final object has eight properties: Red, Green, Yellow, Blue and "0", "1", "18", "19".
AdvancedPitfall: plain numbers in numeric enums

#Reverse mapping

Numeric enums map both ways: name → value (Color.Blue is 19) and value → name (Color[19] is "Blue"). That's what the double assignment in the emitted IIFE builds. It's useful for logging and debugging — turning a numeric status code from an API back into a readable name.

From my notes: "Reverse mapping for ENUM is also possible!!!! (Output would be Blue and Yellow)" — with the Color enum from Numeric enums:

TypeScript
enum Color {
  Red,
  Green,
  Yellow = 18,
  Blue,
}

console.log(Color[19], Color[18]);   // Blue Yellow
console.log(Color[Color.Green]);     // Green
console.log(Color[7]);               // undefined — but typed as string
console.log(Object.keys(Color));
// [ '0', '1', '18', '19', 'Red', 'Green', 'Yellow', 'Blue' ]
console.log(Object.values(Color).filter((v) => typeof v === "number"));
// [ 0, 1, 18, 19 ]
What’s happening
  1. Color[19] reads the reverse-mapping property "19", which the IIFE set to "Blue"; Color[18] is "Yellow". So the output is Blue Yellow.
  2. Color[Color.Green] is a round trip: Color.Green is 1, and Color[1] is "Green". This is the common "get the name of this value" idiom.
  3. Color[7] is undefined at runtime because no member has value 7. TypeScript still types it as string — indexing a numeric enum with a number isn't checked against the actual values. Guard it if the number comes from outside.
  4. Object.keys(Color) shows the price of reverse mapping: the object holds both directions. Integer-like keys come first (JavaScript orders them numerically), then names in insertion order. Iterating an enum naively gives you eight entries for four members.
  5. To get just the values, filter for numbers; to get just the names, filter Object.keys with isNaN(Number(k)).
AdvancedReverse mapping edge cases

Two more facts interviewers like:

  • Only numeric members get reverse mappings. String members emit a single assignment (Direction["Up"] = "UP"), so Direction["UP"] is undefined. A mixed enum has reverse entries only for its numeric members.
  • Duplicate values overwrite each other. In enum Status { Active = 1, Enabled = 1 }, Status[1] is "Enabled" — the last assignment to key "1" wins.
What does this print?
TypeScript
enum Level {
  Low = 1,
  Medium,
  High = 10,
  Critical,
}

console.log(Level.Medium, Level.Critical, Level[11], Level["High"]);
Show answer

2 11 Critical 10

What’s happening
  1. Low = 1 is explicit. Medium has no initialiser, so it's previous + 1 = 2.
  2. High = 10 is explicit. Critical continues from High: 11.
  3. Level[11] uses the reverse mapping: the IIFE ran Level[Level["Critical"] = 11] = "Critical", so key "11" holds "Critical".
  4. Level["High"] is the normal forward mapping (bracket notation with the name), so it's 10.

#String enums

Added

In a string enum every member is initialised with a string literal. There's no auto-increment (the compiler can't guess the "next" string), so every member needs a value. The pay-off is readability at runtime: logs, JSON and network payloads contain "UP" instead of 0, and the values don't shift when someone inserts a member in the middle — a real risk with auto-numbered enums that are stored in a database.

String enums are also stricter than numeric ones. A string enum type accepts only its members, not the equivalent string literal. That makes them nominal — unusual in a structural type system.

TypeScript
enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}

function move(direction: Direction) {
  console.log(`moving ${direction}`);
}

move(Direction.Up); // "moving UP"
move("UP");         // ❌ Argument of type '"UP"' is not assignable to parameter of type 'Direction'.

function parseDirection(input: string): Direction | undefined {
  return (Object.values(Direction) as string[]).includes(input) ? (input as Direction) : undefined;
}
What’s happening
  1. Each member maps a name to a string: Direction.Up is "UP". The name and value don't have to match, but conventionally they're related.
  2. move(Direction.Up) passes the member; at runtime it's just the string "UP", so the log is moving UP.
  3. move("UP") is rejected with TS2345 even though the runtime value would be identical. A string enum member is a distinct type, and a bare literal isn't assignable to it — you must go through the enum. (Compare a literal union "UP" | "DOWN", which happily accepts "UP".)
  4. The flip side: data arriving as string (a query parameter, JSON) has to be validated before it can be a Direction. Object.values(Direction) is ["UP", "DOWN", "LEFT", "RIGHT"] — with no reverse mappings, values are exactly the member values — so a membership check plus an assertion converts it safely.
AdvancedEmitted JavaScript for a string enum
Direction.js (emitted)
var Direction;
(function (Direction) {
    Direction["Up"] = "UP";
    Direction["Down"] = "DOWN";
    Direction["Left"] = "LEFT";
    Direction["Right"] = "RIGHT";
})(Direction || (Direction = {}));
What’s happening
  1. Same IIFE shape as a numeric enum, but each line is a single assignment: Direction["Up"] = "UP". There's no outer Direction["UP"] = "Up", so no reverse mapping.
  2. That's deliberate: a string reverse mapping could collide with a member name (imagine enum E { A = "B", B = "A" }).
  3. The object is { Up: "UP", Down: "DOWN", Left: "LEFT", Right: "RIGHT" }, so Object.keys gives the names and Object.values the values — no filtering needed.
AdvancedHeterogeneous enums

Heterogeneous enums (mixing number and string members) are allowed but almost never a good idea. If you're reaching for one, a literal union or an as const object is clearer.

#Constant and computed enum members

Advanced

Every enum member is either constant — its value is known at compile time, so TypeScript writes the literal value into the output — or computed — its value comes from an expression TypeScript can't (or won't) evaluate, so the expression is emitted and runs at runtime.

A member is constant if it is:

  • the first member with no initialiser (0), or follows a numeric constant member with no initialiser (previous + 1);
  • a number or string literal;
  • a reference to an earlier constant member (including from another enum);
  • a parenthesised constant expression, or a unary + - ~ or binary + - * / % << >> >>> & | ^ operation on constant operands;
  • a template literal whose substitutions are constant strings (TS 5.0+).

Anything else — a function call, a property read like "abc".length, a reference to a computed member — is computed. The one rule to remember: a member without an initialiser can't follow a computed member, because there's no known number to add one to.

TypeScript
enum FileAccess {
  None,                          // constant: 0
  Read = 1 << 1,                 // constant: 2
  Write = 1 << 2,                // constant: 4
  ReadWrite = Read | Write,      // constant: 6
  NameLength = "config.json".length, // computed: evaluated at runtime
  Next = NameLength + 1,         // computed: depends on a computed member
}

enum Keys {
  Prefix = "app",
  Theme = `${Prefix}.theme`,     // constant: "app.theme"
}

enum Broken {
  A = "abc".length,
  B, // ❌ Enum member must have initializer.
}

const canWrite = (FileAccess.ReadWrite & FileAccess.Write) !== 0; // true
What’s happening
  1. None is first with no initialiser → 0. Read = 1 << 1 is a shift of two constants → 2; Write = 1 << 2 → 4. Shifted powers of two give each flag its own bit.
  2. ReadWrite = Read | Write references two earlier constant members, so it's constant too: 2 | 4 → 6. Inside the enum body you can refer to members by bare name.
  3. NameLength = "config.json".length reads a property, which TypeScript doesn't evaluate, so the member is computed. Next = NameLength + 1 depends on it, so it's computed as well.
  4. Theme is a template literal whose substitution is the constant string member Prefix, so TS 5.0+ evaluates it to "app.theme" at compile time.
  5. In Broken, B has no initialiser and follows a computed member. TypeScript can't know A's value at compile time, so it can't compute A + 1: TS1061 "Enum member must have initializer."
  6. canWrite tests a bit: 6 & 4 is 4, which isn't 0, so true. Bit-flag enums like this are the strongest remaining use case for numeric enums.

The difference is visible in the output:

FileAccess.js (emitted)
var FileAccess;
(function (FileAccess) {
    FileAccess[FileAccess["None"] = 0] = "None";
    FileAccess[FileAccess["Read"] = 2] = "Read";
    FileAccess[FileAccess["Write"] = 4] = "Write";
    FileAccess[FileAccess["ReadWrite"] = 6] = "ReadWrite";
    FileAccess[FileAccess["NameLength"] = "config.json".length] = "NameLength";
    FileAccess[FileAccess["Next"] = FileAccess.NameLength + 1] = "Next";
})(FileAccess || (FileAccess = {}));
var Keys;
(function (Keys) {
    Keys["Prefix"] = "app";
    Keys["Theme"] = "app.theme";
})(Keys || (Keys = {}));
What’s happening
  1. Constant members appear as literal values: 1 << 1 became 2, Read | Write became 6, and the template literal became "app.theme". The compiler did the arithmetic.
  2. Computed members keep their expressions: "config.json".length and FileAccess.NameLength + 1 run when the IIFE runs, giving 11 and 12.
  3. A const enum is inlined, so it allows only constant members: "abc".length there is TS2474 "const enum member initializers must be constant expressions.", and constant expressions must evaluate to finite numbers (1 / 0 is TS2477 "'const' enum member initializer was evaluated to a non-finite value.").
  4. Since TS 5.0, every member — constant or computed — gets its own member type, so any enum type behaves as a union of its members for narrowing and exhaustiveness checks.

#Enums as types and values

An enum name lives in both the value world and the type world, just like a class name:

  • As a value, Color is the runtime object ({ Red: 0, …, "0": "Red", … }). You can pass it around, iterate it, and index it.
  • As a type, Color means "any member of this enum" — effectively the union Color.Red | Color.Green | Color.Yellow | Color.Blue.
  • Each member is also a type: Color.Red in a type position is the type of just that member.

To talk about the object at the type level (its keys, for instance), you go through typeof: typeof Color is the type of the object, and keyof typeof Color is the union of member names. Mixing these up is the most common enum confusion.

TypeScript
enum Color {
  Red,
  Green,
  Blue,
}

let c: Color = Color.Red;          // type position: any member
let onlyRed: Color.Red = Color.Red; // member type: just Red
onlyRed = Color.Green;              // ❌ Type 'Color.Green' is not assignable to type 'Color.Red'.

type ColorName = keyof typeof Color; // "Red" | "Green" | "Blue"

function fromName(name: ColorName): Color {
  return Color[name];
}
fromName("Green"); // 1
fromName("Pink");  // ❌ Argument of type '"Pink"' is not assignable to parameter of type '"Blue" | "Green" | "Red"'.

function label(color: Color): string {
  switch (color) {
    case Color.Red:
      return "warm";
    case Color.Green:
    case Color.Blue:
      return "cool";
    default: {
      const unreachable: never = color;
      return unreachable;
    }
  }
}
What’s happening
  1. let c: Color uses the enum as a type: any of its members. let onlyRed: Color.Red uses a single member as a type, so assigning Color.Green is TS2322.
  2. keyof typeof Color: typeof Color is the type of the runtime object; keyof of that is its keys. For a numeric enum TypeScript gives just the member names, "Red" | "Green" | "Blue" (not the reverse-mapping number keys). See keyof typeof.
  3. fromName(name) indexes the object by name: Color["Green"] is 1, typed Color. Passing "Pink" fails because it's not one of the names.
  4. In label, the switch narrows color like a union: after Red, Green and Blue are handled, color in default has type never. Assigning it to never compiles; if someone adds Yellow to the enum, that line becomes an error that lists the missing member. See Exhaustiveness checking with never.
  5. Note: keyof Color (without typeof) would be the keys of a number — "toFixed" | "toString" | … — because the type Color is the member values, which are numbers. You almost always want keyof typeof.
AdvancedUsing the enum value side

Practical uses of the value side: populating a <select> from Object.values(StringEnum), validating input (Object.values(Direction).includes(x)), and reverse lookups for numeric enums. Practical uses of the type side: parameters, discriminants in discriminated unions, and exhaustive switches.

#const enum

A const enum is an enum that exists only at compile time. Every use like Direction.Down is replaced with its literal value (1), and no enum object is emitted at all. My notes say "const enum to improve performance" — the gain is one less object and one less property lookup per use, and smaller bundles. In modern engines that lookup is rarely measurable; the bigger reasons to know about const enum today are the restrictions and the build-tool problems.

TypeScript
const enum Direction {
  Up,
  Down,
}

const move = Direction.Down;
console.log(move); // 1

const all = Object.values(Direction); // ❌ 'const' enums can only be used in property or index access expressions or the right hand side of an import declaration or export assignment or type query.
const name = Direction[0];            // ❌ A const enum member can only be accessed using a string literal.
What’s happening
  1. const enum Direction declares members as usual, but marks the enum as compile-time only.
  2. Direction.Down is a property access, which is allowed. The compiler looks up its constant value, 1, and writes that into the output instead.
  3. Object.values(Direction) needs Direction as a runtime value — and there is none. TS2475 lists the only places a const enum may appear (property access, index access with a string literal, imports/exports, typeof).
  4. Direction[0] asks for a reverse mapping, which also needs the object. TS2476 "A const enum member can only be accessed using a string literal." (Direction["Down"] would be fine and inlined.)
AdvancedEmitted inlined const enum value
const-enum.js (emitted)
const move = 1 /* Direction.Down */;
console.log(move);
What’s happening
  1. There's no var Direction and no IIFE — the declaration produced nothing.
  2. Direction.Down was replaced by 1, with a comment so the output stays readable.
  3. If you need the object anyway (for debugging, or for JavaScript consumers), preserveConstEnums emits it while still inlining uses.
AdvancedPitfall: const enum with isolated transpilers

#Ambient enums

Advanced

An ambient enum is declared with declare enum (usually in a .d.ts file). It describes an enum object that already exists at runtime — created by some other script or library — so TypeScript type-checks uses of it but emits nothing for the declaration. It's the enum version of any declare (see declare and ambient declarations).

globals.d.ts
declare enum LogLevel {
  Debug,
  Info,
  Warn,
  Error,
}

declare const enum Theme {
  Light = "light",
  Dark = "dark",
}
What’s happening
  1. declare enum LogLevel says "a global LogLevel enum object exists at runtime; here are its members". No code is generated from this file — .d.ts files never produce output.
  2. One subtle rule: in a regular enum, Info without an initialiser is Debug + 1 = 1. In a non-const ambient enum, a member without an initialiser is always treated as computed — TypeScript doesn't assume it knows how the runtime numbered them, so it never treats those values as known constants. Give ambient members explicit values if you know them.
  3. Code using LogLevel.Warn compiles to LogLevel.Warn — a real property lookup on whatever object the other script created. If that script didn't run, you get ReferenceError: LogLevel is not defined at runtime, and TypeScript can't warn you.
  4. declare const enum Theme is the other common form, typically found in library type definitions: uses are inlined (Theme.Dark → "dark") and no runtime object is needed. As the const enum gotcha explains, this is exactly what breaks under isolatedModules (TS2748).

Where you'll see them: hand-written declarations for legacy globals, and older library typings. You'll rarely write one yourself; when describing a JavaScript library's constants, a declare const LogLevel: { readonly Debug: 0; … } object type or a literal union is usually a better fit.

#Enums vs literal unions

Most enums can be replaced by one of two plain-TypeScript patterns, and many teams now do exactly that:

  • A string literal union: type Direction = "up" | "down" | "left" | "right". Pure type, fully erased, no runtime object. Callers pass plain strings and get autocomplete and checking.
  • An as const object plus a derived union, when you also want a runtime object (to iterate values, or to write Direction.Up in code). See as const and [Getting every value type with T[keyof T]](#getting-every-value-type-with-t-keyof-t).

The modern push away from enums is about erasability. Tools that strip types without type-checking — Node's built-in TypeScript support, and bundlers running in isolatedModules mode — want TypeScript to be "JavaScript plus annotations you can delete". Enums aren't: they generate code. Node 26 refuses to run a file containing an enum (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX: TypeScript enum is not supported in strip-only mode), and TypeScript 5.8 added the erasableSyntaxOnly option that turns enums, namespaces with code and parameter properties into compile errors.

From my notes, "enum vs union types" — here are the three side by side:

TypeScript
// 1. enum
enum DirectionEnum {
  Up = "UP",
  Down = "DOWN",
}

// 2. literal union
type DirectionUnion = "UP" | "DOWN";

// 3. as const object + derived union
const Direction = {
  Up: "UP",
  Down: "DOWN",
} as const;
type Direction = (typeof Direction)[keyof typeof Direction]; // "UP" | "DOWN"

function moveEnum(d: DirectionEnum) {}
function moveUnion(d: DirectionUnion) {}
function move(d: Direction) {}

moveEnum(DirectionEnum.Up);
moveEnum("UP"); // ❌ Argument of type '"UP"' is not assignable to parameter of type 'DirectionEnum'.

moveUnion("UP");
moveUnion("SIDEWAYS"); // ❌ Argument of type '"SIDEWAYS"' is not assignable to parameter of type 'DirectionUnion'.

move(Direction.Up); // named access, like an enum
move("DOWN");       // and plain strings work too
const values = Object.values(Direction); // ("DOWN" | "UP")[]
What’s happening
  1. Enum: DirectionEnum.Up is the only way in — "UP" is rejected (TS2345) because string enums are nominal. It emits an IIFE-built object.
  2. Literal union: DirectionUnion is "UP" | "DOWN" and nothing else — no runtime trace at all. Plain strings are accepted and checked: "SIDEWAYS" is TS2345.
  3. as const object: as const makes the object deeply readonly and keeps each value as its literal type, so typeof Direction is { readonly Up: "UP"; readonly Down: "DOWN" }.
  4. keyof typeof Direction is "Up" | "Down"; indexing the object type with that union gives the union of the values: "UP" | "DOWN". Declaring type Direction with the same name as the const is legal (one is a value, one a type), so Direction reads just like an enum in both positions.
  5. move accepts Direction.Up (named, refactor-friendly) and "DOWN" (convenient for data from JSON). Object.values(Direction) gives exactly ["UP", "DOWN"] with no reverse-mapping noise. And it's all erasable: the object is ordinary JavaScript.
AdvancedEnum vs union vs as const table
enumliteral unionas const object
Runtime objectYes (IIFE)NoYes (plain object)
Erasable (Node type stripping, erasableSyntaxOnly)NoYesYes
Accepts plain literal "UP"No (string enums)YesYes
Named access X.UpYesNoYes
Iterate valuesYes (numeric: reverse-map noise)No (keep a separate array)Yes
Reverse mappingNumeric onlyNoNo
Accepts any numberNumeric: yesNoNo
Bit flagsNaturalAwkwardPossible

When an enum is still reasonable: numeric bit flags, codebases that already use them consistently, and frameworks that expect them. For new code, a literal union is the simplest choice when you only need a type; the as const object when you also need the values at runtime.

Which line fails to compile?
TypeScript
enum Status {
  Active = "ACTIVE",
  Inactive = "INACTIVE",
}
type StatusUnion = "ACTIVE" | "INACTIVE";

const a: StatusUnion = Status.Active;
const b: Status = "ACTIVE";
Show answer

Only the const b line — it fails with TS2820 "Type '"ACTIVE"' is not assignable to type 'Status'. Did you mean 'Status.Inactive'?"

TypeScript
enum Status {
  Active = "ACTIVE",
  Inactive = "INACTIVE",
}
type StatusUnion = "ACTIVE" | "INACTIVE";

const a: StatusUnion = Status.Active; // fine: an enum member is a subtype of its literal
const b: Status = "ACTIVE";           // ❌ Type '"ACTIVE"' is not assignable to type 'Status'. Did you mean 'Status.Inactive'?
What’s happening
  1. Status.Active has the enum member type Status.Active, whose value is the string "ACTIVE". A string enum member is assignable to its underlying literal type, so it fits StatusUnion — a compiles.
  2. The other direction doesn't work: a bare "ACTIVE" is just a string literal, and string enum types only accept their own members. So b is an error. The "Did you mean" hint is picked by spelling similarity between "ACTIVE" and the member names, and here it's misleading — the member you want is Status.Active.
  3. Assignability runs one way: enum → literal is allowed, literal → enum is not. That asymmetry is what people mean when they call string enums nominal.

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.