TypeScript NotesRohit’s interview study guide
Chapter 09

Type Operators

Building types from other types and values: keyof, typeof, indexed access and the patterns interviews love.

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

#The keyof operator

keyof T takes an object type and gives you the union of its keys as literal types. For interface Person { name: string; age: number }, keyof Person is "name" | "age". It's the type-level cousin of Object.keys — but it works on the type, at compile time, and nothing of it exists at runtime.

It exists so you can write code that is generic over "some property of this object" without falling back to string. A parameter typed keyof Person accepts exactly the valid property names, gets autocompletion, and breaks at compile time when someone renames a property. Combined with generics it gives the type-safe getValue; combined with in it drives every mapped type.

A mental model: keyof answers the question "which keys can I safely use to index this type?". That phrasing explains all the surprising cases below — index signatures, unions, arrays and {}.

TypeScript
interface Person {
  name: string;
  age: number;
}

type PersonKey = keyof Person; // type PersonKey = "name" | "age"

function printProp(p: Person, key: PersonKey) {
  console.log(p[key]);
}

const ann: Person = { name: "Ann", age: 30 };
printProp(ann, "age"); // 30
printProp(ann, "email"); // ❌ Argument of type '"email"' is not assignable to parameter of type 'keyof Person'.
What’s happening
  1. keyof Person looks at the declared members of Person — name and age — and produces one string literal type per key, joined into a union: "name" | "age".
  2. PersonKey is just a name for that union. The checker usually displays it as keyof Person in messages and as the expanded union on hover.
  3. printProp(ann, "age") checks "age" against "name" | "age" — fine — and at runtime p["age"] logs 30.
  4. "email" isn't in the union, so the call fails with TS2345. If someone later renames age to years in the interface, every "age" argument becomes a compile error instead of a silent undefined.
  5. Inside the function, p[key] has the type Person[PersonKey] = string | number — an indexed access type.
Advancedkeyof edge cases with index signatures

The edge cases all follow from "which keys can I safely index with":

TypeScript
type Dict = { [key: string]: boolean };
type DictKeys = keyof Dict; // type DictKeys = string | number

type NumDict = { [index: number]: boolean };
type NumDictKeys = keyof NumDict; // type NumDictKeys = number

type Anything = keyof any; // type Anything = string | number | symbol
type Nothing = keyof {}; // type Nothing = never

type Common = keyof ({ a: 1; b: 2 } | { a: 3; c: 4 }); // type Common = "a"
type All = keyof ({ a: 1 } & { c: 4 }); // type All = "a" | "c"

const id = Symbol("id");
type Mixed = keyof { [id]: number; name: string; 0: boolean }; // typeof id | "name" | 0

type ArrayKeys = keyof string[]; // number | "length" | "push" | "pop" | … every array member
const k: ArrayKeys = "length";
const i: ArrayKeys = 3;
What’s happening
  1. String index signature: { [key: string]: boolean } can be indexed by any string — and by any number, because JavaScript converts obj[0] to obj["0"]. So keyof gives string | number. This is a classic surprise when you expect plain string (see Index signatures).
  2. Number index signature: only numbers are promised, so keyof is number.
  3. keyof any is every type that can be a property key: string | number | symbol — the same as the built-in PropertyKey (see PropertyKey and keyof any). keyof {} is never: the empty object type promises no keys at all.
  4. Union: a value of type A | B might be either, so you can only safely use keys that both have. keyof (A | B) is the intersection of the key sets: just "a". Intersection: a value of type A & B has everything from both, so keyof (A & B) is the union of the key sets: "a" | "c". The operators flip — worth saying out loud in an interview.
  5. Symbol and numeric keys keep their own types: a computed [id] key contributes typeof id (a unique symbol), and the numeric literal key 0 contributes the number literal 0, not "0".
  6. Arrays: keyof string[] is number (the index signature) plus the name of every array property and method — "length", "push", "map" and so on. That's why keyof on arrays is rarely what you want; use T[number] for the element type instead.
AdvancedPitfall: keyof vs runtime keys
AdvancedWhere keyof gets used

Where you'll use it: function parameters that must be a property name (sortBy(users, "age")), the K extends keyof T constraint, and mapped types ({ [K in keyof T]: … }), which is how Partial<T>, Readonly<T> and Pick<T, K> are built.

#typeof in type positions

TypeScript has two typeofs. In an expression, typeof x is the JavaScript operator: it runs at runtime and returns a string like "object". In a type position — after type X =, after a :, inside <…> — typeof x is a type query: it asks the checker "what type did you give this variable?" and produces that type. Nothing runs (see JavaScript typeof vs TypeScript typeof).

