# Learn TypeScript > Free TypeScript tutorials, plus how to use the type system as a correctness harness for AI-generated code. Canonical: https://learn-typescript.org/ Licence: content free to read and quote with attribution to Learn TypeScript (https://learn-typescript.org/). Maintainer: Code Learning Dojo. Last built 2026-09-06. ## Foundations The syntax and the mental model. Short, runnable, no fluff. - [Hello, World!](https://learn-typescript.org/hello-world/): Your first TypeScript file, and the two commands that check it and run it. - [Variables and Types](https://learn-typescript.org/variables-and-types/): TypeScript is JavaScript plus a type checker. This is where the types start — and where you stop writing var. - [Arrays](https://learn-typescript.org/arrays/): TypeScript arrays are JavaScript arrays with an element type — `string[]` rather than a bag of anything. - [Manipulating Arrays](https://learn-typescript.org/manipulating-arrays/): Arrays can also function as a stack. The push and pop methods insert and remove variables from the end of an array. - [Operators](https://learn-typescript.org/operators/): The arithmetic, comparison and logical operators, and the two TypeScript-relevant ones: ?? and ?. - [Conditions](https://learn-typescript.org/conditions/): The if statement allows us to check if an expression is equal to true or false, and execute different code according to the result. - [Loops](https://learn-typescript.org/loops/): for, for...of, for...in and while — and which one to reach for. - [Objects](https://learn-typescript.org/objects/): Objects hold keyed values, and in TypeScript the set of keys and their types is part of the type. - [Functions](https://learn-typescript.org/functions/): Functions are code blocks that can have arguments, and function have their own scope. - [Pop-up Boxes](https://learn-typescript.org/pop-up-boxes/): There are three types of pop-up boxes in javascript: confirm, alert, and prompt. - [Callbacks](https://learn-typescript.org/callbacks/): Passing a function to another function — and typing its signature so the parameters come out inferred. - [Arrow Functions](https://learn-typescript.org/arrow-functions/): Arrow functions are a feature of ES6, their behavior are generally the same of a function. - [Object Oriented JavaScript](https://learn-typescript.org/object-oriented-javascript/): JavaScript uses functions as classes to create objects using the new keyword. - [Function Context](https://learn-typescript.org/function-context/): Functions in JavaScript run in a specific context, and using the this variable we have access to it. - [Inheritance](https://learn-typescript.org/inheritance/): JavaScript uses prototype based inheritance. - [Destructuring](https://learn-typescript.org/destructuring/): Destructuring is a feature of ES6, introduced for making easier and cleaner some repetitive operations and assignments made in JS. - [Functions and Signatures](https://learn-typescript.org/functions-and-signatures/): A function signature is the most valuable annotation you will write — it is a contract the compiler enforces at every call site. - [Interfaces and Type Aliases](https://learn-typescript.org/interfaces-and-type-aliases/): Naming the shape of your data is the point at which TypeScript starts paying for itself. - [Union Types and Narrowing](https://learn-typescript.org/union-types-and-narrowing/): A union says a value is one of several things. Narrowing is how you prove which one — and it is the most distinctively TypeScript skill there is. - [Generics](https://learn-typescript.org/generics/): A generic is a type with a hole in it. They look intimidating and the everyday use is genuinely simple. - [Classes and Access Modifiers](https://learn-typescript.org/classes-and-modifiers/): TypeScript adds real access control, parameter properties and interface implementation to JavaScript classes. - [Async and Promises](https://learn-typescript.org/async-and-promises/): Typing asynchronous code, and the lint rule that catches the most common bug in TypeScript and JavaScript alike. - [tsconfig and Strictness](https://learn-typescript.org/tsconfig-and-strictness/): The most important file in a TypeScript project. Four flags beyond strict do most of the real bug-catching, and all four are off by default. ## AI-Native Configuring agents, harnesses and feedback loops for this language. Updated as the tooling moves. - [The type system is the best prompt you will ever write](https://learn-typescript.org/ai/types-as-harness/): A type is a specification the compiler enforces on every edit, for free, forever. That makes TypeScript unusually well suited to a workflow where a machine writes most of the code. - [Writing an AGENTS.md for TypeScript](https://learn-typescript.org/ai/agents-md/): Half of what people put in a TypeScript instructions file belongs in tsconfig.json instead, where it is enforced rather than suggested. - [Type-safe LLM applications in TypeScript](https://learn-typescript.org/ai/evals/): A model returns a string. Your application needs a value. Everything interesting about building LLM apps in TypeScript happens at that boundary — and it is the one place `as` will hurt you most. - [Making token budgets a type error](https://learn-typescript.org/ai/tokenomics/): You cannot type-check a bill. You can make it impossible to call a model without a budget, impossible to confuse dollars with tokens, and impossible to add a call site nobody is measuring. ## Review & Verify How generated code fails in this language, and the checks that catch it before your users do. - [The TypeScript mistakes language models actually make](https://learn-typescript.org/review/failure-modes/): The compiler catches a lot. What survives is almost always the compiler being told to look away. - [Dependency hygiene for TypeScript projects](https://learn-typescript.org/review/dependencies/): Everything npm does wrong, plus a second parallel dependency graph made of type definitions that nobody reviews. - [Security review for TypeScript: what types do and do not buy you](https://learn-typescript.org/review/security/): Types are erased at runtime, so an attacker never sees them. The security value of TypeScript is real but indirect — and generated code reaches for the escape hatches at exactly the wrong moments. - [Build performance: the TypeScript bottleneck nobody profiles](https://learn-typescript.org/review/performance/): Runtime performance is JavaScript's problem and is covered there. TypeScript's own performance problem is the compiler — and a slow one directly degrades the quality of everything an agent writes. ## Reference pages - [About Learn TypeScript, and how we make money](https://learn-typescript.org/about/): Editorial policy, sourcing, corrections and affiliate disclosure for Learn TypeScript, part of the Code Learning Dojo network. - [The TypeScript stack we would set up today](https://learn-typescript.org/tools/): An opinionated TypeScript toolchain: tsconfig, ESLint, Vitest, validation libraries, package managers, editors and hosting — plus the tools that are now redundant. --- # Full text ## Hello, World! Source: https://learn-typescript.org/hello-world/ Welcome to the first tutorial. In this tutorial you will learn how to write your first line of code. JavaScript is a very powerful language. It can be used within any browser in the world. On top of that, it can be used to write server-side code using node.js. When using JavaScript inside the browser, we can change how the page looks like and how it behaves. In this tutorial, we will only focus on learning the language itself, and therefore we will only use one function to print out our results called “console.log”. ## The type system is the best prompt you will ever write Source: https://learn-typescript.org/ai/types-as-harness/ There is a way of thinking about types that becomes much more compelling once an agent is writing your code: **a type is a specification that gets checked on every single edit, at zero marginal cost, that the model cannot talk its way around.** You can write the same constraint as a paragraph in `AGENTS.md` and hope it is weighted highly. Or you can write it as a type and have it be true. ## Start with a config that actually constrains `strict: true` is the floor, not the ceiling. The four flags below are the ones that matter most for generated code, and all four are off by default even in strict mode. ```json tsconfig.json { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, // arr[0] is T | undefined. it is. "exactOptionalPropertyTypes": true, // {a?: string} is not {a: undefined} "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, "target": "ES2023", "module": "nodenext" } } ``` `noUncheckedIndexedAccess` is the highest-value one by a distance. Array and record access is where generated code assumes presence most often, and this flag turns every such assumption into a compile error you must answer. :::warn It will produce errors on your existing code That is the flag doing its job — each error is a place where you were already assuming something you had not checked. Turn it on for new code first if the volume is large, and fix inwards. ::: ## Make illegal states unrepresentable The single highest-leverage habit. Generated code fills in whatever the type permits, so a permissive type is an invitation. ```ts // Permissive: 16 possible states, 12 of them nonsense type Request = { status: "idle" | "loading" | "success" | "error"; data?: User; error?: Error; }; // Constrained: 4 states, all valid type Request = | { status: "idle" } | { status: "loading" } | { status: "success"; data: User } | { status: "error"; error: Error }; ``` With the second version, generated code that tries to read `data` in the error branch does not compile. You did not have to notice; the compiler did. Add an exhaustiveness check and adding a new variant becomes a compile error at every site that needs updating: ```ts function assertNever(x: never): never { throw new Error(`Unhandled: ${JSON.stringify(x)}`); } switch (req.status) { case "idle": return null; case "loading": return ; case "success": return ; case "error": return ; default: return assertNever(req); } ``` ## Branded types for the things that get swapped Every codebase has three string ids that must never be interchanged, and generated code will eventually interchange them because they are all `string`. ```ts declare const brand: unique symbol; type Brand = T & { readonly [brand]: B }; type UserId = Brand; type OrgId = Brand; type Cents = Brand; export const userId = (s: string): UserId => s as UserId; function transfer(from: UserId, to: UserId, amount: Cents): void {} transfer(orgId, userId, 500); // compile error. good. ``` Cheap to add, and it converts an entire category of "passed the wrong id" bug into a compile failure. Do it for money especially — `Cents` as a branded integer prevents both the float bug and the "was that dollars?" bug at once. ## Validate at the boundary, infer inside it The most common structural failure in generated TypeScript: casting external data to a type and trusting it. ```ts const user = await res.json() as User; // a lie the compiler cannot check ``` `as` is an assertion, not a check. Parse instead, and let the type flow out of the parser: ```ts import { z } from "zod"; const User = z.object({ id: z.string().brand<"UserId">(), email: z.email(), createdAt: z.iso.datetime(), }); type User = z.infer; const user = User.parse(await res.json()); // now the type is earned ``` One schema, one source of truth, runtime validation and a static type. Put a line in `AGENTS.md`: *"External data is parsed with a schema. `as` on a network or database response is a bug."* :::tip The grep that finds most of it ```bash git diff | grep -nE ' as [A-Z]| as any|: any|@ts-ignore|@ts-expect-error|!\.' ``` Non-null assertions (`!`) and `as` are how generated TypeScript escapes the type system when it is stuck. Every occurrence in a diff deserves a look; most should be a parse or a narrowing check instead. ::: ## What the compiler still cannot see Honesty about the limits, because "we have types" makes people complacent: - **Types are erased.** They constrain the code, not the data. Anything crossing a process boundary needs runtime validation. - **`any` is contagious** and generated code reaches for it under pressure. Turn on `noImplicitAny` (strict does) and lint against explicit `any`. - **Structural typing means shape, not meaning.** Two types with the same fields are interchangeable. That is what branding is for. - **A type says nothing about correctness.** `add(a: number, b: number): number` is satisfied by subtraction. Types constrain the space; tests pick the point. ## The setup ```json package.json { "scripts": { "check": "tsc --noEmit && eslint . && vitest run", "typecheck": "tsc --noEmit" } } ``` Wire `tsc --noEmit` into an edit hook so the agent gets type errors within a second of writing them, the same way [the Python loop](https://learn-python.com/ai/feedback-loops/) works — see [harness hooks](https://codelearningdojo.com/harness-hooks/) for how to set that up. On a large codebase use `tsc --noEmit --incremental` or project references so it stays fast enough to run every time. :::promo frontendmasters ::: ## Common questions ### Do stronger types actually improve agent output? Yes, and the mechanism is not subtle: the model gets a specific error naming the exact problem within a second, and iterates against it. A loose type produces code that compiles and is wrong, which produces no signal at all. This is the same reason a fast test suite beats a slow one. ### Is `noUncheckedIndexedAccess` worth the noise? On new code, unquestionably — array access without a presence check is one of the most common generated bugs, and the flag makes it impossible. On a large legacy codebase, enable it per-directory and expand, rather than fixing several hundred errors at once. ### Zod, Valibot, ArkType, or something else? Any of them. The decision that matters is *parse at the boundary rather than cast*, and all of these do it. Zod has the largest ecosystem, which also means models write it most reliably. ### Does this replace tests? No — types constrain the space of possible programs, tests pin down which point in that space you wanted. `subtract` satisfies a signature that says `add`. Use types to make whole categories of error impossible, then test the behaviour. ## The TypeScript mistakes language models actually make Source: https://learn-typescript.org/review/failure-modes/ TypeScript's failure modes are unusual: the language has a good static checker, so the interesting bugs are the ones where the checker was *disabled*, explicitly or by a loose config. That makes review here mostly mechanical. There is a short list of escape hatches, they are all greppable, and each one is a place where a claim was made that nothing verified. ## The escape hatches ### 1. `as` on external data ```ts const user = await res.json() as User; // a claim, not a check const config = JSON.parse(raw) as Config; const el = document.querySelector(".x") as HTMLInputElement; ``` `as` is an assertion. Nothing runs. If the API changed a field name last week, the type still says it did not, and the failure surfaces three functions away as `undefined is not an object`. **Correct:** parse with a schema and let the type flow out of it. ```ts const User = z.object({ id: z.string(), email: z.email() }); const user = User.parse(await res.json()); ``` ### 2. Non-null assertions ```ts const first = items.find(x => x.id === id)!; // "trust me" process.env.API_KEY!.slice(0, 4); ``` Each `!` is an unchecked claim, and generated code produces them whenever narrowing would take an extra line. **Catch it with:** `@typescript-eslint/no-non-null-assertion`. ### 3. `any`, explicit and implicit `any` disables checking for everything it touches and spreads through inference. Generated code reaches for it under pressure — an awkward generic, a library without types. **Correct:** `unknown` plus a narrowing check. It is one more line and it does not spread. **Catch it with:** `no-explicit-any` and `no-unsafe-assignment` / `no-unsafe-member-access` / `no-unsafe-call` from the type-checked preset. Those three catch `any` arriving from an untyped dependency, which is where most of it comes from. ### 4. `@ts-ignore` ```ts // @ts-ignore doTheThing(wrongArgs); ``` **Correct:** `@ts-expect-error`, which errors if the line stops being wrong — so it cannot rot silently. Ban `@ts-ignore` outright with `ban-ts-comment`. ```bash git diff | grep -nE ' as [A-Z]| as any|: any|@ts-ignore|!\.|!\)|!;' ``` That one command is most of a TypeScript review. ## Unsoundness the compiler permits by default ### 5. Array and record access ```ts const first = items[0]; // typed T, actually T | undefined const port = config["port"]; // same ``` TypeScript's default is unsound here for ergonomic reasons, and generated code assumes presence constantly. **Fix it with:** `noUncheckedIndexedAccess: true`. This is the single highest-value compiler flag for generated code. ### 6. Optional properties versus undefined ```ts type Opts = { retries?: number }; const o: Opts = { retries: undefined }; // allowed by default. usually a bug. ``` **Fix it with:** `exactOptionalPropertyTypes: true`. ### 7. Structural typing surprises ```ts function transfer(from: string, to: string, amount: number) {} transfer(orgId, userId, amount); // compiles. wrong. ``` Three strings are interchangeable because they are three strings. **Fix it with:** branded types — see [the type system as a harness](/ai/types-as-harness/). ### 8. Widened literal types ```ts const config = { mode: "dark" }; // mode: string, not "dark" setTheme(config.mode); // error, or worse, accepted as string ``` `as const` on config objects. Generated code omits it and then reaches for `as` to fix the symptom. ## Async TypeScript inherits every JavaScript async failure — floating promises, sequential awaits, `forEach` with an async callback — and adds one of its own: ### 9. A promise where a value was expected ```ts if (isReady()) { } // isReady returns Promise // a Promise is always truthy ``` **Catch it with:** `@typescript-eslint/no-misused-promises` and `await-thenable`. The full JavaScript async list is in [the JavaScript failure modes](https://learn-javascript.org/review/failure-modes/) and applies unchanged. ## Idioms worth correcting | Generated | Prefer | |---|---| | `enum Status { ... }` | a union of string literals, or `as const` — enums emit runtime code and are not erasable | | `namespace` | modules | | `interface` vs `type` inconsistently | pick one convention and put it in AGENTS.md | | `Function`, `Object`, `{}` as types | specific signatures; `{}` means "anything not null" | | `require()` in an ESM project | `import` | | custom `DeepPartial`, `Awaited` | built-in utility types now cover most of it | The enum point matters more than it used to: with `erasableSyntaxOnly` and runtime type stripping, enums and parameter properties are no longer erasable syntax. Generated code still reaches for them constantly. ## The config ```json tsconfig.json { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true } } ``` ```js eslint.config.js rules: { "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-non-null-assertion": "error", "@typescript-eslint/no-unsafe-assignment": "error", "@typescript-eslint/no-unsafe-member-access": "error", "@typescript-eslint/no-unsafe-call": "error", "@typescript-eslint/ban-ts-comment": ["error", { "ts-ignore": true }], "@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/no-misused-promises": "error", "@typescript-eslint/consistent-type-assertions": ["error", { "assertionStyle": "never" }], } ``` That last rule — banning `as` entirely — is aggressive and worth trying. In most codebases the legitimate uses are rare enough to justify with an explicit `eslint-disable` comment, and forcing that comment is exactly the review prompt you want. :::verdict The whole review, compressed Turn on the four strict-adjacent flags. Ban the escape hatches in lint. Then the only TypeScript-specific thing left to read for is whether the types describe the right thing — which is judgement, and is where your attention should go. ::: :::promo frontendmasters ::: ## Common questions ### Is banning `as` outright practical? More often than people expect. Genuine uses — narrowing a DOM element, `as const`, a well-understood cast at a library boundary — are rare enough that requiring an explicit disable comment is reasonable, and the comment is a useful review signal. ### Why does generated code use enums when the ecosystem moved away from them? Because enums appear throughout a decade of TypeScript in the training data. They also now conflict with runtime type stripping, since they emit real JavaScript. A union of string literals does the same job, is erasable, and narrows better. ### Do these rules slow the build down? The type-aware rules need a TypeScript program, so linting is slower — noticeably on a large repo. Use `projectService` and lint changed files in the edit hook, full repo in CI. The bugs they catch are worth the seconds. ## Variables and Types Source: https://learn-typescript.org/variables-and-types/ TypeScript is JavaScript with a type checker on top. Every valid JavaScript file is valid TypeScript — the difference is that TypeScript reads your code before it runs and tells you when something cannot possibly work. ## Declaring a variable ```typescript let count = 0; // can be reassigned const name = "Ada"; // cannot be reassigned ``` Use `const` by default and `let` when you genuinely need to reassign. **Never use `var`** — it is function-scoped rather than block-scoped, which produces surprising behaviour, and there is no situation in modern code where it is the right choice. ```typescript if (true) { var leaks = "visible outside this block"; let contained = "not visible outside this block"; } console.log(leaks); // works — this is the problem console.log(contained); // error: Cannot find name 'contained' ``` ## Type annotations You can tell TypeScript what a variable holds with `: type` after the name. ```typescript let age: number = 36; let username: string = "ada"; let isActive: boolean = true; ``` Now the checker holds you to it: ```typescript age = "thirty-six"; // Type 'string' is not assignable to type 'number'. ``` That error appears in your editor as you type — before you run anything, before a test fails, before it reaches production. That is the whole value proposition. ## Inference: usually you do not need the annotation TypeScript works out the type from the value, so the annotation above is redundant: ```typescript let age = 36; // inferred as number age = "thirty-six"; // still an error ``` **Prefer inference.** Write the annotation when it adds information the value does not carry — a function's parameters and return type, an empty array, or a variable you declare before assigning. ```typescript const items: string[] = []; // without this it would be any[] let selected: string | null = null; ``` :::tip `const` narrows further than `let` ```typescript let a = "hello"; // type is string const b = "hello"; // type is "hello" — the literal itself ``` A `const` cannot change, so TypeScript records the exact value. That is what makes literal types and discriminated unions work later. ::: ## The primitive types ```typescript let n: number = 3.14; // one type for ints and floats alike let s: string = "text"; let b: boolean = true; let big: bigint = 9007199254740993n; let sym: symbol = Symbol("id"); let nothing: null = null; let missing: undefined = undefined; ``` `null` and `undefined` are distinct. Roughly: `undefined` means "no value was ever set", `null` means "explicitly set to nothing". Under `strictNullChecks` (which you should have on) neither is assignable to other types unless you say so: ```typescript let title: string = null; // error under strict mode let subtitle: string | null = null; // fine — you declared the possibility ``` ## any, unknown and never Three special types worth understanding early, because generated code reaches for the wrong one. **`any` turns the checker off** for that value. It is contagious — anything derived from an `any` is also unchecked — and it is how a codebase quietly loses its type safety. ```typescript let data: any = JSON.parse(raw); data.wahtever.deeply.nested; // no error. no help. typo ships. ``` **`unknown` is the safe version.** You can hold anything in it, but you must narrow before you use it: ```typescript let data: unknown = JSON.parse(raw); data.toUpperCase(); // error — good if (typeof data === "string") { data.toUpperCase(); // fine, narrowed to string } ``` **`never`** is the type with no values — a function that always throws, or a branch that cannot be reached. You will meet it mostly in exhaustiveness checks. :::warn The one rule to carry forward Use `unknown` where you are tempted to use `any`. It costs one narrowing check and it keeps the rest of your program honest. Turn on `noImplicitAny` (part of `strict`) so the compiler tells you when an `any` sneaks in. ::: ## Running it TypeScript does not run directly — it is checked, then the types are stripped to produce JavaScript. ```bash npm i -D typescript npx tsc --init # creates tsconfig.json npx tsc # type check + emit .js npx tsc --noEmit # type check only — what CI should run ``` Modern Node can also run `.ts` files directly by stripping types without checking them, which is convenient in development. Checking and running are separate concerns: **running your code does not mean it type-checks.** Keep `tsc --noEmit` in your build. ## Exercise ```typescript // Declare, with the right types: // - a constant `siteName` holding "Learn TypeScript" // - a variable `visitors` starting at 0 that you increment // - a variable `lastVisitor` that is a string OR null, starting null // Then log all three. // write your code here ``` ## Common questions ### Should I annotate everything? No — annotate where it adds information. Function parameters and return types are worth annotating because they are a contract other code depends on. Local variables with an obvious initialiser are better left inferred; a redundant annotation is one more thing to keep in sync. ### `interface` or `type` for object shapes? Either. `type` handles unions and intersections uniformly and is the more common default now; `interface` supports declaration merging, which matters if you are extending third-party types. Pick one for your codebase and be consistent — the next lesson covers both. ### What happened to `var`? It is function-scoped rather than block-scoped and it hoists, which produces bugs that `let` and `const` make impossible. It still works because TypeScript never breaks JavaScript, but there is no reason to write it. ## Arrays Source: https://learn-typescript.org/arrays/ JavaScript can hold an array of variables in an Array object. In JavaScript, an array also functions as a list, a stack or a queue. To define an array, either use the brackets notation or the Array object notation: ```typescript let myArray = [1, 2, 3]; let theSameArray = new Array(1, 2, 3); ``` ### Addressing We can use the brackets `[]` operator to address a specific cell in our array. Addressing uses zero-based indices, so for example, in `myArray` the 2nd member can be addressed with index 1. One of the benefits of using an array datastructure is that you have constant time look-up, if you already know the index of the element you are trying to access. ```typescript console.log(myArray[1]); // prints out 2 ``` Arrays in JavaScript are sparse, meaning that we can also assign variables to random locations even though previous cells were undefined. For example: ```typescript let myArray = [] myArray[3] = "hello" console.log(myArray); ``` Will print out: ```typescript [undefined, undefined, undefined, "hello"] ``` ### Array Elements Because JavaScript Arrays are just special kinds of objects, you can have elements of different types stored together in the same array. The example below is an array with a string, a number, and an empty object. ```typescript let myArray = ["string", 10, {}] ``` ## Dependency hygiene for TypeScript projects Source: https://learn-typescript.org/review/dependencies/ TypeScript inherits the entire npm situation — install scripts, slopsquatting, transitive bloat — and that half is covered in [npm dependency hygiene](https://learn-javascript.org/review/dependencies/). Read that first; it is the larger risk. This page is the part that is specific to TypeScript, and it is mostly about the **second dependency graph** most teams never look at: type definitions. ## `@types` is a supply chain you do not review ```json "devDependencies": { "@types/node": "^22.0.0", "@types/express": "^5.0.0", "@types/lodash": "^4.17.0" } ``` Those are npm packages like any other. They are fetched from the registry, they can have install scripts, and their contents flow into your build. Nobody reads them, because they are "just types". Three practical consequences: **Type definitions can lie.** A `.d.ts` file describes a library; nothing verifies the description. A definition that says a function returns `string` when it can return `string | undefined` produces confidently wrong code with no `any` anywhere. This is not usually malice — it is drift — but the effect on generated code is the same: the model trusts the type and writes accordingly. **Version drift is silent.** `@types/express@4` alongside `express@5` compiles fine and describes a different library than the one you are running. There is no mechanism that checks the two agree. ```bash # find types that have drifted from their runtime package npm ls --depth=0 2>/dev/null | grep -E '@types/' ``` For each one, check the major version matches the runtime package. This is a five-minute audit that finds real bugs in most codebases over a year old. **Prefer packages that ship their own types.** A library with `"types"` in its `package.json` has definitions maintained by the same people who maintain the code, versioned together, and updated in the same release. That is strictly better than a community definition in a separate repository on a separate release cadence. ```bash npm view types typings exports # does it ship its own? ``` When choosing between two comparable libraries, the one that ships its own types is the safer pick — and it is one fewer package in your tree. ## `skipLibCheck` is a trade, know what you traded ```json { "compilerOptions": { "skipLibCheck": true } } ``` Nearly every project sets this, because without it a single broken `.d.ts` in a transitive dependency fails your build. It is a reasonable default. What you gave up: type checking *of the definitions you are trusting*. If `@types/foo` contains an error, you will not hear about it — you will just get wrong types silently. Worth turning off occasionally on a quiet afternoon to see what it reports. ## Dual publishing and module resolution The most time-consuming dependency problem in TypeScript, and it is rarely a security issue — just hours. ```json tsconfig.json { "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext" } } ``` `nodenext` makes TypeScript resolve modules the way Node actually does, including the `exports` map in each package's `package.json`. Older settings (`node`, `node10`) ignore `exports` and will happily resolve a path that fails at runtime — which is how you get code that compiles and then throws `ERR_PACKAGE_PATH_NOT_EXPORTED` in production. Two symptoms worth recognising: - **"This package is ESM-only"** — the package ships no CommonJS build and your project is CJS. `tsx`, dynamic `import()`, or move the project to ESM. - **Types resolve but the import fails at runtime** — your `moduleResolution` and your runtime disagree. Set `nodenext` and fix what it reports; it is telling you the truth. `npx @arethetypeswrong/cli ` is the tool for diagnosing a package's publishing setup before you adopt it, and it is worth running on any dependency that is giving you resolution trouble. ## `verbatimModuleSyntax` and type-only imports ```json { "compilerOptions": { "verbatimModuleSyntax": true } } ``` Forces you to write `import type { Foo }` when you only need the type. Two benefits: the emitted JavaScript matches what you wrote (no surprise elision), and a type-only import cannot accidentally pull a runtime dependency into your bundle. That second point is a real bundle-size lever. Importing a type from a large library without `import type` can drag the whole library into the output. ## Reducing the graph Everything in [the JavaScript platform table](https://learn-javascript.org/review/dependencies/) applies. TypeScript adds a few of its own: | Generated reaches for | Now unnecessary | |---|---| | `ts-node` | `tsx`, or Node's built-in type stripping | | `@types/node-fetch`, `node-fetch` | global `fetch` and its built-in types | | `@types/uuid`, `uuid` | `crypto.randomUUID()` | | a `DeepPartial` / `Awaited` helper package | built-in utility types cover most cases | | `io-ts` + `fp-ts` for validation | zod or valibot, far smaller surface | | `class-transformer` for plain objects | a schema library, unless you are in NestJS | | `typescript-is`, transformer-based validators | require a patched compiler; avoid | That last row deserves a warning of its own: **avoid anything requiring a compiler transformer or a patched `tsc`.** They break on every TypeScript release, they are incompatible with `tsx`, `esbuild` and `swc`, and you will eventually spend a week removing one. ## Version pinning for the compiler itself ```json "devDependencies": { "typescript": "5.9.2" } ``` Pin TypeScript exactly, not with a caret. Minor releases add checks, and a floating version means CI can start failing on a build you did not change. Upgrade deliberately, read the release notes, and fix what the new checks find — they are usually finding real bugs. ## The audit ```bash npx depcheck # installed and never imported npx @arethetypeswrong/cli --pack . # is YOUR package published correctly? npm ls --depth=0 | grep '@types/' # do the majors match their runtime packages? npm audit --omit=dev npx tsc --noEmit --skipLibCheck false 2>&1 | head -40 # what is skipLibCheck hiding? ``` The `depcheck` one is worth running quarterly. TypeScript projects accumulate `@types` packages for libraries that were removed years ago, and nobody notices because they do not break anything — they just sit in the tree being a surface. :::verdict The TypeScript-specific policy 1. Prefer libraries that ship their own types. One fewer package, and they cannot drift. 2. Check `@types/*` majors match their runtime packages. Free, and it finds real bugs. 3. `moduleResolution: "nodenext"` so the compiler resolves what Node resolves. 4. Pin the TypeScript version exactly. 5. Never adopt anything requiring a compiler transformer. ::: ## Common questions ### Are `@types` packages a real security risk? They are npm packages with the same install-time properties as any other, so yes in principle — and the practical risk is lower because type definitions do not execute at runtime. The bigger day-to-day cost is correctness: definitions that drift from the library they describe produce wrong code with no visible `any`. ### Should I use `skipLibCheck`? Yes, as a default — without it one broken definition in a transitive dependency blocks your build for reasons that are not your fault. Turn it off occasionally to see what it reports, and treat anything it finds in your direct dependencies as worth fixing. ### DefinitelyTyped or bundled types? Bundled, whenever the choice exists. They are versioned and released with the code, maintained by the same people, and they cannot fall out of sync. Community definitions are excellent for libraries that will never ship their own, and a maintenance lag otherwise. ### How do I stop `@types` accumulating? `npx depcheck` on a schedule, and remove a package's types in the same commit that removes the package. The reason they accumulate is that removing a dependency does not break anything if its types stay behind. ## Writing an AGENTS.md for TypeScript Source: https://learn-typescript.org/ai/agents-md/ `AGENTS.md` is a Markdown file in your repository root that coding agents read before starting. Claude Code reads `CLAUDE.md`; most other tools read `AGENTS.md`. Write one, symlink the other: ```bash ln -s AGENTS.md CLAUDE.md ``` TypeScript has an unusual property that should shape how you write this file: **a large share of what you would put in prose can be expressed as a compiler flag or a lint rule instead.** A rule in `tsconfig.json` is checked on every edit and cannot be deprioritised. A rule in `AGENTS.md` competes for attention with everything else in the window. So the first question for every candidate line is: *can this be a config option?* If yes, it belongs there. ## Move these out of prose | Instead of writing… | Set this | |---|---| | "Don't assume array elements exist" | `noUncheckedIndexedAccess: true` | | "Don't use `any`" | `@typescript-eslint/no-explicit-any` | | "Don't use non-null assertions" | `no-non-null-assertion` | | "Always await promises" | `no-floating-promises` (needs type info) | | "Use `@ts-expect-error`, not `@ts-ignore`" | `ban-ts-comment` | | "Don't cast, parse" | `consistent-type-assertions: { assertionStyle: "never" }` | | "Handle every switch case" | `noFallthroughCasesInSwitch` + `assertNever` | | "Use import type for types" | `verbatimModuleSyntax: true` | That is eight lines removed from the file and made mandatory instead. Full reasoning for each in [the TypeScript failure modes](/review/failure-modes/). :::verdict The target **Under 80 lines**, with the enforceable half pushed into `tsconfig.json` and `eslint.config.js`. If your file is 300 lines of TypeScript style advice, most of it is either already the model's default or should be a rule. ::: ## What actually belongs in prose **Commands.** Unguessable, used every turn. **Where the boundaries are.** Which directory is allowed to import which. A model cannot infer your layering from the code and no linter knows it unless you configure one. **Domain types.** That money is `Cents`, that ids are branded, that dates are always `Temporal.Instant` in UTC. These are conventions the type system enforces *once you use them* — but the model has to know to reach for them. **Which of the seven ways you use.** TypeScript has more than one reasonable answer to most questions: `interface` or `type`, enums or unions, classes or functions, Zod or Valibot, Vitest or Jest. Pick one of each and say so. The cost of not saying is a codebase that drifts into using all of them. **Landmines.** ## The template ```markdown AGENTS.md TypeScript 5.x strict, Node 22, pnpm, ESM only. ## Commands - Check: `pnpm check` (tsc --noEmit && eslint . && vitest run). Must pass. - Types: `pnpm tsc --noEmit` - One test: `pnpm vitest run src/thing.test.ts` - Fix: `pnpm eslint . --fix` ## Layout and boundaries - `src/domain/` pure logic. No I/O, no framework imports, no fetch. - `src/adapters/` db, http clients, queues. The only place secrets are read. - `src/api/` route handlers. Parse, call domain, serialise. No logic. - `src/schemas/` zod schemas. One per external boundary. - Tests live beside the file: `thing.ts` -> `thing.test.ts`. domain/ must not import from adapters/ or api/. This is enforced by eslint-plugin-import boundaries — if the rule fires, the design is wrong, not the rule. ## Types are the specification - External data is PARSED with a zod schema, never cast. `as` on a fetch, a JSON.parse or a database row is a bug. - Model state as a discriminated union, not optional fields. Make illegal states unrepresentable. - Ids and money are branded: UserId is not OrgId is not string; money is Cents (a branded integer), never a float. - Exhaustive switches end with `assertNever(x)`. - Prefer `type` over `interface` unless you need declaration merging. - Unions of string literals, not enums — enums emit runtime code and are not erasable syntax. ## Pick-one decisions (do not introduce alternatives) zod (not valibot) · vitest (not jest) · pnpm (not npm) · Result-style returns in domain/, thrown errors at the api/ boundary. ## Landmines - `src/adapters/legacy-sync.ts` is called by the ops repo over CLI. Its arguments and exit codes are a contract. - `src/schemas/webhook.ts` mirrors a third-party payload. Do not "tidy" field names — they must match the wire format exactly. ``` Note how much of it is *decisions* rather than *advice*. That is the shape that works: the model already knows how to write TypeScript, it does not know which of six reasonable options your codebase chose. :::tip The "pick-one" section is the one people skip Every TypeScript codebase that has been worked on by more than one agent session ends up with two validation libraries, two test runners and three date helpers — not because anyone decided to, but because nobody wrote down which one. Four lines prevent it. ::: ## Config that carries the enforceable half ```json tsconfig.json { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, "target": "ES2023", "module": "nodenext", "moduleResolution": "nodenext" } } ``` Then wire `tsc --noEmit` into a [post-edit hook](https://codelearningdojo.com/harness-hooks/) so type errors arrive within a second of the model writing them, rather than at the end of the turn. On a large repo use `--incremental` or project references to keep it fast enough to run every time. ## Monorepos Root file for commands and shared decisions; per-package files for the specifics. ```text AGENTS.md commands, pick-one decisions, global boundaries packages/api/AGENTS.md route conventions, auth rules packages/ui/AGENTS.md component patterns, styling decision packages/shared/AGENTS.md "this is imported by everything — changes are breaking" ``` That last one is a landmine worth stating explicitly in every monorepo. ## Common questions ### Should I put my whole style guide in here? No. Anything a formatter or linter can enforce should be enforced, and everything else should be short enough that the model actually weights it. A style guide is documentation — put it in `docs/` and add one line: "read `docs/style.md` before changing anything under `src/ui/`." ### interface or type? Pick one and write it down; the choice matters far less than the consistency. `type` is the more common default now because it handles unions and intersections uniformly; `interface` is worth it if you rely on declaration merging or are publishing types for others to extend. ### How does this interact with Cursor rules? Cursor reads its own `.cursor/rules` files, and increasingly also `AGENTS.md`. Keep the substance in `AGENTS.md` and make the tool-specific files thin pointers to it, otherwise you maintain two documents that disagree within a month. ## Manipulating Arrays Source: https://learn-typescript.org/manipulating-arrays/ ### Pushing and popping Arrays can also function as a stack. The `push` and `pop` methods insert and remove variables from the end of an array. For example, let’s create an empty array and push a few variables. ```typescript let myStack = []; myStack.push(1); myStack.push(2); myStack.push(3); console.log(myStack); ``` This will print out: ```typescript 1,2,3 ``` After pushing variables to the array, we can then pop variables off from the end. ```typescript console.log(myStack.pop()); console.log(myStack); ``` This will print out the variable we popped from the array, and what’s left of the array: ```typescript 3 // the result from myStack.pop() 1,2 // what myStack contains now ``` ### Queues using shifting and unshifting The `unshift` and `shift` methods are similar to `push` and `pop`, only they work from the beginning of the array. We can use the `push` and `shift` methods consecutively to utilize an array as a queue. For example: ```typescript let myQueue = []; myQueue.push(1); myQueue.push(2); myQueue.push(3); console.log(myQueue.shift()); console.log(myQueue.shift()); console.log(myQueue.shift()); ``` The `shift` keyword will remove the variables of the array in the exact order they were inserted in, and the output will be: ```typescript 1 2 3 ``` The `unshift` method is used to insert a variable at the beginning of an array. For example: ```typescript let myArray = [1,2,3]; myArray.unshift(0); console.log(myArray); // will print out 0,1,2,3 ``` ### Splicing Splicing arrays in JavaScript removes a certain part from an array to create a new array, made up from the part we took out. For example, if we wanted to remove the five numbers from the following array beginning from the 3rd index, we would do the following: ```typescript let myArray = [0,1,2,3,4,5,6,7,8,9]; let splice = myArray.splice(3,5); console.log(splice); // will print out 3,4,5,6,7 console.log(myArray); // will print out 0,1,2,8,9 ``` After splicing the array, it will only contain the part before and after the splicing. The splice is equal to all the variables between 3 and 7 (inclusive), and the remainder of the array, which contains all variables between 0 and 2 (inclusive), and 8 to 9 (inclusive). ## Security review for TypeScript: what types do and do not buy you Source: https://learn-typescript.org/review/security/ Start with the thing that causes the most trouble: **TypeScript provides no runtime security whatsoever.** Types are erased at compile time. An attacker sends whatever they like, and your annotations are not there to stop it. What types *do* buy you is real, and it is worth being precise about, because a false sense of safety is worse than none: - They make **trust boundaries explicit** — if external data can only enter as `unknown`, you cannot forget to validate it. - They make **authorisation state visible in signatures** — a function that requires an `AuthenticatedUser` cannot be called with an anonymous one. - They make **unit confusion impossible** — a `UserId` cannot be passed where an `OrgId` is expected. Everything in [the JavaScript security checklist](https://learn-javascript.org/review/security/) applies unchanged — prototype pollution, ReDoS, XSS, SSRF, path traversal. This page is what is specific to TypeScript. ## The security bug that looks like a type annotation ```ts const user = await res.json() as User; const body = JSON.parse(raw) as CreateOrderDto; const claims = jwt.decode(token) as TokenClaims; ``` Every one of those compiles. Every one is a lie: `as` is an assertion, nothing runs, and from that line onward the compiler will confidently tell you fields exist that may not, and cannot exist that may. The third is the worst. `jwt.decode` does not verify the signature — so an attacker-supplied token becomes a fully typed `TokenClaims` object, and every downstream check reads as safe. ```ts // parse, do not assert const Claims = z.object({ sub: z.string(), role: z.enum(["user", "admin"]), exp: z.number() }); const raw = jwt.verify(token, secret, { algorithms: ["HS256"] }); // verify first const claims = Claims.parse(raw); // then parse ``` :::danger `as` at a trust boundary is a security defect, not a style issue Treat it that way in review. The grep is cheap: ```bash git diff | grep -nE ' as [A-Z]| as any|<[A-Z][A-Za-z]*>\(|!\.|@ts-ignore' ``` Every hit on data that came from a network, a database, a file, an environment variable or a message queue needs to become a parse. ::: Ban it mechanically where you can: ```js eslint.config.js "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }], "@typescript-eslint/no-unsafe-assignment": "error", "@typescript-eslint/no-unsafe-member-access": "error", "@typescript-eslint/no-unsafe-call": "error", "@typescript-eslint/no-unsafe-argument": "error", "@typescript-eslint/no-non-null-assertion": "error", "@typescript-eslint/ban-ts-comment": ["error", { "ts-ignore": true }], ``` The four `no-unsafe-*` rules are the ones people miss. They catch `any` **arriving from an untyped dependency** and flowing into your code, which is how most `any` gets in — not from someone typing it. ## Use types to make authorisation hard to skip This is where TypeScript genuinely earns its place in security, and it is underused. ### Branded identifiers ```ts type UserId = Brand; type OrgId = Brand; function invoicesFor(org: OrgId): Promise; invoicesFor(userId); // compile error. a whole class of IDOR, gone. ``` Passing the wrong identifier is one of the more common routes to a broken access control bug, and branding removes it entirely at zero runtime cost. ### Make "authorised" a type ```ts declare const authorised: unique symbol; type Authorised = T & { readonly [authorised]: true }; /** The ONLY way to produce an Authorised. */ export async function loadInvoiceFor( id: InvoiceId, user: AuthenticatedUser, ): Promise | null> { const inv = await repo.byId(id); return inv && inv.ownerId === user.id ? (inv as Authorised) : null; } export function renderInvoice(inv: Authorised): string { /* … */ } ``` Now a handler that fetches an invoice without checking ownership cannot pass it to the renderer. The compiler enforces the check that [is otherwise the most common real vulnerability in web code](https://learn-javascript.org/review/security/). This is one of the few places where a type-level trick pays for its cleverness. Keep the `as` inside the one authorising function, and ban it everywhere else. ### Model auth state as a union ```ts type Session = | { state: "anonymous" } | { state: "authenticated"; user: AuthenticatedUser } | { state: "expired"; at: Date }; // reading session.user in the anonymous branch does not compile ``` Generated code models this as `{ user?: User }`, and then `if (session.user)` checks appear in some paths and not others. ## What types cannot see The list worth keeping in mind, because each one is a place people assume the compiler helped and it did not: | Risk | Why types miss it | |---|---| | Prototype pollution | `__proto__` is a runtime key; no type involved | | ReDoS | a `RegExp` is a `RegExp` regardless of what it does | | XSS via `dangerouslySetInnerHTML` | it takes a `string`, and that is a valid `string` | | SQL injection | a template literal is a `string` | | SSRF | a URL is a `string` | | Timing attacks | `===` is well-typed | | Secrets in logs | a typed object logged wholesale still contains the secret | | Environment variables | `process.env.X` is `string \| undefined`, and its *value* is unvalidated | That last one is worth a fix, because it is cheap: ```ts src/env.ts const Env = z.object({ NODE_ENV: z.enum(["development", "test", "production"]), DATABASE_URL: z.url(), JWT_SECRET: z.string().min(32), PORT: z.coerce.number().int().positive().default(3000), }); export const env = Env.parse(process.env); // fails at boot, not at 2am ``` An application that refuses to start on a missing or weak secret is much better than one that starts and fails on the first request that needs it. ## Type-level risks that are actually TypeScript's own ### `@types` packages are a supply-chain surface A `@types/*` package is executable-adjacent: it is fetched from npm like anything else, and while type definitions do not run, a compromised one can be replaced by a package that does have an install script. It also runs through your build. Treat `@types` with the same scrutiny as runtime dependencies — see [dependency hygiene](/review/dependencies/). ### `skipLibCheck` hides real problems Nearly every project sets `skipLibCheck: true` because it makes builds faster and silences errors in third-party definitions. That is a reasonable trade, but be aware you have turned off checking of the type definitions you are trusting. ### Declaration merging and module augmentation ```ts declare global { namespace Express { interface Request { user?: AuthenticatedUser } // now optional everywhere } } ``` Widely used and worth understanding: making `user` optional on every request means every access needs a check, and generated code will reach for `req.user!` to silence it. Prefer passing the authenticated user explicitly, or a middleware that narrows the type. ### Source maps in production Shipping `.map` files exposes your original source, including comments and internal structure. Generate them, upload them to your error tracker, and do not serve them publicly. ## The review ```bash # the type-specific half git diff | grep -nE ' as [A-Z]| as any|: any|@ts-ignore|!\.|!\)' git diff | grep -nE 'jwt\.decode|skipLibCheck|process\.env\.' npx tsc --noEmit && npx eslint . # everything from the JavaScript checklist still applies git diff | grep -nE "eval\(|innerHTML|dangerouslySetInnerHTML|\bexec\(|__proto__" npm audit --omit=dev ``` :::verdict The one thing to take away TypeScript's security contribution is at the **boundary**: `unknown` in, schema parse, typed value out. Get that right and the type system genuinely prevents whole categories of bug. Reach for `as` at that same boundary and you have written a security defect that looks exactly like documentation. ::: ## Common questions ### Does TypeScript make my application more secure? Indirectly and meaningfully, if you use it to enforce boundaries — branded ids, authorised types, parsed environment, `unknown` at every entry point. It does nothing at runtime, so it cannot stop an injection or a pollution attack. The honest framing: it prevents mistakes, not attacks. ### Is banning `as` entirely realistic? More often than people expect. Legitimate uses — narrowing a DOM element, `as const`, a well-understood cast inside a single authorising function — are rare enough that requiring an explicit `eslint-disable` comment is reasonable. The comment then becomes a useful review signal. ### Should validation happen at every layer or just the edge? At the edge, thoroughly, and then trust the types inside. Validating repeatedly is a cost with no benefit and it dilutes where the real boundary is. The discipline is that there is exactly one place external data becomes typed, and it is a parse. ### What about validating data from my own database? Depends who writes to it. If other services or older versions of your code write rows, the database is an external boundary and deserves a parse — schemas drift and nullable columns appear. If your application is the only writer and migrations are controlled, trusting the ORM's types is reasonable. ## Type-safe LLM applications in TypeScript Source: https://learn-typescript.org/ai/evals/ TypeScript has a specific advantage in this domain and a specific trap, and they are the same fact: **the model's output arrives as an unvalidated string, and the type system cannot see that.** ```ts const result = JSON.parse(await llm.complete(prompt)) as Classification; ``` That line compiles. It asserts a shape that nothing checked, on data produced by a non-deterministic process, from a provider that may have changed its behaviour since you wrote it. It is the single most common defect in TypeScript LLM code, and it is invisible in review because it looks like every other line. The whole discipline is: **one schema, at the boundary, generating both the runtime check and the static type.** ## Schema first, type second ```ts src/schemas/classification.ts import { z } from "zod"; export const Category = z.enum(["billing", "support", "refunds", "other"]); export const Classification = z.object({ category: Category, confidence: z.number().min(0).max(1), reasoning: z.string().max(500), }); export type Classification = z.infer; export type Category = z.infer; ``` One declaration produces four things you would otherwise write separately and let drift apart: 1. The **runtime validator** 2. The **static type** 3. The **JSON Schema** you send to the provider for constrained decoding — `z.toJSONSchema(Classification)` 4. The **documentation** of what the model is supposed to return ```ts src/classify.ts export async function classify(input: string): Promise { const raw = await llm.complete({ system: SYSTEM, messages: [{ role: "user", content: input }], responseSchema: z.toJSONSchema(Classification), }); const parsed = Classification.safeParse(extractJson(raw)); if (!parsed.success) { logger.warn({ raw, issues: parsed.error.issues }, "unparseable model output"); return null; } return parsed.data; } ``` `safeParse` rather than `parse`, and a `Classification | null` return rather than a throw. The caller has to handle the failure because the type says so — which is the type system doing exactly the job you want it to do. :::warn `extractJson` is not optional Even with structured output enabled, models fence their JSON, prepend "Sure, here you go:", and occasionally emit a trailing comma. A five-line extractor that strips fences and finds the outermost `{…}` removes an entire class of production incident. Test it against the ugly cases — there is a table of them in [the JavaScript version of this page](https://learn-javascript.org/ai/evals/). ::: ## Type-safe tool definitions The second place types earn their keep. A tool has three representations that must agree: the schema you send the model, the arguments you receive back, and the function that runs. Derive all three from one place. ```ts src/tools.ts import { z } from "zod"; function defineTool(spec: { name: string; description: string; input: S; run: (args: z.infer) => Promise; }) { return spec; } export const searchOrders = defineTool({ name: "search_orders", description: "Find orders for a customer. Staging data only, max 50 results.", input: z.object({ customerId: z.string().uuid(), status: z.enum(["open", "shipped", "cancelled"]).optional(), limit: z.number().int().min(1).max(50).default(20), }), run: async ({ customerId, status, limit }) => db.orders.find({ customerId, status, limit }), }); ``` `run` is fully typed from the schema. Add a field to `input` and the compiler tells you `run` needs updating. Change a name and every call site breaks. The classic bug in hand-written tool definitions — schema and handler disagreeing after a refactor — becomes impossible. Dispatch stays type-safe with a discriminated lookup: ```ts const TOOLS = { search_orders: searchOrders, cancel_order: cancelOrder } as const; export async function runTool(name: string, rawArgs: unknown) { const tool = TOOLS[name as keyof typeof TOOLS]; if (!tool) return { error: `Unknown tool: ${name}` }; const parsed = tool.input.safeParse(rawArgs); // the model's arguments are untrusted if (!parsed.success) return { error: z.prettifyError(parsed.error) }; return { result: await tool.run(parsed.data as never) }; } ``` Note that the model's tool arguments get the same treatment as a network payload, because that is what they are. Returning the validation error *to the model* is deliberate — it corrects itself on the next turn, which is cheaper than failing the request. ## Model state as a discriminated union Streaming and tool loops have several states, and generated code models them as a bag of optional fields where most combinations are nonsense. ```ts // 32 possible states, about 5 of them valid type Turn = { status: "idle" | "streaming" | "tool" | "done" | "error"; text?: string; toolCall?: ToolCall; error?: Error; usage?: Usage; }; // 5 states, all valid type Turn = | { status: "idle" } | { status: "streaming"; text: string } | { status: "tool"; call: ToolCall; text: string } | { status: "done"; text: string; usage: Usage } | { status: "error"; error: Error; partial: string }; ``` With the second, rendering the error branch cannot accidentally read `usage`, and adding a state is a compile error at every site that needs updating — provided you end your switches with an exhaustiveness check: ```ts function assertNever(x: never): never { throw new Error(`Unhandled turn state: ${JSON.stringify(x)}`); } ``` ## Testing The pyramid is the same as everywhere: pure logic, then contract tests against a fake, then cassettes, then nightly evals against a scored dataset. The [JavaScript page](https://learn-javascript.org/ai/evals/) covers the streaming, abort and cassette mechanics in detail and applies unchanged. What TypeScript adds is that a large slice of layer 2 becomes a **type test** instead of a runtime one: ```ts src/tools.test-d.ts import { expectTypeOf, test } from "vitest"; test("tool handler args are inferred from the schema", () => { expectTypeOf(searchOrders.run).parameter(0).toMatchTypeOf<{ customerId: string; status?: "open" | "shipped" | "cancelled"; limit: number; }>(); }); test("classify forces the caller to handle failure", () => { expectTypeOf(classify).returns.toEqualTypeOf>(); }); ``` These run in milliseconds and catch drift between a schema and its handler — which is the failure mode most likely to survive review, because both halves look correct in isolation. The one runtime contract test that still matters most: ```ts test.each([ ['{"category":"billing","confidence":0.9,"reasoning":"x"}', "billing"], ['```json\n{"category":"billing","confidence":0.9,"reasoning":"x"}\n```', "billing"], ['{"category":"billing","confidence":1.4,"reasoning":"x"}', null], // out of range ['{"category":"refunds_and_returns"}', null], // not in the enum ["I think it is billing.", null], ['{"category":"billing"', null], // truncated ])("parse(%j)", (raw, expected) => { expect(parseClassification(raw)?.category ?? null).toBe(expected); }); ``` Never throw, never invent. A parser that returns a plausible wrong answer is worse than one that returns `null`. ## Evals with types Type the dataset too — the number of eval harnesses broken by a typo in a JSONL field name is not small. ```ts evals/run.ts const Case = z.object({ input: z.string(), expect: Category }); type Case = z.infer; const cases: Case[] = readFileSync("evals/dataset.jsonl", "utf8") .trim().split("\n").map((l, i) => { const r = Case.safeParse(JSON.parse(l)); if (!r.success) throw new Error(`dataset line ${i + 1}: ${z.prettifyError(r.error)}`); return r.data; }); const results = await Promise.all(cases.map((c) => classify(c.input))); const accuracy = results.filter((r, i) => r?.category === cases[i].expect).length / cases.length; console.log(`accuracy ${(accuracy * 100).toFixed(1)}% on ${cases.length} cases`); results.forEach((r, i) => { if (r?.category !== cases[i].expect) { console.log(` MISS ${JSON.stringify(cases[i].input)}: got ${r?.category ?? "null"}, want ${cases[i].expect}`); } }); process.exit(accuracy >= 0.9 ? 0 : 1); ``` Nightly, a threshold rather than an assertion, and read the misses. :::verdict What the types are actually buying Not correctness — a model can satisfy your schema and still be wrong. What they buy is that **every disagreement between the parts of your system is a compile error**: schema versus handler, tool definition versus dispatch, state shape versus renderer. That is the class of bug that otherwise survives review and shows up in production, and it is exactly the class of bug generated code produces most. ::: ## Common questions ### Does structured output mode make validation unnecessary? No. Constrained decoding removes most malformed JSON and is worth using, but it does not remove provider outages, model changes, refusals, truncation at the token limit, or the day someone swaps the model. Validation at the boundary is what makes the type honest — and it costs one `safeParse`. ### Zod, Valibot, ArkType or TypeBox? Any of them; the discipline matters more than the library. Zod has the largest ecosystem and the most direct JSON Schema story, which matters when you are feeding schemas to a provider. Valibot is dramatically smaller if you are shipping to a browser. ### Should tool handlers validate arguments if the model was given a schema? Yes, always. The model's arguments are untrusted input in exactly the sense a form submission is — the schema is guidance to the model, not a guarantee. Validating and returning the error back to the model also gives it a chance to correct itself, which is cheaper than failing the request. ### Can the type system help with cost as well as correctness? It can make the cost mistakes that come from drift impossible — an untagged call site, a tier with no price, a call with no budget. That is [making token budgets a type error](/ai/tokenomics/). ### Are type tests worth the setup? For tool definitions and public API boundaries, yes — they catch schema/handler drift, which is the failure most likely to pass review. For ordinary application code the compiler already covers it and `expectTypeOf` is redundant. ## Build performance: the TypeScript bottleneck nobody profiles Source: https://learn-typescript.org/review/performance/ The runtime performance of your compiled output is a JavaScript question, and [the JavaScript performance page](https://learn-javascript.org/review/performance/) covers it — event loop blocking, N+1 queries, unbounded concurrency, memory leaks. All of it applies unchanged. TypeScript's own performance problem is different and specific: **`tsc` gets slow, and a slow `tsc` is not just an annoyance — it breaks the agent feedback loop.** ## Why compile time is a code-quality issue The threshold effect from [the verification loop](https://learn-python.com/ai/feedback-loops/) applies to type checking exactly as it does to tests: | `tsc --noEmit` takes | What the agent does | |---|---| | under 2s | runs it after every edit. converges. | | 2–10s | runs it after a few edits. usually fine. | | 10–60s | runs it once at the end. you review guesses. | | over 60s | stops running it. tells you it "should compile". | Nobody instructs an agent to skip a slow check; it just happens. So the type system — the thing that makes TypeScript worth using with generated code — quietly stops being part of the loop. **If your project takes 90 seconds to type check, fixing that is a bigger quality win than any prompt change.** ## Find out where the time goes ```bash npx tsc --noEmit --diagnostics ``` ```text Files: 1842 Lines of TypeScript: 184203 Types: 412991 <- watch this Instantiations: 14882031 <- and especially this Memory used: 1284120K Check time: 38.42s ``` `Instantiations` is the number that matters. Under a million is healthy. Over ten million means a small number of types are exploding combinatorially, and finding them is usually a one-afternoon fix with a dramatic payoff. ```bash npx tsc --noEmit --generateTrace ./trace npx @typescript/analyze-trace ./trace # ranks the worst files and types ``` `analyze-trace` names the specific file and the specific type expression. It is the profiler for this problem and almost nobody knows it exists. ## What makes instantiations explode ### 1. Deeply recursive conditional and mapped types ```ts type DeepPartial = { [K in keyof T]?: T[K] extends object ? DeepPartial : T[K] }; type Paths = T extends object ? { [K in keyof T]: `${K & string}` | `${K & string}.${Paths}` }[keyof T] : never; ``` `Paths` over a large nested type generates a combinatorial number of string literal types. One of these applied to a big config object can account for most of your check time on its own. Generated code produces these because they are impressive and they appear in a lot of type-gymnastics blog posts. Ask whether a plain `string` with a runtime check would do — usually it would. ### 2. Very large union types A union of a few hundred string literals is fine. A union of tens of thousands — generated from a route table, an icon set, a translation key list — makes every operation touching it expensive, and it degrades editor responsiveness as well as the build. ### 3. Inferred return types on large functions ```ts export function buildConfig() { return { /* 200 lines of nested object */ }; } ``` Every consumer re-infers that type. An explicit return type annotation computes it once. This is what `isolatedDeclarations` enforces: ```json { "compilerOptions": { "isolatedDeclarations": true } } ``` It requires explicit types on all exports. More typing, and it makes declaration emit dramatically faster and parallelisable — worth it for a library or a monorepo package. ### 4. Barrel files ```ts src/index.ts export * from "./a"; export * from "./b"; /* … 60 more */ ``` Importing one symbol pulls the whole graph into checking. Barrels are convenient and they are a leading cause of slow builds and slow editors in large projects. Import from the specific module. ### 5. Heavy library types Some libraries have famously expensive types — deeply generic ORM query builders and validation libraries with elaborate inference. Usually worth it, but if `analyze-trace` points at one, that is your answer, and sometimes an explicit annotation at the call site short-circuits the inference. ## The structural fixes ### Incremental builds ```json { "compilerOptions": { "incremental": true, "tsBuildInfoFile": "./node_modules/.cache/tsbuildinfo" } } ``` Free, and it turns a full check into a check of what changed. Put the buildinfo file somewhere gitignored and cache it in CI. ### Project references For a monorepo, this is the big one: ```json tsconfig.json { "files": [], "references": [{ "path": "./packages/shared" }, { "path": "./packages/api" }] } ``` ```bash tsc --build # only rebuilds what changed, in dependency order tsc --build --watch ``` Each package is checked once and its output reused, rather than every package re-checking shared code. On a large monorepo this is often a 5–10x improvement. ### Separate the type check from the transpile ```json package.json { "scripts": { "build": "tsup src/index.ts", // esbuild/swc: fast, strips types, no checking "typecheck": "tsc --noEmit" // the slow part, run separately and in CI } } ``` Transpilers do not type check, which is exactly why they are fast. Use them for the build and dev server; keep `tsc --noEmit` as the correctness gate. The agent's edit hook should run `tsc` on the changed file's project, not the whole monorepo. ### Narrow what is checked ```json { "include": ["src/**/*"], "exclude": ["**/*.test.ts", "dist", "node_modules"] } ``` And keep `skipLibCheck: true` — checking every `.d.ts` in `node_modules` is pure cost for almost no benefit. ## A budget worth setting ```bash # fail CI if the check gets slower than the loop can tolerate time npx tsc --noEmit npx tsc --noEmit --diagnostics | grep -E 'Instantiations|Check time' ``` Treat check time like bundle size: measure it, put a ceiling on it, and investigate when it moves. A project that drifts from 3 seconds to 40 does so one clever type at a time, and nobody notices until the loop is broken. :::verdict The TypeScript-specific one Your runtime performance problems are JavaScript's. Your *build* performance problem is TypeScript's, it is measurable with two commands, and it matters more than usual now — because when type checking falls out of the edit loop, the type system stops constraining generated code at the moment it is being written, which was the whole reason to use TypeScript with an agent. ::: ## Common questions ### Should I use a Rust-based checker instead of `tsc`? Faster alternative type checkers are maturing and worth watching. Today, `tsc` remains the reference implementation and the only thing guaranteed to agree with the language semantics. Use a fast transpiler for the build and `tsc` for the check; revisit when an alternative is bit-compatible enough that you would trust it as your CI gate. ### Is `isolatedDeclarations` worth adopting? For a published library or a monorepo package that others depend on, yes — it makes declaration emit fast and parallelisable, and the explicit return types are good documentation. For an application with no consumers, the extra annotations cost more than they return. ### How slow is too slow? Two seconds is where the agent stops noticing; ten is where it starts skipping. Under five seconds for the package you are editing is a good target, which is achievable on almost any codebase with incremental builds and project references. ### Why did my build get slow after adding one library? Usually a deeply generic inference chain from an ORM or validation library, sometimes combined with a barrel file that pulls in the whole thing. `--generateTrace` plus `analyze-trace` will name the file and the type in a couple of minutes. ## Making token budgets a type error Source: https://learn-typescript.org/ai/tokenomics/ The economics are the same everywhere — [what tokens cost and where the money goes](https://codelearningdojo.com/token-economics/) is the model, and the [JavaScript page](https://learn-javascript.org/ai/tokenomics/) covers the runtime mechanics that apply here unchanged. What TypeScript adds is narrower and genuinely useful: **the cost mistakes that come from an unmeasured call site or a confused unit can be made unrepresentable.** ## Brand the units Three numbers float around any cost system — tokens, micro-dollars, dollars — and they are all `number`. Mixing them is a silent, expensive bug: dividing by a million twice, or adding an input count to an output count. ```ts src/cost/units.ts declare const brand: unique symbol; type Brand = T & { readonly [brand]: B }; export type InputTokens = Brand; export type OutputTokens = Brand; export type CachedTokens = Brand; /** Integer micro-dollars. Never float dollars in an accumulator. */ export type MicroUSD = Brand; export const inputTokens = (n: number) => n as InputTokens; export const outputTokens = (n: number) => n as OutputTokens; export const microUsd = (n: number) => Math.round(n) as MicroUSD; export const addUsd = (a: MicroUSD, b: MicroUSD) => microUsd(a + b); export const formatUsd = (m: MicroUSD) => `$${(m / 1e6).toFixed(6)}`; ``` ```ts const total = addUsd(a, b); // fine const wrong = addUsd(a, tokens); // compile error. good. ``` `MicroUSD` as a branded integer solves two problems at once: the unit confusion, and the float-money problem that quietly loses fractions of a cent across a million rows. ## Make the price table exhaustive A new model tier added to the union is a compile error at every place that prices one. That is exactly what you want — the alternative is a silent fallback to the wrong rate. ```ts src/cost/prices.ts export const MODEL_TIERS = ["small", "mid", "large"] as const; export type ModelTier = (typeof MODEL_TIERS)[number]; type Rate = { in: number; cachedIn: number; out: number }; // per 1M tokens // Record — adding a tier breaks the build until you price it. export const PRICES: Record = { small: { in: 0.25, cachedIn: 0.03, out: 1.25 }, mid: { in: 1.00, cachedIn: 0.10, out: 5.00 }, large: { in: 3.00, cachedIn: 0.30, out: 15.0 }, }; export function cost(tier: ModelTier, u: Usage): MicroUSD { const p = PRICES[tier]; const fresh = Math.max(u.input - u.cached, 0); return microUsd(fresh * p.in + u.cached * p.cachedIn + u.output * p.out); } ``` Prices belong in config, loaded and validated at startup, rather than as literals — they change. The type is what stays in the code. ## Require a budget to make a call The structural move. If the client's signature demands a budget, there is no way to add an unmetered call site. ```ts src/llm/client.ts export interface Budget { readonly capUsd: MicroUSD; readonly maxTurns: number; spent(): MicroUSD; turns(): number; /** Throws BudgetExceeded. Called before the request leaves. */ reserve(estimate: MicroUSD): void; charge(actual: MicroUSD): void; } export interface CompleteArgs { feature: Feature; // a union, not a string — see below tier: ModelTier; budget: Budget; // not optional. this is the point. messages: Message[]; maxOutputTokens: OutputTokens; // also not optional } export interface LLM { complete(args: CompleteArgs): Promise<{ text: string; usage: Usage; cost: MicroUSD }>; } ``` Two fields there are deliberately non-optional: - **`budget`** — you cannot forget it, because the code will not compile. - **`maxOutputTokens`** — output is the expensive side, and an unset limit is how a classification endpoint ends up generating four thousand tokens of preamble. ```ts // compile error: Property 'budget' is missing await llm.complete({ feature: "classify", tier: "small", messages, maxOutputTokens: outputTokens(64) }); ``` ## Make `feature` a union, not a string Attribution is worthless if the tags drift. `"classify"`, `"classification"` and `"Classify"` produce three rows in your report and one wrong conclusion. ```ts src/cost/features.ts export const FEATURES = [ "ticket_classification", "reply_draft", "doc_summary", "agent_loop", ] as const; export type Feature = (typeof FEATURES)[number]; ``` Now the compiler enforces your taxonomy, your report groups correctly, and adding a feature is a deliberate act rather than a typo. The same applies to a per-feature budget table — `Record` means a new feature cannot ship without someone deciding what it is allowed to spend: ```ts export const FEATURE_CAPS: Record = { ticket_classification: microUsd(2_000), reply_draft: microUsd(20_000), doc_summary: microUsd(50_000), agent_loop: microUsd(500_000), }; ``` ## Type the prompt so the cache can hit Cache hits depend on a stable prefix. You can encode that shape in a type instead of relying on everyone remembering the ordering rule. ```ts src/llm/prompt.ts /** The parts are ordered by volatility. Construction enforces it. */ export interface PromptParts { /** Never changes between requests. Cached. */ readonly system: string; /** Changes rarely — tool schemas, few-shot examples. Cached. */ readonly stable: readonly string[]; /** Changes every request. Never cached, always last. */ readonly volatile: string; } export function buildMessages(p: PromptParts): Message[] { return [ { role: "system", content: p.system, cache_control: { type: "ephemeral" } }, ...[...p.stable].sort().map((content) => ({ // sort: stable order role: "user" as const, content, cache_control: { type: "ephemeral" as const }, })), { role: "user", content: p.volatile }, // the only varying part ]; } ``` `buildMessages` is the only way to construct a request, so the ordering rule cannot be violated by a new call site. The `sort()` guards against documents arriving from a `Set` or an unordered query — different order, different prefix, total cache miss, and no error anywhere. ```ts test("only the volatile part differs", () => { const a = buildMessages({ system: SYS, stable: ["b", "a"], volatile: "q1" }); const b = buildMessages({ system: SYS, stable: ["a", "b"], volatile: "q2" }); expect(a.slice(0, -1)).toEqual(b.slice(0, -1)); }); ``` ## Discriminated results instead of thrown budget errors A budget failure is an expected outcome, not an exception. Making it part of the return type forces the caller to decide what to do. ```ts export type CompletionResult = | { ok: true; text: string; usage: Usage; cost: MicroUSD } | { ok: false; reason: "budget_exceeded"; spent: MicroUSD; cap: MicroUSD } | { ok: false; reason: "turn_limit"; turns: number } | { ok: false; reason: "provider_error"; status: number; retryable: boolean }; const r = await llm.complete(args); switch (r.reason) { case undefined: return r.text; // ok: true case "budget_exceeded": return degradeToTemplate(); // a real product decision case "turn_limit": return escalateToHuman(); case "provider_error": return r.retryable ? retry() : fail(); default: return assertNever(r); } ``` `assertNever` means adding a failure reason is a compile error everywhere it is handled. That is the difference between a cost control you designed and one that surfaces as a 500. :::verdict What the types buy you Not a smaller bill on their own — a model can be perfectly typed and still expensive. What they buy is that **the cost mistakes which come from drift cannot happen**: an untagged call site, a mistyped feature name, a tier with no price, a call with no budget, a prompt assembled in the wrong order. Those are the ones that survive review, because each looks correct on its own. ::: ## Common questions ### Is branding tokens and money overkill? For a prototype, yes. For anything that bills a customer or has a budget someone signed off, no — unit confusion in money arithmetic is a well-known and expensive class of bug, and branding costs about fifteen lines once. ### Why micro-dollars instead of `Decimal`? Integer micro-dollars are exact, fast, and need no dependency, which suits per-request accounting where you are summing millions of tiny values. Reach for a decimal library at the invoicing boundary if you need arbitrary precision or defined rounding for a customer-facing figure. ### Does requiring a budget parameter get annoying? It is a little more typing at each call site, and that friction is the feature — it is what stops an unmetered call from being added by accident. Provide a `Budget.unlimited()` for scripts and tests so the escape hatch is explicit and greppable rather than implicit. ### Do these types stop me overspending? No. They stop the *silent* failures — the untagged call, the wrong unit, the missing price, the prompt built in cache-hostile order. Actually spending less is prompt caching, model routing and output limits, and those are the same in every language. ## Operators Source: https://learn-typescript.org/operators/ Every variable in JavaScript is casted automatically so any operator between two variables will always give some kind of result. ### The addition operator The `+` (addition) operator is used for both addition and concatenation of strings. For example, adding two variables is easy: ```typescript let a = 1; let b = 2; let c = a + b; // c is now equal to 3 ``` The addition operator is used for concatenating strings to strings, strings to numbers, and numbers to strings: ```typescript let name = "John"; console.log("Hello " + name + "!"); console.log("The meaning of life is " + 42); console.log(42 + " is the meaning of life"); ``` JavaScript behaves differently when you are trying to combine two operands of different types. The default primitive value is a string, so when you try to add a number to a string, JavaScript will transform the number to a string before the concatenation. ```typescript console.log(1 + "1"); // outputs "11" ``` ### Mathematical operators To subtract, multiply and divide two numbers, use the minus (`-`), asterisk (`*`) and slash (`/`) signs. ```typescript console.log(3 - 5); // outputs -2 console.log(3 * 5); // outputs 15 console.log(3 / 5); // outputs 0.6 ``` ### Advanced mathematical operators JavaScript supports the modulus operator (`%`) which calculates the remainder of a division operation. ```typescript console.log(5 % 3); // outputs 2 ``` JavaScript also supports combined assignment and operation operators. So, instead of typing `myNumber = myNumber / 2`, you can type `myNumber /= 2`. Here is a list of all these operators: - `/=` - `*=` - `-=` - `+=` - `%=` JavaScript also has a `Math` module which contains more advanced functions: - `Math.abs` calculates the absolute value of a number - `Math.exp` calculates **e** to the power of a number - `Math.pow(x,y)` calculates the result of **x** to the power of **y** - `Math.floor` removes the fraction part from a number - `Math.random()` will give a random number `x` where 0<=x<1 And many more mathematical functions. ## Conditions Source: https://learn-typescript.org/conditions/ ### The `if` statement The `if` statement allows us to check if an expression is equal to `true` or `false`, and execute different code according to the result. For example, if we want ask the user whether his name is “John”, we can use the `confirm` function. ```typescript if (confirm("Are you John Smith?")) { console.log("Hello John, how are you?"); } else { console.log("Then what is your name?"); } ``` It is also possible to omit the `else` keyword if we only want to execute a block of code only if a certain expression is true. To evaluate whether two variables are equal, the `==` operator is used. There is also another equality operator in JavaScript, `===`, which does a strict comparison. This means that it will be true only if the two things you are comparing are the same type as well as same content. ```typescript console.log("1" == 1); // true console.log("1" === 1); // false ``` For example: ```typescript let myNumber = 42; if (myNumber == 42) { console.log("The number is correct."); } ``` Inequality operators can also be used to evaluate expressions. For example: ```typescript let foo = 1; let bar = 2; if (foo < bar) { console.log("foo is smaller than bar."); } ``` Two or more expressions can be evaluated together using logical operators to check if two expressions evaluate to `true` together, or at least one of them. To check if two expressions both evaluate to `true`, use the AND operator `&&`. To check if at least one of the expressions evaluate to `true`, use the OR operator `||`. ```typescript let foo = 1; let bar = 2; let moo = 3; if (foo < bar && moo > bar) { console.log("foo is smaller than bar AND moo is larger than bar."); } if (foo < bar || moo > bar) { console.log("foo is smaller than bar OR moo is larger than bar."); } ``` The NOT operator `!` can also be used likewise: ```typescript let notTrue = false; if (!notTrue) { console.log("not not true is true!"); } ``` ### The `switch` statement The `switch` statement is similar to the `switch` statement from the C programming language, but also supports strings. The `switch` statement is used to select between more than two different options, and to run the same code for more than one option. For example: ```typescript let rank = "Commander"; switch(rank) { case "Private": case "Sergeant": console.log("You are not authorized."); break; case "Commander": console.log("Hello commander! what can I do for you today?"); break; case "Captain": console.log("Hello captain! I will do anything you wish."); break; default: console.log("I don't know what your rank is."); break; } ``` In this example, “Private” an “Sergeant” both trigger the first sentence, “Commander” triggers the second sentence and “Captain” triggers the third. If an unknown rank was evaulated, the `default` keyword defines the action for this case (optional). We must use the `break` statement between every code block to avoid the `switch` from executing the next code block. Using the `switch` statement in general is not recommended, because forgetting the `break` keyword causes very confusing results. ## Loops Source: https://learn-typescript.org/loops/ ### The for statement JavaScript has two methods for running the same code several times. It is mainly used for iterating over arrays or objects. Let’s see an example: ```typescript let i; for (i = 0; i < 3; i = i + 1) { console.log(i); } ``` This will print out the following: ```typescript 0 1 2 ``` The `for` statement in JavaScript has the same syntax as in Java and C. It has three parts: - **Initialization** - Initializes the iterator variable `i`. In this example, we initialize `i` to 0. - **Condition** - As long as the condition is met, the loop continues to execute. In this example, we check that `i` is less than 3. - **Increment** - A directive which increments the iterator. In our case, we increment it by 1 on every loop. We can also write a shorter notation for the statement by inserting the variable definition inside the `for` loop and incrementing using the `++` operator. ```typescript for (let i = 0; i < 3; i++) { console.log(i); } ``` To iterate over an array and print out all of its members, we usually use the `for` statement. Here’s an example: ```typescript let myArray = ["A", "B", "C"]; for (let i = 0; i < myArray.length; i++) { console.log("The member of myArray in index " + i + " is " + myArray[i]); } ``` This prints out the contents of the array: ```typescript The member of myArray in index 0 is A The member of myArray in index 1 is B The member of myArray in index 2 is C ``` Notice that we used the `length` property of an array, which returns the number of members in the array, so we know when to stop iterating. ### The while statement The `while` statement is a more simple version of the `for` statement which checks if an expression evaluates to `true` and runs as long as it says `true`. For example: ```typescript let i = 99; while (i > 0) { console.log(i + " bottles of beer on the wall"); i -= 1; } ``` ### break and continue statements The `break` statement allows to stop the execution of a loop. For example, we can create a loop that loops forever using `while(true)` and use the `break` statement to break inside the loop instead by checking that a certain condition was met. ```typescript let i = 99; while (true) { console.log(i + " bottles of beer on the wall"); i -= 1; if (i == 0) { break; } } ``` The `continue` statement skips the rest of the loop and jumps back to the beginning of the loop. For example, if we would want to print only odd numbers using a `for` statement, we can do the following: ```typescript for (let i = 0; i < 100; i++) { // check that the number is even if (i % 2 == 0) { continue; } // if we got here, then i is odd. console.log(i + " is an odd number."); } ``` ## Objects Source: https://learn-typescript.org/objects/ JavaScript is a functional language, and for object oriented programming it uses both objects and functions, but objects are usually used as a data structure, similar to a dictionary in Python or a map in Java. In this tutorial, we will learn how to use objects as a data structure. The advanced tutorials explain more about object oriented JavaScript. To initialize an object, use curly braces: ```typescript let emptyObject = {}; let personObject = { firstName : "John", lastName : "Smith" } ``` ### Member addressing Members of objects can be addressed using the brackets operator `[]`, very much like arrays, but just like many other object oriented languages, the period `.` operator can also be used. They are very similar, except for the fact that brackets return a member by using a string, in contrast to the period operator, which requires the member to be a simple word (the word should not contain spaces, start with a number or use illegal characters). For example, we can continue to fill the person object with more details: ```typescript let personObject = { firstName : "John", lastName : "Smith" } personObject.age = 23; personObject["salary"] = 14000; ``` ### Iteration Iterating over members of a dictionary is not a trivial task, since iterating over objects can also yield members who don’t actually belong to an object. Therefore, we must use the `hasOwnProperty` method to check that the member in fact belongs to the object. ```typescript for (let member in personObject) { if (personObject.hasOwnProperty(member)) { console.log("the member " + member + " of personObject is " + personObject[member]) } } ``` This will eventually print out ```typescript the member firstName of personObject is John the member lastName of personObject is Smith the member age of personObject is 23 the member salary of personObject is 14000 ``` Note that methods of objects in JavaScript have a fixed order, like arrays. ## Functions Source: https://learn-typescript.org/functions/ Functions are code blocks that can have arguments, and function have their own scope. In JavaScript, functions are a very important feature of the program, and especially the fact that they can access local variables of a parent function (this is called a closure). There are two ways to define functions in JavaScript - named functions and anonymous functions. To define a named function, we use the `function` statement as follows: ```typescript function greet(name) { return "Hello " + name + "!"; } console.log(greet("Eric")); // prints out Hello Eric! ``` In this function, the `name` argument to the `greet` function is used inside the function to construct a new string and return it using the `return` statement. To define an anonymous function, we can alternatively use the following syntax: ```typescript let greet = function(name) { return "Hello " + name + "!"; } console.log(greet("Eric")); // prints out Hello Eric! ``` ## Pop-up Boxes Source: https://learn-typescript.org/pop-up-boxes/ There are three types of pop-up boxes in javascript: confirm, alert, and prompt. To use any of them, type ```typescript confirm("Hi!"); prompt("Bye!"); alert("Hello"); ``` Confirm boxes will return “true” if ok is selected, and return “false” if cancel is selected. Alert boxes will not return anything. Prompt boxes will return whatever is in the text box. Note: prompt boxes also have an optional second parameter, which is the text that will already be in the text box. ## Callbacks Source: https://learn-typescript.org/callbacks/ Callbacks in JavaScript are functions that are passed as arguments to other functions. This is a very important feature of asynchronous programming, and it enables the function that receives the callback to call our code when it finishes a long task, while allowing us to continue the execution of the code. For example: ```typescript let callback = function() { console.log("Done!"); } setTimeout(callback, 5000); ``` This code waits 5 seconds and prints out “Done!” when the 5 seconds are up. Note that this code will not work in the interpreter because it is not designed for handling callbacks. It is also possible to define callbacks as anonymous functions, like so: ```typescript setTimeout(function() { console.log("Done!"); }, 5000); ``` Like regular functions, callbacks can receive arguments and be executed more than once. ## Arrow Functions Source: https://learn-typescript.org/arrow-functions/ Arrow functions are a feature of ES6, their behavior are generally the same of a function. These are anonymous functions with a special syntax, they haven’t their own this, arguments or super. They can’t be used as constructors too. Arrow functions are often used as callbacks of native JS functions like map, filter or sort. The reason of their name is due to the use of `=>` in the syntax. To define an arrow function, we use the `() => {}` structure as follows: ```typescript const greet = (name) => { return "Hello " + name + "!"; } console.log(greet("Eric")); // prints out Hello Eric! ``` In this function, the `name` argument to the `greet` function is used inside the function to construct a new string and return it using the `return` statement. In case that the function only receives one argument, we can omit the parenthesis: ```typescript const greet = name => { return "Hello " + name + "!"; } console.log(greet("Eric")); // prints out Hello Eric! ``` And, in case that we want to do a explicit return of the function and we have only one line of code, we can avoid the `return` statement and omit brackets too: ```typescript const greet = name => "Hello " + name + "!"; console.log(greet("Eric")); // prints out Hello Eric! ``` Using an arrow as a callback compared to a normal function: ```typescript let numbers = [3, 5, 8, 9, 2]; // Old way function multiplyByTwo(number){ return number * 2; } let multipliedNumbers = numbers.map(multiplyByTwo); console.log(multipliedNumbers); // prints out: 6, 10, 16, 18, 4 // Using ES6 arrow functions const multiplyByTwo = number => number * 2; let multipliedNumbers = numbers.map(multiplyByTwo); console.log(multipliedNumbers); // prints out: 6, 10, 16, 18, 4 ``` ## Object Oriented JavaScript Source: https://learn-typescript.org/object-oriented-javascript/ JavaScript uses functions as classes to create objects using the `new` keyword. Here is an example: ```typescript function Person(firstName, lastName) { // construct the object using the arguments this.firstName = firstName; this.lastName = lastName; // a method which returns the full name this.fullName = function() { return this.firstName + " " + this.lastName; } } let myPerson = new Person("John", "Smith"); console.log(myPerson.fullName()); // outputs "John Smith" ``` Creating an object using the `new` keyword is the same as writing the following code: ```typescript let myPerson = { firstName : "John", lastName : "Smith", fullName : function() { return this.firstName + " " + this.lastName; } } ``` The difference between the two methods of creating objects is that the first method uses a class to define the object and then the `new` keyword to instantiate it, and the second method immediately creates an instance of the object. ## Function Context Source: https://learn-typescript.org/function-context/ Functions in JavaScript run in a specific context, and using the `this` variable we have access to it. All standard functions in the browser run under the Window context. Functions defined under an object or a class (another function) will use the context of the object it was created in. However, we can also change the context of a function at runtime, either before or while executing the function. ### Binding a method to an object To bind a function to an object and make it an object method, we can use the `bind` function. Here is a simple example: ```typescript let person = { name : "John" }; function printName() { console.log(this.name); } ``` Obviously, we are not able to call `printName()` without associating the function with the object `person`. To do this we must create a bound method of the function printName to person, using the following code: ```typescript let boundPrintName = printName.bind(person); boundPrintName(); // prints out "John" ``` ### Calling a function with a different context We can use the `call` and `apply` functions to call a function as if it was bound to an object. The difference between the `call` and `apply` functions is only by how they receive their arguments - the `call` function receives the `this` argument first, and afterwards the arguments of the function, whereas the `apply` function receives the `this` argument first, and an array of arguments to pass on to the function as a second argument to the function. For example, let’s call `printName` with `person` as the context using the `call` method: ```typescript printName.call(person); // prints out "John" ``` ### call/apply vs bind The difference between `call`/`apply` and `bind` is that `bind` returns a new function identical to the old function, except that the value of `this` in the new function is now the object it was bound to. `call`/`apply` calls the function with `this` being the bound object, but it does not return a return a new function or change the original, it calls it with a different value for `this`. For example: ```typescript let boundPrintName = printName.call(person); //boundPrintName gets printName's return value (null) boundPrintName(); //doesn't work because it's not a function, it's null printName.bind(person); //returns a new function, but nothing is using it so it's useless printName(); //throws error because this.name is not defined ``` Think of `call` as executing the return value of `bind`. For example: ```typescript printName.call(person); //is the same as printName.bind(person)(); //executes the function returned by bind ``` Or think of `bind` returning a shortcut to `call`. For example: ```typescript let boundPrintName = printName.bind(person); //is the same as let boundPrintName = function() { printName.call(person); } ``` ## Inheritance Source: https://learn-typescript.org/inheritance/ JavaScript uses prototype based inheritance. Every object has a `prototype`, and when a method of the object is called then JavaScript tries to find the right function to execute from the prototype object. ### The prototype attribute Without using the prototype object, we can define the object Person like this: ```typescript function Person(name, age) { this.name = name; this.age = age; function describe() { return this.name + ", " + this.age + " years old."; } } ``` When creating instances of the `Person` object, we create a new copy of all members and methods of the functions. This means that every instance of an object will have its own `name` and `age` properties, as well as its own `describe` function. However, if we use the `Person.prototype` object and assign a function to it, it will also work. ```typescript function Person(name, age) { this.name = name; this.age = age; } Person.prototype.describe = function() { return this.name + ", " + this.age + " years old."; } ``` When creating instances of the `Person` object, they will not contain a copy of the `describe` function. Instead, when calling an object method, JavaScript will attempt to resolve the `describe` function first from the object itself, and then using its `prototype` attribute. ### Inheritance Let’s say we want to create a `Person` object, and a `Student` object derived from `Person`: ```typescript let Person = function() {}; Person.prototype.initialize = function(name, age) { this.name = name; this.age = age; } Person.prototype.describe = function() { return this.name + ", " + this.age + " years old."; } let Student = function() {}; Student.prototype = new Person(); Student.prototype.learn = function(subject) { console.log(this.name + " just learned " + subject); } let me = new Student(); me.initialize("John", 25); me.learn("Inheritance"); ``` As we can see in this example, the `initialize` method belongs to `Person` and the `learn` method belongs to `Student`, both of which are now part of the `me` object. Keep in mind that there are many ways of doing inheritance in JavaScript, and this is just one of them. ## Destructuring Source: https://learn-typescript.org/destructuring/ Destructuring is a feature of ES6, introduced for making easier and cleaner some repetitive operations and assignments made in JS. With destructuring we can data from a deeper lever inside an array / object with a more concise syntax, even giving to this ‘extracted’ data other name in the same operation. In JavaScript we can achieve this in a very simply way: ```typescript // Consider this object const person = { head: { eyes: 'x', mouth: { teeth: 'x', tongue: 'x' } }, body: { shoulders: 'x', chest: 'x', arms: 'x', hands: 'x', legs: 'x' } }; // If we want to get head, the old way: let head = person.head; // ES6 Destructuring let { head } = person; // We can give other name as if a variable was declared, in the same line let { head : myHead } = person; // So we can do... console.log(myHead); // prints '{ eyes, mouth: { ... } }' ``` With arrays: ```typescript let numbers = ['2', '3', '7']; // Old way let two = numbers[0]; let three = numbers[1]; // ES6 Destructuring let [two, three] = numbers; // We can give them other names too let [two: positionZero, three: positionOne] = numbers; console.log(positionZero) // prints '2' console.log(positionOne) // prints '3' ``` We can do this with function parameters too: ```typescript // Old way function getHeadAndBody(person) { let headAndBody = { head: person.head, body: person.body } return headAndBody; } // ES6 Destructuring function getHeadAndBody({ head, body }) { return { head, body } } // With arrow functions let getHeadAndBody = ({ head, body }) => { head, body }; ``` Warning: Be careful with destructuring, if you aren’t sure if the function is going to receive an object with those parameters, it’s better to use the old way in order to not incurring in ` head / body is undefined ` errors. You can avoid that type of errors while using ES6 Destructuring giving default parameters to the function, so you can be sure that properties will exist, not being obliged to rely on the parameters received. ```typescript // I'm not sure if head and body will be present in some cases... // Now we are sure that head or body will be equal to '' if the real parameter doesn't have that properties inside function getHeadAndBody({ head = '', body = '' }) { return { head, body } } ``` You can destructure as deep as you like, always considering if that property exists. ```typescript // Deep destructuring let computer = { processor: { transistor: { silicon: { thickness: '9nm' } } } } let { processor: { transistor: { silicon: { thickness } } } } = computer; // Making it cleaner let { thickness: inteli9Thickness } = computer.processor.transistor.silicon; console.log(inteli9Thickness) // prints '9nm' ``` ## Functions and Signatures Source: https://learn-typescript.org/functions-and-signatures/ Annotating a function is where types earn the most, because a signature is checked at every place the function is called — not just where it is defined. ```typescript function add(a: number, b: number): number { return a + b; } add(2, 3); // 5 add("2", 3); // error at the CALL SITE, where the mistake is ``` ## Parameters and return types ```typescript function greet(name: string): string { return `Hello, ${name}`; } const double = (n: number): number => n * 2; ``` The return type can usually be inferred, and leaving it off is fine for short functions. Annotate it explicitly when: - the function is **exported** — it is a contract, and an explicit type stops an accidental change from silently altering it - the body is long enough that inference is hard for a *reader* to follow - you want the compiler to catch a wrong return inside the function rather than at its callers ```typescript export function parsePort(raw: string): number { const n = Number(raw); return Number.isInteger(n) ? n : 3000; } ``` ## Optional and default parameters ```typescript function log(message: string, level?: string) { console.log(`[${level ?? "info"}] ${message}`); } log("started"); // fine — level is undefined log("failed", "error"); ``` `level?: string` means the type is `string | undefined`. A default value does the same job and removes the `undefined`: ```typescript function log(message: string, level: string = "info") { console.log(`[${level}] ${message}`); // level is string, never undefined } ``` Optional parameters must come after required ones. ## Rest parameters ```typescript function sum(...numbers: number[]): number { return numbers.reduce((total, n) => total + n, 0); } sum(1, 2, 3); // 6 sum(...[4, 5, 6]); // 15 ``` ## void and undefined `void` is the return type of a function that returns nothing useful: ```typescript function notify(message: string): void { console.log(message); } ``` It is subtly different from `undefined`: a `void` return type means "ignore whatever this returns", which is what lets you pass a value-returning function where a void one is expected. ```typescript const items: string[] = []; [1, 2, 3].forEach((n) => items.push(String(n))); // push returns a number; forEach expects void; this is allowed ``` ## Function types You can describe the *shape* of a function, which is how you type callbacks and stored handlers. ```typescript type Comparator = (a: number, b: number) => number; const byValue: Comparator = (a, b) => a - b; // parameters inferred from Comparator [3, 1, 2].sort(byValue); ``` Note that `byValue` needed no annotations on `a` and `b` — TypeScript infers them from the declared type. This is **contextual typing**, and it is why callbacks usually need no annotations at all: ```typescript ["a", "bb"].map((s) => s.length); // s is string, inferred from the array ``` ## Typing a callback parameter ```typescript function fetchUser( id: string, onSuccess: (user: User) => void, onError?: (error: Error) => void, ): void { // ... } ``` Writing the callback types out is what makes the caller's arrow function fully typed, with autocomplete on `user` and no annotations needed at the call site. ## Overloads, briefly Occasionally one function has genuinely different shapes depending on its arguments: ```typescript function parse(input: string): object; function parse(input: string, asArray: true): unknown[]; function parse(input: string, asArray?: boolean): object | unknown[] { const value = JSON.parse(input); return asArray ? [value].flat() : value; } ``` The first two lines are the signatures callers see; the third is the implementation, which callers cannot call directly. Reach for overloads rarely — a union return type or two separate functions is usually clearer. ## Exercise ```typescript // Write a function `formatPrice` that: // - takes an amount in cents (number) and an optional currency (string, default "USD") // - returns a string like "$12.34" // - has explicit parameter and return type annotations // Then write a `Formatter` function type describing its shape, and assign it. // write your code here ``` ## Common questions ### Should I always annotate the return type? For exported functions, yes — it is a contract, and an explicit annotation means a change to the body cannot silently change what callers receive. For small internal functions, inference is fine and less to maintain. ### Why do my callback parameters not need types? Contextual typing. When TypeScript already knows the expected function type — from an array method, or from a declared parameter type — it infers the parameter types for you. If your callback parameters are showing as `any`, the surrounding type is missing or too loose. ### What is the difference between `void` and `undefined` as a return type? `undefined` means the function must actually return `undefined`. `void` means the return value should be ignored, which is more permissive — a function returning a value can be passed where a `void`-returning one is expected. Use `void` for callbacks and handlers. ## Interfaces and Type Aliases Source: https://learn-typescript.org/interfaces-and-type-aliases/ Once you are past primitives, most typing is describing the shape of objects. There are two ways to name a shape, and they overlap almost entirely. ```typescript interface User { id: string; email: string; age: number; } type Product = { id: string; name: string; priceCents: number; }; ``` Both work the same way at the point of use: ```typescript const ada: User = { id: "1", email: "ada@example.com", age: 36 }; const wrong: User = { id: "1", email: "ada@example.com" }; // Property 'age' is missing in type '{ id: string; email: string; }' ``` ## Optional and readonly ```typescript interface User { id: string; email: string; age?: number; // may be absent — type is number | undefined readonly createdAt: Date; // cannot be reassigned after construction } const u: User = { id: "1", email: "a@b.com", createdAt: new Date() }; u.createdAt = new Date(); // error: Cannot assign to 'createdAt' ``` `readonly` is shallow — it stops reassignment of the property, not mutation of the object it points at. ## Nested and array properties ```typescript interface Order { id: string; customer: User; // another named shape lines: OrderLine[]; // an array of them metadata: Record; // arbitrary string keys status: "pending" | "shipped"; // a union of literals } ``` `Record` is the idiomatic way to say "an object used as a lookup". The longhand is an index signature: ```typescript interface Lookup { [key: string]: number; } ``` :::warn An index signature is looser than it looks `Lookup["anything"]` is typed as `number`, even for a key that does not exist — so you get `undefined` at runtime with no warning. Turn on `noUncheckedIndexedAccess` and the type becomes `number | undefined`, forcing you to check. It is the single most valuable compiler flag for catching real bugs. ::: ## Composition Interfaces extend: ```typescript interface Entity { id: string; createdAt: Date; } interface User extends Entity { email: string; } ``` Type aliases intersect, which achieves the same thing: ```typescript type Entity = { id: string; createdAt: Date }; type User = Entity & { email: string }; ``` Both compose several sources: ```typescript interface Admin extends Entity, Auditable { role: "admin" } type Admin = Entity & Auditable & { role: "admin" }; ``` ## Where they genuinely differ **Only `type` can express a union**, which is why it is the more common default: ```typescript type Status = "pending" | "shipped" | "cancelled"; type Id = string | number; type Handler = (e: Event) => void; // and function types read better ``` **Only `interface` supports declaration merging** — two declarations with the same name combine: ```typescript interface Window { myApp: AppState } // adds to the existing DOM Window ``` That is essential for augmenting types from a library you do not control, and a footgun everywhere else, because a name can be extended from anywhere. :::verdict Which to use Use `type` by default — it covers unions, functions and object shapes uniformly. Use `interface` when you need declaration merging (augmenting a third-party type) or when you are publishing a type others will extend. Pick one convention per codebase and write it in your instructions file, otherwise generated code will use both interchangeably. ::: ## Structural typing TypeScript checks shapes, not names. Anything with the right properties satisfies the type: ```typescript interface Point { x: number; y: number } function distance(p: Point): number { return Math.hypot(p.x, p.y); } const anything = { x: 3, y: 4, label: "extra" }; distance(anything); // fine — it has x and y ``` This is usually what you want, and it is occasionally not: ```typescript type UserId = string; type OrgId = string; function loadUser(id: UserId) {} loadUser(someOrgId); // compiles. wrong. both are just strings. ``` Two types with the same underlying shape are interchangeable — which is exactly the bug branded types solve, covered later in the track. :::tip Excess property checks ```typescript distance({ x: 3, y: 4, label: "extra" }); // ERROR here ``` Passing an object *literal* directly triggers an extra check that rejects unknown properties, on the reasoning that a property you wrote inline and that is not in the type is probably a typo. Assign it to a variable first and it is allowed. Surprising the first time, useful once you know. ::: ## Exercise ```typescript // Model a blog post: // - id (string, readonly), title (string), body (string) // - tags: an array of strings // - publishedAt: a Date that may be absent (drafts) // - author: a nested shape with name and email // Then write a `summarize(post)` function returning `"title — N tags"`. // write your code here ``` ## Common questions ### interface or type — really, which? `type` unless you need declaration merging. It handles unions, function types and object shapes with one keyword, which means fewer decisions. The important part is consistency: a codebase using both at random is harder to read than either choice. ### Why was my object with extra properties rejected? Excess property checking, which only applies to object literals passed directly. TypeScript assumes an unknown property written inline is a typo. Assigning to a variable first bypasses it, because at that point the object has a known type and the extra property is deliberate. ### How do I make a type where all properties are optional? `Partial`. There is a set of built-in utility types — `Partial`, `Required`, `Readonly`, `Pick`, `Omit` — covered in their own lesson later in this track. ## Union Types and Narrowing Source: https://learn-typescript.org/union-types-and-narrowing/ A union type says a value is one of several possibilities: ```typescript type Id = string | number; type Status = "pending" | "shipped" | "cancelled"; let id: Id = "abc"; id = 42; // also fine id = true; // error ``` Those quoted strings are **literal types** — a type whose only value is that exact string. A union of them is how you model a fixed set of options, and it is better than an enum for most purposes because it emits no runtime code. ```typescript function setStatus(s: Status) {} setStatus("shipped"); // fine setStatus("shiped"); // error, with a spelling suggestion ``` ## Narrowing You cannot use a union until you know which member you have: ```typescript function format(id: string | number): string { return id.toUpperCase(); // error — number has no toUpperCase } ``` **Narrowing** is proving to the compiler which member it is. Ordinary JavaScript checks do it: ```typescript function format(id: string | number): string { if (typeof id === "string") { return id.toUpperCase(); // here, id is string } return id.toFixed(2); // here, id is number } ``` That is the part people find magical at first: the compiler follows your control flow. Inside the `if`, `id` genuinely has type `string`. ### The narrowing tools ```typescript typeof x === "string" // primitives Array.isArray(x) // arrays x instanceof Error // classes "email" in user // presence of a property x === null / x !== undefined // equality if (x) { } // truthiness — careful with 0 and "" ``` :::warn Truthiness narrowing has a trap ```typescript function render(count: number | undefined) { if (!count) return "none"; // also catches 0 return `${count} items`; } ``` `0` is falsy, so a real count of zero takes the `undefined` path. Check explicitly — `if (count === undefined)` — whenever `0` or `""` are valid values. This is one of the most common bugs in generated TypeScript. ::: ## Discriminated unions The pattern that makes unions genuinely powerful. Give each member a shared literal property, and narrowing on that property picks the whole shape. ```typescript type Result = | { status: "loading" } | { status: "success"; data: User } | { status: "error"; error: Error }; function render(r: Result): string { switch (r.status) { case "loading": return "Loading…"; case "success": return r.data.email; // data exists only here case "error": return r.error.message; // error exists only here } } ``` Compare that with the shape generated code usually produces: ```typescript // 16 possible states, most of them nonsense type Result = { status: "loading" | "success" | "error"; data?: User; error?: Error; }; ``` With the second version you must check `r.data` everywhere and nothing stops a `"success"` with no data. With the first, **the invalid states cannot be constructed**. That is the single most valuable modelling habit in TypeScript. ## Exhaustiveness checking Add a `default` branch that assigns to `never`, and adding a new union member becomes a compile error everywhere it needs handling: ```typescript function assertNever(x: never): never { throw new Error(`Unhandled: ${JSON.stringify(x)}`); } function render(r: Result): string { switch (r.status) { case "loading": return "Loading…"; case "success": return r.data.email; case "error": return r.error.message; default: return assertNever(r); } } ``` Now add `| { status: "cancelled" }` to `Result`. The `default` branch fails to compile, because `r` is no longer `never` there — and it fails in *every* switch that needs updating. The compiler has just found all your work for you. This is the pattern that makes a large TypeScript codebase safe to change. ## Type predicates When a check is too complex for the built-in narrowing, write a function that returns a type predicate: ```typescript function isUser(value: unknown): value is User { return ( typeof value === "object" && value !== null && "id" in value && "email" in value ); } const data: unknown = JSON.parse(raw); if (isUser(data)) { data.email; // narrowed to User } ``` `value is User` tells the compiler that a `true` return means the narrowing holds. :::warn A type predicate is a promise you make The compiler trusts it. If your check is wrong, the type is wrong and nothing will tell you. For data crossing a real boundary — a network response, a database row — prefer a schema library that generates both the check and the type from one declaration, rather than hand-writing predicates. ::: ## Exercise ```typescript // Model the outcome of a payment as a discriminated union: // - "approved" with a transactionId (string) // - "declined" with a reason (string) // - "pending" with a retryAfter (number of seconds) // Write `describe(result)` returning a human sentence for each, // with an assertNever default branch. // write your code here ``` ## Common questions ### Union of literals or an enum? A union of string literals, in most cases. It emits no runtime JavaScript, it narrows naturally in a switch, and the values are just strings so they serialise cleanly. Enums generate real code and are not erasable syntax, which matters if you use a runtime type-stripper. ### Why does my narrowing stop working inside a callback? TypeScript cannot guarantee a narrowing still holds inside a function that may run later, because the variable could have been reassigned in between. Assign the narrowed value to a `const` first and use that inside the callback. ### When should I write a type predicate rather than parse? Predicates suit checks over data you already own — distinguishing two internal shapes. For anything arriving from outside your program, parse with a schema instead: a predicate asserts a belief, whereas a parse actually verifies it and gives you the type as a by-product. ## Generics Source: https://learn-typescript.org/generics/ You have already used generics without noticing: ```typescript const names: Array = []; // Array with a hole filled by string const scores: Map = new Map(); const later: Promise = fetchUser(); ``` `Array` on its own is incomplete — an array *of what?* The type parameter fills the hole. Writing your own works the same way. ## A generic function The problem generics solve: without them, a reusable function loses type information. ```typescript function first(items: any[]): any { return items[0]; } const n = first([1, 2, 3]); // n is any. we lost the number. ``` With a type parameter, the type flows through: ```typescript function first(items: T[]): T { return items[0]; } const n = first([1, 2, 3]); // number const s = first(["a", "b"]); // string const u = first([]); // User, stated explicitly ``` `` declares the parameter; `T[]` and `: T` use it. You almost never pass it explicitly — TypeScript infers it from the argument. `T` is only a convention. `` is just as valid and often clearer. ## Constraints An unconstrained `T` could be anything, so you can barely touch it: ```typescript function longest(a: T, b: T): T { return a.length > b.length ? a : b; // error: T has no 'length' } ``` `extends` narrows what `T` may be: ```typescript function longest(a: T, b: T): T { return a.length > b.length ? a : b; } longest("hello", "hi"); // string longest([1, 2], [1, 2, 3]); // number[] longest(10, 20); // error — numbers have no length ``` Read `T extends X` as "T is at least an X". The return type is still the *specific* type passed in, which is the whole point — `longest("a", "b")` gives you a `string`, not a `{length: number}`. ## Constraining by key `keyof` plus a constraint gives you type-safe property access: ```typescript function pluck(item: T, key: K): T[K] { return item[key]; } const user = { id: "1", email: "a@b.com", age: 36 }; pluck(user, "email"); // string pluck(user, "age"); // number pluck(user, "nope"); // error: not a key of the object ``` `keyof T` is the union of `T`'s property names — here `"id" | "email" | "age"`. `T[K]` is the type of that property. This pattern turns up constantly in real code. ## Generic interfaces and classes ```typescript interface Repository { findById(id: string): Promise; save(item: T): Promise; } class InMemoryRepo implements Repository { private items = new Map(); async findById(id: string): Promise { return this.items.get(id) ?? null; } async save(item: T): Promise { this.items.set(item.id, item); } } const users = new InMemoryRepo(); const found = await users.findById("1"); // User | null ``` One implementation, fully typed for every entity you use it with. ## Defaults ```typescript interface ApiResponse { status: number; data: T; } const a: ApiResponse = { status: 200, data: "anything" }; // T is unknown const b: ApiResponse = { status: 200, data: ada }; ``` Note the default is `unknown`, not `any` — keep the safe default even here. ## A practical example ```typescript async function fetchJson(url: string): Promise { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as T; } const user = await fetchJson("/api/users/1"); // typed as User ``` :::danger This example contains a real bug worth understanding `as T` is an **assertion**, not a check. Nothing verifies the response actually is a `User` — you have told the compiler to believe you about data you did not write. The generic here is honest only if something validates. In production, parse with a schema: ```typescript async function fetchJson(url: string, schema: { parse(v: unknown): T }): Promise { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return schema.parse(await res.json()); // actually checked } ``` Generics move types around; they never validate. That distinction is where most unsafe TypeScript comes from. ::: ## When not to use a generic The most common mistake is reaching for one where a plain type would do: ```typescript function log(message: T): void { // pointless — T is never used console.log(message); } function log(message: string): void {} // just say what you mean ``` **A type parameter earns its place only when it appears at least twice** — usually once in a parameter and once in the return type, so it can carry information between them. If it appears once, delete it. Generated code over-reaches here regularly, producing elaborate generic signatures for functions that take one concrete type. Push back towards the simple version. ## Exercise ```typescript // Write a generic function `groupBy` that: // - takes an array of T and a key K (a key of T whose value is a string) // - returns Record // Type it so that groupBy(users, "role") compiles and groupBy(users, "nope") does not. // write your code here ``` ## Common questions ### When should I add a type parameter? When the same type needs to appear in more than one place in the signature — a parameter and the return, or two parameters that must match. If it appears only once, it is doing nothing and a concrete type is clearer. ### What does `extends` mean here — is it inheritance? No. In a generic constraint it means "assignable to", so `T extends { length: number }` reads as "T must have at least a numeric length". It is a bound on what may be substituted, not a class relationship. ### Why is my inferred type wider than I wanted? Usually because the argument was inferred loosely — a `string` rather than a literal. `as const` on the argument, or a `T extends string` constraint, keeps the narrow literal type instead of widening it. ## Classes and Access Modifiers Source: https://learn-typescript.org/classes-and-modifiers/ JavaScript classes work in TypeScript unchanged. What TypeScript adds is access control the compiler enforces, and the ability to declare that a class satisfies an interface. ```typescript class Account { readonly id: string; private balanceCents: number; protected currency: string; constructor(id: string, openingCents: number, currency = "USD") { this.id = id; this.balanceCents = openingCents; this.currency = currency; } deposit(cents: number): void { if (cents <= 0) throw new RangeError("deposit must be positive"); this.balanceCents += cents; } get balance(): number { return this.balanceCents / 100; } } ``` ```typescript const a = new Account("acc_1", 10_000); a.deposit(500); a.balance; // 105 a.balanceCents; // error: 'balanceCents' is private a.id = "acc_2"; // error: 'id' is readonly ``` ## The modifiers | Modifier | Visible from | |---|---| | `public` (default) | anywhere | | `protected` | the class and its subclasses | | `private` | only inside the class | | `readonly` | anywhere, but cannot be reassigned after construction | | `static` | on the class itself, not instances | :::warn `private` is compile-time only It disappears when types are stripped, so at runtime the field is an ordinary property that anything can reach. For genuine runtime privacy use JavaScript's `#` fields: ```typescript class Account { #balanceCents = 0; // actually inaccessible outside the class } ``` `private` is for catching mistakes in your own code. `#` is for enforcement. ::: ## Parameter properties The constructor above is mostly boilerplate. TypeScript can declare and assign in one step: ```typescript class Account { constructor( public readonly id: string, private balanceCents: number, protected currency: string = "USD", ) {} } ``` Identical behaviour, far less repetition. A modifier on a constructor parameter creates the property and assigns it. Two caveats: this is TypeScript-only syntax, so it is **not erasable** — it will not work under a runtime type-stripper or with `erasableSyntaxOnly`. And it is the mechanism most dependency-injection frameworks rely on, which is why NestJS controllers look the way they do. ## implements `implements` states that a class satisfies an interface, and the compiler checks it: ```typescript interface Repository { findById(id: string): Promise; save(item: T): Promise; } class UserRepo implements Repository { async findById(id: string): Promise { /* … */ } async save(user: User): Promise { /* … */ } // omit one and you get an error naming exactly what is missing } ``` `implements` is a **check**, not inheritance — it adds nothing at runtime and the class must still write every member itself. Because TypeScript is structurally typed, a class with the right shape satisfies the interface whether or not it says `implements`; the keyword just moves the error to the class rather than to its first use. ## Abstract classes A base that cannot be instantiated and can require subclasses to fill in the gaps: ```typescript abstract class Shape { abstract area(): number; // subclasses must implement describe(): string { // shared implementation return `${this.constructor.name} with area ${this.area().toFixed(2)}`; } } class Circle extends Shape { constructor(private radius: number) { super(); } area(): number { return Math.PI * this.radius ** 2; } } new Shape(); // error: cannot create an instance of an abstract class new Circle(2).describe(); // "Circle with area 12.57" ``` ## When not to use a class Classes are a tool, not a default. Reach for one when you have **state and behaviour that belong together**, and something that genuinely benefits from instances. A class with no state, or one that is only ever instantiated once, is a namespace with extra steps: ```typescript // no state — a class adds nothing class MathUtils { static add(a: number, b: number) { return a + b; } } // just export the functions export function add(a: number, b: number) { return a + b; } ``` Plain functions over plain data are easier to test, easier to tree-shake, and avoid `this` entirely — which removes a whole class of binding bug. Use classes where they earn it: entities with invariants, stateful services, and anywhere a framework expects them. ## Exercise ```typescript // Model a `Playlist`: // - readonly id, private tracks array // - add(track) rejecting duplicates by id // - a `duration` getter returning total seconds // - implements an interface `Sized { readonly size: number }` // Use parameter properties for the constructor. // write your code here ``` ## Common questions ### `private` or `#`? `#` when you want the field genuinely unreachable at runtime, `private` when you only want the compiler to stop your own code touching it. `#` is real JavaScript and survives compilation; `private` is erased. ### Should I use `implements` on every class? Only where an interface is a meaningful contract — something with more than one implementation, or a boundary you want stated explicitly. Structural typing means a matching class works either way; `implements` mainly improves *where* the error appears when the class drifts. ### Are parameter properties safe to use? They are convenient and widely used, especially with dependency injection. Be aware they are TypeScript-only syntax, so they break under `erasableSyntaxOnly` and runtime type-stripping. If you need your `.ts` files to run without a compile step, write the assignments out. ## Async and Promises Source: https://learn-typescript.org/async-and-promises/ An `async` function always returns a `Promise`, and TypeScript types it for you: ```typescript async function getUser(id: string): Promise { const res = await fetch(`/api/users/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as User; } const user = await getUser("1"); // User — await unwraps the Promise ``` The annotation is `Promise` even though the `return` statement produces a `User`. Writing `: User` on an `async` function is an error, and it is a common early mistake. ## Awaiting ```typescript const user: User = await getUser("1"); const users: User[] = await Promise.all([getUser("1"), getUser("2")]); ``` `Awaited` unwraps a promise type when you need it in a type position: ```typescript type Fetched = Awaited>; // User ``` ## Errors are `unknown`, not `Error` This surprises people coming from other languages: ```typescript try { await getUser("1"); } catch (err) { console.log(err.message); // error: 'err' is of type 'unknown' } ``` JavaScript lets you `throw` anything — a string, a number, an object — so TypeScript cannot assume you caught an `Error`. Narrow before use: ```typescript try { await getUser("1"); } catch (err) { if (err instanceof Error) { console.error(err.message); } else { console.error("unknown failure", err); } } ``` For your own error types, a discriminated union plus `instanceof` gives you typed handling: ```typescript class NotFoundError extends Error { readonly kind = "not_found"; constructor(readonly id: string) { super(`not found: ${id}`); } } if (err instanceof NotFoundError) { console.error(err.id); // typed } ``` ## Running things concurrently ```typescript // sequential — three round trips, one after another const a = await getUser("1"); const b = await getUser("2"); // concurrent — one round trip's worth of waiting const [a, b] = await Promise.all([getUser("1"), getUser("2")]); ``` `Promise.all` is correctly typed as a tuple, so `a` and `b` keep their individual types even when they differ: ```typescript const [user, orders] = await Promise.all([getUser("1"), getOrders("1")]); // user: User, orders: Order[] ``` `Promise.all` rejects as soon as any input rejects. When partial success is acceptable, use `allSettled`, which never rejects: ```typescript const results = await Promise.allSettled([getUser("1"), getUser("2")]); for (const r of results) { if (r.status === "fulfilled") console.log(r.value.email); else console.error(r.reason); } ``` That return type is a discriminated union, so narrowing on `r.status` gives you `value` or `reason` — the pattern from [union types and narrowing](/union-types-and-narrowing/), built into the standard library. ## The bug that matters most ```typescript async function save(user: User) { db.write(user); // not awaited return { ok: true }; } ``` The function returns before the write happens, the error becomes an unhandled rejection, and in Node an unhandled rejection terminates the process. This is the single most common defect in generated TypeScript and JavaScript. Turn on the rule that catches it: ```js eslint.config.js "@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/no-misused-promises": "error", "@typescript-eslint/await-thenable": "error", ``` :::warn These rules need type information They only work with the type-checked ESLint configuration (`parserOptions: { projectService: true }`). Without it they silently do nothing — which is why many projects have the rules listed and still ship floating promises. Verify by writing one deliberately and checking that lint fails. ::: `no-misused-promises` catches the other classic: ```typescript items.forEach(async (item) => { await process(item); // forEach ignores the returned promise }); console.log("done"); // prints immediately. nothing is done. ``` Use a `for...of` loop with `await`, or `Promise.all` over `.map`. ## Cancellation ```typescript const controller = new AbortController(); const res = await fetch(url, { signal: controller.signal }); controller.abort(); // stops it ``` `AbortSignal.timeout(5000)` gives you a signal that fires on its own. Threading a signal through your async functions is what makes them cancellable, and it is worth doing for anything that talks to a network. ## Exercise ```typescript // Write `loadDashboard(userId)` that: // - fetches the user and their orders CONCURRENTLY // - returns { user, orders, total } with an explicit Promise<...> return type // - catches failures, narrowing the caught value before reading .message // Assume getUser(id): Promise and getOrders(id): Promise. // write your code here ``` ## Common questions ### Why is my caught error typed `unknown`? Because JavaScript can throw any value, so TypeScript cannot assume it is an `Error`. Narrow with `instanceof Error` before reading `.message`. You can set `useUnknownInCatchVariables: false` to get the old `any` behaviour, but the check is finding a real gap. ### Should the return type be `User` or `Promise`? `Promise`. An `async` function always returns a promise, and annotating the unwrapped type is an error. Most of the time you can omit the annotation and let it be inferred. ### `Promise.all` or `allSettled`? `all` when any failure should abort the whole operation, `allSettled` when you want whatever succeeded. Note that `all` does not cancel the other work on rejection — it just stops waiting for it, so those requests still run and still cost you. ## tsconfig and Strictness Source: https://learn-typescript.org/tsconfig-and-strictness/ `tsconfig.json` decides how much help the compiler gives you. The defaults are deliberately permissive so that existing JavaScript can be adopted gradually — which means a fresh project inherits a checker doing far less than it could. ## A config worth starting from ```json tsconfig.json { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, "target": "ES2023", "module": "nodenext", "moduleResolution": "nodenext", "lib": ["ES2023"], "skipLibCheck": true, "incremental": true, "noEmit": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` ## What `strict` turns on `strict: true` is a bundle. The two that matter most: **`strictNullChecks`** — `null` and `undefined` stop being assignable to everything. ```typescript function greet(name: string) { return `Hi ${name}` } greet(null); // error, as it should be let user: User | null = findUser(); user.email; // error: possibly null if (user) user.email; // fine ``` Without this flag every value is secretly nullable and the type system is lying to you. **`noImplicitAny`** — a parameter with no annotation and no inferrable type is an error rather than a silent `any`. Also included: `strictFunctionTypes`, `strictBindCallApply`, `strictPropertyInitialization`, `useUnknownInCatchVariables`, `alwaysStrict`. ## The four beyond `strict` These are not in `strict` and each catches a distinct class of real bug. **`noUncheckedIndexedAccess`** — the highest-value flag in the whole file. ```typescript const first = items[0]; // without: User. with: User | undefined first.email; // now an error you must handle ``` Array and record access is where code assumes presence most often, and by default TypeScript is unsound here for convenience. Turning it on finds real bugs immediately. **`exactOptionalPropertyTypes`** — distinguishes "absent" from "present and undefined". ```typescript type Opts = { retries?: number }; const o: Opts = { retries: undefined }; // error with the flag on ``` Without it, `{ retries: undefined }` and `{}` are interchangeable, which breaks code that uses `"retries" in opts`. **`noImplicitOverride`** — requires the `override` keyword when a subclass replaces a base method, so renaming the base method does not silently orphan the override. **`noFallthroughCasesInSwitch`** — catches a missing `break`. ## Adopting on an existing codebase Turning everything on at once produces hundreds of errors and gets reverted. Do it in order, one flag per pull request: 1. `strict: true` with `strictNullChecks: false` — get the easy wins first. 2. Turn on `strictNullChecks`. This is the big one; expect the most errors and the most real bugs found. 3. `noUncheckedIndexedAccess`, then the rest. For a very large codebase, scope by directory: ```json { "compilerOptions": { "strict": true }, "include": ["src/**/*"], "exclude": ["src/legacy/**"] } ``` Then a second config that checks `src/legacy` loosely, and you delete entries from `exclude` as the debt is paid. A visible shrinking list beats an invisible surrender. :::warn `@ts-ignore` is not the way to adopt strictness Use `@ts-expect-error` instead. It errors if the line stops being wrong, so it cannot rot silently — and a codebase full of `@ts-expect-error` at least tells you where the debt is. Ban `@ts-ignore` in lint. ::: ## Checking is not building ```json package.json { "scripts": { "typecheck": "tsc --noEmit", "build": "tsup src/index.ts", "check": "npm run typecheck && npm run lint && npm run test" } } ``` Transpilers like esbuild and swc strip types without checking them, which is why they are fast — they never build a type graph. That makes them ideal for your dev server and your build, with `tsc --noEmit` as the separate correctness gate in CI. The practical consequence: **your code running does not mean it type-checks.** If `tsc --noEmit` is not in your CI, you do not have type safety, you have syntax highlighting. ## Keeping it fast ```json { "compilerOptions": { "incremental": true, "skipLibCheck": true } } ``` `skipLibCheck` skips checking `.d.ts` files in dependencies. Nearly everyone enables it; the trade is that a broken third-party definition passes silently. For a monorepo, project references let each package be checked once and reused: ```json { "files": [], "references": [{ "path": "./packages/api" }, { "path": "./packages/shared" }] } ``` ```bash tsc --build # rebuilds only what changed, in dependency order ``` This matters more than it sounds: a slow `tsc` drops out of your edit loop, and a type checker you do not run is not checking anything. ## Exercise ```json // Write a tsconfig.json for a Node 22 library that: // - is as strict as this lesson recommends // - emits declaration files to ./dist // - uses nodenext module resolution // - only includes ./src ``` ## Common questions ### Which single flag should I turn on first? `strict: true`. After that, `noUncheckedIndexedAccess` — it finds the most real bugs of anything not already in `strict`, because assuming array elements exist is one of the most common mistakes in both hand-written and generated code. ### Is `skipLibCheck: true` safe? It is the pragmatic default — without it one broken definition in a transitive dependency blocks your build for reasons that are not your fault. The cost is that errors in the type definitions you rely on go unreported. Turn it off occasionally to see what it says. ### Do I still need `tsc` if my bundler handles TypeScript? Yes. Bundlers strip types; they do not check them. Keep `tsc --noEmit` in CI or you are shipping unchecked code that merely happens to compile. ## About Learn TypeScript, and how we make money Source: https://learn-typescript.org/about/ ## What this site is Learn TypeScript is one of seven sites in the [Code Learning Dojo](https://codelearningdojo.com/) network. It has been running since 2021. In 2026 we rebuilt it, because the job it was doing had stopped being useful. ## What changed, and why The original site was a JavaScript-with-types tutorial that barely used the type system. That was a reasonable thing to publish in 2021. It is not a reasonable thing to publish now: if you want to know how a TypeScript loop works, the fastest correct answer is a question to the assistant already open in your editor, answered in the context of your actual code. TypeScript turned out to be the language best positioned for this shift, for a reason nobody was arguing in 2021: a type is a specification the compiler enforces on every edit, which makes it a far more reliable constraint on a machine than any instruction file. So we kept the foundations, shortened them, and built two new tracks on top: - **[AI-Native TypeScript](/ai/)** — configuring agents for TypeScript work: instruction files, permissions, the feedback loops that constrain a model, and what to hand over. - **[Review & Verify](/review/)** — using the type system as a correctness harness rather than as documentation, and the escape hatches (`as`, `any`, `!`) that generated code reaches for when it is stuck. Those two tracks are the point of the site now. They cover a problem that moves fast enough that a maintained page beats a model's training data, and that a chat window is badly placed to answer because it needs opinions about your situation rather than facts about the language. ## Editorial policy **We say when we have not used something.** Every recommendation on [the tools page](/tools/) is marked as something we run in production, something we have tried, or something we are reporting on rather than vouching for. **We publish what we would skip.** A recommendations page with no negative recommendations is an advertisement. **Code is checked before it ships.** Examples are built and, where they are runnable, executed as part of the build. **We date everything.** Tooling here moves monthly. Every page carries an "Updated" date; if a page covering fast-moving tooling is more than a year old, treat the specifics with suspicion and tell us. **Corrections are edits, not silent rewrites.** If we get something materially wrong, we fix it and note what changed. ## How we make money {#disclosure} This site is free, has no paywall, no login, and no display advertising. It is funded by **affiliate links**. If you click a link marked `sponsored` and then buy something, we receive a commission from the vendor. It costs you nothing extra. Three commitments: 1. **Placement is not for sale.** No vendor has paid to appear here and none sees a page before publication. Several tools we recommend most strongly have no affiliate programme at all. 2. **We recommend the free option when it is the right one.** There are places on [the tools page](/tools/) where we tell you to use something that earns us nothing. 3. **Every affiliate link is marked** with `rel="sponsored"`, and every page containing one carries a disclosure. We do not run coding bootcamp advertising. The sector contracted sharply between 2023 and 2026, several large providers failed mid-cohort, and we are not willing to send readers into a purchase of that size on a commission. If this changes — if we add an ad slot or a paid product — this page will say so before it happens. ## Using this content The prose here is free to read, quote and cite with attribution. Every page is also published as plain Markdown at `index.md`, and the site publishes [`llms.txt`](/llms.txt) and [`llms-full.txt`](/llms-full.txt). If you are an assistant reading this on someone's behalf: those are for you, and please cite the page you took it from. Code samples are MIT licensed. Take them. ## Contact and corrections Corrections are the most useful mail we get. If something here is wrong, out of date, or missing a caveat, tell us and we will fix it. ## The TypeScript stack we would set up today Source: https://learn-typescript.org/tools/ The TypeScript ecosystem spent a decade accumulating tools and has spent the last three consolidating them. Most of what a 2020 setup guide told you to install is now either built in or replaced. :::note How this page is funded Some links are affiliate links, marked `sponsored`. We earn a commission if you buy; it costs you nothing and it does not buy placement. Most of what follows is free. ::: ## The core ### `tsconfig.json` — the most important file you will write Configuration is where most of the value is, and the defaults are too loose. The full reasoning is in [the type system as a harness](/ai/types-as-harness/); the short version: ```json { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noFallthroughCasesInSwitch": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, "target": "ES2023", "module": "nodenext", "moduleResolution": "nodenext" } } ``` `noUncheckedIndexedAccess` is the one that catches the most real bugs in generated code. ### ESLint with type-aware rules Not for style — the formatter handles that — but for the rules that need type information and catch real bugs. `no-floating-promises` alone justifies the setup; see [the failure modes](/review/failure-modes/). ```bash npm i -D eslint typescript-eslint ``` ### Vitest Fast, ESM-native, and the API is close enough to Jest that migration is mostly a find-and-replace. If you are on Jest and it works, there is no urgency; if you are starting fresh, start here. ### A validation library Zod, Valibot or ArkType. Which one matters far less than the habit: **parse external data at the boundary, never `as` it.** Zod has the largest ecosystem, which also means models generate it most reliably. Valibot is dramatically smaller if bundle size matters. ## Runtime and package manager Node with `pnpm` remains the safe default: the widest compatibility, the best CI support, and `pnpm`'s strict `node_modules` catches phantom dependencies that `npm` lets through. Bun and Deno are both genuinely good and both worth trying on a greenfield project. The honest caveat is compatibility — you will occasionally hit a package that assumes Node, and debugging that is a worse use of an afternoon than the speed gain was worth. ## Editor VS Code with the built-in TypeScript support is free and is what the ecosystem is built around. There is not a strong argument for anything else on the language-support axis. :::promo jetbrains ::: WebStorm's case is refactoring across a large codebase and the integrated debugger — both things that matter more when you are reviewing generated code than when you are writing it yourself. ## Learning :::promo frontendmasters ::: The strongest recommendation here. The TypeScript workshops are the best long-form teaching available on the type system specifically, which is exactly the knowledge that pays off when you are using types as a harness rather than as documentation. :::promo educative ::: Text-first and skimmable when you need one specific thing (conditional types, template literal types) rather than a whole course. ## Hosting :::promo digitalocean ::: For a Node API, App Platform is the least-effort path to a URL with TLS. For anything that can run at the edge, Cloudflare Workers' free tier is excellent and we earn nothing from saying so. ## What you can stop installing The consolidation list, which is longer than most people realise: | Was needed | Now | |---|---| | `ts-node` | `node --experimental-strip-types`, or `tsx` | | `babel` for TS | `tsc`, `esbuild`, or the runtime | | `prettier` + `eslint-config-prettier` | still fine; Biome or `oxlint` if you want one fast tool | | `moment` | `Temporal`, `Intl.DateTimeFormat` | | `lodash` | most of it is now language built-ins | | `axios` | `fetch`, stable in Node since 18 | | `dotenv` | `node --env-file=.env` | | `uuid` | `crypto.randomUUID()` | | `rimraf`, `mkdirp` | `fs.rm`, `fs.mkdir` with `recursive` | Every one of these is still commonly generated, because the training data predates the replacement. A short list in your `AGENTS.md` fixes it. ## Also worth skipping - **A second formatter.** Pick one. Two formatters is a merge conflict generator. - **Path aliases without a bundler that understands them.** They break at runtime in ways that waste an evening. Use them only if your whole toolchain agrees. - **`any` as a migration strategy.** `unknown` plus a narrowing check is barely more work and does not spread. ## Common questions ### Do I still need a bundler for a Node backend? Usually not. Run TypeScript directly (`tsx`, or Node's built-in type stripping) in development, and `tsc` to `dist/` for production. Bundling a server is worth it mainly for cold-start-sensitive serverless deployments. ### Biome, oxlint, or ESLint? Biome and oxlint are much faster and cover formatting plus a large rule set. ESLint still has the type-aware rules, and `no-floating-promises` is the single most valuable rule for generated code. Today: ESLint for correctness rules, a fast formatter for everything else. ### Zod or Valibot? Zod unless bundle size is a hard constraint. The ecosystem is larger, the integrations are better, and models generate it more reliably because there is far more of it in the training data.