EduFlow

Section 3 · Type-Level Programming

keyof, typeof and Indexed Access Types

16 min4 min readCode playground

Lesson at a glance

Derive types from runtime values and from other types with typeof, keyof and indexed access, so a change in one place flows through the codebase instead of drifting.

  1. 1typeof: from values to types
  2. 2keyof: the names of the properties
  3. 3Indexed access: the type of a property
  4. 4Why Object.keys returns string[]

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Cartwheel’s shipping zones live in a config object. The first version of the code described them twice: once as data, once as types.

TypeScript
const SHIPPING_ZONES = {
  domestic: { countries: ['US', 'CA'], feeCents: 499 },
  gulf: { countries: ['AE', 'SA'], feeCents: 1299 },
  europe: { countries: ['GB', 'DE'], feeCents: 999 },
};

type Zone = 'domestic' | 'gulf' | 'europe'; // written by hand

Six months later someone added a levant zone to the object and forgot the type. Nothing failed to compile; the new zone just could not be selected anywhere. Every hand-written copy of a fact is a copy that can drift. This lesson covers the three operators that let you write the fact once and derive the rest.

typeof: from values to types

In a type position, typeof takes a value and gives you the type TypeScript inferred for it. It is not the JavaScript typeof operator (which returns strings like 'object' at runtime); it only exists at compile time.

TypeScript
const SHIPPING_ZONES = {
  domestic: { countries: ['US', 'CA'], feeCents: 499 },
  gulf: { countries: ['AE', 'SA'], feeCents: 1299 },
  europe: { countries: ['GB', 'DE'], feeCents: 999 },
} as const;

type ShippingZones = typeof SHIPPING_ZONES;
// { readonly domestic: { readonly countries: readonly ['US', 'CA']; readonly feeCents: 499 }; … }

as const matters here, as it did in Literal Types, as const and satisfies. Without it, countries would be string[] and feeCents would be number, and you would lose the details worth deriving.

typeof works on functions too: typeof formatPrice is the function’s full signature, which you can feed to utility types such as ReturnType<typeof formatPrice>. You will build ReturnType yourself in two lessons.

When should the value be the source and the type derived, rather than the other way round? Use typeof when the data is the definition: lookup tables, zone configs, lists of options that also render in the UI. Write the type first when the shape is a contract with someone else, such as an API payload, and check values against it.

Two worlds. In the value world sits the SHIPPING_ZONES object. typeof carries it into the type world as a type. From there, keyof produces the union of zone names, and indexed access with those names produces the union of zone configs, and indexing further gives fees. Values (runtime) SHIPPING_ZONES typeof Types (compile time) Zones keyof → 'domestic' | … [K]['feeCents'] → 499 | …
typeof crosses from values to types; keyof and indexing work on types

keyof: the names of the properties

keyof T is the union of T’s property names:

TypeScript
type Zone = keyof typeof SHIPPING_ZONES; // 'domestic' | 'gulf' | 'europe'

Add levant to the object and Zone updates itself. That is the whole point.

Two details are worth knowing. First, for a type with an index signature such as Record<string, number>, keyof gives string (and string | number for { [k: string]: … }, because JavaScript converts numeric keys to strings). Second, keyof on a union gives only the keys every member has. keyof (ShippedOrder | CancelledOrder) is 'id' | 'status', because reading trackingNumber from something that might be a cancelled order is not safe.

Indexed access: the type of a property

T[K] looks up the type of property K in T, using the same bracket syntax as runtime property access:

TypeScript
type GulfZone = ShippingZones['gulf'];             // { readonly countries: …; readonly feeCents: 1299 }
type GulfFee = ShippingZones['gulf']['feeCents'];  // 1299
type ZoneConfig = ShippingZones[Zone];             // union of all three configs
type FeeCents = ShippingZones[Zone]['feeCents'];   // 499 | 1299 | 999
type ShippingCountry = ShippingZones[Zone]['countries'][number]; // 'US' | 'CA' | 'AE' | …

Indexing with a union gives a union of the results, so ShippingZones[Zone] is “the value type of any zone”. The idiom (typeof X)[keyof typeof X] is how you get the union of an object’s values, and it comes up often enough that many codebases name it ValueOf<T>.

Arrays and tuples are indexed by number: T[number] is the element type. That is the (typeof COUNTRIES)[number] you used in section 1, and the last line above, which digs through two levels of objects and one array to collect every country in every zone.

The same trick works on API types you did not write. If a generated client gives you a big CheckoutResponse, you can name the line-item type as CheckoutResponse['lines'][number] instead of copying its fields.