Why it exists: types and values live in separate namespaces. A const config = {…} is a value; you can't write config where a type is expected. typeof config is the bridge from the value world to the type world. It lets the value be the single source of truth, so you don't write the shape twice and have it drift.

TypeScript
const config = {
  host: "localhost",
  port: 8080,
  debug: false,
};

type Config = typeof config;
// type Config = { host: string; port: number; debug: boolean; }

function connect(c: Config) {
  console.log(`${c.host}:${c.port}`);
}

connect({ host: "db.internal", port: 5432, debug: true }); // "db.internal:5432"
connect({ host: "db.internal" }); // ❌ Type '{ host: string; }' is missing the following properties from type '{ host: string; port: number; debug: boolean; }': port, debug

type Wrong = config; // ❌ 'config' refers to a value, but is being used as a type here. Did you mean 'typeof config'?
What’s happening
  1. When config was declared, the checker inferred a type for it. Property values in a mutable object literal are widened: "localhost" becomes string, 8080 becomes number, false becomes boolean — because config.port = 9000 would be allowed later.
  2. typeof config in the type Config = position reads that inferred type back: { host: string; port: number; debug: boolean }. No runtime code is generated for this line.
  3. connect uses Config like any other type, so the first call compiles and logs db.internal:5432. The second is missing port and debug and fails with TS2739 (the message prints the expanded object type, not the alias name Config).
  4. type Wrong = config tries to use a value as a type, and the checker tells you exactly what you meant (TS2749). Every value name needs typeof before it can appear in a type.
  5. If you wanted the literal types ("localhost", 8080), declare the object as const — then typeof config is { readonly host: "localhost"; readonly port: 8080; readonly debug: false }. See as const.
AdvancedFunction types with typeof

The doc's heading here was "Function types with typeof": typeof on a function gives its whole signature, which is how you name the type of a function you didn't declare a type for — typically to pass it to ReturnType or Parameters.

TypeScript
function createUser(name: string, age = 0) {
  return { id: crypto.randomUUID(), name, age };
}

type CreateUserFn = typeof createUser;
// type CreateUserFn = (name: string, age?: number) => { id: `${string}-${string}-${string}-${string}-${string}`; name: string; age: number; }

type User = ReturnType<typeof createUser>;
type Args = Parameters<typeof createUser>; // type Args = [name: string, age?: number]

const factory: CreateUserFn = (name) => ({ id: crypto.randomUUID(), name, age: 1 });

