Derive types from values
Derive types straight from runtime values with TypeScript's typeof, keyof, and indexed-access operators, so the two can't drift apart.
You’ve spent the last few lessons modeling shapes that prevent bug classes: discriminated unions for impossible states, transitions for valid moves, brands for IDs. Every one of those patterns has the same skeleton: a runtime value paired with a type that describes it, and so far you’ve written both halves by hand. This lesson derives the type straight from the value, so you write the value once and the type follows.
const ROUTES = { home: '/', invoices: '/invoices', settings: '/settings', reports: '/reports',} as const;
type RouteName = 'home' | 'invoices' | 'settings';type RoutePath = '/' | '/invoices' | '/settings';
const navigate = (name: RouteName): void => { switch (name) { case 'home': return go(ROUTES.home); case 'invoices': return go(ROUTES.invoices); case 'settings': return go(ROUTES.settings); }};Two sources of truth here: the ROUTES value and the two type aliases. Someone added reports to ROUTES and forgot the aliases. The compiler accepts it, because each piece stays internally consistent and the switch handles every variant RouteName lists. Reviewers miss it and the build passes; what catches it is a support ticket from the user who clicked a navigation item that did nothing.
const ROUTES = { home: '/', invoices: '/invoices', settings: '/settings', reports: '/reports',} as const;
type RouteName = keyof typeof ROUTES;type RoutePath = (typeof ROUTES)[RouteName];
const navigate = (name: RouteName): void => { switch (name) { case 'home': return go(ROUTES.home); case 'invoices': return go(ROUTES.invoices); case 'settings': return go(ROUTES.settings); case 'reports': return go(ROUTES.reports); }};One source of truth here: the ROUTES value. The two type lines read its shape directly, so adding reports extended RouteName to 'home' | 'invoices' | 'settings' | 'reports' on its own. The exhaustive switch then refused to compile until you handled the new case, so the bug class is gone.
Whenever a hand-written type alias mirrors the keys, values, or structure of a runtime value, replace it with a derived type . Two sources of truth can drift apart; one can’t.
Three operators do the work: typeof V lifts a value into the type register, keyof T reads the keys of a shape, and T[K] reads the value at a key. Composed, they give you the two idioms you reach for daily: keyof typeof OBJ for the keys of a typed config, and typeof ARR[number] for the elements of a frozen array.
Value-level and type-level registers
Section titled “Value-level and type-level registers”Your code lives in two parallel registers.
The value-level register is the JavaScript your code compiles down to: variables, function calls, if statements, everything that runs at runtime.
The type-level register is what the compiler reasons about while it type-checks: type Foo = string, keyof T, Partial<User>. All of it is erased before the runtime sees it, so there is no keyof at runtime.
Most operators belong to one register only, and the two can’t see each other: type expressions can’t run, and runtime expressions can’t appear inside a type.
One keyword breaks the pattern. typeof exists in both registers and means something different in each; position decides which.
At the value level, typeof x is the runtime operator: it returns a string like 'string' or 'object', as in if (typeof input === 'string') to narrow a value.
At the type level, wherever the compiler reads a type expression, typeof V is an extractor: it reads the inferred type off a value identifier and lifts it into the type register. typeof ROUTES doesn’t return 'object'; it returns the actual { readonly home: '/'; readonly invoices: '/invoices'; ... } shape, ready for other type-level operators.
One mechanical rule keeps them apart: the type-level typeof needs a value identifier on its right, not an arbitrary expression. typeof ROUTES works; typeof getRoutes() does not, since a call gives it no identifier to read from. For that case you want ReturnType<typeof getRoutes>, which the next lesson covers.
Extracting a value’s type with typeof
Section titled “Extracting a value’s type with typeof”The first operator applies to the ROUTES value from the introduction.
const ROUTES = { home: '/', invoices: '/invoices', settings: '/settings',} as const;
type Routes = typeof ROUTES;typeof ROUTES evaluates to the shape of the value, every property at its narrowest literal type:
type Routes = typeof ROUTES;ROUTES lives at runtime; typeof ROUTES is its compile-time description, and every type-level operator below reads that description, not the value.
The catch from the previous chapter: as const must come first. as const holds the property values at their literals instead of widening to string. Without it, typeof ROUTES resolves to { home: string; invoices: string; settings: string }, and every derivation that reads the paths degrades to string, too wide to narrow a switch or match a URL.
Reading an object’s keys with keyof
Section titled “Reading an object’s keys with keyof”The second operator reads the keys out of a shape, again from ROUTES.
keyof T produces the union of keys of an object type T. If T has the keys home, invoices, and settings, then keyof T is 'home' | 'invoices' | 'settings'.
Reach for it when you have a typed config and need its key names as a type. The two operators compose:
const ROUTES = { home: '/', invoices: '/invoices', settings: '/settings',} as const;
type RouteName = keyof typeof ROUTES;Inside first: lift the value. typeof ROUTES reads the type off the value: { readonly home: '/'; readonly invoices: '/invoices'; readonly settings: '/settings' }. This is the shape keyof reads from.
const ROUTES = { home: '/', invoices: '/invoices', settings: '/settings',} as const;
type RouteName = keyof typeof ROUTES;Outside next: read the keys. keyof returns the union of property names: 'home' | 'invoices' | 'settings'. Read inside-out: typeof lifts, keyof reads.
This is the first of the two load-bearing idioms: keyof typeof OBJ. Given any const FOO = { ... } as const, its key union is keyof typeof FOO — the move behind every typed-config object that needs a matching literal-union type, from route names to plan tiers.
typeof is only the lift. When the shape is already a type, an interface, a type alias, or one imported from a module, keyof operates on it directly:
type User = { id: string; email: string; name: string };type UserKey = keyof User;// ^? 'id' | 'email' | 'name'Rule of thumb: if the right-hand side of keyof is a value identifier (declared with const, let, or as a function), put typeof in front of it; if it’s already a type, you don’t.
Reading the values with indexed access T[K]
Section titled “Reading the values with indexed access T[K]”The third operator reads a value’s types, again from ROUTES.
Indexed access is the bracket syntax you already use on values, lifted to types. At the value level, ROUTES['home'] is '/'; at the type level, (typeof ROUTES)['home'] is also '/', except you’re now reading the type at the key, not the value. The notation is T[K]: a type T indexed by a key type K, returning the type stored there. It takes two forms.
const ROUTES = { home: '/', invoices: '/invoices', settings: '/settings',} as const;
type HomePath = (typeof ROUTES)['home'];type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];Name one key, as in (typeof ROUTES)['home'], and you get the type at it. Index by keyof typeof ROUTES instead, and the key is itself a union ('home' | 'invoices' | 'settings'), so the result is the type at every key at once: '/' | '/invoices' | '/settings'. That second form is what turns a typed config into a literal union of its values, and it’s the idiom this chapter is built around.
Element types from arrays with typeof ARR[number]
Section titled “Element types from arrays with typeof ARR[number]”The second idiom reads element types out of an array. The shape differs from the config object, but the move is the same: lift the value, then read inside it.
Start with the bug. You have a list of supported locales:
const LOCALES = ['en', 'es', 'fr'];
type Locale = (typeof LOCALES)[number];// ^? stringLocale resolves to string, not 'en' | 'es' | 'fr', which is useless for narrowing a switch. Same cause as before: without as const, the array widens to string[], and a string[] indexed by number is string.
Freeze the value first, then derive:
const LOCALES = ['en', 'es', 'fr'] as const;
type Locale = (typeof LOCALES)[number];// ^? 'en' | 'es' | 'fr'Now LOCALES is a tuple , readonly ['en', 'es', 'fr'], and indexing it by number yields the union of every element type: 'en' | 'es' | 'fr'.
The [number] is worth a closer look. It isn’t indexing position 0, 1, or 2, and it isn’t a runtime expression at all. It’s type-level indexed access (T[K]) with the key type number, asking what type lives at any numeric index — the union across every position. It’s the same T[K] from the previous section, with a union-typed key instead of a literal one.
Reach for it whenever a value list should double as a literal-union type: locale codes, permission strings, plan tiers. Declare the list as const and derive the alias with typeof ARR[number], and keep the alias even when the list is all you need today, so the union is there the moment someone wants it.
const ROLES = ['member', 'admin', 'owner'] as const;type Role = (typeof ROLES)[number];// ^? 'member' | 'admin' | 'owner'
const PLAN_TIERS = ['free', 'pro', 'enterprise'] as const;type PlanTier = (typeof PLAN_TIERS)[number];// ^? 'free' | 'pro' | 'enterprise'Combining all three: a permissions config
Section titled “Combining all three: a permissions config”Now combine all three operators on a realistic shape: a permissions table and the API that reads it.
Start with the role names, derived from a frozen list as before:
const ROLES = ['member', 'admin', 'owner'] as const;type Role = (typeof ROLES)[number];// ^? 'member' | 'admin' | 'owner'Now the table: keys are permission strings, values list the roles allowed to use each one. The as const satisfies pattern does both jobs, checking the shape against the Record<string, readonly Role[]> contract while keeping every literal narrow for the derivations to read.
const PERMISSIONS = { 'invoice:read': ['member', 'admin'], 'invoice:write': ['admin'], 'org:billing': ['owner'],} as const satisfies Record<string, readonly Role[]>;Three derivations turn that one value into a fully typed API:
const ROLES = ['member', 'admin', 'owner'] as const;type Role = (typeof ROLES)[number];
const PERMISSIONS = { 'invoice:read': ['member', 'admin'], 'invoice:write': ['admin'], 'org:billing': ['owner'],} as const satisfies Record<string, readonly Role[]>;
type Permission = keyof typeof PERMISSIONS;type AllowedRole = (typeof PERMISSIONS)[Permission][number];Role: the union of role names. typeof ROLES lifts the tuple into the type register, [number] reads the union of its elements: 'member' | 'admin' | 'owner'. It constrains both the PERMISSIONS contract and the API below.
const ROLES = ['member', 'admin', 'owner'] as const;type Role = (typeof ROLES)[number];
const PERMISSIONS = { 'invoice:read': ['member', 'admin'], 'invoice:write': ['admin'], 'org:billing': ['owner'],} as const satisfies Record<string, readonly Role[]>;
type Permission = keyof typeof PERMISSIONS;type AllowedRole = (typeof PERMISSIONS)[Permission][number];Permission: the keys of the table. keyof typeof PERMISSIONS produces 'invoice:read' | 'invoice:write' | 'org:billing'. Adding a permission to the value extends this union automatically.
const ROLES = ['member', 'admin', 'owner'] as const;type Role = (typeof ROLES)[number];
const PERMISSIONS = { 'invoice:read': ['member', 'admin'], 'invoice:write': ['admin'], 'org:billing': ['owner'],} as const satisfies Record<string, readonly Role[]>;
type Permission = keyof typeof PERMISSIONS;type AllowedRole = (typeof PERMISSIONS)[Permission][number];AllowedRole: every role appearing in any permission. A two-step lookup: (typeof PERMISSIONS)[Permission] indexes the table by every key at once, giving the union of role arrays readonly ['member', 'admin'] | readonly ['admin'] | readonly ['owner']; [number] then reads each array’s element type, collapsing them to 'member' | 'admin' | 'owner'. Rename a role in ROLES and the PERMISSIONS literal stops compiling until every reference is updated.
Now type the API:
const hasPermission = (role: Role, permission: Permission): boolean => { const allowedRoles: readonly Role[] = PERMISSIONS[permission]; return allowedRoles.includes(role);};The argument types come straight from the value, so the compiler rejects hasPermission('bogus', 'invoice:read') and hasPermission('admin', 'fake:permission'): neither argument is in its union.
The allowedRoles annotation widens the per-permission tuple (readonly ['admin'] for 'invoice:write') back to readonly Role[], so .includes(role) accepts any Role rather than only that permission’s own literals.
This value-first, type-derived pattern is the stack default, from Drizzle’s $inferSelect to Zod’s z.infer.
You can’t derive a value from a type
Section titled “You can’t derive a value from a type”You can derive a type from a value, but never a value from a type. Types are erased before the code runs, so at runtime there is nothing for keyof T to read, typeof V to produce, or T[K] to walk. Derivation only runs from value to type.
So when you need both a runtime list and a compile-time union, make the value the source: keep the list as an as const array or object and derive the type from it. Never hand-write both.
type Locale = 'en' | 'es' | 'fr';
const LOCALES: Locale[] = ['en', 'es', 'fr'];The type and the array are hand-written separately, so adding a locale means editing both. Add 'de' to the type but not the array and LOCALES.includes(userLocale) gives wrong answers; add it to the array but not the type and the union drifts the same way. The compiler catches neither mismatch.
const LOCALES = ['en', 'es', 'fr'] as const;
type Locale = (typeof LOCALES)[number];The value is the source and the type reads off it. Adding 'de' to LOCALES extends Locale; removing one shrinks the union and breaks every consumer that still reads the missing variant. With one side to edit, nothing can drift.
One exception. When the type comes from an external schema, such as Stripe’s Subscription.status union or a third-party SDK’s enum, and your codebase owns no value list that mirrors it, hand-writing type Status = 'active' | 'past_due' | ... is fine. Drift needs two copies that disagree; with only one in your codebase, you write the part you own.
Exercise: type a permissions API
Section titled “Exercise: type a permissions API”The starter is the worked example with the three derived types stubbed out as any and hasPermission’s arguments left untyped. Derive the types, wire them into the signature, and watch the compiler reject the bogus call.
While the stubs are any, the bogus call compiles and the @ts-expect-error above it is flagged as unused. Wire the derivations correctly and the call errors, the directive turns valid, and every diagnostic clears.
Derive Role, Permission, and AllowedRole from the ROLES and PERMISSIONS values, then replace the anys in hasPermission's signature with the derived unions. The two ^? markers should resolve to literal unions and the @ts-expect-error directive on the bogus call should become valid once the types are wired correctly.
-
Type query at line 4 must resolve to a type containing
"member" | "admin" | "owner" -
Type query at line 13 must resolve to a type containing
"invoice:read" | "invoice:write" | "org:billing"
Reveal the reference solution
type Role = (typeof ROLES)[number];type Permission = keyof typeof PERMISSIONS;type AllowedRole = (typeof PERMISSIONS)[Permission][number];
const hasPermission = (role: Role, permission: Permission): boolean => { const allowedRoles: readonly Role[] = PERMISSIONS[permission]; return allowedRoles.includes(role);};Role reads the element union off the ROLES tuple, Permission reads the keys of PERMISSIONS, and AllowedRole indexes the table at every key, then reads each value array’s element type with a second [number]. The signature consumes both unions directly, so 'bogus' isn’t a Role: the bad call fails to compile and the @ts-expect-error becomes valid.
Exercise: pick the right derivation
Section titled “Exercise: pick the right derivation”Each chip describes a type you want. Match it to the derivation form that produces it, or to the hand-write bucket when no value mirrors it.
Each chip describes a type you want. Drop it into the derivation form that produces it. One bucket is for cases where there's no value to derive from. Drag each item into the bucket it belongs to, then press Check.
ROUTES.homeInvoice from an external library — no local value mirrors itExternal resources
Section titled “External resources”The canonical reference for the type-level `typeof` form — what it accepts on the right, what it doesn't, and how it composes with utility types.
`keyof` reference, including the interaction with index signatures and the `keyof typeof` composition the lesson promotes.
`T[K]` at depth — single-key lookups, union-keyed lookups, and the `[number]` indexed access on tuples that anchors the second idiom.
Matt Pocock's chapter on the `typeof` extractor and the `keyof typeof` composition — the same idea this lesson installs, with extra examples and exercises.