Skip to content
Chapter 5Lesson 5

Derive types from values

Use TypeScript's typeof, keyof, and indexed-access operators to derive types straight from your runtime values, so the two never drift out of sync.

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 (a request state, an event, an ID) paired with a type that describes it. So far you’ve written both halves by hand. This lesson cuts that work in half by deriving the type half straight from the value.

Start with the bug class this solves.

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);
}
};

There are two sources of truth here: the ROUTES value and the two type aliases. Someone added reports to ROUTES and forgot to update the type aliases. The compiler accepts the change, because each piece is internally consistent on its own: the value is consistent, the types are consistent, and the switch handles every variant RouteName lists. Reviewers don’t catch it, and the build doesn’t catch it. The thing that catches it is the support ticket from the user who clicked a navigation item that did nothing.

The rule comes down to one sentence: if a hand-written type alias mirrors the keys, values, or structure of a runtime value, replace it with a derived type instead. Two sources of truth can drift apart; one source of truth 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 every 2026 SaaS codebase reaches for daily: keyof typeof OBJ for the keys of a typed config, and typeof ARR[number] for the elements of a frozen array. The rest of the lesson walks each operator over the same ROUTES value, so each new operator is a fresh lens on a shape you already know. It then names the two idioms and ties everything together on a worked permissions example you’ll copy into real code.

Before any operator does work, you need one mental split that the rest of TypeScript depends on. TypeScript code lives in two parallel worlds.

The first is the value-level register: the JavaScript your code compiles down to. Variables, function calls, object literals, if statements, everything that runs at runtime. This is the part of TypeScript that isn’t TypeScript at all; it’s the JavaScript the engine executes.

The second is the type-level register: the expressions the TypeScript compiler reasons about while it type-checks. type Foo = string, keyof T, Partial<User>, Awaited<Promise<X>>, all of it lives in this register and is erased before the runtime ever sees it. There is no keyof at runtime, because the keyword means nothing to the engine.

Most operators belong to one register only. if, for, +, and JSON.parse are value-level. keyof, extends (in a constraint), infer, and indexed access T[K] are type-level. The two registers can’t see each other directly: type expressions can’t run, and runtime expressions can’t appear inside a type.

Two registers
Value-level
Type-level
Lives in
Runtime (the JavaScript engine)
Compile time (the TypeScript checker)
Operates on
Values: numbers, strings, objects, functions
Types: shapes, unions, parameters
Erased at runtime?
No — it runs
Yes — disappears before runtime
typeof here means
Predicate: "what kind of value is this?"
Extractor: "what type does this value have?"
Examples
const x = 1;
if (a) { … }
arr.map(…)
type X = string;
keyof User;
T[K]
The split that determines what each operator can do.

One detail here trips people up. The keyword typeof exists in both registers, and it means different things in each. Position determines which one applies.

At the value level, typeof x is the runtime operator you’ve used for years: it returns a string like 'string', 'number', or 'object'. You write it inside if (typeof input === 'string') to narrow a value. The previous chapter introduced it as one of the narrowing tools.

At the type level, meaning inside a type declaration, after a colon in an annotation, or anywhere the compiler is reading a type expression, typeof V does something different. It’s an extractor that reads the inferred type off a value identifier and lifts it into the type register. typeof ROUTES here doesn’t return 'object'; it returns the actual { readonly home: '/'; readonly invoices: '/invoices'; ... } shape, ready for other type-level operators to process.

Same keyword, two registers, two meanings. The compiler tells them apart by position, and you tell them apart by knowing which world you’re in. One mechanical rule helps: the type-level typeof needs a value identifier on the right, not an arbitrary expression. typeof ROUTES works, but typeof getRoutes() does not, because a call expression gives it no value identifier to read from. When you reach for that second form, what you actually want is ReturnType<typeof getRoutes>, which the next lesson covers.

Start with the first operator, taking the same ROUTES value from the introduction and looking at it through one new lens.

const ROUTES = {
home: '/',
invoices: '/invoices',
settings: '/settings',
} as const;
type Routes = typeof ROUTES;

What does typeof ROUTES evaluate to? The literal-typed shape of the value, with every property preserved at its narrowest:

type Routes = typeof ROUTES;

That resolved shape is the output of the operator. Read the operator as “take the inferred type of this value, and hand me back the type expression that describes it.” The value ROUTES lives at runtime, while typeof ROUTES is the compile-time description of what’s in ROUTES. From there, every other type-level operator in the lesson works on this description rather than on the value directly.