type NoCall = ReturnType<createUser>; // ❌ 'createUser' refers to a value, but is being used as a type here. Did you mean 'typeof createUser'?
What’s happening
  1. createUser has no declared return type, so the checker infers one from the return. crypto.randomUUID() is declared in the DOM/Node typings to return a template literal type of five dash-separated strings, which is why id isn't just string.
  2. typeof createUser captures the full signature: parameter names, the fact that age has a default (so it's optional: age?: number), and the inferred return type.
  3. ReturnType<typeof createUser> extracts the return type, so User is { id: …; name: string; age: number } — and it stays in sync automatically if the function changes. Parameters<…> gives the parameters as a named tuple.
  4. factory: CreateUserFn must match that signature; an arrow that takes only name is fine (fewer parameters is allowed), and its return object is checked against the inferred return type.
  5. ReturnType<createUser> forgets the typeof. Generic utility types take types, and a function name is a value, so TS2749 again.

#keyof typeof

The doc marked this "important !!", and it is: keyof typeof obj is how you get the keys of a value as a union type. It's two operators, and you read them right to left: first typeof obj turns the value into its type, then keyof takes that type's keys.

You need both because keyof only works on types. keyof config fails for the same reason type X = config fails: config is a value. So the pattern is always: value → typeof → type → keyof → union of keys.

TypeScript
const config = {
  host: "localhost",
  port: 8080,
  debug: false,
};

type ConfigKey = keyof typeof config; // type ConfigKey = "host" | "port" | "debug"

function readSetting(key: keyof typeof config) {
  return config[key];
}

readSetting("port"); // 8080 (type: string | number | boolean)
readSetting("timeout"); // ❌ Argument of type '"timeout"' is not assignable to parameter of type '"debug" | "host" | "port"'.

type Broken = keyof config; // ❌ 'config' refers to a value, but is being used as a type here. Did you mean 'typeof config'?
What’s happening
  1. Start on the right: typeof config produces the type { host: string; port: number; debug: boolean } (the widened type the checker inferred for the object literal).
  2. Then keyof that type gives "host" | "port" | "debug". The keys are literal types even though the values were widened — keys are never widened.
  3. readSetting("port") checks "port" against that union — fine — and config["port"] is 8080 at runtime. Its type is (typeof config)[keyof typeof config], which is string | number | boolean, because the parameter is the whole key union (make it generic to get the exact type — see getValue).
  4. readSetting("timeout") fails with TS2345 — note the message expands keyof typeof config into the actual union (printed in sorted order; order inside a union doesn't matter).
  5. keyof config without typeof fails with TS2749, the same "value used as a type" error. If you remember one thing: keyof needs a type; typeof makes one from a value.
AdvancedLookup object as single source of truth

The most common real use is a lookup object as the single source of truth — instead of an enum, or instead of maintaining a union by hand next to the object:

TypeScript
const ROUTES = {
  home: "/",
  profile: "/profile",
  settings: "/settings",
};

type RouteName = keyof typeof ROUTES; // "home" | "profile" | "settings"

function navigate(route: RouteName) {
  console.log(`going to ${ROUTES[route]}`);
}

navigate("profile"); // "going to /profile"
navigate("admin"); // ❌ Argument of type '"admin"' is not assignable to parameter of type '"home" | "profile" | "settings"'.
What’s happening
  1. ROUTES is an ordinary object that exists at runtime — you can iterate it, log it, export it.
  2. keyof typeof ROUTES derives the union of route names from it at compile time. Adding about: "/about" to the object automatically adds "about" to RouteName; there's no second list to forget.
  3. navigate("profile") is checked against the union and logs going to /profile. navigate("admin") fails with TS2345.
  4. This value-first pattern is the usual alternative to enums (see Enums vs literal unions). To get the values ("/" | "/profile" | …) as a union too, add as const and use [T[keyof T]](#getting-every-value-type-with-t-keyof-t).
keyof typeof on an enum
TypeScript
enum Direction {
  Up,
  Down,
}

type A = keyof typeof Direction;
type B = keyof Direction;

What are A and B?

Show answer

A is "Up" | "Down". B is keyof number — the names of Number.prototype's methods ("toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString").

What’s happening
  1. An enum declaration creates two things with the same name: a runtime object (the value Direction) and a type (Direction, meaning "one of this enum's members"). See Enums as types and values.
  2. typeof Direction refers to the object: { Up: Direction.Up, Down: Direction.Down } plus the reverse mapping. keyof of that object type gives its member names, "Up" | "Down" — this is what you want when you need the names, e.g. to accept "Up" from a config file.
  3. Direction in a type position is the enum type, i.e. the member values Direction.Up | Direction.Down. Those are numbers (0 and 1), so keyof Direction is keyof number: the methods every number has.
  4. The numeric reverse mapping (Direction[0] === "Up") exists at runtime but is not part of keyof typeof Direction — the checker special-cases numeric access on a numeric enum object (Direction[0] is typed string) instead of adding keys, so the result is exactly "Up" | "Down".

#Indexed access types

An indexed access type T[K] looks up the type of a property: Person["age"] is "the type of age in Person", i.e. number. It's property access, but on types — the same square-bracket syntax you'd use to read the value, applied in a type position.

It exists so you can refer to part of another type instead of restating it. If Person["address"] is used everywhere, changing the address shape in Person updates every use. And inside generics, T[K] is the only way to say "the type of whatever property K is" — which is what makes getValue and the generic setter work.

TypeScript
interface Person {
  name: string;
  age: number;
  address: { city: string; zip: string };
  tags: string[];
  nickname?: string;
}

type Age = Person["age"]; // type Age = number
type Address = Person["address"]; // type Address = { city: string; zip: string; }
type City = Person["address"]["city"]; // type City = string
type NameOrAge = Person["name" | "age"]; // type NameOrAge = string | number
type Nick = Person["nickname"]; // type Nick = string | undefined

type Typo = Person["nmae"]; // ❌ Property 'nmae' does not exist on type 'Person'.
type Dotted = Person.age; // ❌ Cannot access 'Person.age' because 'Person' is a type, but not a namespace. Did you mean to retrieve the type of the property 'age' in 'Person' with 'Person["age"]'?
What’s happening
  1. Person["age"]: the index is the string literal type "age". The checker finds the age member and returns its declared type, number.
  2. Person["address"]["city"] chains lookups left to right: first Person["address"] gives { city: string; zip: string }, then ["city"] on that gives string.
  3. Person["name" | "age"] indexes with a union. The checker looks up each member separately and unions the results: Person["name"] | Person["age"] = string | number.
  4. Person["nickname"] is string | undefined: an optional property's type includes undefined, and the lookup carries that along.
  5. Person["nmae"] fails with TS2339 — indexed access is checked, so typos are caught. Person.age fails with TS2713: dot syntax in a type position means "a type exported from a namespace", not a property lookup, and the message suggests the bracket form.
AdvancedIndexing with types, not values

The index must be a type, not a value — the same value/type split as typeof:

TypeScript
interface Person {
  name: string;
  age: number;
}

const key = "name";
type ByValue = Person[key]; // ❌ Type 'key' cannot be used as an index type.
type ByTypeof = Person[typeof key]; // type ByTypeof = string

type Lookup<T, K extends keyof T> = T[K];
type L = Lookup<Person, "age">; // type L = number
What’s happening
  1. key is a const variable — a value. Person[key] tries to use it as a type, which fails. The compiler actually reports two errors on that line: TS2538 ("Type 'key' cannot be used as an index type") and TS2749 ("'key' refers to a value… Did you mean 'typeof key'?").
  2. typeof key is the literal type "name" (a const keeps the literal), so Person[typeof key] is Person["name"] = string.
  3. Lookup<T, K extends keyof T> is the generic form. The constraint is required: without it, T[K] fails with "Type 'K' cannot be used to index type 'T'", because the checker can't know K is a valid key.
  4. Lookup<Person, "age"> substitutes T = Person, K = "age" and resolves to Person["age"], i.e. number. The two special index forms, T[keyof T] and T[number], get their own topics next.

#Getting every value type with T[keyof T]

The doc's note: "Burn this to brain!! someObject[keyof someObject] extracts all values as a union". Put the two previous topics together: keyof T is the union of all keys, and indexing with a union gives the union of the looked-up types. So T[keyof T] is the union of all of T's value types — the type-level Object.values.

For a value instead of a type, add typeof (the doc: "add keyof at the end"): (typeof obj)[keyof typeof obj]. And for that to give literal values rather than string, the object must be as const.

TypeScript
interface Person {
  name: string;
  age: number;
  active: boolean;
}

type PersonValue = Person[keyof Person]; // type PersonValue = string | number | boolean

type ValueOf<T> = T[keyof T];
type Flags = ValueOf<{ a: 1; b: "two"; c: true }>; // type Flags = 1 | "two" | true
What’s happening
  1. keyof Person is "name" | "age" | "active".
  2. Person[keyof Person] is therefore Person["name" | "age" | "active"], and indexing with a union distributes: Person["name"] | Person["age"] | Person["active"].
  3. That's string | number | boolean — every value type, in one union.
  4. ValueOf<T> packages it as a reusable helper (TypeScript doesn't ship one). With literal property types you get the literals back: 1 | "two" | true.
AdvancedValue union from a runtime object

The interview version starts from a runtime object:

TypeScript
const STATUS = {
  active: "ACTIVE",
  banned: "BANNED",
  pending: "PENDING",
} as const;

type StatusKey = keyof typeof STATUS; // "active" | "banned" | "pending"
type Status = (typeof STATUS)[keyof typeof STATUS]; // type Status = "ACTIVE" | "BANNED" | "PENDING"

function setStatus(s: Status) {
  console.log(s);
}

setStatus(STATUS.banned); // "BANNED"
setStatus("PENDING"); // fine — the literal is in the union
setStatus("DELETED"); // ❌ Argument of type '"DELETED"' is not assignable to parameter of type 'Status'.
What’s happening
  1. as const makes the object deeply readonly and stops widening, so typeof STATUS is { readonly active: "ACTIVE"; readonly banned: "BANNED"; readonly pending: "PENDING" }.
  2. keyof typeof STATUS gives the keys: "active" | "banned" | "pending".
  3. (typeof STATUS)[keyof typeof STATUS] indexes that object type with all of its keys at once, which distributes into "ACTIVE" | "BANNED" | "PENDING" — the values as a union.
  4. setStatus(STATUS.banned) passes the value "BANNED" (its type is the literal "BANNED"), and a raw "PENDING" works too, which is nicer than enums: callers can use either the constant or the literal. "DELETED" fails with TS2345 (this time the message keeps the alias name Status instead of expanding it).
  5. This as const object + derived union is the modern replacement for a string enum — it's plain JavaScript at runtime and works with Node's type stripping (see Enums vs literal unions).
AdvancedPitfall: widened values without as const
What is Value?
TypeScript
const sizes = {
  small: 8,
  medium: 12,
  large: { min: 16, max: 20 },
} as const;

type Value = (typeof sizes)[keyof typeof sizes];
Show answer

8 | 12 | { readonly min: 16; readonly max: 20 }.

What’s happening
  1. as const keeps every value literal and adds readonly all the way down, so typeof sizes is { readonly small: 8; readonly medium: 12; readonly large: { readonly min: 16; readonly max: 20 } }.
  2. keyof typeof sizes is "small" | "medium" | "large".
  3. Indexing with that union looks up each key and unions the results: 8, 12 and the nested object type.
  4. T[keyof T] only goes one level deep — it doesn't flatten nested objects. To collect nested leaf values you'd need a recursive type.

#Element types with T[number]

Indexing with number asks "what do I get when I index this with any number?" For an array type that's the element type: string[][number] is string. For a tuple it's the union of all the element types. The doc's headings were "Typeof array[number]", "Array element extraction" and "Tuple example", and the famous one-liner is:

TypeScript
const roles = ["admin", "user"] as const;
type Role = (typeof roles)[number]; // type Role = "admin" | "user"
AdvancedT[number] as the array T[keyof T]

It's the array equivalent of T[keyof T]: an array's index signature is [n: number]: T, so number is the key that reaches the elements.

TypeScript
type Names = string[];
type Name = Names[number]; // type Name = string

const roles = ["admin", "user"] as const;
// typeof roles = readonly ["admin", "user"]
type Role = (typeof roles)[number]; // type Role = "admin" | "user"
type RoleCount = (typeof roles)["length"]; // type RoleCount = 2

const loose = ["admin", "user"];
type LooseRole = (typeof loose)[number]; // type LooseRole = string

function hasRole(r: Role) {
  return roles.includes(r);
}
hasRole("admin");
hasRole("guest"); // ❌ Argument of type '"guest"' is not assignable to parameter of type '"admin" | "user"'.
What’s happening
  1. string[] has the index signature [n: number]: string, so indexing the type with number gives string.
  2. roles is declared as const, so its type is the readonly tuple readonly ["admin", "user"] — fixed length, literal elements. A tuple has members 0: "admin" and 1: "user" plus a number index signature whose type is the union of the elements. So (typeof roles)[number] is "admin" | "user".
  3. Tuples also have a literal length, so (typeof roles)["length"] is the number literal type 2.
  4. loose has no as const, so its type is string[] and its element type is string — same widening trap as with T[keyof T].
  5. hasRole("guest") fails with TS2345. The runtime array (for iteration and validation) and the type (for checking) now come from one declaration — add "editor" to the array and Role updates.
AdvancedElement types of tuples and object arrays

Tuples and arrays of objects:

TypeScript
type Row = [id: number, name: string, active: boolean];

type First = Row[0]; // type First = number
type AnyCell = Row[number]; // type AnyCell = string | number | boolean
type Len = Row["length"]; // type Len = 3
type Past = Row[3]; // ❌ Tuple type 'Row' of length '3' has no element at index '3'.

const users = [
  { id: 1, name: "Ann" },
  { id: 2, name: "Bob", admin: true },
];
type User = (typeof users)[number];
// type User = { id: number; name: string; admin?: undefined; } | { id: number; name: string; admin: boolean; }
What’s happening
  1. Row[0] uses a numeric literal index, so it reads one position: number. (The id: labels are just names; see Named, optional and variadic tuples.)
  2. Row[number] uses the general number index, which means "any position", so it's the union of all three element types.
  3. Row["length"] is the literal 3 because a tuple's length is part of its type; Row[3] is past the end and fails with TS2493.
  4. users is an array of two object literals with different shapes. The checker normalises them so they can be compared: the first gets admin?: undefined added, so the element type is a union of the two shapes.
  5. (typeof users)[number] extracts that element type, giving you a User type straight from sample data or a fixture — handy when the data is the source of truth (and handled more precisely with as const or satisfies; see satisfies).
AdvancedEdge case: includes with literal arrays
T[number] vs T[keyof T] on a tuple
TypeScript
type Pair = ["a", "b"];

type X = Pair[number];
type Y = Pair[keyof Pair];

Are X and Y the same?

Show answer

No. X is "a" | "b". Y is a much bigger union that includes "a" | "b" but also 2 (the length) and every array method's function type.

What’s happening
  1. Pair[number] uses the number index, which on a tuple is the union of the elements: "a" | "b".
  2. keyof Pair isn't just the indexes. It's "0" | "1" plus number plus "length", "push", "map" and every other array member (the same thing we saw for keyof string[] in The keyof operator).
  3. So Pair[keyof Pair] unions the types of all of those: "a", "b", 2, and the function types of push, map and the rest.
  4. That's why T[keyof T] is the idiom for objects and T[number] is the idiom for arrays and tuples.

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.