Pustakam Library

Free Programming learning guide

TypeScript for Web Development: A Practical Guide

TypeScript for Web Development: A Practical Guide — a free intermediate-level guide covering learn typescript for web development. Learn with clear...

101 min read12 chaptersintermediate

What you will learn

  1. Type System Fundamentals & Configuration
  2. Interfaces, Types, and Custom Structures
  3. Functions and Advanced Typing Patterns
  4. Generics and Reusable Components
  5. Type Narrowing and Control Flow Analysis
  6. Object-Oriented TypeScript
  7. DOM Manipulation and Browser APIs
  8. Asynchronous TypeScript
  9. TypeScript with React
  10. Module Systems and Declaration Files
  11. Tooling, Linting, and Build Pipelines
  12. Advanced Patterns and Type Gymnastics

1. Type System Fundamentals & Configuration

The JavaScript Safety Net Imagine pushing a critical hotfix to production on a Friday afternoon. The code runs perfectly in your local environment, passes all automated tests, and deploys without a hitch. Twenty minutes later, the error tracker lights up: TypeError: Cannot read properties of undefined (reading 'map'). A backend API endpoint subtly changed its response payload, returning a single object instead of an array. Because JavaScript dynamically accepts almost anything at runtime, the bug lay dormant until the exact line of code executed for a real user. This scenario is the daily reality of maintaining large JavaScript codebases. TypeScript exists to catch these failures at compile-time—before they ever reach the browser. By adding a static type system on top of JavaScript, TypeScript enforces constraints at authoring and build time, turning a class of silent runtime errors into immediate editor feedback. For an intermediate developer, the challenge isn't understanding what a "string" or a "number" is. The challenge lies in configuring the compiler to enforce strictness without getting in the way, understanding how TypeScript evaluates types, and knowing when to let the compiler work for you versus when to take manual control. Configuring tsconfig.json for Modern Web Projects A TypeScript project is defined by its tsconfig.json file. This configuration tells the compiler which files to include, how strictly to check them, and what kind of JavaScript to emit. For modern web development, a generic tsc --init is rarely sufficient. You need a configuration tailored to catch bugs early while remaining compatible with modern toolchains like Vite, Webpack, or esbuild. The Strictness Flag The single most important setting in a modern tsconfig.json is "strict": true. This master flag enables a family of stricter type-checking options: - noImplicitAny: Prevents variables from being implicitly typed as any, forcing you to handle unknown data structures explicitly. - strictNullChecks: Makes null and undefined their own distinct types. A variable declared as string can no longer be null unless explicitly typed as string | null. - strictFunctionTypes: Enables contravariant checking of function parameters. - strictBindCallApply: Ensures bind, call, and apply are invoked with correct argument types. If you are migrating a legacy JavaScript project, you might enable these flags one by one. For a new project, always start with strict: true. A Modern Web Configuration When building for the browser, you typically use a bundler to compile and bundle your TypeScript. Therefore, TypeScript's job is purely type-checking, not code emission. Here is a robust starting tsconfig.json for a modern web project: Let's break down the non-obvious choices: - target: "ES2022": Tells the compiler to assume the target environment supports modern features like top-level await and class fields. Because a separate bundler (like Vite) handles transpilation down …

2. Interfaces, Types, and Custom Structures