One dependency from the previous chapter matters here: as const must come first, before the derivation. as const is what keeps the property values at their literal types instead of widening to string. Without it, typeof ROUTES would resolve to { home: string; invoices: string; settings: string }, and every downstream derivation that reads the path values would degrade to string, which is useless for narrowing a switch or matching a URL. The rule is short:

One more thing worth filing away: typeof V also composes with utility types. You’ll see ReturnType<typeof saveInvoice> to pull the return type off a function value, Parameters<typeof saveInvoice> to pull its argument tuple, and Awaited<ReturnType<typeof fetchInvoices>> to unwrap the resolved value of an async function. Those compositions belong to the next lesson. For now, the thing to hold onto is that typeof is the bridge: it lifts a value into the type register, and other operators take it from there.

On to the second operator, applied to the same ROUTES value through one new lens.

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 the type 'home' | 'invoices' | 'settings'. That’s the whole operator: given a shape, it hands back the property names as a literal union.

You reach for this 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 inferred type off the value and produces { readonly home: '/'; readonly invoices: '/invoices'; readonly settings: '/settings' }. This is the shape keyof will read keys from.

const ROUTES = {
home: '/',
invoices: '/invoices',
settings: '/settings',
} as const;
type RouteName = keyof typeof ROUTES;

Outside next: read the keys. keyof walks the shape and returns the union of property names: 'home' | 'invoices' | 'settings'. Read the compound expression inside-out: typeof lifts, keyof reads.

1 / 1

This is the first of the two load-bearing 2026 idioms, and it’s worth memorizing as a unit: keyof typeof OBJ. Any time you’ve written const FOO = { ... } as const and you also want a type that’s the union of FOO’s keys, such as 'a' | 'b' | 'c', the answer is keyof typeof FOO. Route names, permission keys, feature-flag identifiers, locale codes, plan tiers: every typed-config object that needs a matching literal-union type reaches for this idiom.

One mechanical note that saves confusion later: typeof is only the lift. Once a shape is already in the type register, whether it’s an interface you declared, a type alias, or a type imported from a module, keyof operates on it directly with no typeof needed:

type User = { id: string; email: string; name: string };
type UserKey = keyof User;
// ^? 'id' | 'email' | 'name'

No typeof here, because User is already a type and there’s nothing to lift. The rule of thumb: if the right-hand side of keyof is a value identifier (something declared with const, let, or as a function), you need typeof in front of it. If it’s already a type identifier, you don’t.

The third operator, applied to the same ROUTES value through one final lens.

Indexed access mirrors the bracket-access syntax you already use on values, lifted into the type register. At the value level, ROUTES['home'] is '/'. At the type level, (typeof ROUTES)['home'] is also '/': same key, same result, except now you’re asking for the type at that key instead of the value at that key. The operator’s full notation is T[K]: an expression of type T indexed by a key of type K, returning the type at that key.

The operator takes two forms, and the second is the one this chapter is built around.

const ROUTES = {
home: '/',
invoices: '/invoices',
settings: '/settings',
} as const;
type HomePath = (typeof ROUTES)['home'];
type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];

The first form, (typeof ROUTES)['home'], is the literal lookup. You name a specific key, and you get the type at that key. Reach for it when you need exactly one field’s type.

The second form, (typeof ROUTES)[keyof typeof ROUTES], is the union sweep. The key argument is itself a union ('home' | 'invoices' | 'settings'), so the result is the union of types at every one of those keys: '/' | '/invoices' | '/settings'. The compiler reads it as “give me the type at any of these keys” and hands back the union of all the answers. This is the form that gives you a literal union of values from a typed config: every URL the routes map can produce, every status a state machine can hold, every locale string an i18n config supports.

You now have three lenses on the same value. The table below anchors them side by side.

Three lenses on ROUTES
Operator
Reads
Returns
typeof ROUTES
the value's full shape
{ readonly home: '/';
readonly invoices: '/invoices';
readonly settings: '/settings' }
keyof typeof ROUTES
the property names
'home' | 'invoices' | 'settings'
(typeof ROUTES)[keyof typeof ROUTES]
the value types across keys
'/' | '/invoices' | '/settings'
Each operator reads a different layer of the same value.

Once the three-row map is in your head, the right operator is easy to reach for. When you want the shape, use typeof. When you want the keys, use keyof typeof. When you want the values, use indexed access through the keys.

Element types from arrays with typeof ARR[number]

Section titled “Element types from arrays with typeof ARR[number]”

This is the second load-bearing idiom, and its syntax is hard to read the first time you meet it. The shape is different from the config object, but the principle is the same: lift the value, then read inside it.

Start with the bug class. You have a list of supported locales:

const LOCALES = ['en', 'es', 'fr'];
type Locale = (typeof LOCALES)[number];
// ^? string

