Chapter 14 of 24 · TypeScript for Beginners — Complete Free Tutorial
keyof, Indexed Access and Mapped Types
Every utility type you met in chapter 13 is built from three operators that inspect and generate object types. Chapter 14 of this 24-chapter series covers exactly those: keyof T for asking a type for its keys, T['field'] for reading a property's type back out, and mapped types for looping over every key — the machinery behind Partial, Pick and Record.
keyof: the keys as a union
Where Object.keys() gives you the keys of a value, keyof gives you the keys of a type, delivered as a union of string literals. That union is then an ordinary type, so it works anywhere a union works — annotations, generic constraints, switch narrowing and the utilities from the last chapter.
interface Course {
id: string;
title: string;
minutes: number;
}
type CourseKey = keyof Course; // 'id' | 'title' | 'minutes'
const sortField: CourseKey = 'title'; // ok
const typo: CourseKey = 'titel'; // compile error
Expected output: CourseKey resolves to 'id' | 'title' | 'minutes', and the typo fails with error TS2322: Type '"titel"' is not assignable to type 'keyof Course'.
Two rules appear often enough to memorise. keyof of a union is the intersection of the member keys — only keys every member shares. And keyof any is string | number | symbol, the widest key set, which is why generic helpers usually constrain with K extends keyof T instead of string. Interfaces themselves are covered by Interfaces and Type Aliases; here the interface is only the raw material.
Indexed access: reading a property's type
The square-bracket operator works in the type world too: Course['id'] is the type of whatever lives under id. Index one key for a single type, index keyof Course for the union of all value types, and inside a generic function index a type parameter with its key parameter.
type IdType = Course['id']; // string
type MinutesType = Course['minutes']; // number
type AnyValue = Course[keyof Course]; // string | number
function get<T, K extends keyof T>(row: T, key: K): T[K] {
return row[key];
}
declare const course: Course;
const minutes = get(course, 'minutes'); // number
get(course, 'titel'); // compile error
type Broken<T, K> = T[K]; // no constraint on K
Expected output: IdType is string, MinutesType is number, AnyValue is string | number, minutes is number, and the two mistakes are reported as error TS2345: Argument of type '"titel"' is not assignable to parameter of type '"id" | "title" | "minutes"'. and error TS2536: Type 'K' cannot be used to index type 'T'.
That get helper is the payoff: the argument type K and the return type T[K] are bound together, so a bad key and a bad read are the same mistake, caught in one place. Without extends keyof T the compiler cannot know which keys exist — hence the TS2536 error on Broken.
Mapped types: a loop over keys
A mapped type generates one property per key of another type. The syntax { [P in Keys]: Value } reads like for (const P of Keys): choose the key universe, then say what each property looks like.
type MyPartial<T> = { [P in keyof T]?: T[P] };
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyRecord<K extends string, V> = { [P in K]: V };
// TypeScript's own definitions, for reference:
// type Partial<T> = { [P in keyof T]?: T[P] };
// type Pick<T, K> = { [P in K]: T[P] };
// type Record<K, V> = { [P in K]: V };
Expected output: no runtime output — these are type-level functions. MyPartial<{ readonly id: string }> resolves to { readonly id?: string | undefined }: note that readonly survived, because a mapped type written over keyof T is homomorphic — it iterates an existing object type, so TypeScript copies each property's modifiers across.
Record<K, V> really is a mapped type; its entire definition is { [P in K]: V }. The difference is the key universe: MyRecord maps over keyof T (an object type, so modifiers carry over) while Record<'a' | 'b', V> maps over a plain union and knows nothing about any object — that is why Record<keyof T, V> quietly throws away your ? and readonly modifiers.
Modifiers: +, -, readonly and ?
Homomorphic mapped types let you rewrite modifiers explicitly. readonly sits before the [P in…] clause; the optional marker ? sits after it. Prefix a modifier with - to remove it, with + to add it (and + is simply the default).
interface Sample {
readonly id?: string;
label: string;
}
type Mutable<T> = { -readonly [P in keyof T]: T[P] };
type RequiredFields<T> = { [P in keyof T]-?: T[P] };
type FrozenOptional<T> = { +readonly [P in keyof T]+?: T[P] };
type M1 = Mutable<Sample>;
type M2 = RequiredFields<Sample>;
type M3 = FrozenOptional<Sample>;
Expected output: M1 is { id?: string | undefined; label: string } (readonly removed), M2 is { readonly id: string; label: string } (optional removed, readonly kept) and M3 is { readonly id?: string | undefined; readonly label?: string | undefined }.
Rewrite that in your head as four operations — add or remove readonly, add or remove ? — and you can build Partial, Required and Readonly yourself from memory. Chapter 13's Required, for instance, is exactly M2.
Template literal types and key remapping
TypeScript can build string types out of pieces with backticks, and it can remap a mapped type's keys with an as clause. Put the two together and you can derive key names instead of repeating them.
type ClickEvents = 'click' | 'dblclick' | 'focus';
type OnHandlers = {
[E in ClickEvents as `on${Capitalize<E>}`]: () => void;
};
const handlers: OnHandlers = {
onClick: () => console.log('clicked'),
onDblclick: () => console.log('double'),
onFocus: () => console.log('focus'),
};
console.log(Object.keys(handlers).join(', '));
Expected output: keyof OnHandlers is 'onClick' | 'onDblclick' | 'onFocus', and the log prints onClick, onDblclick, onFocus.
Capitalize<'click'> is 'Click' — capitalisation touches only the first character, while Lowercase and Uppercase act on the whole string. Remapping can also filter: map an unwanted key to never and it disappears from the result.
Worked example: CamelCase keys
APIs often hand you snake_case fields while your UI wants camelCase. A recursive template literal type splits each key on underscores, and one mapped type rewrites every key in an interface.
type SnakeToCamel<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Lowercase<Head>}${Capitalize<SnakeToCamel<Tail>>}`
: S;
type CamelCased<T> = {
[K in keyof T as SnakeToCamel<K & string>]: T[K];
};
interface ApiUser {
user_id: number;
display_name: string;
created_at: string;
}
type UiUser = CamelCased<ApiUser>;
const raw: ApiUser = { user_id: 7, display_name: 'Ada', created_at: '2026-01-05' };
const ui: UiUser = { userId: 7, displayName: 'Ada', createdAt: '2026-01-05' };
console.log(ui.displayName, ui.userId, raw.user_id);
Expected output:
UiUser = { userId: number; displayName: string; createdAt: string }
Ada 7 7
Read SnakeToCamel slowly: match `Head_Tail`, lowercase the head, then recurse on the tail so first_name_x becomes firstNameX. One warning about remapping in general — if two source keys map to the same output key, TypeScript merges their property types instead of raising an error, and the merged result is almost never what you meant. Check your remapped keys with keyof after a rename.
Common mistakes
- Indexing without a constraint —
T[K]whereKis free fails withTS2536; declareK extends keyof Tso the compiler knows the key universe. - Expecting mapped types to drop modifiers — a homomorphic map over
keyof Tcopies?andreadonly; you must say-readonlyor-?to remove them. - Trying to rename keys without
as—{ [P in keyof T]: Something }keeps every original name; only a key remapping clauseas NewNamechanges them. - Reaching for
Record<keyof T, V>to transform a type — it overwrites every value type withVand erases optionality; a mapped type overkeyof Tthat readsT[P]preserves the shape. - Assuming
Capitalizetitle-cases a whole string — it changes one character; pair it withLowercase(which does affect the whole string) when building camelCase keys.
Practice checklist
- Write a
gethelper typed<T, K extends keyof T>(row: T, key: K): T[K]and prove a wrong key is rejected. - Derive
keyof CourseandCourse[keyof Course]for your own interface; hover to confirm the union types. - Rebuild
Partial<T>andRequired<T>from scratch with mapped types and compare them with the built-ins. - Create an
on*handler object for three event names using template literal keys, then add a fourth. - Run the
CamelCasedtransformation over an interface containingfirst_nameandis_active, and check both resolved keys.
What's next?
Mapped types loop over keys; the next chapter introduces types that branch. See how T extends U ? X : Y and the infer keyword power the standard library in Conditional Types and infer.
Frequently Asked Questions
TypeScript is a strict superset of JavaScript that adds a static type system. You annotate the promises your data makes, the compiler checks every one before the code runs, then erases the types and emits ordinary JavaScript. You catch wrong arguments, typoed properties and forgotten null checks while you are still typing, and refactoring plus editor autocomplete both improve sharply.
You should. This series assumes you can already read and write JavaScript: functions, arrays and objects, closures, modules and async/await. TypeScript audits that knowledge rather than replacing it, and types cannot rescue code you do not understand. If those fundamentals feel shaky, work through the JavaScript for Beginners series on this site first, and this course will go much faster.
No. TypeScript is compiled, not interpreted: tsc parses your file, type-checks it, then erases every annotation and writes ordinary JavaScript. The browser and Node.js only ever run that emitted .js, so runtime behaviour, performance and debugging are unchanged. It also never validates API data at runtime, so the series teaches you to distrust every boundary that leaves the compiler.
If you already know JavaScript, TypeScript is a modest step rather than a new language: the syntax is the same plus a colon. Most beginners read their first red squiggle within days and feel comfortable in two to four weeks of regular practice. The curve steepens later around generics and advanced types, which is where this 24-chapter series deliberately spends its second half.
Both name the same object shapes, so either works for a simple contract. The differences decide the rest: only interfaces merge when declared twice, while only type aliases can express unions, primitives, tuples and mapped types. The series default is interface for props and exported shapes other files consume, and type for everything else. Pick one style per project and stay consistent.
strict: true is a bundle of safety switches, not a single behaviour. It turns on noImplicitAny, so every parameter must be inferrable, strictNullChecks, so null and undefined are their own types you must narrow, strictFunctionTypes, noImplicitThis, strictPropertyInitialization, and useUnknownInCatchVariables, which types catch values as unknown. Every example in this series compiles with it on.
any accepts anything and switches the compiler off, so one any spreads through inference into everything derived from it and you are back to dynamic typing with a .ts extension. unknown accepts anything too, but forbids property access until you narrow it with a typeof or instanceof check. The series pushes you toward unknown because it keeps the check you installed TypeScript to get.
Yes to both. Vite understands TypeScript natively: scaffold the vanilla-ts or react-ts template and a tsconfig.json is waiting for you, with moduleResolution set for the bundler. JSX belongs in .tsx files, props get an interface, and hooks infer or accept explicit type arguments. The series covers typed React in depth, from props, state and events to refs, context and fetch effects.
Enums still compile and work fine in TypeScript 5.x, but they are no longer the default recommendation. A union of string literals such as "light" | "dark" needs no runtime object, matches JSON directly and plays well with single-file transpilers like Vite's esbuild. Keep enum for existing codebases that already use it, avoid const enum, and prefer literal unions in new code.
Adopt it incrementally instead of rewriting. Install typescript, add a tsconfig.json with allowJs and noEmit so .js and .ts coexist while your bundler keeps building, then check individual files with a // @ts-check comment. Rename leaf modules to .ts one at a time, fix implicit any errors first, and gate every pull request with tsc --noEmit so untyped code cannot sneak back in.