Welcome to typesafe-hypermedia, a framework for building type-safe Hypermedia APIs in TypeScript. This guide distills practical experience from building real-world examples (like the HATEOAS BFF) into actionable advice.
Think of your API as a state machine. Define resources and the links between them.
import { defineLinks } from 'typesafe-hypermedia';
import { Type } from '@sinclair/typebox';
export const myApiDef = defineLinks(['root', 'product'], {
root: {
schema: Type.Object({
welcomeMessage: Type.String(),
productsUrl: Type.String() // <--- "Natural" String Link
}),
links: {
// Map the string property to a target resource
'productsUrl': { to: 'product' }
}
},
product: {
schema: Type.Object({ id: Type.String(), name: Type.String() }),
links: {}
}
});Create a navigable link to start from.
import { linkTo } from 'typesafe-hypermedia';
const rootLink = linkTo({
api: myApiDef,
resource: 'root',
url: 'http://localhost:3000/api'
});Use navigate to follow links and navigateAll for parallel fetches.
import { navigate, navigateAll } from 'typesafe-hypermedia';
// Fetch the root resource — rootLink is a single-link navigable, so navigate auto-resolves it
const root = await navigate(rootLink);
// root has multiple links (productsUrl, usersUrl, ...) — pick one by name
const products = await navigate(root, { link: 'productsUrl' });
// navigateAll resolves an array of single-link navigables in parallel
const allProducts = await navigateAll(root.productLinks);The mental model: a navigable is any object that contains link properties.
- If the navigable has exactly one link →
navigate(navigable)auto-resolves it. - If the navigable has multiple links →
navigate(navigable, { link: 'name' })to pick one. - For URI templates with parameters → pass
params(thelinkoption is only required if the navigable also has multiple links).
Why navigateAll? TypeScript's overload resolution doesn't work correctly with .map(navigate) on arrays of Navigables. The type inference fails because the compiler can't determine which overload to use in a mapped context. Use navigateAll(array) or the more verbose .map(nav => navigate(nav)) for proper type safety.
When you call defineLinks(...), TypeScript infers a deeply nested structural type. IDE hovers show this in full — which is useful for small APIs but unreadable for larger ones. If you want a clean, named type that collapses in tooltips, use the Simplify utility with the 3-line pattern:
import { defineLinks, Simplify } from 'typesafe-hypermedia';
const apiDef = defineLinks(['root', 'product'], { /* ... */ });
export interface MyApi extends Simplify<typeof apiDef> {}
export const myApi: MyApi = apiDef;apiDef is an internal variable whose type drives the interface. MyApi is the named type you use in annotations like FetchFactory<MyApi> or Resource<'root', MyApi>. myApi is the runtime value you pass to linkTo() — the : MyApi annotation anchors it to the named interface so all downstream inferred types (from navigate(), linkTo(), etc.) display MyApi in IDE tooltips instead of the full structural expansion.
TypeScript displays interface names as-is in hovers, but expands type aliases transparently. So type MyApi = typeof myApi would still show the full expansion — only interface extends collapses it.
The catch: typeof can't appear directly in an extends clause (interface X extends typeof y is a syntax error). But it can appear inside a generic type argument. Simplify<T> is { [K in keyof T]: T[K] } — a mapped identity type that resolves to an object type, which interfaces can extend.
The : MyApi annotation on the exported const is essential. Without it, typeof myApi is the raw defineLinks return type, and TypeScript has no named type to reference — every tooltip expands the full API schema inline.
- Exporting an API for consumers — use
Simplifyso downstream code seesPetshopApirather than a 50-line structural type. FetchFactory<MyApi>— the named type reads better in signatures.- Cross-file type references — named interfaces are easier to import and reason about.
- Tests and local definitions — if the API definition is local to a file and nobody hovers over it, the raw
defineLinksreturn is fine. - Simple APIs with 1-2 resources — the inferred type is already readable.
typesafe-hypermedia supports two link representations that reflect real-world hypermedia API design patterns. Both work at massive scale in production.
Example:
{
"name": "Pet Store",
"products": { "href": "/api/products" },
"search": { "href": "/api/search{?q}", "title": "Search Products" }
}Client code:
// shop.products is a link object with a single href — auto-resolves
const products = await navigate(shop.products);Used by: 7 out of 9 major hypermedia specifications (HAL, Siren, JSON:API, Collection+JSON, UBER, Mason, JSON Home)
Tradeoffs:
- ✅ Can include metadata (title, type, deprecation, description)
- ✅ Add metadata later without breaking changes
- ✅ Clear distinction when grouped (
_links,actions) - ✅ Single-link auto-resolve — no need to name the link
- ❌ More verbose JSON (nested objects)
- ❌ Extra payload bytes per link
Example:
{
"name": "Pet Store",
"productsUrl": "/api/products",
"searchUrl": "/api/search{?q}"
}Client code:
// shop has multiple link properties — name the link explicitly
const products = await navigate(shop, { link: 'productsUrl' });Used by: GitHub API (one of the world's most-used APIs serving millions of developers), JSON-LD/Hydra
Tradeoffs:
- ✅ Simpler, lighter JSON (direct strings)
- ✅ Smaller payloads
- ✅ Familiar pattern (URL fields are common in non-hypermedia APIs)
- ❌ Can't add metadata without awkward parallel properties or breaking changes
- ❌ Requires explicit
{ link: 'name' }when the navigable has multiple links - ❌ Less standard in formal hypermedia specs
Choose Link Objects when:
- You might need metadata on links (titles, deprecation notices, type hints)
- You want room to evolve without breaking changes
- You're following hypermedia specifications (HAL, JSON:API, etc.)
- Payload size isn't critical
Choose String Properties when:
- You're certain links won't need metadata
- Payload size is critical (mobile, IoT, high-volume APIs)
- You're integrating with existing patterns that use URL fields
- You prefer minimal JSON structure
Real-world insight: Both patterns prove successful at scale. HAL powers numerous production APIs with link objects. GitHub serves millions with string properties. JSON:API explicitly supports both. Choose based on your requirements, not ideology.
A Navigable is any object that contains one or more link properties. That's the entire mental model.
CRITICAL: Navigable ≠ Resource
Navigable: ANY object with link properties — can be nested anywhere in the JSON (top-level, nested objects, array elements, link objects)Resource: Specifically whatnavigate()returns when you fetch a top-level resource — always a Navigable, but not all Navigables are Resources
Key design choice: Link properties can live ANYWHERE in your JSON structure, not just at the root. This is what makes typesafe-hypermedia flexible.
Two link styles:
- Link objects: Objects with a single
hrefproperty (e.g.,{ href: "/api/products", title: "Products" }) — the link object itself is the navigable - Link properties on a parent: String properties containing URIs (e.g.,
{ productsUrl: "/api/products", usersUrl: "/api/users" }) — the parent object is the navigable
One function, two modes:
- Single-link auto-resolve: when the navigable has exactly one link →
navigate(navigable) - Named link mode: when the navigable has multiple links →
navigate(navigable, { link: 'name' })
You only need to specify { link: 'name' } when the navigable has more than one link to choose from. URI template parameters are passed via params (and link is only required if the navigable also has multiple links).
In practice, a Navigable can take three different forms:
The object returned by linkTo() — a single-link navigable pointing at the API root:
const rootLink = linkTo({ api: myApiDef, resource: 'root', url: 'http://api.example.com' });
// rootLink = { href: "http://api.example.com" } // single link → auto-resolves
const root = await navigate(rootLink);Objects with a single href property embedded within resources, typically in arrays:
const catalog = await navigate(root, { link: 'productsUrl' });
// catalog.products = [
// { href: "/products/1", title: "Widget" },
// { href: "/products/2", title: "Gadget" }
// ]
// Each element is a single-link navigable — auto-resolves
const product = await navigate(catalog.products[0]);Key insight: Any object with link properties is Navigable — can be top-level Resources, nested objects, or array elements!
const shop = await navigate(apiRoot);
// shop = {
// id: "shop-1",
// name: "Pet Store",
// actions: { // ← Navigable nested object with two links
// listPets: "/api/pets",
// searchPets: "/api/search{?q}"
// },
// categories: [ // ← Array of single-link navigables
// { name: "Dogs", productsUrl: "/api/categories/dogs" },
// { name: "Cats", productsUrl: "/api/categories/cats" }
// ]
// }
// shop.actions has two links — name the one you want
const pets = await navigate(shop.actions, { link: 'listPets' });
const matches = await navigate(shop.actions, { link: 'searchPets', params: { q: 'corgi' } });
// Each category has only one link — auto-resolves
const dogProducts = await navigate(shop.categories[0]);The Link Object Model
If you use link objects with href everywhere, every navigable has exactly one link, and you never need to use { link: 'name' }:
// Link object approach (like the petshop example)
const root = await navigate(apiRoot);
// root.actions = {
// listPets: { href: "/pets" },
// searchPets: { href: "/pets{?q}" }
// }
//
// root.actions.listPets is a link object with a single href — auto-resolves
const catalog = await navigate(root.actions.listPets);
// catalog.pets = [
// { href: "/pets/1", title: "Fido" },
// { href: "/pets/2", title: "Whiskers" }
// ]
//
// Each pet is also a single-link navigable:
const pet = await navigate(catalog.pets[0]);This trades payload size for a uniform navigation pattern. See "Choosing Your Link Style" above for the full tradeoff analysis.
typesafe-hypermedia is format-agnostic - it gives you the flexibility to follow any link in any location of a JSON document, regardless of media type conventions.
HAL-style (links in _links):
{
"name": "Widget",
"_links": {
"self": { "href": "/products/1" },
"reviews": { "href": "/products/1/reviews" }
}
}Links as direct properties:
{
"name": "Widget",
"self": { "href": "/products/1" },
"reviews": { "href": "/products/1/reviews" }
}Links as simple strings:
{
"name": "Widget",
"selfUrl": "/products/1",
"reviewsUrl": "/products/1/reviews"
}All of these work with typesafe-hypermedia. You define where links live in your API definition, and the library handles the rest.
Pro tip: Using link objects with an href property (second example) keeps your code simpler — every navigable has exactly one link, so single-link auto-resolve always works and you never need { link: 'name' }. See "Understanding Navigable Objects" above.
typesafe-hypermedia validates responses against your TypeBox schemas at runtime.
- Gotcha: If your backend returns extra fields or missing fields, the client will throw an error.
- Fix: Ensure your schemas exactly match your payload. Use
Type.Optional()liberally if data is sporadic.
The framework caches compiled link-path traversal functions in a WeakMap keyed by the link-definitions object on each ResourceDefinition. Definitions declared once at module scope (the normal case — const api = defineLinks(...)) benefit from this cache: each link-path is compiled once and reused on every fetch of that resource type.
Definitions constructed dynamically per call — e.g. spreading partial definitions together inline like { ...baseLinks, ...extraLinks } — produce a new object reference each time and thrash the cache silently. There is no warning; just slower first traversals. If you need conditional or merged link sets, build the merged definition once and reuse the reference.
expandUriTemplate({ template, schema, values }) is intentionally tolerant: it expands whatever variables the template declares and ignores any extra keys in values. This is RFC 6570 compliance (unknown variables are simply not emitted), but it enables a subtle footgun when you use the spread operator to build values:
// QuerySchema has { view, category, sku, ... } — no qty field
const url = expandUriTemplate({
template: '/bff{?view,category,sku}',
schema: QuerySchema,
values: { ...req.query, view: 'cart' } // ← req.query is a *different* schema
});If req.query comes from a different schema (e.g., an action route with { sku, qty, cart }), the extra keys are silently dropped. The URL is "correct" in the sense that it matches the template — but it silently loses state you thought you were carrying through. FINDING-06 in the hateoas-bff round-2 review was exactly this: an add-to-cart handler was spreading an action-route query into a main-view URL, and nothing flagged the mismatch until someone audited the redirect chain.
Fix: Prefer explicit field picks over spreads when building values for expandUriTemplate. If you know the template wants { cart, sku, title, message }, write those four fields out by name. The spread pattern is fine when the source is the same schema (e.g., updatedStateUrl(query, { sort: 'price' }) where query is already QueryState), but mixing schemas through { ...otherSchema, ...overrides } is where the silent drop bites.
A future library feature may warn on unknown keys in development builds — see if that would have caught your bug before adopting spreads.
If you have a massive Union type (like a "God Object" with 20 possible views), TypeScript's inference for navigate might bail out or return any.
- Workaround: Explicitly type your variables if needed, or cast when you are certain of the flow.
const cartPage = await navigate(productPage, { link: 'addToCartUrl' }) as CartView;Add expect when the server returns structured, typed JSON error responses that your client code should handle — not just HTTP status codes, but actual error resources with fields, messages, and ideally recovery links.
Use expect when ALL of these are true:
- The server returns controlled JSON output for certain error status codes (not generic 500s)
- That JSON is worth handling — it contains actionable information (validation details, business reasons, alternatives)
- Ideally, the error response contains links for follow-up actions (retry, search alternatives, go back)
Good candidates for expect:
- Business operation failures: "Out of stock" with a link to similar products, "Insufficient funds" with a link to top-up
- Validation errors: Server returns structured field-level errors the UI can render
- Not-found with alternatives: "Product not found" with a link to search results or suggestions
- Conflict resolution: "Version conflict" with a link to the latest version
Don't use expect for:
- Server-controlled links: Links embedded in successful responses (e.g.,
catalog.pets[]) — the server gave you these links, they work - Generic server errors: If the server just returns
500 Internal Server Errorwith no useful body, there's nothing to type - Network failures: These are truly exceptional —
try/catchis the right tool
This is a feature, not a bug. When you add expect to a link definition, the return type of navigate() changes:
// Before: safe link — returns the resource directly
const order = await navigate(product.order);
// ^? Resource<"order">
// After adding expect: { 409: 'outOfStock' }
// This line now has a type error — navigate() returns a tuple
const order = await navigate(product.order);
// ^? [Resource<"order">, null] | [null, Failure]
// TS Error: Property 'orderId' does not exist on type '[Resource<"order">, null] | [null, Failure]'You are forced to refactor the call site to destructure the tuple and handle the error:
const [order, error] = await navigate(product.order);
if (!order) {
switch (error.kind) {
case 'outOfStock':
return navigate(error.resource.alternativesLink);
default:
console.error(error.message);
return null;
}
}
// Now 'order' is narrowed to Resource<"order">
console.log(order.orderId);This means you can safely evolve your API definition by adding expect to links that previously didn't have it. The TypeScript compiler will flag every call site that needs updating — no error path goes silently unhandled.
The "adding expect breaks callers" behavior is intentional, but the cost is paid at the worst moment — right when you've discovered a new failure case and need to ship a handler. You can frontload that cost at API design time by adding an empty expect: {} to any link you think might grow failure cases later, even before you know what they are:
defineLinks(['root', 'order'], {
root: {
schema: Type.Object({ createOrderUrl: Type.String() }),
links: {
createOrderUrl: {
to: 'order',
params: { sku: Type.String() },
expect: {} // No declared failures yet, but forces tuple shape
}
}
},
// ...
});An empty expect: {} still flips the return type of navigate() to a tuple, so every call site destructures [order, failure] from day one:
const [order, failure] = await navigate(root, {
link: 'createOrderUrl',
params: { sku: 'PROD-001' }
});
if (failure) {
// failure.kind is 'unexpected' — the library-defined catch-all
// (network / uriExpansion / unmappedStatus / invalidJson / invalidStructure)
console.error(failure.message);
return;
}
console.log(order.orderId);Later, when you discover the server returns a structured 409 Conflict for duplicate orders, adding expect: { 409: 'duplicateOrder' } is a purely additive change at call sites: the tuple shape is already there, the if (failure) guard is already there, you just add a case 'duplicateOrder': branch to the switch (or keep the generic failure.message handler if that's still enough).
When to use this pattern:
- Links on POST/PUT/PATCH actions — server-side validation, conflict, and business-rule failures are common and tend to be discovered incrementally
- Links where the server is still evolving — you suspect failure responses will grow
- Links whose consumers are numerous or spread across modules — the cascade cost scales with consumer count
When not to use it:
- Links where a failure is truly exceptional (internal root discovery, self-links) — adding
expect: {}just forces ceremonial destructuring on call sites that have nothing useful to do on failure - Small codebases where rewriting a handful of call sites is cheap — the eager pattern has a cost of its own (every caller writes
if (failure) ...), and for 3-4 call sites it may not pay for itself
This is a design-time decision, not a library feature — the library behavior is exactly the same as declaring any other expect map. The pattern is just recognising that empty-but-present is a useful middle ground between "safe link, throws on anything" and "prone link, declare every failure upfront."
When a link has expect, it returns a tuple: [Resource, null] | [null, Failure]. Failure is a discriminated union — switch on its kind field for type-safe narrowing:
import { navigate } from 'typesafe-hypermedia';
// Link definition with expect:
// links: {
// 'searchUrl': {
// to: 'searchResults',
// params: SearchParamsSchema,
// expect: { 404: 'notFound', 400: 'validationError' }
// }
// }
// root has multiple links — name the templated one and pass params
const [results, error] = await navigate(root, {
link: 'searchUrl',
params: { query: userInput }
});
if (!results) {
switch (error.kind) {
case 'notFound':
console.log(error.resource.message); // Typed as NotFoundResource
// Error resources can have recovery links!
return navigate(error.resource.suggestionsLink);
case 'validationError':
error.resource.errors.forEach(e => showFieldError(e.field, e.message));
return null;
case 'unexpected':
// Sub-discriminate by `reason` to find out which step failed.
// The 'network' branch has no `response`; the others always do.
if (error.reason === 'network') {
console.error('Offline:', error.message);
} else {
// error.response: { status, statusText, headers, body? }
const retryAfter = error.response.headers.get('retry-after');
console.error(`HTTP ${error.response.status} (${error.reason}):`, error.message, { retryAfter });
}
return null;
}
}
// Happy path - results is typed correctly
console.log(results.items);Error resources are just regular resources - they can have links too! This enables "recovery patterns":
const ErrorResourceSchema = Type.Object({
message: Type.String(),
suggestionsLink: Type.String(), // Link to alternative results
homeLink: Type.String() // Link back to home
});
// In your API definition:
{
notFound: {
schema: ErrorResourceSchema,
links: {
'suggestionsLink': { to: 'searchResults' },
'homeLink': { to: 'home' }
}
}
}Error resources get their links hydrated automatically, allowing users to navigate out of error states seamlessly.
You don't have to handle each error kind separately. Every variant — expected and unexpected — has a message field, so the simplest valid error handling is:
const [results, error] = await navigate(root, {
link: 'searchUrl',
params: { query: userInput }
});
if (!results) {
console.error(error.message);
return null;
}You can add a switch on the kind field later as your error handling matures — the compiler won't force you to handle each kind individually, only to acknowledge the tuple.
typesafe-hypermedia focuses on three core concerns:
- Type safety - ensuring resources match their schemas
- URI template expansion - resolving templated links with parameters
- Link/resource resolution - navigating the hypermedia graph
The library intentionally does NOT guess what's required at the HTTP level to make successful requests. It doesn't know whether a link needs POST vs GET, what authentication headers are required, or what request body to send.
By design, the library only expands and follows URIs - it doesn't set a single option on fetch() calls. Everything else is delegated to your FetchFactory, keeping the library focused on hypermedia concerns rather than HTTP concerns.
The default fetch factory only supports GET requests. For custom HTTP methods, authentication, headers, or request bodies, provide a custom FetchFactory to linkTo().
FetchFactory is YOUR code - implement whatever logic makes sense for your API. You can:
- Read HTTP metadata from server responses (if your API provides it)
- Decide HTTP method based on URL patterns or target resource type
- Add authentication based on server name, resource type, or endpoint path
- Implement any custom logic you need
The approach you choose depends on what level of hypermedia information your server provides.
Implement HTTP behavior based on the context information (URL, resource type, etc.):
import { linkTo, FetchFactory } from 'typesafe-hypermedia';
import { MyApi } from './my-api'; // your exported API type
const contextBasedFactory: FetchFactory<MyApi> = (context) => {
return async (url: string) => {
// Decide method based on URL pattern
const method = url.includes('/search') ? 'GET' :
url.includes('/delete') ? 'DELETE' :
url.includes('/create') ? 'POST' : 'GET';
// Add auth based on target resource type
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (context.targetResourceName === 'admin') {
headers['Authorization'] = `Bearer ${getAdminToken()}`;
} else if (context.targetResourceName === 'user') {
headers['Authorization'] = `Bearer ${getUserToken()}`;
}
return fetch(url, { method, headers });
};
};
const rootLink = linkTo({
api: myApiDef,
resource: 'root',
url: 'https://api.example.com',
fetchFactory: contextBasedFactory
});If your API includes HTTP metadata in responses, you can read it from context.navigable:
import { FetchFactory } from 'typesafe-hypermedia';
import { MyApi } from './my-api'; // your exported API type
// Server returns links with HTTP metadata:
{
"name": "Pet Store",
"actions": {
"createPet": {
"href": "/pets",
"method": "POST", // ← Server specifies the method
"template": { // ← Server provides body template
"name": "",
"species": ""
}
}
}
}
// FetchFactory reads server-provided metadata:
const serverDrivenFactory: FetchFactory<MyApi> = (context) => {
return async (url: string) => {
// Read from server response (with defaults if not provided)
const method = context.navigable?.method || 'GET';
const bodyTemplate = context.navigable?.template;
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`
}
};
if (bodyTemplate && method !== 'GET') {
options.body = JSON.stringify(bodyTemplate);
}
return fetch(url, options);
};
};Note: If you include properties like method or template in your server responses, you MUST define them in your TypeBox schemas so they're validated and type-checked.
Mix server-driven and client-side logic where it makes sense:
import { FetchFactory } from 'typesafe-hypermedia';
import { MyApi } from './my-api'; // your exported API type
const hybridFactory: FetchFactory<MyApi> = (context) => {
return async (url: string) => {
// Use server-provided method if available, otherwise infer from URL
const method = context.navigable?.method ||
(url.includes('/create') ? 'POST' : 'GET');
// Client decides auth strategy based on resource type
const token = context.targetResourceName === 'admin'
? getAdminToken()
: getPublicToken();
// Server can provide custom confirmation requirements
if (context.navigable?.requiresConfirmation) {
const message = context.navigable?.confirmationMessage || 'Are you sure?';
if (!confirm(message)) {
throw new Error('Operation cancelled by user');
}
}
return fetch(url, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
};
};- You control the logic - FetchFactory is your code; implement whatever makes sense for your use case
context.navigablecontains the current link object - it has whatever properties your server included (or none)context.targetResourceNametells you the resource type - useful for resource-based decisions- Always use optional chaining -
context.navigable?.propertysince properties only exist if the server included them - Choose your hypermedia level - fully client-driven, fully server-driven, or hybrid
See: test/integration/navigate.spec.ts Section 5 for comprehensive examples of custom fetch patterns.
Based on developer experience, here are areas where typesafe-hypermedia could evolve:
- "Loose Mode" Validation: Currently, validation failures are fatal. A "warn-only" mode would be helpful for production resilience or rapid prototyping.
- Schema Generation: Writing TypeBox schemas manually is tedious. Tooling to generate them from existing JSON responses or OpenAPI specs would be a huge win.
- Link Visualization: A CLI tool that reads
defineLinksand outputs a Mermaid diagram of the state machine would help visualize complex flows. - Mutations Require Bypassing
navigate(): Any POST/PUT/PATCH with a request body today falls out of the typesafe-hypermedia pipeline — you end up writingfetch(url, { method: 'POST', body: JSON.stringify(...) })by hand, losing URL resolution, body schema validation, and response typing in one shot. Thefeat/bff-showcaseexample had to introduce apostToErp()helper for exactly this reason. Tracked as roadmap §5 "Client-Provided Request Data". - Return Types of
navigate()Cannot Be Named:navigate()return types are unnamed intersection types overNavigable<...>/Resource<...>phantom brands. Extracting route logic into a module-level helper forces you to either (a) accepttypeof someLocalVariableparameters (which doesn't cross module boundaries), or (b) re-declareResource<'root', MyApi>aliases at every call site — and even then, intermediatenavigate()results have no expressible signature. This blocks normal refactoring hygiene in BFF code. Tracked as roadmap §13 "Nameable Navigation Return Types".
Happy Hacking with Hypermedia!