Locale is string, not 'en' | 'es' | 'fr', which is useless for narrowing a switch. The reason is the one you saw earlier: without as const, the array’s inferred type is string[], and a string[] indexed by number returns string. The literals you wrote are gone before the derivation runs.

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 the type number gives the union of all possible element types: 'en' | 'es' | 'fr'. That’s exactly what you wanted.

Two pieces of that expression deserve a closer look, because the syntax stays opaque until you see what it’s actually doing.

The first piece is [number] as an index. This is not indexing the array at position 0, 1, or 2; it isn’t a runtime expression at all. It’s type-level indexed access (T[K]) where the key type is number. The question you’re asking is: “for any numeric index into this tuple, what type lives there?” The answer is the union of every type that lives at any numeric position: 'en' | 'es' | 'fr'. It’s the same T[K] operator from the previous section, just with a union-typed key instead of a literal one.

The second piece is knowing when to reach for it. Any time you have a value list that should double as a literal-union type, such as locale codes, permission strings, plan tiers, status enums, or the MIME types your uploader accepts, declare it with as const and derive the union with typeof ARR[number]. Even when the value list is the only thing you need today, leave the type alias derived, so the moment someone wants the union it’s already there.

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'

You’ll see this same shape again in the i18n chapter (locales), the RBAC chapter (role names), and the billing chapter (plan tiers): three units, one idiom.

Now combine all three operators on a more realistic shape. The permissions table below is the artifact you’ll actually copy into the RBAC unit, and the derivations under it bring together everything the rest of this lesson has built.

Start by deriving the role names from a frozen list, the pattern you just learned:

const ROLES = ['member', 'admin', 'owner'] as const;
type Role = (typeof ROLES)[number];
// ^? 'member' | 'admin' | 'owner'

Now the permissions table itself. The keys are permission strings, and the values are arrays of which roles may exercise each permission. The as const satisfies pattern from the previous chapter does both jobs at once: it validates the shape against the Record<string, readonly Role[]> contract, and keeps 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 permissions API. Walk through them in order:

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, lifted from the value list. typeof ROLES lifts the ['member', 'admin', 'owner'] tuple into the type register, and [number] reads the union of element types. The result is 'member' | 'admin' | 'owner', ready to constrain both the PERMISSIONS value’s 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 permissions table. keyof typeof PERMISSIONS produces 'invoice:read' | 'invoice:write' | 'org:billing'. Adding a new permission to the value extends this union for free.

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 that appears in any permission. This is a two-step lookup. First, (typeof PERMISSIONS)[Permission] indexes the table by the union of all permission keys, producing the union of role arrays: readonly ['member', 'admin'] | readonly ['admin'] | readonly ['owner']. Then the trailing [number] reads the element type of each array, collapsing them to 'member' | 'admin' | 'owner'. Renaming a role in ROLES stops the PERMISSIONS literal from compiling until you update every reference.

1 / 1

The payoff is the contract you’ve now wired. Adding a new permission to the PERMISSIONS value updates Permission for free, so every consumer of that union immediately sees the new key. Adding a role to an existing permission updates AllowedRole for free. Renaming a role in ROLES won’t compile until the PERMISSIONS literal is updated too, because the as const satisfies contract anchors the role names to the source list.

You can now type the API around this without any extra ceremony:

const hasPermission = (role: Role, permission: Permission): boolean => {
const allowedRoles: readonly Role[] = PERMISSIONS[permission];
return allowedRoles.includes(role);
};

Notice what just happened: the function’s argument types come from the value. The compiler refuses hasPermission('bogus', 'invoice:read') because 'bogus' isn’t a Role, and it refuses hasPermission('admin', 'fake:permission') because 'fake:permission' isn’t a Permission. Keeping this surface coherent is now the compiler’s job rather than something the team has to remember.

The intermediate allowedRoles binding widens the per-permission tuple (readonly ['admin'] for 'invoice:write') back up to the contract type, readonly Role[]. That widening is what lets .includes(role) accept any Role, not just the literals at that one permission. The two-step read is the clearer shape. The alternative, includes(role as never), compiles but hides the widening from anyone reading the function.

You’ll see this same pattern again when the RBAC unit lands. The shape stays fixed; the only thing that grows is the PERMISSIONS value itself.

One short rule heads off the most common misconception about derivation.

You can derive a type from a value, but you cannot derive a value from a type. Types are erased at compile time, so there is nothing for keyof T to read at runtime, nothing for typeof V to produce when there’s no value, and nothing for T[K] to walk. Types are descriptions, and values are the thing being described. The arrow only goes one way.

