TypeScript Next Steps
You’ve learned the TypeScript fundamentals — types, interfaces, generics, unions, and how the compiler checks your code before it ever runs. This lesson is different from the others: instead of teaching one new feature, it’s a map of where to go next. It points you toward the advanced type-system tools you’ll meet in real codebases, the tooling and configuration choices that separate a hobby project from a production one, and the habits that keep you fast and confident with TypeScript long after this course ends.
Overview: How Your TypeScript Skills Keep Growing
Everything you’ve learned so far — interface, type, generics, union and intersection types — is the foundation the rest of the type system is built on. TypeScript’s more advanced features aren’t a separate language bolted on top; they’re the same ideas applied at a higher level. A conditional type is an if statement that runs on types instead of values, evaluated entirely by the compiler. A mapped type is a loop over the keys of a type. A template literal type is string concatenation performed on string literal types. Once you see them this way, the \”advanced\” type system stops feeling like new syntax to memorize and starts feeling like the natural continuation of what you already know.
It helps to think of your growth along four tracks, and you don’t need to master them in order:
| Track | What it covers |
|---|---|
| Type system | Conditional types, mapped types, template literal types, infer, and the built-in utility types built from them |
| Tooling & configuration | Reading and tuning tsconfig.json, strict mode flags, project references, incremental builds |
| Ecosystem & frameworks | Typing React/Node/Express APIs, consuming and writing .d.ts declaration files, publishing typed packages |
| Testing & quality | Type-level tests, running tsc --noEmit in CI, and linting rules that catch type anti-patterns |
All of it rests on one fact you already know: TypeScript’s type system exists only at compile time. Every annotation, generic parameter, and advanced type you meet next is erased when the compiler emits JavaScript — it exists purely to catch mistakes before your code runs, and never changes what actually runs.
Syntax: The Building Blocks of Advanced Types
Before you dive into full lessons on each of these, it helps to recognize their shape when you see them in library type definitions or a teammate’s code:
| Feature | General form | Purpose |
|---|---|---|
| Conditional type | T extends U ? X : Y |
Pick one type or another depending on whether T is assignable to U |
infer |
T extends Array<infer U> ? U : never |
Extract a nested type from inside a conditional type’s extends clause |
| Mapped type | { [K in keyof T]: NewType } |
Build a new type by transforming every property of an existing type |
| Template literal type | `prefix${T}suffix` |
Combine string literal types to generate new string literal types |
| Built-in utility types | Partial<T>, Pick<T, K>, Record<K, V>, ReturnType<F> |
Common transformations the standard library already built using the tools above |
You’ll rarely write these from scratch every day — most of the time you’ll reach for a built-in utility type. But recognizing the underlying syntax means you’re never stuck when you open a library’s .d.ts file and see something like T extends (infer U)[] ? U : never.
Examples
Example 1: A Conditional Type With infer
type Flatten<T> = T extends Array<infer U> ? U : T;
type NumberArrayElement = Flatten<number[]>; // number
type StringElement = Flatten<string>; // string
function describeType(value: NumberArrayElement | StringElement): string {
return typeof value === \"number\" ? `number: ${value}` : `string: ${value}`;
}
console.log(describeType(5));
console.log(describeType(\"hi\"));
Output:
number: 5
string: hi
Flatten<T> asks the compiler a question: \”if T is an array, what’s the type of its elements?\” The infer U tells the compiler to capture whatever type fills that slot and call it U. Flatten<number[]> resolves to number, and Flatten<string> falls through the false branch and stays string. Neither of these types exist at runtime — by the time describeType runs, its parameter is just a plain number | string value.
Example 2: A Mapped Type for a Frozen Config
interface UserConfig {
name: string;
theme: \"light\" | \"dark\";
notifications: boolean;
}
type ReadonlyConfig<T> = {
readonly [K in keyof T]: T[K];
};
const config: ReadonlyConfig<UserConfig> = {
name: \"Ada\",
theme: \"dark\",
notifications: true,
};
console.log(config.name, config.theme, config.notifications);
Output:
Ada dark true
ReadonlyConfig<T> loops over every key K of T with [K in keyof T] and re-declares it as readonly, keeping its original type T[K]. This is exactly how TypeScript’s built-in Readonly<T> utility type is implemented. Try assigning to config.name after this and tsc reports Cannot assign to 'name' because it is a read-only property — a compile-time guarantee with zero runtime cost.
Example 3: Template Literal Types for Typed Event Names
type EventName = \"click\" | \"hover\" | \"scroll\";
type HandlerName = `on${Capitalize<EventName>}`;
const handlers: Record<HandlerName, () => void> = {
onClick: () => console.log(\"clicked\"),
onHover: () => console.log(\"hovered\"),
onScroll: () => console.log(\"scrolled\"),
};
function trigger(name: HandlerName): void {
handlers[name]();
}
trigger(\"onClick\");
trigger(\"onScroll\");
Output:
clicked
scrolled
Capitalize<EventName> turns \"click\" | \"hover\" | \"scroll\" into \"Click\" | \"Hover\" | \"Scroll\", and the template literal type `on${...}` combines that with the prefix \"on\" to produce the literal union \"onClick\" | \"onHover\" | \"onScroll\". Misspell a key in handlers, or call trigger(\"onDrag\"), and tsc catches it immediately — the set of valid handler names is generated automatically from the event names, so the two can never drift apart.
Under the Hood: What tsc Actually Does
As you move into these features, it’s worth understanding the pipeline you’ve been relying on the whole course:
- Parse — your
.tssource is turned into an abstract syntax tree, same as any JavaScript parser would do, plus type annotations. - Bind — the compiler resolves every identifier to the scope and declaration it refers to.
- Type-check — this is where conditional types get evaluated, mapped types get expanded, generics get instantiated with concrete arguments, and unions get narrowed based on your
if/typeof/Array.isArraychecks. Every error you’ve seen fromtsccomes from this phase. - Emit — all type syntax is stripped out, newer JavaScript syntax is downleveled to your
targetif needed, and (ifdeclarationis enabled) a.d.tsfile is generated so other TypeScript projects can use your types without re-checking your source.
For larger codebases, two features become important once single-file projects aren’t enough: incremental builds (tsc --incremental, which caches type information between compiles) and project references (splitting a large repo into smaller tsconfig.json projects that reference each other, so changing one package doesn’t force a full re-check of everything). You don’t need these on day one, but knowing they exist saves you from reaching for slower workarounds later.
Common Mistakes
Mistake 1: Using infer outside a conditional type. It’s tempting to think infer is a general-purpose \”extract this type\” keyword you can use anywhere.
type ElementType<T> = infer U;
This fails with 'infer' declarations are only permitted in the 'extends' clause of a conditional type, because infer only has meaning as part of a comparison the compiler is making — there’s no comparison here for it to hook into. The fix is to give it a conditional type to live inside:
type ElementType<T> = T extends Array<infer U> ? U : never;
type Item = ElementType<string[]>; // string
const sample: Item = \"hello\";
console.log(sample);
Mistake 2: Trying to make an interface extend a union type. Interfaces model object shapes, not arbitrary type expressions, so this breaks down as soon as the thing you’re extending isn’t an object type:
type Status = \"loading\" | \"success\" | \"error\";
interface Task extends Status {
id: number;
}
tsc reports An interface can only extend an object type or intersection of object types with statically known members, because Status is a union of string literals, not an object shape. Switch to a type alias, which can freely combine a union member as a property instead:
type Status = \"loading\" | \"success\" | \"error\";
type Task = {
id: number;
status: Status;
};
const task: Task = { id: 1, status: \"loading\" };
console.log(task.id, task.status);
Best Practices
- Turn on
\"strict\": trueintsconfig.jsonfor every new project — retrofitting it onto a large, loosely-typed codebase later is far more painful than starting with it. - Reach for a built-in utility type (
Partial,Pick,Omit,Record,ReturnType) before writing your own mapped or conditional type — most needs are already covered. - Run
tsc --noEmitas a dedicated CI step so type errors fail the build even if your bundler would otherwise ignore them. - Read the type definitions of libraries you depend on (their
.d.tsfiles) — it’s the fastest way to learn advanced patterns from code that’s already battle-tested. - Avoid reaching for
anyas a shortcut past a type error you don’t understand; preferunknownplus a narrowing check, which keeps you honest about what you actually know. - Learn your framework’s typing conventions specifically — React’s generic component and hook types, Node’s
@types/node, Express’s request/response generics — rather than assuming core TypeScript knowledge transfers automatically. - When a type gets hard to read, break it into named intermediate types instead of nesting conditionals and mapped types inline; future readers (including you) will thank you.
- Keep TypeScript itself up to date — template literal types, `satisfies`, and other quality-of-life features arrived in relatively recent releases.
Practice Exercises
- Write a conditional type
UnwrapPromise<T>that resolves to the type inside aPromise(e.g.UnwrapPromise<Promise<string>>should bestring) and falls back toTitself whenTisn’t aPromise. - Given an interface
Product { id: number; name: string; price: number }, write a mapped typeOptional<T>that makes every property optional, then create a value of typeOptional<Product>that only setsname. - Create a
tsconfig.jsonwith\"strict\": false, write a small function with an untyped parameter, then flipstricttotrueand fix every new errortscreports. Notice which mistakes strict mode alone would have caught.
Summary
- Advanced TypeScript features — conditional types, mapped types, template literal types — are the same core ideas you’ve learned, applied at the type level.
inferonly works inside theextendsclause of a conditional type; it captures a type instead of matching it exactly.- Mapped types transform every property of an existing type, which is how utility types like
Readonly<T>andPartial<T>are actually implemented. - Template literal types combine string literal types the same way template literals combine strings, generating precise unions automatically.
- All types are erased at compile time — the emitted JavaScript has no trace of them, regardless of how advanced the type expression was.
- Beyond the type system, growth also means learning your tooling (
tsconfig.json, strict flags, project references), your ecosystem (framework typings,.d.tsfiles), and testing habits (tsc --noEmitin CI). - Prefer built-in utility types and
unknownover hand-rolled advanced types andanyuntil you have a concrete reason to reach further.