Defining Data Shapes Imagine you are building the checkout flow for an e-commerce platform. The data flowing through this system is multifaceted: a shopping cart containing various products, user shipping details, payment metadata, and API responses. If you leave these shapes to inference alone, a typo like billngAddress instead of billingAddress won't be caught until it crashes your payment gateway in production. To build robust web applications, you must explicitly define the contracts of your data. TypeScript provides two primary mechanisms for this: type aliases and interfaces. Both allow you to create custom, reusable structures, but they have distinct syntactic nuances and capabilities that influence how you architect your codebase. Type Aliases vs. Interfaces As we established in the first chapter, TypeScript is a structural type system. This means its primary concern is the shape of the data. If an object has the required properties, TypeScript considers it valid, regardless of how it was defined. Both type aliases and interfaces serve to describe these shapes. A type alias creates a name for any type—primitives, unions, tuples, or objects. An interface is a construct specifically designed to define the shape of objects. Here is a quick look at defining a simple object shape using both approaches: In everyday scenarios, these two definitions are functionally identical. TypeScript will allow you to assign an object with id, name, and price to a variable typed as either ProductType or ProductInterface. Because TypeScript is structural, it doesn't care which keyword you used to define the shape. So, when do you choose one over the other? - Use a type alias when you need to define unions, intersections, primitives, or tuples. Interfaces cannot do this. For example, type Status = "pending" | "shipped" is only possible with a type alias. - Use an interface when you are defining purely object shapes, especially if you intend to build hierarchical data models through inheritance. Interfaces offer a cleaner syntax for extension and "declaration merging." Modifying Object Shapes Whether you use a type alias or an interface, you will frequently need to modify how properties behave. TypeScript provides several modifiers to enforce stricter rules on your data structures. Optional and Readonly Properties In real-world data, not all fields are required, and some fields should never be changed after initialization. You handle these cases using the ? (optional) and readonly modifiers. Using readonly is highly recommended for properties like database IDs or timestamps. It prevents accidental mutations during runtime, which is a common source of bugs in complex state management (like in React or Redux flows). Note that readonly is enforced at compile-time; if you need deep immutability, you will need to apply it recursively or use utility types (covered …

3. Functions and Advanced Typing Patterns

Function Signatures: Parameters and Returns A web application is essentially a pipeline of data transformations, and functions are the segments of that pipe. In Chapter 1, we saw how type inference eliminates boilerplate by deducing types from context. When defining functions, inference is highly effective for return types: if your function returns a string or a Promise<User, TypeScript knows without being told. However, inference is not your friend for function parameters. Without an explicit annotation, a parameter implicitly becomes any, which violates the noImplicitAny rule established in your tsconfig.json. Therefore, the fundamental rule of TypeScript functions is: always annotate parameters, let inference handle the return type—unless the return type is ambiguous. Consider a basic function signature: Here, amount and currency require explicit annotations. The : string return type is technically redundant due to inference, but providing it is useful when you want to enforce a contract that prevents future implementations from accidentally changing the return type. Void and Never Two return types often trip up intermediate developers because they seem to represent "nothing." However, void and never serve fundamentally different purposes in TypeScript's type system. void indicates that a function executes its side effects but does not return a value. It is the standard return type for callbacks like setTimeout or event handlers. Importantly, a function returning void can still return a value (e.g., return undefined;), but the type system guarantees the caller cannot assign or use that returned value. never represents a value that will never occur. A function returning never cannot reach its end point. This happens in two scenarios: 1. The function throws an error. 2. The function contains an infinite loop. While never might seem academic, it is a critical tool for exhaustive type checking—a pattern we will see more when combining types and handling control flow in later chapters. If a function is expected to return never but TypeScript determines it can actually finish executing, the compiler will throw an error. Flexible Parameters Web APIs rarely conform to rigid parameter lists. You need mechanisms to handle optional configurations, default values, and variadic arguments. Optional and Default Parameters You can make a parameter optional by appending a ? to its name. Optional parameters must always come after required parameters in the signature. Alternatively, you can provide a default value. When you provide a default, TypeScript automatically infers the parameter's type from the default value, and you do not need to use the ? modifier. Default parameters do not count toward the required parameter count. Rest Parameters When a function accepts an indefinite number of arguments, you use rest parameters (via the spread operator ...). In TypeScript, you must type rest parameters as an array. Because we …

4. Generics and Reusable Components

The Problem with "Any" and the Need for Abstraction Imagine you are building a data-fetching utility for a web application. You need a function that retrieves a user, another that fetches a list of products, and a third that pulls in application settings. Without generics, you face a bleak choice: write three identical functions that differ only in their return types, or write a single function that returns any. As we established in Chapter 1, relying on any effectively turns off the type checker. Under noImplicitAny, explicitly typing things as any is a conscious opt-out of TypeScript’s safety net. You lose type inference, autocomplete, and compile-time guarantees. Generics solve this dilemma. They allow you to write a component that can work over a variety of types rather than a single one, while still preserving strict type safety. Think of generics as type variables—a way to pass types as parameters into your functions, interfaces, and classes. Generic Functions A generic function declares a type parameter (commonly T, U, V) that it captures from the input and uses to define its output. Let’s look at a practical example: a function that retrieves the first item from an array. Inference vs. Explicit Annotations In Chapter 1, we established that inference is your friend. When calling a generic function, you usually don't need to explicitly provide the type parameter. TypeScript infers T from the provided arguments. However, use explicit annotations when the inference isn't sufficient or you want to enforce a specific type down the chain. For instance, if you are initializing an API client or a state container that might initially be empty: Generic Interfaces and Classes Just as functions can be generic, so can interfaces and classes. This is particularly useful for building data structures and API response wrappers. Building a Generic API Response Wrapper When interacting with REST APIs, responses usually share a common envelope (e.g., status codes, metadata) but contain different payload shapes. We can model this using a generic interface: Generic Data Structures Generics shine when creating reusable data structures. Consider a simple Stack class: Because we referenced the Tuple Pitfall and structural typing in previous chapters, it's worth noting that TypeScript’s structural type system applies to generics as well. A Stack<number is structurally identical to any other class that implements push(item: number) and pop(): number | undefined, regardless of what the class is actually named. Applying Constraints (extends) Left unchecked, a generic type T can be literally anything. While flexible, this limits what you can do inside the function. If you try to access a property on T, TypeScript will complain because it doesn't know if that property exists. To solve this, we apply constraints using the extends …

5. Type Narrowing and Control Flow Analysis

The Problem with Broad Types Imagine you are building a payment processing component. The function accepts a payload that could be one of several shapes: a credit card object, a bank transfer object, or a digital wallet object. You define a union type to represent these possibilities: Because TypeScript’s structural type system allows payment to be any of those three shapes, accessing payment.cardNumber directly results in a type error. The property doesn't exist on the bank or wallet variants. To solve this, TypeScript needs to safely narrow the broad union type down to a specific, predictable shape. This is where type guards and control flow analysis come in. Understanding Control Flow-Based Type Analysis In earlier chapters, we relied heavily on type inference to let TypeScript figure out the types of our variables. TypeScript doesn't just infer a variable's type and forget it; it actively tracks how that variable is used throughout your code. This continuous tracking is called control flow-based type analysis. As your code executes through conditionals (if, else, switch), loops (for, while), and assignments, TypeScript constantly updates the narrowed type of a variable. Consider this simple example: Because strictNullChecks is typically enabled in robust TypeScript configurations, control flow analysis also handles null and undefined. If you check for a nullish value, TypeScript removes it from the union: Type Guards: The Mechanics of Narrowing Type guards are expressions that perform a runtime check that guarantees a type in a specific scope. TypeScript recognizes several built-in type guards and allows you to build your own. typeof Guards The typeof operator is the most fundamental type guard, perfect for narrowing primitive types. It works seamlessly with TypeScript's control flow analysis. in Guards When working with object types—like the interfaces and custom structures covered in Chapter 2—the in operator checks for the existence of a property. This is highly effective for narrowing object unions where certain properties are unique to specific variants. instanceof Guards While in and typeof work on structural shapes and primitives, instanceof checks an object's prototype chain. This is particularly useful when dealing with class instances or built-in JavaScript errors. User-Defined Type Guards Sometimes, a simple typeof or in check isn't enough. You might need to validate complex object shapes or check multiple properties. TypeScript allows you to write custom type guard functions using a special return type: parameterName is Type. By using res is ErrorResponse, you are explicitly telling TypeScript that if this function returns true, the runtime type of res can be safely narrowed. Discriminated Unions for Predictable State While in checks work, they can become brittle if object shapes overlap. The most robust way to manage state in TypeScript is through discriminated unions. A discriminated union …

6. Object-Oriented TypeScript

Classes and Access Modifiers Imagine you are building a payment processing system. You have a base Payment class that handles logging and validation, but you quickly realize that exposing the raw transaction validation logic to every consumer of your class leads to bypassed security checks. You need a way to strictly encapsulate state and behavior, exposing only what the outside world needs to interact with. While TypeScript utilizes a structural type system for general types (as covered in Interfaces, Types, and Custom Structures), its class system introduces a layer of nominal typing through access modifiers. This allows you to enforce strict encapsulation and control exactly how state is mutated and accessed. The Modern Class Syntax If you have been using modern JavaScript (specifically targeting target: "ES2022"), you are likely familiar with class fields. TypeScript builds on this foundation, adding type annotations and access modifiers directly into the syntax. Enforcing Encapsulation with Modifiers TypeScript provides three primary access modifiers to control the visibility of class members: - public: The member is accessible from anywhere. This is the default if no modifier is specified, but explicitly marking it often aids readability. - private: The member is accessible only within the class it is declared in. TypeScript also enforces this at compile-time, preventing external code from accessing it. (Note: JavaScript's private syntax is fully supported and provides runtime encapsulation, but the private keyword is still widely used for its flexibility with inheritance and reflection). - protected: The member is accessible within the class it is declared in and within any class that derives (extends) from it. Consider a scenario where we need to manage sensitive configuration. We want derived classes to be able to hook into the update lifecycle, but we do not want them to overwrite the configuration state directly. Strict Property Initialization Because we operate under strict compiler flags (like strictNullChecks), TypeScript requires that class properties are definitively assigned before they are accessed. Type inference will not save you here; if a property is declared but not initialized in the constructor, the compiler will throw an error. You have three ways to satisfy the strict property initializer check: 1. Initialize it inline: private status: string = "idle"; 2. Assign it in the constructor. 3. Use the definite assignment assertion operator (!), which tells the compiler, "Trust me, this will be assigned before it is used (often by a framework or decorator)." Implementing Interfaces in Classes In Interfaces, Types, and Custom Structures, we explored how interfaces define the shape of objects. In an object-oriented context, interfaces act as strict contracts for your classes. By using the implements keyword, you instruct TypeScript to verify that a class satisfies a specific interface. Strict Contracts …

7. DOM Manipulation and Browser APIs

The DOM is a Typed API The Document Object Model (DOM) has historically been a minefield for JavaScript developers. You query an element, assume it’s a button, try to access its value property, and watch the application crash at runtime because the element was actually a generic div—or worse, null because the selector didn't match anything. TypeScript acts as a safety net here. Because the DOM is a structured API with well-defined interfaces, TypeScript ships with built-in type definitions for every native DOM object. When you combine these definitions with the strict compiler flags established in Type System Fundamentals & Configuration, the compiler forces you to prove an element exists and is the correct type before you interact with it. Selecting Elements and the HTMLElement Hierarchy When you use document.getElementById or document.querySelector, TypeScript relies on its structural type system to infer the return type. For getElementById, the return type is HTMLElement | null. HTMLElement is a base interface. While it includes properties like id, className, and standard event listeners, it lacks properties specific to specific tags. If you grab a button by its ID, TypeScript still only knows it's an HTMLElement unless you tell it otherwise. To fix this, you must use a type assertion to narrow the element to its specific interface, such as HTMLButtonElement. With querySelector, TypeScript is slightly smarter. It parses the CSS selector string literal to infer the element type. If you use a tag selector, TypeScript automatically infers the specific element type. However, if you use a class or ID selector, it falls back to the generic Element or HTMLElement. Rely on inference when: using tag-name selectors with querySelector. Use explicit annotations when: using ID or class selectors where you need access to tag-specific properties. Safe DOM Interactions With strictNullChecks enabled, the compiler refuses to let you interact with an element that might be null. This eliminates the dreaded "Cannot read properties of null" runtime error. The Assertion vs. Guard Dilemma A common anti-pattern when developers first adopt TypeScript is using the non-null assertion operator (!) to bypass null checks. While ! satisfies the compiler, it defeats the purpose of strict null safety. Instead, leverage Type Narrowing and Control Flow Analysis to handle absence gracefully. querySelector is a generic function. By providing a generic parameter—querySelector<HTMLInputElement(...)—you can assert the specific element type while still respecting the | null union in the return type. This is generally preferred over as assertions because it reads more cleanly and maintains the null check. Typing Event Listeners DOM events are another area where TypeScript's type inference shines. When you attach an event listener, the Event object passed to your callback is typed based on the event name. If you extract …

8. Asynchronous TypeScript

The Anatomy of a Typed Promise A JavaScript Promise is inherently generic. When you write new Promise((resolve) = resolve(42)), TypeScript’s type inference inspects the resolve argument and assigns the promise the type Promise<number. Because we established in Generics and Reusable Components that generics capture types for later use, a Promise<T simply means: "I will eventually yield a value of type T, or I will fail." While TypeScript’s inference is excellent at tracking resolved values, intermediate learners often encounter friction when typing rejections. Explicit Resolutions and Rejections Consider a function that retrieves a user profile from a cache. We can explicitly define the return type using the Promise interface combined with the structures we learned in Interfaces, Types, and Custom Structures. Notice the explicit Promise<User annotation. Relying on inference here is risky; if the resolve call were accidentally omitted or passed a string, TypeScript would infer Promise<string or Promise<void, silently breaking downstream code. As a rule of thumb from Type System Fundamentals & Configuration: rely on inference for local variables, but use explicit annotations for function boundaries—especially async ones. The PromiseConstructor and Typing Rejections There is a fundamental asymmetry in TypeScript's Promise typing: the generic parameter T strictly types the resolution value, but it cannot type the rejection reason. A promise's catch handler (or try/catch block) always receives any (or unknown under stricter configurations). Because of this, rejecting a promise with a custom object instead of a native Error is a common source of runtime bugs. If you reject with a string, TypeScript will not stop you, but your downstream .catch() handler might crash expecting an Error object. To handle asynchronous failure robustly, we need to establish a pattern for custom errors. Designing Custom Error Types for Async Failures Native JavaScript errors (Error, TypeError, RangeError) provide a stack trace and a message, but they lack the context necessary for robust API error handling. When an API request fails, we usually want the HTTP status code, the endpoint URL, and perhaps the server's error payload. In Object-Oriented TypeScript, we covered how to extend base classes. We can apply this to the native Error class to create a domain-specific error hierarchy for our asynchronous operations. Creating an API Error Hierarchy When extending the Error class in TypeScript (especially when targeting older environments, though we are targeting ES2022), you must be careful with prototype chain manipulation to ensure instanceof checks work correctly. By defining these custom errors, we give our application a predictable shape for failures. Later, when we use Type Narrowing and Control Flow Analysis, TypeScript will allow us to safely access error.statusCode inside a catch block after an instanceof check. Implementing Typed async/await for Data Fetching The async/await syntax is syntactic sugar …

9. TypeScript with React

Typing Component Props and State React's component model maps cleanly to TypeScript's structural type system. Because TypeScript uses duck typing, a React component doesn't care about the specific name of a prop's type—it only cares that the prop has the expected shape. When defining functional components, you should explicitly type the props object. While React provides a generic React.FC (Function Component) type, the modern React community largely favors defining props as a plain interface or type alias and applying it directly to the parameter. This avoids some historical quirks with React.FC (such as implicitly including children in older TypeScript versions) and keeps the syntax clean. Handling Component State For basic state, type inference does all the heavy lifting. If you initialize useState with a primitive value or a concrete object, TypeScript infers the state type and the setter type automatically. However, you must provide explicit type arguments to useState in two common scenarios: 1. Future State: The initial state is null or undefined, but it will eventually hold a value. 2. Complex Unions: The state can be one of several distinct shapes. When typing useState, remember that the setter accepts a partial update if you provide a callback, but requires the full shape if you pass a value directly. If you are storing objects, ensure your type accurately reflects what will be stored. Props with Children and Render Props When your component wraps other elements, you need to type the children prop. React exports a specific type for this: React.ReactNode. ReactNode is broader than string or JSX.Element because it includes null, undefined, boolean, and arrays of these types—everything React knows how to render. For render props—where a function is passed as a prop to handle rendering logic—you apply the function typing patterns covered previously. Typing Custom Hooks Custom hooks are just functions that utilize React's primitive hooks. Because they return dynamic data, typing their return values explicitly is crucial for the consuming components to benefit from TypeScript's type narrowing and control flow analysis. If a hook returns an array (like the built-in useState), TypeScript will infer it as a standard array, not a tuple. This is a common pitfall: an array type [T, U] means "an array of T or U," whereas a tuple type [T, U] means "an array of exactly two elements, the first of type T and the second of type U." You must explicitly define the return type as a tuple. When consuming this hook, TypeScript understands that destructuring const [data, loading, error] = useFetch<User('/api/user') yields a User | null, a boolean, and an Error | null respectively. If your hook returns an object, inference works perfectly without explicit annotations, but providing an explicit return type …

10. Module Systems and Declaration Files

The Global Scope Problem and Modern Modules Imagine you've just integrated a legacy JavaScript date formatting library into your TypeScript project. You add <script src="date-formatter.js"</script to your HTML, write a function that calls formatDate(new Date()), and TypeScript immediately throws an error: Cannot find name 'formatDate'. The function exists in the global scope at runtime, but TypeScript has no idea what it is, what arguments it accepts, or what it returns. This scenario highlights the two core challenges of scaling a TypeScript application: organizing your own code across files, and communicating type information to the compiler when consuming untyped JavaScript. In modern web development, we solve the first challenge using ES modules, and the second using declaration files. ES Modules vs. Namespaces Before ES modules were standardized in ECMAScript 2015, TypeScript introduced its own mechanism for organizing code called namespaces (previously known as internal modules). Namespaces use a simple object-oriented approach to group related code under a single global object. While namespaces are still supported, they are considered a legacy pattern for modern web development. They rely on global scope pollution and require complex <script tag ordering in your HTML. Today, ES modules are the universal standard for both JavaScript and TypeScript. ES modules use a file-based scoping mechanism. Every file is its own module, and you explicitly define what is shared using export and import statements. This aligns perfectly with modern bundlers (like Webpack, Vite, and esbuild) and native browser support. When to use which: - ES Modules: Use this for all modern web development. It integrates seamlessly with tree-shaking, lazy loading, and modern bundler pipelines. - Namespaces: Only use these when maintaining legacy TypeScript codebases or when generating declaration files for specific global script setups. Importing and Exporting Across Boundaries As established in Interfaces, Types, and Custom Structures, TypeScript allows you to define custom types. Moving these types and their associated logic across file boundaries requires specific syntax, especially under strict modern compiler settings. The import type Distinction In Type System Fundamentals & Configuration, we discussed isolatedModules and verbatimModuleSyntax. When verbatimModuleSyntax is enabled, TypeScript strictly enforces that you do not mix runtime values and compile-time types in the same import statement. This forces you to use import type for type-only imports. This distinction is vital. Bundlers use this syntax to safely strip types during transpilation without accidentally dropping a runtime import or failing to erase a type that has no runtime presence. Re-exporting for Public APIs When organizing code, you often split features into multiple files but want to expose a single, clean entry point. You can re-export types and values using export ... from. Consumers can now import directly from the components directory, keeping import paths clean and …

11. Tooling, Linting, and Build Pipelines

ESLint: Parsing and TypeScript-Specific Rules You’ve written a robust React component library. Your types are airtight, your generics are reusable, and you’ve confidently configured strictNullChecks and strictFunctionTypes in your tsconfig.json. Yet, during a routine code review, a colleague points out that a developer imported a default export from a module that only exports named ones, and another developer used a floating promise—forgetting to await an async function entirely. TypeScript didn't catch these issues. Why? Because TypeScript is a type checker, not a linter. To enforce code quality, catch logical errors, and maintain stylistic consistency across a codebase, you need a dedicated linter. For TypeScript projects, the industry standard is ESLint configured with TypeScript-specific parsing and rules. Configuring the TypeScript Parser Out of the box, ESLint does not understand TypeScript syntax. It will throw parsing errors the moment it encounters a type annotation, an interface, or a generic parameter. To bridge this gap, you use @typescript-eslint/parser. This package takes your TypeScript code, converts it into an Abstract Syntax Tree (AST) that ESLint can understand, and allows ESLint to evaluate your code against its rules. To get started, you typically install ESLint alongside the parser and the plugin that provides TypeScript-specific rules: Historically, ESLint configuration was done via .eslintrc.json or .eslintrc.js. Modern versions of ESLint (v9+) use a flat configuration system (eslint.config.js), which simplifies plugin resolution and avoids complex hierarchical overrides. Here is what a baseline flat configuration looks like for a TypeScript project: Type-Aware vs. Syntax-Only Rules The @typescript-eslint plugin provides two tiers of rules: syntax-only and type-aware. Syntax-only rules evaluate the AST without needing to understand the underlying types. For example, the rule @typescript-eslint/no-unused-vars simply checks if a variable declaration exists without being referenced. Type-aware rules, on the other hand, use the TypeScript compiler API under the hood to understand type relationships. This is incredibly powerful. A prime example is @typescript-eslint/no-floating-promises. Because JavaScript allows you to call an async function without awaiting it, a forgotten promise can silently swallow errors. TypeScript won't catch this by default, but a type-aware ESLint rule will. To enable type-aware rules, you must point the parser to your tsconfig.json by setting project: true (or explicitly providing the path) in parserOptions. This allows ESLint to leverage the exact same type information you configured back in Type System Fundamentals & Configuration. Extending and Fine-Tuning Rules The tseslint.configs.recommended config turns on a baseline of sensible rules. However, for an intermediate project, you often want stricter enforcement. You can step up to tseslint.configs.strict or tseslint.configs.stylistic, or manually override specific rules: By integrating these rules, your linter acts as an intelligent gatekeeper, catching runtime hazards and architectural inconsistencies that the TypeScript compiler is intentionally designed to ignore. Integrating Prettier for …

12. Advanced Patterns and Type Gymnastics

Imagine you are building a strongly-typed HTTP client. You want to give developers an API where the return type of a request is automatically inferred based on the URL they pass in. If they call client.get("/users"), they should get back a User[]. If they call client.get("/users/123"), they should get a single User. On the surface, this seems to require runtime parsing of the string to determine the type. But with TypeScript’s advanced type-level programming features, you can compute this entirely at compile time. We’ve spent the last eleven chapters building a solid foundation—from configuring the compiler and understanding structural typing to mastering generics and control flow analysis. Now, we reach the apex of TypeScript’s type system: type gymnastics. This is where types stop being mere annotations and become a functional programming language of their own. Conditional Types: The Type-Level if/else At the heart of dynamic type logic is the conditional type. Syntactically, it mirrors the ternary operator in JavaScript. It allows the type system to make a choice based on the relationship between two types. The basic syntax is T extends U ? X : Y. If T is assignable to U, the type resolves to X; otherwise, it resolves to Y. Distributive Conditional Types When a conditional type operates on a generic union type, it automatically distributes the logic across each member of the union. This is a critical behavior to grasp for building custom utilities. Notice that the result is not (string | number)[]. TypeScript evaluated ToArray<string and ToArray<number separately and combined the results into a union. If you want to prevent distribution, wrap the types in square brackets to force tuple evaluation: The infer Keyword: Type Extraction Conditional types become vastly more powerful when combined with the infer keyword. Placed within the extends clause of a conditional type, infer allows you to declare a new type variable that TypeScript will figure out based on the structure it is matching. Think of it as a type-level regex capture group. Extracting Return Types You can extract the return type of a function without invoking it: Extracting from Complex Structures You aren't limited to functions. You can infer types from arrays, promises, or deeply nested objects. By combining conditionals and infer, you can traverse and destructure types recursively, tearing apart complex library APIs to extract exactly the piece you need. Template Literal Types: String Manipulation at Compile Time Introduced to allow string manipulation at the type level, template literal types bring the syntax of JavaScript template literals to type definitions. They allow you to concatenate, manipulate, and enforce specific string formats. Combining with Unions and Conditionals When you use a union of strings in a template literal, TypeScript generates every …

Continue learning