That settles every “do I declare the type or the value first?” question: when you need both a runtime list and a compile-time union, the value is the source. The list lives in code as an as const array or object, and the type is derived from it. Never hand-write both.

type Locale = 'en' | 'es' | 'fr';
const LOCALES: Locale[] = ['en', 'es', 'fr'];

Both the type and the value list are hand-written. Add 'de' to the type but forget the array, and LOCALES.includes(userLocale) starts giving wrong answers; add it to the array but forget the type, and the literal union admits the same drift. Adding a locale means updating both, and the compiler won’t catch the case where you update one and forget the other. The two sides drift the moment one of them changes alone.

There is one carve-out worth naming so you don’t over-apply the rule. 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 doesn’t own a value list that mirrors it, hand-writing type Status = 'active' | 'past_due' | ... on its own is fine. The drift only happens when both the value list and the type alias live in your codebase and disagree. If only one of them is yours, derivation has nothing to derive from, so you write the part you own.

You’ve now installed the three operators and the two idioms. The next lesson sits one layer above this one, covering utility types like ReturnType, Parameters, Awaited, Partial, Pick, and the rest of the surface you reach for daily. These compose with typeof to pull function-shape data straight off a function value: ReturnType<typeof saveInvoice> gives you the return type without restating it, Parameters<typeof saveInvoice>[0] gives you the first argument’s type, and Awaited<ReturnType<typeof fetchInvoices>> unwraps the resolved value of an async function. This lesson sets up the seam; the next one covers the utility types in depth.

The principle also shows up across the SaaS stack as the default for typed surfaces. Three places you’ll meet it in coming units:

  • Drizzle. typeof invoices.$inferSelect derives the row type from the schema value. The schema is the source, and the type is the derivation. You’ll see this when the database unit lands.
  • Zod. z.infer<typeof invoiceSchema> derives the validated type from the schema value. Same principle, different value source.
  • Next.js. Route segment params derive their type from the route’s params schema. Same arrow: value first, type derived.

The pattern you just installed isn’t a niche TypeScript trick. It’s the default shape every modern data layer and framework reaches for when it needs a type to track a value. The next lesson ships the utility chains that compose on top of it.

The starter below mirrors the worked example: a ROLES list, a PERMISSIONS table, and three type stubs (Role, Permission, AllowedRole) that you derive from the values. Once the derivations are in place, complete the hasPermission signature so its arguments are typed by the right unions, then watch the compiler refuse the bogus call at the bottom.

Two ^? markers surface the resolved types under Role and Permission. A @ts-expect-error directive sits on a call to hasPermission('bogus', 'invoice:read'), asserting that the line below fails to compile once the types are wired correctly. Right now the three type stubs are any, so the bogus call slips through and the directive itself reports “Unused ‘@ts-expect-error’ directive.” Once you fix the derivations, the bogus call errors, the directive becomes valid, and every diagnostic clears.

Derive `Role`, `Permission`, and `AllowedRole` from the `ROLES` and `PERMISSIONS` values, then replace the `any`s 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"
Booting type-checker…
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 lifts the ROLES tuple and reads the element union with [number]. Permission reads the keys of the PERMISSIONS value. AllowedRole indexes the table at every permission key (returning the union of role arrays), then reads each array’s element type with a second [number]. The hasPermission signature consumes the two unions directly. Since 'bogus' isn’t a Role, the bogus call now fails to compile and the @ts-expect-error directive above it becomes valid.

If the three type derivations clicked, the lesson has landed. The signature wiring is the smaller payoff: a function whose argument types come from the same values its body operates on, with no parallel hand-maintained list to keep in sync.

This exercise gives you eight “I want this type” prompts. For each one, decide which derivation form produces it. The buckets are the five shapes from this lesson: four derivations plus one “hand-write” option for the case where there’s nothing to derive from.

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.

keyof typeof OBJ Keys of a typed-config object.
(typeof OBJ)[K] The type at one specific key of a config.
(typeof OBJ)[keyof typeof OBJ] The union of all value types across a config's keys.
typeof ARR[number] Elements of a frozen array or tuple.
Hand-write No value to derive from — write the type alias directly.
The locale codes my i18n module supports (list of strings)
The keys of my routes map
The URL string at ROUTES.home
The union of all route paths
The plan tiers Stripe ships (frozen array of names)
The role names my RBAC module recognizes (frozen array)
The MIME type strings my uploader accepts
The shape of an Invoice from an external library — no local value mirrors it

The pattern across the seven derivable items: each one describes a value list or a config object that your codebase owns, and each maps to the operator chain that reads the type off it. The last item is the carve-out from the reverse-trap section: the type comes from outside and no value mirror exists, so hand-writing is the only option.