diff --git a/AGENTS.md b/AGENTS.md index 81e6521..eceb2cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This file provides guidance for AI agents working with this repository. **CRITICAL MUST DO**: You **MUST** update `AGENTS.md`, `README.md`, and `docs/how-it-works.md` to be in sync with the code after every implementation, refactoring, or feature addition. It is NOT optional. Using the code as the source of truth does *not* mean you can leave the docs outdated. -**Public API surface** (kept deliberately minimal): `linkTo`, `navigate`, `navigateAll`, `defineLinks`, `expandUriTemplate`, plus the supporting types (`ConnectOptions`, `Resource`, `Navigable`, `LinkSpec`, `FetchFactory`, `FetchContext`, `Failure`, `ResponseInfo`, `Simplify`, `Verbosity`, `ExpandUriTemplateConfig`). Everything else in `src/` is internal. The library is pre-release/experimental — breaking the public API is acceptable when it's the right call; just update docs and examples in the same change. +**Public API surface** (kept deliberately minimal): `linkTo`, `navigate`, `navigateAll`, `defineLinks`, `expandUriTemplate`, plus the supporting types (`ApiDefinition`, `ConnectOptions`, `Resource`, `Navigable`, `RootNavigable`, `LinkSpec`, `FetchFactory`, `FetchContext`, `Failure`, `ResponseInfo`, `Simplify`, `Verbosity`, `ExpandUriTemplateConfig`) and the `NavigationError` class (thrown for unknown-link / missing-metadata programming errors). Everything else in `src/` is internal. The library is pre-release/experimental — breaking the public API is acceptable when it's the right call; just update docs and examples in the same change. ## Project Structure & Active Files @@ -14,7 +14,7 @@ The library implements a Type-Safe Hypermedia Client (`typesafe-hypermedia`). ### Core Library -* **`src/type-system.ts`**: **TYPE SYSTEM CORE**. Defines the phantom type system (`Navigable`, `LinkSpec`, `Resource`), public API types (`ConnectOptions`, `RootNavigable`, `LinkedResource`), and helper types. +* **`src/type-system.ts`**: **TYPE SYSTEM CORE**. Defines the phantom type system (`Navigable`, `LinkSpec`, `Resource`), public API types (`ConnectOptions`, `RootNavigable`), internal helpers (`LinkedResource`), and other supporting types. * **`src/navigate.ts`**: **PUBLIC API**. Exports `linkTo`, `navigate`, and `navigateAll`. `navigate` supports both single-link auto-resolve mode (when the navigable has exactly one link) and named-link mode (`{ link: 'name' }`). * **`src/api-client.ts`**: **CLIENT RUNTIME**. Implements `ApiClient`, responsible for creating entry points, resolving links, and executing fetches. One unified fetch pipeline (`fetchResource`) feeds both safe and prone links: it returns a `{ resource, failure, baseURL }` result covering URI expansion, transport, HTTP status, parse, and validation failures. `resolve` dispatches at the boundary — prone links get a `[resource, failure]` tuple, safe links throw `failureToError(failure, verbosity)` from `error-handling.ts`. Delegates all runtime metadata bookkeeping to `runtime-metadata.ts`. * **`src/runtime-metadata.ts`**: **RUNTIME METADATA**. Three module-level `WeakMap`s — `apiClientByNavigable` (navigable → `ApiClient`), `linksByNavigable` (navigable → known links), and `accessorCache` (link-defs → compiled traversal functions). Free functions `rememberLinks`, `rememberEntryPoint`, `recallLink`, and `getOwningClient` operate on them. `KnownLink` is the per-link record. This is the runtime counterpart to `type-system.ts` (compile-time phantom metadata via symbols). diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d620ac..6b0374c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Export `NavigationError` class thrown by `navigate` / `linkTo` when a requested link name is not available on a navigable. + +### Changed + +- `navigate` and `linkTo` now throw `NavigationError` (extends `Error`) for unknown-link errors instead of plain `Error`. `instanceof Error` continues to work; `instanceof NavigationError` gives callers a precise catch path. + +### Removed + +- **BREAKING**: removed `LinkedResource` type export — it was an internal alias with no observed external callers. Migration: inline `Resource`. + ## [0.1.0] - 2026-04-16 ### Added diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 7116117..f11a5bd 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -251,7 +251,7 @@ const user = await navigate(root, { link: 'currentUser' }); const product = await navigate(shop, { link: 'getProduct', params: { id: '123' } }); ``` -If a non-existent link name is provided, `navigate` throws a `TypeError` listing the requested name and available links. This error is always verbose regardless of `errorVerbosity` — link names are compile-time constants from the API definition, not sensitive runtime data. +If a non-existent link name is provided, `navigate` throws a `NavigationError` listing the requested name and available links. `NavigationError` extends `Error`, so `instanceof Error` continues to work for broad-catch code; `instanceof NavigationError` gives callers a precise catch path for this specific programming error. This error is always verbose regardless of `errorVerbosity` — link names are compile-time constants from the API definition, not sensitive runtime data. ### 6.4 `navigateAll(links)` Convenience helper that resolves an array of single-link navigables in parallel. diff --git a/docs/roadmap.md b/docs/roadmap.md index 751f691..5ccea94 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -247,9 +247,9 @@ This should be batched with any other public type renames to minimise the number ### Problem -Errors thrown by the library are plain `Error` and `TypeError` instances with descriptive messages but no class-based discrimination. Consumers cannot use `instanceof` to distinguish a navigation error (e.g. unknown link name) from a validation error (response failed schema check) from a configuration error (bad `defineLinks` input). Catch blocks must inspect message strings, which is brittle. +Most errors thrown by the library are plain `Error` instances with descriptive messages but no class-based discrimination. Consumers cannot use `instanceof` to distinguish a navigation error (e.g. unknown link name) from a validation error (response failed schema check) from a configuration error (bad `defineLinks` input). Catch blocks must inspect message strings, which is brittle. -The `errorVerbosity` system standardizes the *messages* across error paths, and `error-handling.ts` centralizes prone-link error *responses*, but there's no class hierarchy for *thrown* errors. +The `errorVerbosity` system standardizes the *messages* across error paths, and `error-handling.ts` centralizes prone-link error *responses*, but the full class hierarchy for *thrown* errors is still incomplete. ### Why It Matters @@ -259,18 +259,16 @@ For library consumers writing higher-level wrappers (BFFs, SDK layers, retry log Introduce a small hierarchy in `src/error-handling.ts`: -- `HypermediaError` — base class extending `Error` -- `NavigationError` — bad link name, navigable without metadata, missing client -- `ValidationError` — schema check failed on response or params -- `ConfigurationError` — `defineLinks` validation failures, bad URI templates +- `HypermediaError` — base class extending `Error` *(still open)* +- `NavigationError` — bad link name, navigable without metadata, missing client **— DONE:** landed in `src/error-handling.ts`; thrown by `navigate` / `linkTo` via `runtime-metadata.ts` and `navigate.ts` for unknown-link and missing-metadata cases. Exported from `src/index.ts`. +- `ValidationError` — schema check failed on response or params *(still open)* +- `ConfigurationError` — `defineLinks` validation failures, bad URI templates *(still open)* -Update all `throw` sites in `api-client.ts`, `navigate.ts`, `link-definition.ts`, `uri-templates.ts`, and `link-extraction.ts` to throw the appropriate subclass. Preserve the existing message strings (and their `verbose`/`safe` variants) so this is purely additive at the message level. - -This is a breaking change for any consumer that catches `TypeError` specifically — the `navigate` "no link named X" path currently throws `TypeError`, and would become `NavigationError extends Error`. Document in the release notes. +The remaining work is to update all `throw` sites in `api-client.ts`, `link-definition.ts`, `uri-templates.ts`, and `runtime-metadata.ts` (for invariant/validation throws, not the link-not-found ones already using `NavigationError`) to throw the appropriate subclass. Preserve the existing message strings (and their `verbose`/`safe` variants) so this is purely additive at the message level. ### Constraint -Batch with other breaking changes (e.g. §7 `ConnectOptions` rename) to minimize the number of major releases. +Batch remaining subclasses with other breaking changes (e.g. §7 `ConnectOptions` rename) to minimize the number of major releases. --- diff --git a/src/error-handling.ts b/src/error-handling.ts index bf8925f..bf9487c 100644 --- a/src/error-handling.ts +++ b/src/error-handling.ts @@ -2,6 +2,30 @@ import { ApiDefinition, ErrorResourceMap, LinkDefinition, ResourceDefinition } f import { Value } from '@sinclair/typebox/value'; import { Resource, LinkSpec, LinkedResource, Verbosity } from './type-system'; +// ============================================================================ +// NavigationError — thrown for unknown-link / missing-metadata programming errors +// ============================================================================ + +/** + * Thrown by `navigate` / `linkTo` when a requested link name is not + * available on the navigable (or when the navigable has no attached + * metadata). This is a programming error — the calling code asked for + * a link that the server never advertised — so it always throws + * verbosely regardless of `ConnectOptions.errorVerbosity`. + * + * Extends `Error` so `instanceof Error` still works for broad-catch + * callers, while `instanceof NavigationError` gives a precise catch + * path for calling code that wants to handle this specifically. + * + * @public + */ +export class NavigationError extends Error { + constructor(message: string) { + super(message); + this.name = 'NavigationError'; + } +} + // ============================================================================ // Failure — the one public discriminated-union type for prone-link failures // ============================================================================ diff --git a/src/index.ts b/src/index.ts index e2d3120..46f05f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,10 @@ export type { ResponseInfo, } from './error-handling'; +export { + NavigationError, +} from './error-handling'; + export { linkTo, navigate, @@ -32,7 +36,6 @@ export type { LinkSpec, ConnectOptions, RootNavigable, - LinkedResource, Simplify, Verbosity, } from './type-system'; diff --git a/src/navigate.ts b/src/navigate.ts index f3fd34d..af8c4c0 100644 --- a/src/navigate.ts +++ b/src/navigate.ts @@ -1,6 +1,6 @@ import { ApiDefinition } from './link-definition'; import { Navigable, LinkSpec, LinkedResource, ConnectOptions, RootNavigable, ResourceNameFrom } from './type-system'; -import { ResourceOrFailure } from './error-handling'; +import { NavigationError, ResourceOrFailure } from './error-handling'; import { ApiClient } from './api-client'; import { getOwningClient } from './runtime-metadata'; import { Static, TObject } from '@sinclair/typebox'; @@ -197,7 +197,7 @@ export async function navigate( ): Promise { const client = getOwningClient(navigable); if (!client) { - throw new Error('Link metadata not found. Object was not created by typesafe-hypermedia.'); + throw new NavigationError('Link metadata not found. Object was not created by typesafe-hypermedia.'); } return client.resolve(navigable, options?.link, options?.params); diff --git a/src/runtime-metadata.ts b/src/runtime-metadata.ts index 8747af8..7b33434 100644 --- a/src/runtime-metadata.ts +++ b/src/runtime-metadata.ts @@ -1,5 +1,6 @@ import type { ApiClient } from './api-client'; import { LinkDefinition, ResourceDefinition } from './link-definition'; +import { NavigationError } from './error-handling'; // ============================================================================ // Module-level state @@ -171,7 +172,7 @@ export function recallLink(navigable: object, name?: string): KnownLink { const link = links.get(name); if (!link) { const available = Array.from(links.keys()); - throw new Error( + throw new NavigationError( `Link "${name}" is not available on this resource ` + `(available: ${available.join(', ')}). ` + `If this is an optional link, check that the property exists before navigating.` @@ -186,7 +187,7 @@ export function recallLink(navigable: object, name?: string): KnownLink { // size === 0: resource defines links but the server didn't include any. if (links.size === 0) { - throw new Error( + throw new NavigationError( 'No links are available on this resource. ' + 'If this resource defines optional links, the server did not include any of them.' ); diff --git a/test/integration/runtime-guards.spec.ts b/test/integration/runtime-guards.spec.ts index 2985fdc..3813cc9 100644 --- a/test/integration/runtime-guards.spec.ts +++ b/test/integration/runtime-guards.spec.ts @@ -18,7 +18,7 @@ */ import { Type } from '@sinclair/typebox'; -import { defineLinks, linkTo } from '../../src'; +import { defineLinks, linkTo, NavigationError } from '../../src'; import { navigate } from '../../src/navigate'; import { petshopApi, PetshopSchema, PetSchema } from '../../examples/petshop-api'; import { mockResponse } from '../mock-responses'; @@ -360,4 +360,56 @@ describe('navigate — runtime guards', () => { }); }); + describe('NavigationError class contract', () => { + // These tests pin the subclass contract: `NavigationError extends + // Error`, so broad-catch code relying on `instanceof Error` still + // works, while specific catch code can use `instanceof + // NavigationError` for a precise catch path. + + const namedLinkApi = defineLinks(['dashboard', 'catalog', 'product'], { + dashboard: { + schema: DashboardSchema, + links: { + catalogUrl: { to: 'catalog' }, + productUrl: { to: 'product', params: { id: Type.String() } }, + }, + }, + catalog: { schema: SimpleCatalogSchema, links: {} }, + product: { schema: SimpleProductSchema, links: {} }, + }); + + const dashboardBody = { + welcomeMessage: 'Hello', + catalogUrl: '/catalog', + productUrl: '/products/{id}', + }; + + it('throws NavigationError (and instanceof Error) when navigating with an unknown link name', async () => { + mockResponse(DashboardSchema, dashboardBody); + const dashboard = await navigate(linkTo({ + api: namedLinkApi, + resource: 'dashboard', + url: 'http://localhost:3000', + })); + + // @ts-expect-error — 'bogus' is not a key of dashboard's link record + const thrown = await navigate(dashboard, { link: 'bogus' }).catch(e => e); + + expect(thrown).toBeInstanceOf(NavigationError); + expect(thrown).toBeInstanceOf(Error); // broad-catch compat + expect(thrown.name).toBe('NavigationError'); + expect((thrown as Error).message).toMatch(/Link "bogus" is not available on this resource/); + }); + + it('throws NavigationError (and instanceof Error) when passed a non-navigable object', async () => { + // @ts-expect-error — plain object bypasses the type system + const thrown = await navigate({ href: '/nope' }).catch(e => e); + + expect(thrown).toBeInstanceOf(NavigationError); + expect(thrown).toBeInstanceOf(Error); + expect(thrown.name).toBe('NavigationError'); + expect((thrown as Error).message).toMatch(/Link metadata not found/); + }); + }); + });