Why Object.keys returns string[]

Sooner or later you will write this and be annoyed:

TypeScript
for (const zone of Object.keys(SHIPPING_ZONES)) {
  SHIPPING_ZONES[zone]; // Error: 'string' can't be used to index type …
}

Object.keys returns string[], not Zone[], and this is deliberate. Remember structural typing: a value whose type is { a: number } can be an object with ten more properties at runtime. If Object.keys promised (keyof T)[], it would be lying about those ten.

For a const object you defined yourself, with no way for extra keys to sneak in, a local cast is reasonable and honest: Object.keys(SHIPPING_ZONES) as Zone[]. For objects that came from elsewhere, keep string and check each key.

In the exercise you will derive five types from Cartwheel’s shipping config and API response. Next lesson goes one step further: instead of reading properties from a type, you will transform every property of a type at once.

Key takeaways

  • In a type position, typeof value gives the type TypeScript inferred for that value, bridging runtime code into the type system.
  • keyof T is the union of T’s property names, and T[K] is the type of the property (or properties) named by K.
  • (typeof CONFIG)[keyof typeof CONFIG] gives the union of an object’s value types, and T[number] gives an array’s element type.
  • Object.keys returns string[] on purpose: structural typing means an object can have more keys than its type lists.

Further reading

Check your understanding

4 questions

Question 1 of 4

What is Zone?

TypeScript
const SHIPPING_ZONES = {
  domestic: { feeCents: 499 },
  gulf: { feeCents: 1299 },
} as const;
type Zone = keyof typeof SHIPPING_ZONES;
Question 2 of 4

type CheckoutResponse = { lines: { sku: string; qty: number }[] }. Which type is { sku: string; qty: number }?

Question 3 of 4

What is keyof (ShippedOrder | CancelledOrder) when both have id and status, only ShippedOrder has trackingNumber, and only CancelledOrder has cancelReason?

Question 4 of 4

Why does Object.keys(order) return string[] instead of (keyof Order)[]?

Practice

Code playground

Derive the shipping types

Replace the five hand-written types with types derived from SHIPPING_ZONES and CheckoutResponse, so that adding a zone or changing the API type updates them automatically:

  • Zone: the zone names.
  • ShippingCountry: every country in every zone.
  • FeeCents: every zone’s fee, as literal numbers.
  • LineItem: one element of CheckoutResponse['lines'].
  • CheckoutStatus: the type of the order’s status.

Then make zoneFor and feeFor compile with the new types (no any).

const SHIPPING_ZONES = {
  domestic: { countries: ['US', 'CA'], feeCents: 499 },
  gulf: { countries: ['AE', 'SA'], feeCents: 1299 },
  levant: { countries: ['EG', 'JO'], feeCents: 899 },
  europe: { countries: ['GB', 'DE'], feeCents: 999 },
} as const;

// Shape of the checkout API response (generated from the backend's schema).
type CheckoutResponse = {
  order: { id: number; status: 'processing' | 'shipped'; totalCents: number };
  lines: { sku: string; quantity: number; unitPriceCents: number }[];
};

type Zone = string;
type ShippingCountry = string;
type FeeCents = number;
type LineItem = unknown;
type CheckoutStatus = string;

function zoneFor(country: ShippingCountry): Zone {
  for (const [zone, config] of Object.entries(SHIPPING_ZONES)) {
    if ((config.countries as readonly string[]).includes(country)) return zone;
  }
  throw new Error(`No shipping zone for ${country}`);
}

function feeFor(country: ShippingCountry): FeeCents {
  return SHIPPING_ZONES[zoneFor(country)].feeCents;
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<Zone, 'domestic' | 'gulf' | 'levant' | 'europe'>>;
type T2 = Expect<Equal<ShippingCountry, 'US' | 'CA' | 'AE' | 'SA' | 'EG' | 'JO' | 'GB' | 'DE'>>;
type T3 = Expect<Equal<FeeCents, 499 | 1299 | 899 | 999>>;
type T4 = Expect<Equal<LineItem, { sku: string; quantity: number; unitPriceCents: number }>>;
type T5 = Expect<Equal<CheckoutStatus, 'processing' | 'shipped'>>;

function typeTests() {
  // @ts-expect-error: France is not in any shipping zone
  zoneFor('FR');
}

console.log(zoneFor('JO'), feeFor('SA'));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors, including all type tests
  • zoneFor finds the right zone for a country
  • feeFor returns the zone’s fee