TypeScript Roadmap
TypeScript adds a static type system to JavaScript. It catches mistakes before your code runs, makes large codebases safe to change and turns the editor into a guide that knows the shape of your data. Most new front-end, Node.js and full-stack projects use it, and frameworks such as Angular are built on it, so interviews for those roles assume you can read and write it fluently.
This roadmap starts with installing the compiler and annotating simple values, then moves through functions, interfaces, unions and narrowing, classes and generics, to the advanced type features used in libraries and the tooling that holds a real project together. Each topic explains how the feature works, shows a short example and gives you an exercise. Core topics are what every TypeScript developer is expected to know, recommended topics come up in most jobs, and optional topics can wait until you need them.
By the end you will be able to type an application from end to end, explain your choices in an interview and fix confusing type errors without reaching for any. Practise typed problems in coding practice and track your progress in progress page.
- Level
- Beginner to advanced
- Time
- 8–12 weeks at about 8 hours a week, less if you already write modern JavaScript daily
- Scope
- 56 topics in 9 stages
- Before you start
- Solid modern JavaScript Roadmap: functions, objects, classes, modules, array methods, promises and
async/await; Comfort with npm, the terminal and a code editor such as VS Code; Basic Git and GitHub Roadmap for saving and sharing your projects
What you will be able to do
- Set up, compile and run TypeScript projects for Node.js and the browser with a strict configuration.
- Model data with interfaces, type aliases, unions and discriminated unions so that invalid states cannot be represented.
- Narrow types safely with type guards and predicates, and keep assertions and
anyto a justified minimum. - Write generic functions, types and classes with sensible constraints.
- Read and write utility, mapped, conditional and template literal types.
- Use and write declaration files, migrate JavaScript code gradually and configure linting and builds.
- Answer common TypeScript interview questions with precise, practical explanations.
The roadmap at a glance
- Stage 1Getting Started with TypeScriptBeginner · 1 week · 6 topics
- Stage 2Everyday TypesBeginner · 1–2 weeks · 8 topics
- Stage 3Functions, Interfaces and Type AliasesBeginner · 1–2 weeks · 8 topics
- Stage 4Combining Types and NarrowingIntermediate · 2 weeks · 7 topics
- Stage 5Classes and Object-Oriented TypeScriptIntermediate · 1–2 weeks · 6 topics
- Stage 6GenericsIntermediate · 1–2 weeks · 4 topics
- Stage 7Utility and Advanced TypesAdvanced · 2 weeks · 7 topics
- Stage 8Modules, Declarations and InteroperabilityAdvanced · 1–2 weeks · 5 topics
- Stage 9Tooling and the TypeScript EcosystemAdvanced · 1–2 weeks · 5 topics
Stage 1 Getting Started with TypeScript
Goal. Install the compiler, run TypeScript code three different ways and understand what the type checker does and does not do.
Introduction to TypeScript Core
TypeScript is JavaScript with a static type layer on top. You write ordinary JavaScript plus annotations such as name: string, and a checker reads the whole program before it runs to tell you where values are used in ways that cannot work: calling a method that does not exist, passing a number where a string is expected, or forgetting that something may be undefined.
Two facts shape everything else. First, types are erased: the output is plain JavaScript and no type information exists at run time, so types cannot validate data arriving from a network call. Second, the type system is structural, not nominal: two types are compatible when their shapes match, whatever they are called.
Microsoft created the language and develops it in the open. It is now the default choice for most new front-end and Node.js codebases, and every major framework ships its own type definitions.
Practice. Write down three bugs from your own JavaScript code that a type checker would have caught before running it.
TypeScript vs JavaScript Core
Every valid JavaScript file is very nearly valid TypeScript, so the comparison is about what you gain and what it costs.
- Gain: errors found while typing instead of in production, editor autocompletion that knows your data, safe renames across a large codebase, and types that act as always-current documentation.
- Cost: a compile or type-strip step, time spent describing complex shapes, and occasional fights with the checker over code that is correct but hard to express.
What TypeScript does not change: the runtime. Equality, closures, this, the event loop and prototypes all behave exactly as they do in JavaScript, which is why the JavaScript Roadmap is the real prerequisite. A few TypeScript features do emit code (enums, namespaces, parameter properties); modern projects increasingly avoid them so that the output is just the input with the types removed.
Practice. Rename a .js file to .ts, fix the errors that appear, and list which were real bugs.
Installation and Configuration Core
Install the compiler per project, not globally, so every developer and the CI server use the same version.
npm install --save-dev typescript
npx tsc --init # writes a commented tsconfig.json
npx tsc --versionEditors such as VS Code bundle a TypeScript language service, but you can point the editor at the workspace version so editor errors match the build. Add type definitions for runtimes and untyped libraries as development dependencies, for example @types/node.
For quick experiments without any setup, the TS Playground in the browser shows your code, the emitted JavaScript, the declaration output and errors side by side, and lets you switch compiler versions and options. It is the best place to test a tricky type before you put it in a project, and a shareable playground link is a good way to ask a colleague about a type error.
Practice. Create an empty project, install TypeScript locally, generate a config and compile one file.
tsc Core
tsc is the official compiler. It does two jobs that you can separate: type checking and emitting JavaScript.
npx tsc # check and emit using tsconfig.json
npx tsc --noEmit # check only (common when a bundler emits)
npx tsc --watch # re-check on every save
npx tsc -b # build project references in orderIn many modern setups a faster tool (esbuild, SWC or the bundler inside Vite) strips the types and produces JavaScript, while tsc --noEmit runs separately in CI and in the editor to report type errors. Those fast tools work file by file and do not type-check at all, so never assume a successful bundle means the types are correct.
Microsoft is also porting the compiler to native code for much faster checking of large projects; the language itself stays the same.
Practice. Add a typecheck script that runs tsc --noEmit and make it fail by introducing a deliberate type error.
Running TypeScript Core
There are several ways to execute TypeScript, and interviewers like to hear that you know the trade-offs.
- Compile with
tscto adistfolder, then run the JavaScript with Node. Simple and predictable for libraries and servers. - Use a runner that compiles in memory. ts-node was the long-standing option; it type-checks by default, which is slow, and it can be awkward with ES modules. Many teams now use
tsx, which strips types with esbuild and starts quickly. - Recent Node.js versions can strip type annotations natively and run
.tsfiles directly. This only works for erasable syntax, so features that generate code, such as enums and namespaces, are not supported in that mode. - In the browser, a bundler such as Vite handles
.tsfiles for you.
Whichever runner you choose, keep tsc --noEmit in the pipeline for type checking.
Practice. Run the same script with tsc then Node, with tsx, and with Node directly, and note the start-up time of each.
tsconfig.json Core
tsconfig.json marks the root of a project and tells the compiler which files to include and how strict to be. A sensible starting point for a modern Node or bundled app:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"]
}include, exclude and files select the inputs. extends lets several packages share a base config. For apps built by Vite or another bundler, "module": "ESNext" with "moduleResolution": "bundler" and "noEmit": true is the usual pairing. Turn on strict from day one; adding it to an old codebase later is far more work.
Practice. Write a tsconfig.json from memory for a Node service, then compare it with the one tsc --init generates.
Stage 2 Everyday Types
Goal. Annotate variables, arrays and objects correctly and know when to let inference do the work.
TypeScript Types Core
The primitive TypeScript types mirror JavaScript's run-time values: string, number, boolean, bigint, symbol, null and undefined. Note the lowercase names; String and Number refer to wrapper objects and should not be used as types.
The String type covers all text, including template literals. The Number type in TypeScript covers integers and floats alike, because JavaScript has one numeric type; use bigint for very large integers. The Boolean type in TypeScript has exactly two values, and TypeScript treats true and false as literal types of their own.
let title: string = 'Roadmap';
let count: number = 3;
let done: boolean = false;
let big: bigint = 9007199254740993n;Annotations go after the name with a colon. In practice you rarely write them on local variables, because the checker infers them, but you always write them on function parameters.
Practice. Declare one variable of each primitive type and hover over each in your editor to see what is inferred without annotations.
null and undefined Core
With strictNullChecks (part of strict), null and undefined are separate types and are not assignable to anything else. This is the most valuable check TypeScript offers, because "cannot read properties of undefined" is the most common JavaScript crash.
The Null type in TypeScript has one value, null, usually meaning "deliberately empty". undefined means "not set", and it is what optional properties and missing arguments produce.
function findUser(id: string): User | undefined { /* ... */ }
const user = findUser('42');
user.name; // error: possibly undefined
user?.name; // ok: optional chaining
const name = user?.name ?? 'Guest';Pick one convention per codebase, usually undefined for "missing", and model absence explicitly in return types instead of returning a fake value.
Practice. Turn on strictNullChecks in an old file and fix every error with narrowing or optional chaining rather than assertions.
Array and Tuple Core
An Array holds any number of elements of one type. Write it as string[] or Array<string>; both mean the same. A readonly string[] cannot be pushed to or sorted in place, which is a good default for function parameters.
A Tuple is an array with a fixed length where each position has its own type. Tuples model small, ordered results such as a pair of coordinates or the return value of a hook.
const tags: string[] = ['ts', 'js'];
const point: [number, number] = [10, 20];
const entry: [key: string, value: number] = ['a', 1]; // labelled
const rest: [string, ...number[]] = ['scores', 9, 7];By default, reading tags[5] has type string, even though it is undefined at run time. The option noUncheckedIndexedAccess adds | undefined to index reads, which is stricter and catches real bugs.
Practice. Write a function returning a [min, max] tuple for an array of numbers and destructure the result.
Object Types Core
Object types in TypeScript describe the shape a value must have: property names, their types, and whether each is optional or read-only.
type Product = {
readonly id: string;
name: string;
price: number;
description?: string; // may be missing
[extra: string]: unknown; // index signature
};When you pass an object literal directly to something typed, TypeScript runs an excess property check and flags unknown keys, which catches typos such as nmae. The same object assigned to a variable first and then passed is not checked for extras, because of structural typing.
Index signatures describe dictionaries with unknown keys, but they weaken checking; prefer Record with a union of known keys, or a Map, when you can.
Practice. Model a blog post with required, optional and read-only fields, then try to assign to the read-only one.
any, unknown, never and void Core
These four special types are a favourite interview topic.
anyswitches checking off for a value and spreads silently. Everyany, includingas any, is a hole in your safety net.noImplicitAnystops it appearing by accident.- The Unknown type is the safe counterpart: anything can be assigned to it, but you must narrow it before using it. Use it for parsed JSON, caught errors and untrusted input.
- The Never type has no values. It is the return type of functions that always throw or loop forever, and the result of narrowing away every possibility, which makes it useful for exhaustiveness checks.
- The Void type means a function's return value should be ignored. It is not the same as
undefined: a callback typed as returningvoidmay still return something.
function parse(text: string): unknown { return JSON.parse(text); }
function fail(msg: string): never { throw new Error(msg); }
Practice. Replace every any in a small file with unknown and add the narrowing each use now needs.
Literal Types Core
A literal type is a single exact value used as a type: 'GET', 404 or true. On their own they are odd; combined in a union they replace magic strings with a checked set of options.
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
function request(url: string, method: Method) { /* ... */ }
request('/users', 'PATCH'); // errorInference widens literals in some places. const x = 'GET' has type 'GET', but let x = 'GET' has type string, because a let can change. Object properties are also widened, so { method: 'GET' } gives method: string. That is why as const (covered later) is so useful: it keeps the narrow literal types.
Literal unions work with editor autocompletion and are the building block for discriminated unions and template literal types.
Practice. Type a setAlign function that accepts only left, centre or right and observe the editor suggestions.
Enum Recommended
An enum is a named set of constants. Numeric enums auto-increment from zero; string enums need explicit values and are easier to debug.
enum Status { Draft = 'draft', Published = 'published' }
const s: Status = Status.Draft;Unlike most TypeScript features, enums generate a real JavaScript object, and numeric enums also generate a reverse mapping. const enum inlines values but causes problems with isolated, file-by-file compilers.
Many teams now prefer a union of string literals, or an as const object with a derived union, because both disappear at compile time and work with type stripping. The erasableSyntaxOnly compiler option flags enums for exactly this reason. You still need to read enums, since plenty of existing code uses them.
const Status = { Draft: 'draft', Published: 'published' } as const;
type Status = typeof Status[keyof typeof Status];
Practice. Rewrite an enum as an as const object plus union type and confirm call sites still compile.
Type Inference Core
TypeScript infers types from initial values, return statements and context, so most code needs fewer annotations than beginners expect.
- Variables take the type of their initialiser.
- Return types are inferred from every
returnstatement. - Contextual typing flows inward: in
items.map(x => x.id),xalready knows its type. - The best common type is chosen for arrays such as
[1, 'a'], giving(string | number)[].
A good rule: annotate function parameters and public return types, and let inference handle locals. An explicit return type on an exported function stops an accidental change inside the body from silently changing the API, and it gives clearer error messages.
Hover over values in the editor constantly; reading inferred types is the fastest way to learn the type system.
Practice. Remove every annotation from a small module, then add back only the ones the checker or your API actually needs.
Stage 3 Functions, Interfaces and Type Aliases
Goal. Type functions of any shape and describe data with interfaces and type aliases, choosing between them with a reason.
Typing Functions Core
TypeScript functions are typed at the parameters and, optionally, at the return value. Parameters can be optional (?), have defaults, or collect the rest into an array.
function greet(name: string, greeting = 'Hello', ...tags: string[]): string {
return `${greeting}, ${name}`;
}
type Comparator<T> = (a: T, b: T) => number;
const byAge: Comparator<{ age: number }> = (a, b) => a.age - b.age;A function type can be written as an arrow signature, as above, or as a call signature inside an object type. Callbacks are checked by parameter count leniently: a handler that takes fewer parameters than provided is fine, which is why arr.forEach(x => ...) compiles.
For this, you can declare a fake first parameter, function (this: HTMLElement), which the compiler removes. Async functions return Promise<T>.
Practice. Write a typed debounce function that accepts a callback and a delay and returns a function of the same parameter types.
Function Overloading Recommended
Function overloading lets one implementation expose several typed call signatures, so the return type can depend on the arguments.
function parse(input: string): Date;
function parse(input: number): Date;
function parse(input: string[]): Date[];
function parse(input: string | number | string[]): Date | Date[] {
return Array.isArray(input) ? input.map(s => new Date(s)) : new Date(input);
}Only the overload signatures are visible to callers; the implementation signature must be compatible with all of them but is hidden. Order matters, because the checker picks the first signature that matches, so list the most specific first.
Before writing overloads, check whether a union parameter or a generic does the job. Overloads are best when the relationship between input and output cannot be expressed in one signature.
Practice. Write an overloaded getElement that returns a single element for a string ID and an array for a list of IDs.
Interface Core
An Interface declaration names an object shape. It is the classic way to describe the props of a component, a response body or the public face of a class.
interface User {
id: string;
email: string;
nickname?: string;
readonly createdAt: Date;
greet(other: User): string;
}Interfaces can describe call signatures, construct signatures and index signatures as well as properties. A class can declare implements User to have the compiler check that it has every member.
Interfaces with the same name in the same scope merge their members. This declaration merging is what allows libraries to let you add fields to their types, but it can also surprise you if two files accidentally use the same global name.
Practice. Define TypeScript interfaces for a to-do item and a to-do list service, then implement the service with a class.
Extending Interfaces Core
Extending interfaces builds bigger shapes from smaller ones with extends, and an interface can extend several at once.
interface Entity { id: string; createdAt: Date }
interface Timestamped { updatedAt: Date }
interface Article extends Entity, Timestamped {
title: string;
}If a property appears in both the base and the child, the child's type must be assignable to the base's, so you can narrow status: string to status: 'draft' | 'live' but not change it to a number. That rule gives clear errors, which is one reason interfaces are often preferred for object hierarchies.
Interfaces can also extend type aliases of object types and even classes, in which case they inherit the class members without the implementation.
Practice. Build an Admin interface that extends User and adds permissions, then try to override a field with an incompatible type.
Type Aliases Core
A type alias gives a name to any type, not just object shapes: unions, tuples, function types, primitives and computed types.
type ID = string | number;
type Point = { x: number; y: number };
type Handler = (event: Event) => void;
type Pair<T> = [T, T];Aliases do not create a new type; ID is just another name for string | number, and anywhere you could write one you can write the other. They cannot be reopened or merged like interfaces.
Aliases are required for anything that is not a plain object: unions, mapped types, conditional types and template literal types are all written with type. To combine object aliases you use intersections, covered in the next stage.
Practice. Create aliases for an API response that is either a success with data or a failure with an error message.
Types vs Interfaces Core
Types vs interfaces is a classic question with a short honest answer: for plain object shapes they are almost interchangeable, so pick one convention and be consistent. The real differences:
- Interfaces can be merged by redeclaring them; type aliases cannot. Merging is needed to augment library types.
- Type aliases can name unions, tuples, primitives, mapped and conditional types; interfaces only describe object shapes.
extendson interfaces reports conflicts as clear errors, while intersecting conflicting aliases silently producesneverfor that property.- Interfaces are cached by name in the compiler, which can make very large codebases check slightly faster.
A common team rule is "interfaces for object shapes and public APIs, type aliases for everything else". Another is "type everywhere unless you need merging". Both are defensible.
Practice. Write the same Shape model once with interfaces and once with aliases, and list which features forced your choice.
Hybrid Types Optional
Hybrid types describe values that are several things at once, typically a function that also carries properties. This is common in older JavaScript libraries, such as a function you can call that also has a version field or helper methods.
interface Counter {
(start: number): string; // call signature
interval: number; // property
reset(): void; // method
}
function makeCounter(): Counter {
const c = ((start: number) => String(start)) as Counter;
c.interval = 1;
c.reset = () => {};
return c;
}Since TypeScript understands property assignments on function declarations, you can often avoid the assertion by declaring a function and assigning its properties directly. You mostly meet hybrid types when writing declaration files for existing libraries.
Practice. Write a typed logger that can be called directly and also has logger.warn and logger.level.
Type Compatibility Recommended
Type compatibility in TypeScript is structural: a value fits a type when it has at least the required members with compatible types, regardless of names or declarations.
interface Named { name: string }
class Dog { constructor(public name: string, public breed: string) {} }
const n: Named = new Dog('Rex', 'collie'); // fine: extra members allowedFunctions are compared by parameters and return types. A function with fewer parameters can be used where more are supplied. With strictFunctionTypes, function-typed properties check parameters contravariantly, but method-style declarations are still checked bivariantly for historical reasons, which is a subtle source of unsoundness.
When you need two structurally identical types to stay separate, such as UserId and OrderId, use a branded type: intersect the base with a unique marker property that exists only in the type system.
Practice. Create branded UserId and OrderId string types and show that mixing them up is now a compile error.
Stage 4 Combining Types and Narrowing
Goal. Model data as unions, narrow them safely with type guards, and use assertions only where they are justified.
Union Types Core
Union types say a value can be one of several types: string | number. You can only use members that exist on every part of the union until you narrow it.
The most useful pattern is the discriminated union: each variant has a common literal property, the discriminant, that tells them apart.
type Result =
| { status: 'loading' }
| { status: 'success'; data: string[] }
| { status: 'error'; message: string };
function render(r: Result) {
switch (r.status) {
case 'success': return r.data.join(', ');
case 'error': return r.message;
case 'loading': return '...';
}
}This makes impossible states unrepresentable: there is no way to have data and an error together. Discriminated unions are the most common answer to "how would you model this state" questions.
Practice. Model a network request, a payment and a form field state as discriminated unions.
Intersection Types Core
Intersection types combine types with &: the result has every member of every part. They are the main way of combining types written as aliases.
type WithId = { id: string };
type WithTimestamps = { createdAt: Date; updatedAt: Date };
type Post = WithId & WithTimestamps & { title: string };Intersections are handy for mixins and for adding fields to generic types, such as T & { selected: boolean }. When two parts declare the same property with incompatible types, the property becomes never instead of producing an error, which is easy to miss.
Do not confuse the names with set logic on values. A union of object types accepts more values but allows fewer property accesses; an intersection accepts fewer values but allows more property accesses.
Practice. Combine two object types where one property conflicts and inspect the resulting type in the editor.
Type Guards / Narrowing Core
Narrowing is how the checker refines a broad type to a specific one inside a branch, by following your ordinary run-time checks. The checks it understands are called type guards.
- typeof type guards work for primitives:
typeof x === 'string'. Remembertypeof nullis'object'. - The instanceof operator narrows to a class:
err instanceof HttpError. - The
inoperator narrows objects by property:'data' in result. - Truthiness checks remove
null,undefinedand other falsy values, but beware that0and''are falsy too. - Equality in type guards and narrowing:
x === ynarrows both sides to the types they share, andx != nullremoves bothnullandundefined.
function len(v: string | string[] | null) {
if (!v) return 0;
return typeof v === 'string' ? v.length : v.length;
}Control-flow analysis also follows return, throw and assignments.
Practice. Write a function that formats string | number | Date | null and narrows each case with a different guard.
Type Predicates Core
When the built-in guards are not enough, write your own guard function whose return type is a type predicate, value is Type. Calling it narrows the argument in the caller.
interface Cat { meow(): void }
interface Dog { bark(): void }
function isCat(pet: Cat | Dog): pet is Cat {
return 'meow' in pet;
}
const cats = pets.filter(isCat); // Cat[]The compiler trusts your predicate, so a wrong implementation creates wrong types; keep guards small and test them. Assertion functions are a related form, asserts value is Type, which throw instead of returning false.
Recent compiler versions can infer a type predicate from simple arrow functions such as x => x !== undefined, so filter often narrows without a hand-written guard.
Practice. Write a guard that checks parsed JSON is a valid User, and use it to filter an unknown[].
Exhaustiveness with never Recommended
Once every member of a union has been handled, the remaining type is never. You can use that to make the compiler tell you when a new variant is added and some switch statement forgot to handle it.
function assertNever(x: never): never {
throw new Error('Unhandled: ' + JSON.stringify(x));
}
switch (shape.kind) {
case 'circle': return Math.PI * shape.r ** 2;
case 'square': return shape.side ** 2;
default: return assertNever(shape); // error if a kind is missing
}This is cheap insurance in reducers, state machines and API handlers. The satisfies never form, default: shape satisfies never, gives the same check without a helper.
Practice. Add a new variant to a union used in three switch statements and let the compiler find each place to update.
Type Assertions with as Core
Type assertions with as tell the compiler "trust me, this value has this type". They change nothing at run time and are only allowed when one type could plausibly be the other.
const input = document.getElementById('email') as HTMLInputElement;
const data = JSON.parse(text) as Config; // uncheckedAssertions are sometimes necessary, for example with DOM lookups, but every one is a place where the compiler stops protecting you. The double assertion, x as unknown as Target or as any, forces any conversion at all and should be rare and commented.
The Non-null assertion operator, a ! after an expression as in map.get(key)!.count, removes null and undefined. Prefer narrowing, a default with ??, or an explicit error, and keep ! for cases the compiler genuinely cannot see, such as after a framework guarantees initialisation.
Practice. Find every as and ! in a project, and replace at least half with narrowing or validation.
as const and the satisfies Keyword Core
as const is a const assertion: it makes literals as narrow as possible, turns arrays into read-only tuples and makes object properties read-only.
const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = typeof ROLES[number]; // 'admin' | 'editor' | 'viewer'The Satisfies keyword checks that a value matches a type without changing the value's inferred type. With an annotation you lose detail; with satisfies you keep it and still get errors.
type Theme = Record<string, string | number[]>;
const theme = {
primary: '#0055ff',
spacing: [4, 8, 16],
} satisfies Theme;
theme.spacing.map(n => n * 2); // still number[], not string | number[]Combining both, { ... } as const satisfies Config, gives you a checked, fully literal configuration object. This is the modern replacement for many assertions.
Practice. Define a route table with as const satisfies and derive a union of route names from it.
Stage 5 Classes and Object-Oriented TypeScript
Goal. Write and read typed classes, including inheritance, abstract classes and decorators used by frameworks.
Classes Core
TypeScript classes are JavaScript classes with typed fields, parameters and members. With strictPropertyInitialization, every field must be assigned in its declaration or the constructor.
class Account {
balance = 0;
readonly owner: string;
constructor(owner: string) { this.owner = owner; }
deposit(amount: number): void {
if (amount <= 0) throw new Error('Amount must be positive');
this.balance += amount;
}
}A class name is two things at once: a value (the constructor function) and a type (the shape of instances). implements checks a class against an interface without inheriting anything. Static members belong to the class itself.
Many modern TypeScript codebases use classes sparingly, preferring functions and plain objects; Angular and NestJS use them heavily, so you must be fluent in both styles.
Practice. Model a shopping cart as a class with typed items, totals and a method that applies a discount code.
Constructor Parameters Recommended
TypeScript has a shorthand called parameter properties: adding an access modifier or readonly to a constructor parameter declares and assigns the field in one step.
class UserService {
constructor(
private readonly http: HttpClient,
public baseUrl = '/api',
) {}
}This is everywhere in Angular, where dependencies arrive through the constructor. Parameter properties generate code, so they are not allowed under erasableSyntaxOnly and cannot run with pure type stripping; some teams avoid them for that reason.
Constructor overloading uses the same mechanism as function overloads: several constructor signatures followed by one implementation that handles every case.
class Point {
constructor(x: number, y: number);
constructor(coords: [number, number]);
constructor(a: number | [number, number], b?: number) { /* ... */ }
}
Practice. Rewrite a class with explicit field assignments using parameter properties, then add a second constructor signature.
Access Modifiers Core
Access modifiers control who can use a member: public (the default), protected (the class and subclasses) and private (the class only). readonly stops reassignment after construction.
TypeScript's private is checked only at compile time; the field is still visible at run time and can be reached with a cast. JavaScript's native private fields, written #balance, are enforced by the engine.
class Wallet {
#secret = 'pin'; // truly private at run time
private cache = new Map(); // private to the compiler only
protected log(msg: string) { console.log(msg); }
}Use # fields when real encapsulation matters, for example in libraries. Use TypeScript modifiers when you want parameter properties or compatibility with frameworks that expect them.
Practice. Write a class with a # field and a private field and show which one you can read from outside with a cast.
Abstract Classes Recommended
Abstract classes are base classes that cannot be instantiated. They can contain shared implementation plus abstract members that every subclass must implement.
abstract class Shape {
abstract area(): number;
describe() { return `Area: ${this.area().toFixed(2)}`; }
}
class Circle extends Shape {
constructor(private r: number) { super(); }
area() { return Math.PI * this.r ** 2; }
}Compared with an interface, an abstract class can hold code and state but limits a class to a single parent. Interviewers often ask when you would choose one: an interface for a contract only, an abstract class when subclasses really share behaviour. In TypeScript, composition with plain functions is often simpler than either.
Practice. Write an abstract Notifier with a shared send method and two subclasses for email and SMS.
Inheritance vs. Polymorphism Core
Inheritance vs. polymorphism is about mechanism versus effect. Inheritance is a way to reuse code: a subclass gets the members of its parent with extends. Polymorphism is the ability to treat different types through one interface, so the caller does not care which concrete type it holds.
Method overriding is where they meet: a subclass replaces a parent method with its own version. Mark it with the override keyword, and turn on noImplicitOverride so the compiler catches a typo that would otherwise create a new method silently.
class Logger { write(msg: string) { console.log(msg); } }
class JsonLogger extends Logger {
override write(msg: string) { console.log(JSON.stringify({ msg })); }
}
function run(l: Logger) { l.write('started'); } // polymorphic callIn TypeScript you get polymorphism without inheritance too, since any object with the right shape fits an interface.
Practice. Write two unrelated classes that both satisfy one interface and pass them to the same function.
Decorators Optional
Decorators are functions applied with @ to classes and class members to add behaviour or metadata. There are two incompatible versions.
- Legacy or experimental decorators, enabled with
experimentalDecorators, are what Angular, NestJS and TypeORM have long used. They can decorate parameters and work with emitted metadata. - Standard decorators follow the ECMAScript proposal and work without a flag in TypeScript 5 and later. They receive a context object and do not support parameter decorators.
function logged(fn: any, ctx: ClassMethodDecoratorContext) {
return function (this: unknown, ...args: unknown[]) {
console.log('call', String(ctx.name));
return fn.apply(this, args);
};
}
class Api { @logged fetchAll() { /* ... */ } }Learn to read the style your framework uses; write your own only when a framework expects them.
Practice. Write a standard method decorator that measures and logs how long the method takes.
Stage 6 Generics
Goal. Write reusable functions and types that keep precise type information, and constrain them correctly.
Generics Core
Generics are type parameters: placeholders that the caller fills in, so one function or type works with many types without falling back to any.
function first<T>(items: T[]): T | undefined {
return items[0];
}
const n = first([1, 2, 3]); // number | undefined
const s = first(['a', 'b']); // string | undefinedThe compiler usually infers T from the arguments, so you seldom write first<number>(...). The point is to link types: the return type depends on the input type. If a type parameter appears only once in a signature, it is not doing anything and should be removed.
Generics are behind most library APIs you use: Promise<T>, Array<T>, Map<K, V>, React's useState<T> and Angular's Observable<T>.
Practice. Write generic groupBy and uniqueBy functions and check that callers get precise result types.
Generic Types Core
Generic types apply type parameters to interfaces, aliases and classes, which is how you describe containers and wrappers.
interface ApiResponse<T> {
data: T;
error?: string;
}
type Paginated<T> = { items: T[]; page: number; total: number };
class Stack<T> {
private items: T[] = [];
push(item: T) { this.items.push(item); }
pop(): T | undefined { return this.items.pop(); }
}
const res: ApiResponse<Paginated<User>> = await getUsers();Type parameters can have defaults, interface Box<T = string>, which helps when a parameter is usually the same. A generic type is like a function at the type level: Paginated takes a type and returns a new one.
Practice. Write a typed Result<T, E> type and a generic Cache<K, V> class with expiry.
Generic Constraints Core
Generic constraints restrict what a type parameter can be with extends, so the body can safely use members of it.
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest('abc', 'de'); // ok
longest([1, 2], [3]); // ok
longest(10, 20); // error: number has no lengthConstraints combine with keyof to type property access safely:
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}Two newer tools help with inference: a const modifier on a type parameter, <const T>, infers literal types as if the caller wrote as const, and the NoInfer utility stops one argument from influencing what T becomes.
Practice. Write sortBy(items, key) where key must be a property of the item whose value is a string or number.
keyof and typeof Operators Core
The keyof operator produces a union of an object type's property names. The typeof type operator, used in a type position, gives the type of an existing value. Together with indexed access types, T[K], they let you derive types from code instead of repeating yourself.
const config = { port: 3000, host: 'localhost', debug: false };
type Config = typeof config; // { port: number; host: string; debug: boolean }
type ConfigKey = keyof Config; // 'port' | 'host' | 'debug'
type Port = Config['port']; // number
function get<K extends ConfigKey>(k: K): Config[K] { return config[k]; }Do not confuse the type operator with the run-time typeof used for narrowing; the position in the code decides which one it is. Deriving types this way keeps a single source of truth.
Practice. Derive a type and a key union from a constant object, then add a property and watch both update.
Stage 7 Utility and Advanced Types
Goal. Transform types with the built-in utilities and write your own mapped, conditional and template literal types.
Utility Types for Objects Core
Utility types are generic types built into TypeScript for common transformations. The object-shaping ones are used daily.
- The Partial type makes every property optional: ideal for update payloads.
Requireddoes the opposite.Readonlymakes every property read-only, at the top level only.Pickkeeps the listed keys;Omitremoves them.- The Record utility type builds an object type from key and value types.
interface User { id: string; name: string; email: string; role: Role }
type UserPatch = Partial<Omit<User, 'id'>>;
type PublicUser = Pick<User, 'id' | 'name'>;
type ByRole = Record<Role, User[]>;Omit does not check that the keys exist on the type, so a typo silently removes nothing. A stricter version is easy to write with a constraint if your team wants it.
Practice. Type a create, update and public-view variant of one entity using only utility types.
Utility Types for Unions Core
Some utilities filter union members.
Exclude<U, X>removes members ofUassignable toX.Extract<U, X>keeps only those members.- The NonNullable utility type removes
nullandundefined.
type Event = 'click' | 'focus' | 'blur' | 'keydown';
type FocusEvents = Extract<Event, 'focus' | 'blur'>; // 'focus' | 'blur'
type Others = Exclude<Event, FocusEvents>; // 'click' | 'keydown'
type Name = NonNullable<string | null | undefined>; // stringWith discriminated unions, Extract<Shape, { kind: 'circle' }> pulls out one variant, which is handy for typing handler maps. Remember the difference from Omit: Exclude works on union members, while Omit works on object keys.
Practice. From a union of action objects, extract the type of one action and exclude all actions that have no payload.
Utility Types for Functions and Classes Recommended
Another group reads types out of functions, classes and promises, which saves you from duplicating signatures you do not own.
- The Parameters utility type gives a tuple of a function's parameter types.
ReturnTypegives what it returns.ConstructorParametersandInstanceTypedo the same for classes.Awaitedunwraps promises, recursively.
async function loadUser(id: string, withPosts: boolean) { /* ... */ return user; }
type LoadArgs = Parameters<typeof loadUser>; // [string, boolean]
type Loaded = Awaited<ReturnType<typeof loadUser>>; // the user type
type Svc = InstanceType<typeof UserService>;Use these when a library exports a function but not the type of its options or results.
Practice. Wrap a third-party function in a caching layer typed entirely with Parameters and Awaited<ReturnType<...>>.
Mapped Types Recommended
Mapped types build a new object type by looping over keys, which is how Partial, Readonly and Record are written.
type Flags<T> = { [K in keyof T]: boolean };
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }The + and - modifiers add or remove readonly and ?. The as clause renames keys or filters them out by mapping to never. Mapped types over a generic T are homomorphic: they keep the original modifiers, which is why Partial<User> keeps read-only fields read-only.
Practice. Write a Nullable<T> mapped type and a version of Pick that keeps only properties whose values are functions.
Conditional Types Recommended
Conditional types choose between two types with a ternary-like syntax: T extends U ? X : Y. With infer, they can capture part of a type.
type IsString<T> = T extends string ? true : false;
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type Unwrap<T> = T extends Promise<infer V> ? V : T;
type A = ElementOf<string[]>; // string
type B = Unwrap<Promise<number>>; // numberWhen T is a bare type parameter and receives a union, the condition is distributive: it is applied to each member separately and the results are joined. Wrap both sides in brackets, [T] extends [U], to switch that off.
Most application code never needs conditional types, but you must be able to read them, because library types and ReturnType itself are built with them.
Practice. Reimplement ReturnType and Awaited yourself with conditional types and infer.
Template Literal Types Recommended
Template literal types build string literal types from other types, using the same backtick syntax as JavaScript template strings.
type Size = 'sm' | 'md' | 'lg';
type Side = 'top' | 'bottom';
type Margin = `m${Capitalize<Side>}-${Size}`; // 'mTop-sm' | 'mTop-md' | ...
type EventName<T extends string> = `on${Capitalize<T>}`;
type Route = `/users/${string}`;
const r: Route = '/users/42'; // okUnions are combined as a cross product, so keep inputs small. Built-in helpers Uppercase, Lowercase, Capitalize and Uncapitalize transform strings. With infer you can even parse strings at the type level, for example extracting :id parameters from a route pattern, which typed routers do.
Practice. Write a type that turns a route string such as /posts/:postId/comments/:id into { postId: string; id: string }.
Recursive Types Optional
Recursive types refer to themselves, which is how you describe trees, nested JSON and deeply transformed objects.
type Json =
| string | number | boolean | null
| Json[]
| { [key: string]: Json };
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
interface TreeNode { value: string; children: TreeNode[] }The compiler has depth limits, and heavy recursive types can slow the editor down noticeably or end with "type instantiation is excessively deep". Keep recursion shallow in application code and reach for well-tested helpers from a utility package before writing elaborate ones.
Practice. Write a DeepPartial<T> type and test it on a nested settings object.
Stage 8 Modules, Declarations and Interoperability
Goal. Organise code in modules, use and write declaration files, and make TypeScript and JavaScript work together.
TypeScript Modules Core
TypeScript modules follow standard ES modules: any file with a top-level import or export is a module with its own scope. These are what older documentation calls external modules in TypeScript, as opposed to internal modules, the old name for namespaces.
// math.ts
export function add(a: number, b: number) { return a + b; }
export type Vec = [number, number];
// app.ts
import { add } from './math.js';
import type { Vec } from './math.js';import type imports only types and is always erased; with verbatimModuleSyntax the compiler insists you mark type-only imports so the output is predictable. The module and moduleResolution options must match where the code runs: NodeNext for Node, which needs file extensions on relative imports, and bundler for Vite and similar tools. CommonJS and ES module interop is the most common source of confusing configuration errors.
Practice. Set up a small Node project with ES modules and NodeNext, and fix the import errors it reports.
Namespaces Optional
Namespaces are TypeScript's original way of grouping code under a name, from before ES modules existed. They compile to an object wrapped in a function.
namespace Geometry {
export const PI2 = Math.PI * 2;
export function circumference(r: number) { return PI2 * r; }
}
Geometry.circumference(2);In new code, use modules instead: they are standard, work with every bundler and support tree shaking. Namespaces also generate code, so they cannot be used with type stripping or erasableSyntaxOnly.
You will still see them in two places: older codebases, and declaration files, where declare namespace describes a global library object such as an old jQuery plugin, or groups related types.
Practice. Convert a small namespace-based file into ES modules and compare the emitted JavaScript.
Declaration Files and Ambient Modules Core
A declaration file, ending in .d.ts, contains only types. It describes JavaScript that exists elsewhere, so the checker can type-check your use of it.
Types for popular untyped packages are published separately under the @types scope, maintained by the community in the DefinitelyTyped repository. Many modern packages bundle their own types instead.
When a package has no types at all, declare an ambient module, which describes a module by name without implementation:
// types/legacy-charts.d.ts
declare module 'legacy-charts' {
export function draw(el: HTMLElement, data: number[]): void;
}
declare module '*.svg' {
const url: string;
export default url;
}declare statements tell the compiler something exists at run time. Setting declaration: true makes tsc produce .d.ts files for your own library.
Practice. Write a declaration file for a small untyped JavaScript helper and use it from TypeScript.
Global and Namespace Augmentation Optional
Augmentation adds members to types you do not own. Module augmentation reopens a library's module; global augmentation adds to the global scope from inside a module file.
// express.d.ts
import 'express';
declare module 'express' {
interface Request { userId?: string }
}
// globals.d.ts
export {};
declare global {
interface Window { analytics: { track(e: string): void } }
}Both rely on interface merging. Namespace augmentation works the same way for libraries that expose their types through a declared namespace: you reopen the namespace and add an interface member.
Augment sparingly. Every addition applies to the whole program, so a field added to Request is visible even in routes where it was never set.
Practice. Augment the global Window type for a third-party script and type the code that uses it.
TS and JS Interoperability Recommended
TypeScript is designed for gradual adoption. With allowJs, .js files become part of the program; with checkJs, they are type-checked too, using JSDoc comments as annotations.
// @ts-check
/** @param {string} name @returns {string} */
export function greet(name) { return 'Hi ' + name; }A typical migration: enable allowJs, move files to .ts one folder at a time starting from the leaves, add types at module boundaries, and raise strictness gradually. // @ts-expect-error silences one line and fails if the error disappears, which is safer than // @ts-ignore.
Some libraries are written entirely in JavaScript with JSDoc types and still ship accurate declaration files, which shows how far the interop goes.
Practice. Add checkJs to a JavaScript project, fix the first ten errors with JSDoc, then convert one file to TypeScript.
Stage 9 Tooling and the TypeScript Ecosystem
Goal. Configure strictness, builds, linting and formatting for a real project and choose libraries that work well with types.
Compiler Options Core
Compiler options decide how much the checker protects you. The ones to know by name:
strictturns on a family of checks, includingnoImplicitAny,strictNullChecks,strictFunctionTypesanduseUnknownInCatchVariables.noUncheckedIndexedAccessaddsundefinedto array and index reads.exactOptionalPropertyTypesdistinguishes a missing property from one set toundefined.noImplicitOverride,noFallthroughCasesInSwitchandnoUnusedLocalscatch common slips.isolatedModulesandverbatimModuleSyntaxkeep code safe for file-by-file compilers.skipLibCheckskips checking.d.tsfiles to save time.pathsandbaseUrldefine import aliases, which your bundler must also understand.
When an interviewer asks how you would harden a codebase, describe raising these options one at a time, fixing errors per folder.
Practice. Turn on noUncheckedIndexedAccess in a project and categorise the errors it finds into real bugs and noise.
Build Tools Recommended
Build tools around TypeScript fall into three groups.
- Type checkers:
tsc, run with--noEmitin CI, orvue-tscand Angular's compiler for their templates. - Transpilers that only strip types, very fast: esbuild, SWC and Babel's TypeScript preset.
- Bundlers and app tools that use those transpilers: Vite, webpack with a loader, Rollup, Rspack, and framework CLIs.
For publishing a library, tools such as tsup or tsdown bundle JavaScript in several module formats and generate declaration files. Project references with composite: true and tsc -b let monorepos rebuild only changed packages.
Because fast transpilers see one file at a time, enable isolatedModules so that code which needs whole-program information, such as re-exporting a type without export type, is flagged.
Practice. Set up a library with tsup that outputs ES module and CommonJS builds plus declaration files.
Linting Core
Linting with ESLint and the typescript-eslint packages catches problems the compiler allows: floating promises, unsafe any usage, unused variables and confusing patterns.
// eslint.config.js (flat config)
import tseslint from 'typescript-eslint';
export default tseslint.config(
...tseslint.configs.recommendedTypeChecked,
{ languageOptions: { parserOptions: { projectService: true } } },
);Type-aware rules, such as no-floating-promises and no-unsafe-assignment, need type information and run slower, but they find real bugs. ESLint's flat config file is now the default format; the old .eslintrc style is deprecated.
Biome and Oxlint are newer, very fast alternatives that combine linting and formatting with fewer type-aware rules.
Practice. Enable the type-checked recommended rules on a project and fix every floating promise they report.
Formatting Recommended
Formatting is not about types, but every TypeScript team agrees on one formatter and runs it automatically, so style never comes up in code review.
Prettier is the most common choice. It reformats the whole file on save with very few options. Biome offers a compatible, faster formatter. Configure the editor to format on save, add a check in CI (prettier --check .), and optionally run it on staged files with a pre-commit hook.
Keep the linter for correctness and the formatter for layout. Turning off ESLint's stylistic rules avoids the two tools fighting over the same lines. Agree on the formatter before the codebase grows, because reformatting a large repository later creates huge, noisy diffs.
Practice. Add Prettier with format-on-save and a CI check to a TypeScript project.
TypeScript Ecosystem and Useful Packages Recommended
The wider TypeScript ecosystem matters because types end at your program's edges. Useful packages to know:
- Run-time validation: Zod, Valibot or ArkType define a schema once and infer the static type from it, so API responses and form input are checked at run time.
- Type utilities:
type-festcollects well-tested helper types. - Execution:
tsxfor running files, Vitest for tests with TypeScript support built in. - End-to-end typed APIs: tRPC, and OpenAPI or GraphQL code generators that produce types from a schema.
import { z } from 'zod';
const User = z.object({ id: z.string(), email: z.string().email() });
type User = z.infer<typeof User>;
const user = User.parse(await res.json());The Node.js Roadmap and React Roadmap roadmaps show TypeScript in those settings.
Practice. Validate an external API response with a Zod schema and remove the type assertion it replaces.
Projects to build
Build these in order. Each one should end up in a public repository with a short README that explains your decisions.
- Typed command-line to-do appBeginner
Build a Node.js command-line tool that adds, lists, completes and deletes tasks stored in a JSON file. Use strict mode, model a task with an interface and statuses with a literal union, and validate the file contents with a type guard when loading.
tsconfig.json · Interfaces · Literal types · Type guards · Running TypeScript
- Typed API clientIntermediate
Write a small client for a public REST API with a generic
request<T>function, typed endpoints and aResulttype for success and failure. Validate responses with Zod and infer the static types from the schemas so they cannot drift apart.Generics · Discriminated unions · Zod · Async functions · Utility types
- Form state libraryIntermediate
Build a tiny form helper that takes an initial values object and returns typed
values,errors,touchedand asetField(key, value)function where the value type depends on the key. Add tests for both behaviour and types.keyof and indexed access · Mapped types · Generic constraints · Type-level testing
- Typed event emitter and routerAdvanced
Write an event emitter whose
onandemitmethods are checked against an event map, and a router that extracts parameter names from route strings such as/users/:idinto a typed params object. Publish it as a package with declaration files.Template literal types · Conditional types · infer · Build tools · Declaration files
- JavaScript to TypeScript migrationAdvanced
Take an existing JavaScript project of a few thousand lines and migrate it: enable
allowJsandcheckJs, convert folders one by one, add types at module boundaries, then turn onstrictandnoUncheckedIndexedAccess. Write a short report of the bugs you found on the way.TS and JS interoperability · Compiler options · Declaration files · Linting
Interview questions
Try to answer each question out loud before you open the answer.
What is the difference between any and unknown?Junior
Both accept any value. any also turns off checking for everything you do with it, and that spreads: a property read from any is any too, so errors disappear silently. unknown is the type-safe version: you can store anything in it, but you cannot call, index or pass it as a specific type until you narrow it with a guard or validate it. I use unknown for parsed JSON, caught errors and other untrusted input, and treat any as a last resort that needs a comment.
When would you use an interface and when a type alias?Junior
For object shapes they are nearly interchangeable, so consistency matters most. Interfaces can be reopened and merged, which is needed to augment library types, and extends gives clear errors on conflicting members. Type aliases can name anything: unions, tuples, function types, mapped and conditional types. My default is interfaces for object shapes and public contracts, and aliases for unions and computed types, but I would follow an existing team convention rather than mix styles.
Are TypeScript types available at run time?Junior
No. Types are erased when TypeScript is compiled or stripped, and the output is plain JavaScript. That means a type annotation on a fetch result is only a promise you make to the compiler, not a check. To protect the boundaries of a program, such as API responses, form data and environment variables, you need run-time validation, for example a Zod schema that both validates the value and produces the static type. Enums, namespaces and decorators are exceptions in that they emit some code.
What is a discriminated union and why is it useful?Mid
It is a union of object types that share a literal property, such as status: 'loading' | 'success' | 'error'. Checking that property narrows the value to one variant, so inside the success branch the compiler knows data exists. It makes impossible combinations, such as having both data and an error, unrepresentable, and with an exhaustive switch ending in a never check, the compiler points out every place to update when a new variant is added.
Explain type narrowing and list the ways TypeScript narrows types.Mid
Narrowing is the checker following control flow and refining a broad type inside a branch. It understands typeof for primitives, instanceof for classes, the in operator for properties, equality checks against literals or null, truthiness checks, and discriminant property checks on unions. It also follows early returns, throws and assignments. When none of those fit, I write a type predicate function, value is Type, or an assertion function. Narrowing is preferable to as, because it is backed by a real run-time check.
What does the satisfies operator do, and how is it different from a type annotation?Mid
An annotation sets the variable's type to the annotated type, so you lose the detail of what you actually wrote. satisfies checks that the value is compatible with a type but keeps the more precise inferred type. For a config object typed as Record<string, string | number[]>, an annotation makes every property the full union, while satisfies still knows that spacing is a number[]. It also gives excess property checks, so it catches typos without widening anything.
What are generics, and what is a generic constraint?Mid
Generics are type parameters that let a function or type work with many types while linking them together, so first<T>(items: T[]): T | undefined returns the element type of whatever array you pass. A constraint, written T extends Something, limits which types are allowed so the body can use members of that type, for example T extends { length: number }. Combined with keyof, as in K extends keyof T, constraints let you type property access such as a pluck or sortBy function precisely.
How do Partial, Pick, Omit and Record work?Junior
They are built-in mapped types. Partial<T> makes every property optional, which suits update payloads. Pick<T, K> keeps the listed keys and Omit<T, K> removes them. Record<K, V> builds an object type whose keys are K and values are V, useful for lookup tables keyed by a union. Under the hood, Partial is { [P in keyof T]?: T[P] }. One catch: Omit does not require its keys to exist on T, so a typo silently removes nothing.
What is structural typing, and how can you stop two identical types being mixed up?Mid
TypeScript compares types by shape, not by name, so any value with the required members fits, even if it was declared as a different type. That is flexible but means a UserId and an OrderId that are both strings are interchangeable. The common fix is a branded type: type UserId = string & { readonly __brand: 'UserId' }, created only through a function that validates the value. The brand exists only in the type system, so there is no run-time cost.
Explain conditional types and the infer keyword with an example.Senior
A conditional type picks one of two types based on assignability: T extends U ? X : Y. infer declares a type variable inside the condition and captures the matching part, so type Unwrap<T> = T extends Promise<infer V> ? V : T extracts a promise's value type. When T is a naked type parameter and receives a union, the condition is applied to each member separately, which is why Exclude works. Wrapping both sides in a tuple turns that off. ReturnType and Awaited are built this way.
What is the difference between never and void?Junior
void means a function returns nothing useful; callers should ignore its result, and a callback typed as returning void may in fact return a value. never means there is no value at all: a function that always throws or never finishes, or a variable after every possible type has been narrowed away. never is useful for exhaustiveness checks, because passing a value to a parameter of type never only compiles when every case has been handled.
How would you migrate a large JavaScript codebase to TypeScript?Senior
Gradually. I add a tsconfig.json with allowJs, then optionally checkJs with JSDoc to get early value. I convert files from the leaves inwards, utilities and data models first, so the types flow into the rest of the code. I type module boundaries and API responses properly, allow some any during migration but track it with lint rules, and turn on strict options one at a time per folder. CI runs tsc --noEmit from the first day, so progress cannot slip backwards.
Why might a project avoid enums, and what would you use instead?Mid
Enums are one of the few TypeScript features that emit JavaScript, numeric enums have a reverse mapping and loose assignability, and const enum causes problems with file-by-file compilers. They also do not run under pure type stripping, which Node.js now supports. A union of string literals is usually enough, and when you need the values at run time, an as const object with a derived union type, typeof Obj[keyof typeof Obj], gives the same safety with zero generated code.
What does the strict flag enable, and why does it matter?Junior
strict is a group of checks: noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict and useUnknownInCatchVariables. The most important is strictNullChecks, which forces you to handle null and undefined, the source of many run-time crashes. Without strict, TypeScript misses a large share of the bugs it could catch. I turn it on for every new project and add noUncheckedIndexedAccess on top.
How do you add a property to a type from a third-party library?Senior
With declaration merging. For a module, I write a .d.ts file that imports the module and then uses declare module 'name' with an interface of the same name, for example adding user to Express's Request. For globals, such as a property on Window, I use declare global inside a module file. It works because interfaces with the same name merge. I keep these augmentations in one place, because they apply everywhere in the program.
Your build passes but type errors appear in the editor. What could be wrong?Mid
Most likely the build uses a transpiler such as esbuild or SWC that only strips types and never type-checks, so the bundle succeeds regardless. I would add tsc --noEmit to CI and the pre-push hook. Other causes are the editor using a different TypeScript version from the workspace, or the editor and CLI reading different tsconfig.json files, for example when include misses a folder. I would check the version selector in the editor and run tsc --showConfig to see the effective settings.
Common mistakes
- Using
anyoras anyto silence errors; useunknownand narrow, or fix the real type mismatch. - Trusting a type annotation on API data as if it were validation; validate external data at run time with a schema library.
- Starting a project without
strict; turn it on from the first commit because adding it later is expensive. - Annotating every local variable; let inference work and annotate parameters and exported return types.
- Reaching for type assertions and the non-null
!operator instead of narrowing; each one disables the check that would catch the bug. - Assuming a successful bundle means the code type-checks; run
tsc --noEmitin CI because fast transpilers skip checking. - Writing clever recursive and conditional types in application code; keep application types simple and leave type gymnastics to well-tested libraries.
- Modelling state with many optional flags such as
isLoading,error?anddata?; use a discriminated union so impossible combinations cannot compile.
Ready-for-interview checklist
- I can set up a TypeScript project with a strict
tsconfig.jsonand explain the main compiler options. - I can explain the difference between
any,unknown,neverandvoidwith examples. - I can choose between an interface and a type alias and justify the choice.
- I can model state with discriminated unions and write an exhaustive
switchwith anevercheck. - I can narrow types with
typeof,instanceof,in, equality and custom type predicates. - I can explain when
as,as const,satisfiesand the non-null assertion are appropriate. - I can write generic functions and types with constraints using
keyofand indexed access. - I can use
Partial,Pick,Omit,Record,ReturnTypeandAwaitedwithout looking them up. - I can read and write simple mapped, conditional and template literal types.
- I can write a declaration file and augment a library type.
- I can explain why types are erased and how to validate data at run time.
- I can describe how to migrate a JavaScript codebase to TypeScript step by step.
Related roadmaps
- JavaScript RoadmapLanguage core, async, the event loop, modules and the DOM.
- React RoadmapComponents, hooks, state, data fetching, performance and testing.
- Angular RoadmapComponents, DI, RxJS, signals, routing, forms and testing.
- Node.js RoadmapEvent loop, modules, HTTP, streams, frameworks and deployment.
- Vue RoadmapReactivity, components, Composition API, Pinia and Nuxt.
- Next.js RoadmapApp Router, server components, data fetching, caching and deployment.
FAQ
How long does it take to learn TypeScript?
If you already write modern JavaScript, you can be productive with everyday types in one to two weeks. Becoming comfortable with generics, narrowing, utility types and configuration, which is what interviews test, usually takes two to three months of regular use at around eight hours a week. Reading library type definitions fluently takes longer and comes mostly from practice on real projects.
Should I learn JavaScript before TypeScript?
Yes. TypeScript does not change how code runs, so questions about closures, this, promises and the event loop are still JavaScript questions. Learn the fundamentals in the JavaScript Roadmap first, then add TypeScript once you can build small programs without help. Some people start writing TypeScript early in a course, which is fine, as long as they understand the JavaScript underneath.
Is TypeScript worth learning, or will JavaScript catch up?
It is worth learning. Most new professional front-end and Node.js projects use it, and job listings for those roles commonly ask for it. There is a proposal to let JavaScript engines ignore type annotations, but even if it lands it would only allow types in the syntax; checking them would still need TypeScript or a similar tool.
Do I need to know advanced types such as conditional and mapped types for a job?
For most application developers, you need to read them and write simple ones, not invent complex type-level programs. Interviews for front-end and full-stack roles focus on unions, narrowing, generics and utility types. Advanced types matter more if you build libraries, design systems or shared SDKs.