Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ 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

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).
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<L['Target'], L['Api']>`.

## [0.1.0] - 2026-04-16

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 8 additions & 10 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

---

Expand Down
24 changes: 24 additions & 0 deletions src/error-handling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export type {
ResponseInfo,
} from './error-handling';

export {
NavigationError,
} from './error-handling';

export {
linkTo,
navigate,
Expand All @@ -32,7 +36,6 @@ export type {
LinkSpec,
ConnectOptions,
RootNavigable,
LinkedResource,
Simplify,
Verbosity,
} from './type-system';
Expand Down
4 changes: 2 additions & 2 deletions src/navigate.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -197,7 +197,7 @@ export async function navigate(
): Promise<any> {
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);
Expand Down
5 changes: 3 additions & 2 deletions src/runtime-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ApiClient } from './api-client';
import { LinkDefinition, ResourceDefinition } from './link-definition';
import { NavigationError } from './error-handling';

// ============================================================================
// Module-level state
Expand Down Expand Up @@ -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.`
Expand All @@ -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.'
);
Expand Down
54 changes: 53 additions & 1 deletion test/integration/runtime-guards.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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/);
});
});

});
Loading