diff --git a/.changeset/dull-pens-love.md b/.changeset/dull-pens-love.md
new file mode 100644
index 0000000..92d0ba5
--- /dev/null
+++ b/.changeset/dull-pens-love.md
@@ -0,0 +1,5 @@
+---
+'@dphonys/nuxt-typed-handler': minor
+---
+
+Initial release. `@dphonys/nuxt-typed-handler` is one Nuxt module installed instead of `@dphonys/nuxt-handler-errors` and `@dphonys/nuxt-handler-validation`, composing both through their `internals/*` entries. `defineTypedEventHandler({ validate, errors }, fn)` declares a route's request schemas and its expected failures in one place and hands the handler one flat context - the validated sources, plus `fail` when errors were declared - with a built-in `validation-failed` variant that every validating route carries and no route may declare. The Typed fetch family (`useTypedFetch`, `useLazyTypedFetch`, `useRequestTypedFetch`, `useTypedAsyncData`, `useLazyTypedAsyncData`, `$typedFetch` with `.try`, and `event.$typedFetch`) types every call site per route and method for what it may send - `body` and `query` from the schemas' input types - and for what it can fail with. Both parents' public surfaces are re-exported from `/server`, `/shared` and `/types`, so an app imports everything from one package; both parents are pinned exactly, and a project lists this module or them, never both.
diff --git a/.changeset/nuxt-declared-as-peer.md b/.changeset/nuxt-declared-as-peer.md
new file mode 100644
index 0000000..98ffd73
--- /dev/null
+++ b/.changeset/nuxt-declared-as-peer.md
@@ -0,0 +1,6 @@
+---
+'@dphonys/nuxt-handler-errors': patch
+'@dphonys/nuxt-handler-validation': patch
+---
+
+Declare `nuxt` as a peer dependency (`>=4.5.1 <5.0.0`), so the Nuxt range the readme already states is one the package manager checks as well.
diff --git a/knip.ts b/knip.ts
index cbe9792..5fb76d6 100644
--- a/knip.ts
+++ b/knip.ts
@@ -79,6 +79,22 @@ export default {
],
},
+ 'packages/nuxt-typed-handler': {
+ ...nuxtModuleWorkspace,
+
+ // As in both parents: this package's suites import `@nuxt/schema`'s types.
+ ignoreDependencies: ['@nuxt/devtools'],
+
+ entry: [
+ ...nuxtModuleWorkspace.entry,
+
+ // Deliberately broken sources, compiled by path by
+ // `test/types/compile-harness.ts` so a suite can assert on their
+ // diagnostics. The package tsconfig excludes them for the same reason.
+ 'test/types/fixtures/**/*.ts',
+ ],
+ },
+
'packages/nuxt-handler-validation/playground': {
...playgroundWorkspace,
@@ -87,5 +103,14 @@ export default {
// `.nuxt`.
entry: ['module-options.check.ts'],
},
+
+ 'packages/nuxt-typed-handler/playground': {
+ ...playgroundWorkspace,
+
+ // As in the validation playground above: compiler-asserted by `vue-tsc`,
+ // never imported. It lives in an app because the route map it reads is
+ // only generated inside one.
+ entry: ['request-typing.check.ts'],
+ },
},
} satisfies KnipConfig
diff --git a/packages/nuxt-handler-errors/README.md b/packages/nuxt-handler-errors/README.md
index 4c67002..8364ee8 100644
--- a/packages/nuxt-handler-errors/README.md
+++ b/packages/nuxt-handler-errors/README.md
@@ -21,6 +21,8 @@ The module has one option, `channelToken` - see
counterpart, and nothing about a route's failures is configured - it is
declared, in the route.
+**Requirements:** Nuxt `>=4.5.1 <5.0.0`, Node 22.19+ / 24.11+ / 26+.
+
## Declaring what a route can fail with
```ts
diff --git a/packages/nuxt-handler-errors/package.json b/packages/nuxt-handler-errors/package.json
index 582d57f..8de4845 100644
--- a/packages/nuxt-handler-errors/package.json
+++ b/packages/nuxt-handler-errors/package.json
@@ -122,6 +122,9 @@
"vue-tsc": "catalog:",
"zod": "catalog:"
},
+ "peerDependencies": {
+ "nuxt": ">=4.5.1 <5.0.0"
+ },
"engines": {
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
}
diff --git a/packages/nuxt-handler-errors/vitest.config.ts b/packages/nuxt-handler-errors/vitest.config.ts
index 221917a..10d9a85 100644
--- a/packages/nuxt-handler-errors/vitest.config.ts
+++ b/packages/nuxt-handler-errors/vitest.config.ts
@@ -1,13 +1,6 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
-// Three tiers: `unit` is fast, `types` suites are asserted by the compiler
-// under `typecheck`, and `e2e` builds real apps. The `include` patterns also
-// feed knip's entry points.
-
-// These specifiers only resolve inside a real build, so `unit` aliases them
-// to doubles. Scoping the aliases to `unit` is deliberate: the e2e tier must
-// see the real thing.
const aliases = [
{
find: /^#app$/,
@@ -43,6 +36,9 @@ export default defineConfig({
test: {
name: 'types',
include: ['test/types/**/*.test.ts'],
+ // Each file compiles a real TypeScript program, which a loaded CI runner
+ // stretches well past vitest's 5s default.
+ testTimeout: 60_000,
},
},
{
@@ -50,9 +46,8 @@ export default defineConfig({
name: 'e2e',
include: ['test/e2e/**/*.test.ts'],
testTimeout: 120_000,
- // Every file here writes into a real app's build directory - one
- // prepares the playground and edits its emitted map, another builds
- // and boots it. Run in parallel they race over the same `.nuxt`.
+ // Every file here works against a real build directory - run in
+ // parallel they race over the same `dist` and `.nuxt`.
fileParallelism: false,
},
},
diff --git a/packages/nuxt-handler-validation/package.json b/packages/nuxt-handler-validation/package.json
index 333669f..d144ee8 100644
--- a/packages/nuxt-handler-validation/package.json
+++ b/packages/nuxt-handler-validation/package.json
@@ -98,6 +98,9 @@
"vue-tsc": "catalog:",
"zod": "catalog:"
},
+ "peerDependencies": {
+ "nuxt": ">=4.5.1 <5.0.0"
+ },
"engines": {
"node": "^22.19.0 || ^24.11.0 || >=26.0.0"
}
diff --git a/packages/nuxt-handler-validation/vitest.config.ts b/packages/nuxt-handler-validation/vitest.config.ts
index 35f82bf..2847ad2 100644
--- a/packages/nuxt-handler-validation/vitest.config.ts
+++ b/packages/nuxt-handler-validation/vitest.config.ts
@@ -1,9 +1,5 @@
import { defineConfig } from 'vitest/config'
-// Three tiers, mirroring the sibling: `unit` is fast, `types` suites are
-// asserted by the compiler under `typecheck`, and `e2e` works against real
-// built artifacts. The `include` patterns also feed knip's entry points.
-
export default defineConfig({
test: {
projects: [
@@ -17,6 +13,9 @@ export default defineConfig({
test: {
name: 'types',
include: ['test/types/**/*.test.ts'],
+ // Each file compiles a real TypeScript program, which a loaded CI runner
+ // stretches well past vitest's 5s default.
+ testTimeout: 60_000,
},
},
{
diff --git a/packages/nuxt-typed-handler/LICENSE b/packages/nuxt-typed-handler/LICENSE
new file mode 100644
index 0000000..2f10591
--- /dev/null
+++ b/packages/nuxt-typed-handler/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Daniel Petr Honys
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/nuxt-typed-handler/README.md b/packages/nuxt-typed-handler/README.md
new file mode 100644
index 0000000..c7145fd
--- /dev/null
+++ b/packages/nuxt-typed-handler/README.md
@@ -0,0 +1,662 @@
+# Nuxt Typed Handler
+
+Declare a Nitro handler's request schemas and expected failures once, and get
+both typed at every call site: the compiler knows what a route accepts as
+`body` and `query`, and what it can answer with. One wrapper,
+`defineTypedEventHandler`, and one flat second parameter carrying the validated
+values and `fail`.
+
+One sentence for the whole model: **a route declares what it validates and
+what it can fail with, and every caller - `useTypedFetch`, `$typedFetch`,
+`event.$typedFetch` - is typed from the route path alone.**
+
+This module composes [`@dphonys/nuxt-handler-errors`][errors] and
+[`@dphonys/nuxt-handler-validation`][validation] and is installed _instead of_
+them - never alongside. It re-exports both parents' public surface, bar their
+two wrappers, so an app imports everything from one package. Each feature
+below links to the parent that documents it in full.
+
+## Installation
+
+```sh
+pnpm add @dphonys/nuxt-typed-handler
+```
+
+```ts
+export default defineNuxtConfig({
+ modules: ['@dphonys/nuxt-typed-handler'],
+})
+```
+
+The module has one option, `channelToken` - see [Channel
+gating](#channel-gating). Nothing about a route's inputs or failures is
+configured; both are declared, in the route.
+
+Bring your own schema library. Anything implementing [Standard
+Schema](https://standardschema.dev) works - [zod](https://zod.dev),
+[valibot](https://valibot.dev), [arktype](https://arktype.io), and others -
+nothing is bundled for you.
+
+**Requirements:** Nuxt `>=4.5.1 <5.0.0`, Node 22.19+ / 24.11+ / 26+.
+
+If the app already uses a parent, remove it from `modules` and uninstall it
+first - registering a parent beside this module throws at startup. See
+[Migrating from the parents](#migrating-from-the-parents).
+
+## Quick start
+
+```ts
+// server/api/users.post.ts
+import { z } from 'zod'
+import { userErrors } from '~~/server/errors/users'
+
+const createUser = z.object({
+ name: z.string().min(1),
+ email: z.email(),
+})
+
+export default defineTypedEventHandler(
+ { validate: { body: createUser }, errors: userErrors.pick('user-exists') },
+ async (event, { body, fail }) => {
+ if (await taken(body.email))
+ return fail('user-exists', { email: body.email })
+
+ return { created: body.name }
+ }
+)
+```
+
+```vue
+
+```
+
+- **`validate` alone and `errors` alone are both valid**, and the context
+ carries only what was declared: no `validate`, no source keys; no `errors`,
+ no `fail`. Declaring neither is a compile error, and a runtime one for a
+ JavaScript caller.
+- **Your return type flows to Nitro's typed routes unchanged.** The wrapper
+ returns a `TypedEventHandler` - still assignable to h3's `EventHandler` - so
+ the response type infers exactly as it would with `defineEventHandler`.
+- **Auto-imported where you use it.** `defineTypedEventHandler`, `defineError`,
+ `payload`, `recognizeKnownError` and `recognizeValidationError` are ambient
+ inside `server/`, like `defineEventHandler`; `useTypedFetch` and its siblings
+ are ambient in app code, like `useFetch`; `$typedFetch` is a global, like
+ `$fetch`. `matchError` is imported from `@dphonys/nuxt-typed-handler/shared`,
+ because it is used on both sides.
+- **Three entries.** `@dphonys/nuxt-typed-handler/server` carries the runtime
+ and depends on h3 - import it where auto-imports do not reach (Nitro plugins
+ and tasks, tests, `imports.autoImport: false`), never from client code.
+ `/shared` is safe everywhere. `/types` is type-only, for app code.
+
+## Declaring what a route can fail with
+
+```ts
+// server/errors/users.ts - or anywhere; the values travel, no registry exists.
+// Outside server/, import from '@dphonys/nuxt-typed-handler/server'.
+export const userErrors = defineError({
+ 'user-not-found': { status: 404, payload: payload<{ userId: string }>() },
+ 'user-exists': { status: 409, payload: payload<{ email: string }>() },
+})
+
+export const forbidden = defineError('forbidden', {
+ status: 403,
+ payload: payload<{ requiredRole: 'admin' | 'owner' }>(),
+})
+```
+
+```ts
+export default defineTypedEventHandler(
+ { errors: [...userErrors.pick('user-not-found'), forbidden] },
+ async (event, { fail }) => {
+ const userId = event.context.params?.id ?? ''
+ const user = await lookup(userId)
+
+ if (!user) return fail('user-not-found', { userId })
+
+ return user
+ }
+)
+```
+
+- The unit is the **variant as a value**; a group is an array of those values,
+ and **spread is the only composition operator**. `.pick()` narrows a group.
+- `payload()` is the no-library door; any Standard Schema works in the same
+ position and is read for its inferred output type - **never executed**. The
+ payload must survive JSON serialization or it is a compile error.
+- `fail` returns `never`, so the success type still infers from the handler
+ body, and `fail('nope')` - a tag this route did not declare - is a compile
+ error.
+- **`'validation-failed'` is reserved on every route**, whether or not it
+ validates: declaring it is a compile error and a declaration-time throw, and
+ `fail('validation-failed')` never typechecks.
+- A duplicate tag across two declared variants is a compile error, and a
+ variant value produced by a _different copy_ of the module throws at
+ declaration.
+
+Rationale, and the full model: [Declaring what a route can fail with][errors-declaring].
+
+## Validating the request
+
+```ts
+export default defineTypedEventHandler(
+ {
+ validate: {
+ routerParams: v.object({ id: v.pipe(v.string(), v.transform(Number)) }),
+ query: [pagination, sorting],
+ body: z.object({ name: z.string() }),
+ },
+ },
+ async (event, { routerParams, query, body }) => update(routerParams.id, body)
+)
+```
+
+- **Schemas nest under `validate`**, keyed by source. The four sources are
+ `routerParams`, `query`, `headers` and `body`, validated in exactly that
+ order, **fail-fast**, before the handler body runs.
+- Values arrive typed as their schema's **output**, so coercions and transforms
+ land already applied. Undeclared sources are **absent** from the context.
+- **Mix libraries freely**, including inside one composed tuple. Async schemas
+ are awaited.
+- A tuple composes several schemas onto one source: every element parses the
+ whole raw source in order and you receive the merge. Two compile-time rules,
+ both reported at the offending source key: every composed output must be an
+ **object**, and their output keys must be **pairwise disjoint**.
+- Sources arrive exactly as h3 yields them - query values are
+ `string | string[]`, headers are lowercased, route params are URL-decoded -
+ so all coercion belongs in the schema.
+- A method that cannot carry a body, and an empty body, both validate
+ `undefined`. A body the request made unreadable becomes exactly one issue,
+ `{ source: 'body', message: 'Request body could not be parsed', path: [] }`.
+
+Rationale, the per-source detail and the composition rules in full:
+[Reusing and composing schemas][validation-composing] and [What each source
+receives][validation-sources].
+
+## The Handler context
+
+The wrapper's second parameter is one flat object, built fresh per request:
+
+```text
+(event, { routerParams, query, headers, body, fail }) => …
+```
+
+- **Only what was declared is there.** Each validated source appears iff
+ `validate` declared it; `fail` appears iff `errors` declared something.
+ Reading an undeclared key is a compile error naming the key.
+- **It is the only door to validated values.** Calling `readBody(event)` in the
+ handler hands back h3's memoized _unvalidated_ parse - not what your schema
+ produced.
+- **An `errors`-only route never reads the request.** No plan, no body read, no
+ extra `await`: it is the errors parent's behaviour byte for byte.
+- The object is a plain object and is not frozen; nothing else is smuggled onto
+ it.
+
+## Request typing at the call site
+
+Every member of the [Typed fetch family](#fetching) takes
+`TypedRequestOptions` - vanilla's `NitroFetchOptions` with `body` and
+`query` typed from the route's declared schemas, `method` accepted in either
+case, and ofetch's deprecated `params` alias removed for everyone.
+
+```ts
+// `body` required and typed from the schema's *input* side.
+await $typedFetch('/api/users', {
+ method: 'post',
+ body: { name: 'Ada', email: 'a@b.c' },
+})
+
+// Excess keys rejected on a plain object literal: `nope` is an error here.
+await $typedFetch('/api/users', {
+ method: 'post',
+ body: { name: 'Ada', nope: 1 },
+})
+
+// `page` is `string` here: what the client sends, before `z.coerce`.
+await $typedFetch('/api/search', { query: { page: '2' } })
+```
+
+- **Typed per `(route, method)`.** The method defaults to `get` when the route
+ has one, else to the one it has - Nuxt's own rule.
+- **Required iff sending nothing would fail validation.** An all-optional
+ schema keeps the option optional, but typed.
+- **The types read the schemas' _input_ side**, not the handler's values: a
+ `z.coerce.number()` query is `string` at the call site and `number` in the
+ handler.
+- **An undeclared source is untouched**, whatever else the route declares - it
+ types exactly as vanilla types it.
+- **A route this module did not produce degrades to vanilla**, key for key.
+
+### What the types can and cannot see
+
+- **Reactive sources weaken excess-key rejection on `useTypedFetch`.** A plain
+ object literal is excess-key checked; the same value behind `ref()` or a
+ getter is not - the option is a union of reactive forms, and `ref()` infers
+ its own type. Values are still checked; only the extra key slips through.
+- **`body` is omitted on `get` / `head` for routes that declare validation.**
+ On a branded route the option is gone rather than typed, so a `get` cannot
+ carry one. An unbranded route keeps vanilla's `body` on every method, which
+ is what makes the degradation key for key.
+
+## Handling failures
+
+```ts
+matchError(
+ error,
+ {
+ 'user-not-found': (e) => notFound(e.userId),
+ 'validation-failed': (e) => showIssues(e.issues),
+ },
+ (err, unrecognized) => {
+ if (unrecognized) return report(`unknown failure: ${unrecognized.tag}`)
+ showError(err)
+ }
+)
+```
+
+One call absorbs the `if (error)` and the is-it-known check. **The arms are
+exhaustive over what the route declared**, each arm receives the whole variant,
+and the fallback is positional and required. `matchError` is imported from
+`@dphonys/nuxt-typed-handler/shared` - it is used on the server too.
+
+### The built-in `validation-failed` variant
+
+Every route that declares any `validate` source implicitly declares one extra
+variant, `validation-failed`, `400`, carrying the rejected source's issues.
+It is always on, it cannot be raised with `fail`, and the wire is a known
+error rather than the validation parent's own `400`:
+
+```jsonc
+{
+ "statusCode": 400,
+ "message": "validation-failed", // the tag; no `statusMessage`
+ "data": {
+ "issues": [
+ { "source": "query", "message": "Expected number", "path": ["page"] },
+ ],
+ },
+}
+```
+
+The known-error marker rides in `data` beside `issues` and is stripped for
+callers off the channel, exactly as for any known error - `data.issues`
+survives, so a plain `$fetch` client still reads
+`err.data.data.issues`. Both predicates answer on the thrown error:
+`recognizeKnownError` returns `{ tag: 'validation-failed', status: 400, issues }`
+and `recognizeValidationError` returns `{ issues }`.
+
+Issues are the validation parent's projection - `{ source, message, path }` and
+nothing else - and one failure's issues all share one `source`, because
+validation is fail-fast. The unparseable-body case arrives as the same variant.
+
+### A `validate`-only route is still typed
+
+```ts
+const { data, error } = await $typedFetch.try('/api/search', {
+ query: { page: 'nope' },
+})
+
+if (error) {
+ matchError(
+ error, // typed as exactly `validation-failed`
+ { 'validation-failed': (e) => showIssues(e.issues) },
+ (err) => showError(err)
+ )
+ return null
+}
+
+return data // narrowed to the route's response type
+```
+
+## Fetching
+
+Five composables - `useTypedFetch`, `useLazyTypedFetch`,
+`useRequestTypedFetch`, `useTypedAsyncData`, `useLazyTypedAsyncData` - one
+global, `$typedFetch`, and one event-bound instance, `event.$typedFetch`. Each
+mirrors its vanilla counterpart and adds the route's typed request options and
+error union.
+
+- **`$typedFetch(…)` is vanilla: it throws.** `$typedFetch.try(…)` returns
+ `{ data, error }` - a discriminated union, so `if (error) return` narrows
+ `data` with no second guard. `.raw`, `.native` and `.create(defaults)` are
+ ofetch's, forwarded.
+- **`useTypedAsyncData`** is vanilla `useAsyncData` over a handler that returns
+ `.try` results instead of throwing; the union rides the handler's return
+ type, so no route is ever restated and forgetting `.try` is a compile error.
+ Request-side it adds nothing - the inner `$typedFetch.try` call types its own
+ options.
+- **`event.$typedFetch`** is the server-to-server instance: same shape, with
+ `throw` as the exit, forwarding the request's cookies and headers. Always
+ `.try` plus translation arms - letting a callee's failure escape leaks its
+ status line as your route's answer.
+- **`useRequestTypedFetch()`** mirrors Nuxt's `useRequestFetch()`: the
+ event-bound instance while rendering, the global on the client.
+
+Rationale and the longer worked examples: [Fetching][errors-fetching].
+
+## Channel gating
+
+Responses to callers that are **not your app** go out with the known-error
+marker stripped: third parties get an ordinary error response, while your own
+calls - browser and SSR alike - get the full wire. This is **on by default**,
+under the default channel token `'nuxt-typed-handler'`.
+
+```ts
+export default defineNuxtConfig({
+ modules: ['@dphonys/nuxt-typed-handler'],
+ typedHandler: { channelToken: 'my-app' },
+})
+```
+
+- Every fetch surface of this module sends the token as the
+ `x-known-error-channel` request header; the match is always **by value**.
+- **The token is a channel tag, not a secret.** It ships in the client bundle
+ by design, marks first-party intent, and authorises nothing.
+- **It is build-time**: a module option baked into both bundles. No env
+ override, no runtime config; changing it is a rebuild.
+- `channelToken: false` turns gating off entirely. `''` disables it too but
+ warns, because only `false` can mean it on purpose.
+- **The thrown error always carries the marker** - only the serialized response
+ is ever stripped, so observability sees failures identically no matter who
+ called.
+
+Rationale: [Channel gating][errors-channel].
+
+## Observability
+
+One hook, and this module suppresses nothing on its own:
+
+```ts
+// server/plugins/observability.ts
+export default defineNitroPlugin((nitroApp) => {
+ nitroApp.hooks.hook('error', (error) => {
+ // A route's own declared failure, `validation-failed` included.
+ if (recognizeKnownError(error) && error.unhandled === false) return
+
+ report(error)
+ })
+})
+```
+
+The same predicate works in Sentry's `beforeSend` over
+`hint.originalException`. The `unhandled === false` half is load-bearing: a
+declared failure that **escaped** an inner handler reaches the hook carrying a
+marker too, and that one is a caller bug that must keep reporting - so it must
+not be added to the arm above.
+
+**Both predicates answer on a `validation-failed` error**, by design:
+`recognizeKnownError` returns the variant `{ tag, status, issues }` and
+`recognizeValidationError` returns `{ issues }`. Reach for the second one when
+input rejections are routed somewhere else than declared failures; it answers
+`undefined` for every other failure, including a route's own `fail`.
+
+## Turning the module off
+
+There is no `typedHandler: false`. `typedHandler` is a flat bag with exactly
+one key - a stray key is a compile error. To turn the module off, remove
+`'@dphonys/nuxt-typed-handler'` from `modules`.
+
+## Troubleshooting
+
+### A parent is registered beside this module
+
+```text
+[nuxt-typed-handler] `@dphonys/nuxt-handler-errors` is also registered in `modules`. @dphonys/nuxt-typed-handler replaces it: remove `@dphonys/nuxt-handler-errors` (and uninstall it), then move any `channelToken` under `typedHandler`.
+```
+
+Thrown at `modules:done`, once for the first parent found, whether the parent
+was listed by package name or as a module value. This module _replaces_ both
+parents; running them side by side would give a route two wrappers, two channel
+tokens and two generated maps.
+
+### A leftover parent config key
+
+```text
+[nuxt-typed-handler] `handlerErrors` in nuxt.config is ignored: this module replaces the parent it configured. Move `channelToken` under `typedHandler` and delete `handlerErrors`.
+```
+
+Warned once per key, for `handlerErrors` and `handlerValidation`, whenever the
+key is present at all - `handlerValidation: false` included, since there is
+nothing left for it to switch off.
+
+### `'validation-failed'` in a route's declared errors
+
+```text
+[nuxt-typed-handler] The error tag "validation-failed" is reserved for the built-in validation variant. Rename the declared error.
+```
+
+The compile guard says the same thing at the declaration
+(`__reservedErrorTag__: 'validation-failed is reserved for the built-in variant'`);
+this throw is the answer a JavaScript caller gets. Rename the declared variant.
+
+### `satisfies`, never `: ValidationSchemas`
+
+This is the one footgun worth memorizing. Annotating a declaration compiles,
+but delivers no readable sources:
+
+```ts
+// Wrong: `ctx.query` is a compile error, even though query is declared.
+const schemas: ValidationSchemas = { query: pagination }
+
+// Right: the inferred literal is what the second parameter is computed from.
+const schemas = { query: pagination } satisfies ValidationSchemas
+```
+
+The annotation throws away the very value the inference needed. Leave the
+literal inline, or use `satisfies`.
+
+Everything else is documented where the rule lives: the parents' own
+declaration diagnostics, the runtime errors for what the types cannot see and
+the edges the compile-time guard does not catch are in
+[Troubleshooting][validation-troubleshooting] in the validation parent, and
+[When the call site does not know the tag][errors-unknown-tag] in the errors
+parent.
+
+## Migrating from the parents
+
+Already on `@dphonys/nuxt-handler-errors` or
+`@dphonys/nuxt-handler-validation`? Almost everything is a rename.
+
+| Before | After |
+| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `modules: ['@dphonys/nuxt-handler-errors', '@dphonys/nuxt-handler-validation']` | `modules: ['@dphonys/nuxt-typed-handler']` |
+| `handlerErrors: { channelToken }` / `handlerValidation: …` | `typedHandler: { channelToken }` |
+| `defineCheckedEventHandler({ errors }, …)` / `defineValidatedEventHandler({ validate }, …)` | `defineTypedEventHandler({ errors \| validate }, …)` |
+| `useCheckedFetch`, `useLazyCheckedFetch`, `useRequestCheckedFetch`, `useCheckedAsyncData`, `useLazyCheckedAsyncData`, `$checkedFetch`(`.try`), `event.$checkedFetch` | `useTypedFetch`, `useLazyTypedFetch`, `useRequestTypedFetch`, `useTypedAsyncData`, `useLazyTypedAsyncData`, `$typedFetch`(`.try`), `event.$typedFetch` |
+| imports from `@dphonys/nuxt-handler-errors/{shared,types}` and `@dphonys/nuxt-handler-validation/types` | the same names from `@dphonys/nuxt-typed-handler/{shared,types}` |
+| **Unchanged:** `defineError`, `payload`, `matchError`, `recognizeKnownError`, `recognizeValidationError`, `KnownErrorsOfRoute`, `ValidationErrorData`, `ValidationSchemas`, every other parent name | same name, new specifier only |
+
+Substitute **exact identifiers**, never the bare words `Checked` or
+`Validated`, which would also hit kept names such as `CheckedEventHandler`
+and `ValidatedContext`. With GNU `sed` and
+[ripgrep](https://github.com/BurntSushi/ripgrep), from the app root:
+
+```sh
+rg -l --glob '!node_modules' -e 'defineCheckedEventHandler|defineValidatedEventHandler|use(Lazy)?Checked(Fetch|AsyncData)|useRequestCheckedFetch|\$checkedFetch|@dphonys/nuxt-handler-(errors|validation)/' | xargs sed -i -e 's/defineCheckedEventHandler/defineTypedEventHandler/g' -e 's/defineValidatedEventHandler/defineTypedEventHandler/g' -e 's/useRequestCheckedFetch/useRequestTypedFetch/g' -e 's/useLazyCheckedFetch/useLazyTypedFetch/g' -e 's/useCheckedFetch/useTypedFetch/g' -e 's/useLazyCheckedAsyncData/useLazyTypedAsyncData/g' -e 's/useCheckedAsyncData/useTypedAsyncData/g' -e 's/\$checkedFetch/$typedFetch/g' -e 's#@dphonys/nuxt-handler-errors/\(server\|shared\|types\)#@dphonys/nuxt-typed-handler/\1#g' -e 's#@dphonys/nuxt-handler-validation/\(server\|types\)#@dphonys/nuxt-typed-handler/\1#g'
+```
+
+`nuxt.config` is deliberately outside that pass - its option keys need a
+judgement no substitution can make. Edit it by hand: replace both `modules`
+entries with `'@dphonys/nuxt-typed-handler'`, rename `handlerErrors:
+{ channelToken }` to `typedHandler: { channelToken }`, and **delete**
+`handlerValidation` rather than renaming it. Renaming both keys would collide
+in one object, and `handlerValidation: false` would become a `typedHandler:
+false` this module has no off-switch for.
+
+### Not a rename: a hand-nested route becomes one flat context
+
+Composing the two parents by hand gave a route **two** second parameters -
+`{ fail }` from the outer wrapper, the validated values from the inner one.
+Under the umbrella there is one wrapper and one context.
+
+```ts
+// Before - two wrappers, two second parameters, one call forwarded by hand.
+export default defineCheckedEventHandler(
+ { errors: userErrors.pick('user-exists') },
+ (event, { fail }) =>
+ defineValidatedEventHandler(
+ { validate: { body: createUser } },
+ (_event, { body }) =>
+ taken(body.email)
+ ? fail('user-exists', { email: body.email })
+ : create(body)
+ )(event)
+)
+```
+
+```ts
+// After - one wrapper, one flat Handler context.
+export default defineTypedEventHandler(
+ { validate: { body: createUser }, errors: userErrors.pick('user-exists') },
+ (event, { body, fail }) =>
+ taken(body.email)
+ ? fail('user-exists', { email: body.email })
+ : create(body)
+)
+```
+
+### Not a rename: the default channel token changes
+
+The default token moves from `'nuxt-handler-errors'` to
+`'nuxt-typed-handler'`. Every first-party fetch surface follows automatically -
+the composables, the globals and `event.$typedFetch` all send the new value.
+Only a **non-Nuxt client that hard-coded** the old `x-known-error-channel`
+value has to change. Pinning your own `channelToken` makes this a non-event.
+
+### Not a rename: `validate`-only routes gain a typed failure
+
+Under the validation parent a rejected request answered its own `400` and the
+call site saw an untyped `FetchError`. Under the umbrella every validating
+route implicitly declares `validation-failed`, so:
+
+- `.try` and `useTypedFetch` type the `error` as a union that **includes**
+ `validation-failed` - a new exhaustive arm your existing `matchError` calls
+ do not have yet, reported by the compiler;
+- the wire becomes the known-error body ([Handling
+ failures](#handling-failures)): `message` is the tag and there is no
+ `statusMessage: 'Validation Error'` to branch on.
+
+Code that read `error.data.data.issues` off a raw `FetchError` still finds the
+issues there, but move it to `matchError`'s `validation-failed` arm (client) or
+`recognizeValidationError` (server) - both are typed, and neither depends on
+the envelope.
+
+### `handlerValidation: false` has no equivalent
+
+See [Turning the module off](#turning-the-module-off).
+
+### The order to do it in
+
+1. Swap `modules` to `['@dphonys/nuxt-typed-handler']` and uninstall both
+ parents.
+2. Run the one-liner above.
+3. Fix the three non-renames.
+4. Run `nuxt typecheck`.
+
+**Step 1 breaks the build until step 2, by design.** The sibling throw is the
+guard against a half-migration: an app cannot sit with one foot in each model.
+
+## API reference
+
+Umbrella-owned surface, in three positions. `defineTypedEventHandler` comes
+from `@dphonys/nuxt-typed-handler/server` and is auto-imported inside
+`server/`. The five composables are **app-side auto-imports** and are exported
+from no package entry - write them bare, as you would `useFetch`; the two
+fetch handles are globals. Types come from
+`@dphonys/nuxt-typed-handler/types`, which is type-only and safe to import
+from components.
+
+| Export | Role |
+| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
+| `defineTypedEventHandler({ validate, errors }, fn)` | The wrapper. One signature; at least one of the two keys. Returns a `TypedEventHandler`. |
+| `useTypedFetch` / `useLazyTypedFetch` | `useFetch` with the route's Request input on the options and its error union on the `error` ref. |
+| `useTypedAsyncData` / `useLazyTypedAsyncData` | `useAsyncData` over a handler returning `.try` results. |
+| `useRequestTypedFetch()` | The request-bound instance for SSR-safe imperative calls; the global on the client. |
+| `$typedFetch` (`.try`, `.raw`, `.native`, `.create`) | The global typed fetch; `.try` returns `{ data, error }` instead of throwing. |
+| `event.$typedFetch` | The event-bound instance, forwarding the request's identity. |
+| `TypedRequestOptions` | The options every family member takes for a route and method. |
+| `TypedFetch`, `TypedFetchTry`, `$TypedFetch`, `TypedEventFetch` | The fetch signatures behind those bindings. |
+| `TypedEventHandler` | What the wrapper returns: an h3 `EventHandler` carrying both parents' brands. |
+| `TypedContext` | The Handler context: the validated sources, plus `fail` iff errors were declared. |
+| `TypedErrors` | A route's failure union: the declared variants plus `ValidationFailed` iff it validates. |
+| `TypedHandlerFn`, `DefineTypedEventHandler` | The handler function shape and the wrapper's own call signature. |
+| `AtLeastOne`, `ReservedTagGuard` | The compile-time guards behind the bare-`{}` and reserved-tag diagnostics. |
+| `ValidationFailed` | `{ tag: 'validation-failed'; status: 400; issues: ValidationIssue[] }`. |
+| `RequestInputOfRoute` | A route's declared Request input from its path alone; `never` means "declares no sources". |
+| `KnownApiRequestInputs` | The generated map of every route's Request input - you never write to it. |
+| `ModuleOptions` | From `@dphonys/nuxt-typed-handler`: `{ channelToken: string \| false }`. |
+
+### Re-exported from the parents
+
+Same names, new specifier. Roles are documented in the parent that owns them
+([errors][errors], [validation][validation]).
+
+**`@dphonys/nuxt-typed-handler/server`** (all auto-imported inside `server/`):
+`defineError`, `payload`, `recognizeKnownError`, `recognizeValidationError`.
+
+**`@dphonys/nuxt-typed-handler/shared`**: `matchError`, `KNOWN_ERROR_KEY`.
+
+**`@dphonys/nuxt-typed-handler/types`**, from `nuxt-handler-errors`:
+`$CheckedFetch`, `CheckedEventHandler`, `CheckedFetch`, `Fail`, `Fallback`,
+`KnownApiErrors`, `KnownError`, `KnownErrorBody`, `KnownErrorCarrier`,
+`KnownErrorFor`, `KnownErrorGroup`, `KnownErrorKey`, `KnownErrorsOf`,
+`KnownErrorsOfHandler`, `KnownErrorsOfRoute`, `KnownVariant`, `TryResult`,
+`VariantsOf`.
+
+**`@dphonys/nuxt-typed-handler/types`**, from `nuxt-handler-validation`:
+`InputOf`, `MergedInput`, `MergedOutput`, `OutputOf`, `RequestInput`,
+`RequestInputOfHandler`, `SourceInput`, `SourceSchemas`, `SourceValue`,
+`ValidatedContext`, `ValidatedEventHandler`, `ValidationDeclarationError`,
+`ValidationErrorData`, `ValidationIssue`, `ValidationSchemas`,
+`ValidationSchemasGuard`, `ValidationSource`.
+
+**One caveat on `$checkedFetch`.** Re-exporting the errors parent's types also
+loads its ambient declarations, so `$checkedFetch` and `event.$checkedFetch`
+still _typecheck_ under the umbrella. Nothing binds them: this module installs
+`$typedFetch` only, and a call would find `undefined` at runtime. Use the
+`Typed` names.
+
+## Repository development
+
+From the repository root:
+
+```sh
+pnpm --filter @dphonys/nuxt-typed-handler dev
+pnpm --filter @dphonys/nuxt-typed-handler typecheck
+pnpm --filter @dphonys/nuxt-typed-handler test
+pnpm --filter @dphonys/nuxt-typed-handler build
+pnpm --filter @dphonys/nuxt-typed-handler publint
+```
+
+This package composes the parents' `internals/*` entries, which are documented
+for it alone in each parent's `INTERNALS.md`.
+
+## License
+
+Licensed under the [MIT License](./LICENSE).
+
+[errors]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-errors/README.md
+[errors-declaring]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-errors/README.md#declaring-what-a-route-can-fail-with
+[errors-fetching]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-errors/README.md#fetching
+[errors-channel]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-errors/README.md#channel-gating
+[errors-unknown-tag]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-errors/README.md#when-the-call-site-does-not-know-the-tag
+[validation]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-validation/README.md
+[validation-composing]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-validation/README.md#reusing-and-composing-schemas
+[validation-sources]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-validation/README.md#what-each-source-receives
+[validation-troubleshooting]: https://github.com/DPHonys/dph-nuxt-stuff/blob/main/packages/nuxt-handler-validation/README.md#troubleshooting
diff --git a/packages/nuxt-typed-handler/package.json b/packages/nuxt-typed-handler/package.json
new file mode 100644
index 0000000..86e6141
--- /dev/null
+++ b/packages/nuxt-typed-handler/package.json
@@ -0,0 +1,102 @@
+{
+ "name": "@dphonys/nuxt-typed-handler",
+ "version": "0.1.0",
+ "description": "Declare a Nitro handler's request schemas and expected failures once, and get both typed at every call site.",
+ "keywords": [
+ "nuxt",
+ "nuxt-module",
+ "nuxt-typed-handler"
+ ],
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/DPHonys/dph-nuxt-stuff.git",
+ "directory": "packages/nuxt-typed-handler"
+ },
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "sideEffects": false,
+ "main": "./dist/module.mjs",
+ "typesVersions": {
+ "*": {
+ ".": [
+ "./dist/types.d.mts"
+ ],
+ "types": [
+ "./dist/runtime/types/index.d.ts"
+ ],
+ "server": [
+ "./dist/runtime/server/index.d.ts"
+ ],
+ "shared": [
+ "./dist/runtime/shared/index.d.ts"
+ ]
+ }
+ },
+ "exports": {
+ ".": {
+ "types": "./dist/types.d.mts",
+ "import": "./dist/module.mjs"
+ },
+ "./types": {
+ "types": "./dist/runtime/types/index.d.ts",
+ "import": "./dist/runtime/types/index.js"
+ },
+ "./server": {
+ "types": "./dist/runtime/server/index.d.ts",
+ "import": "./dist/runtime/server/index.js"
+ },
+ "./shared": {
+ "types": "./dist/runtime/shared/index.d.ts",
+ "import": "./dist/runtime/shared/index.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "nuxt-module-build build",
+ "dev": "pnpm run dev:prepare && nuxt dev playground",
+ "dev:build": "nuxt build playground",
+ "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
+ "lint": "eslint .",
+ "prebuild": "nuxt-module-build prepare",
+ "prepack": "pnpm run build",
+ "pretest": "nuxt-module-build prepare",
+ "pretypecheck": "pnpm run build",
+ "publint": "publint",
+ "test": "vitest run",
+ "test:watch": "vitest watch",
+ "typecheck": "nuxt prepare playground && vue-tsc --noEmit && vue-tsc --noEmit --project playground/tsconfig.json"
+ },
+ "dependencies": {
+ "@dphonys/nuxt-handler-errors": "workspace:0.4.0",
+ "@dphonys/nuxt-handler-validation": "workspace:0.2.0",
+ "@nuxt/kit": "catalog:",
+ "h3": "catalog:",
+ "nitropack": "catalog:",
+ "vue": "catalog:"
+ },
+ "devDependencies": {
+ "@nuxt/devtools": "catalog:",
+ "@nuxt/module-builder": "catalog:",
+ "@nuxt/schema": "catalog:",
+ "@nuxt/test-utils": "catalog:",
+ "@types/node": "catalog:",
+ "nuxt": "catalog:",
+ "publint": "catalog:",
+ "typescript": "catalog:",
+ "vitest": "catalog:",
+ "vue-tsc": "catalog:",
+ "zod": "catalog:"
+ },
+ "peerDependencies": {
+ "nuxt": ">=4.5.1 <5.0.0"
+ },
+ "engines": {
+ "node": "^22.19.0 || ^24.11.0 || >=26.0.0"
+ }
+}
diff --git a/packages/nuxt-typed-handler/playground/app.vue b/packages/nuxt-typed-handler/playground/app.vue
new file mode 100644
index 0000000..e412629
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/app.vue
@@ -0,0 +1,76 @@
+
+
+
+
+ Nuxt Typed Handler
+ {{ created }}
+ {{ page }}
+ {{ described }}
+
+
diff --git a/packages/nuxt-typed-handler/playground/nuxt.config.ts b/packages/nuxt-typed-handler/playground/nuxt.config.ts
new file mode 100644
index 0000000..3b2cd68
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/nuxt.config.ts
@@ -0,0 +1,11 @@
+export default defineNuxtConfig({
+ modules: ['@dphonys/nuxt-typed-handler'],
+ devtools: { enabled: true },
+ compatibilityDate: 'latest',
+ typedHandler: {
+ // A channel tag, not a secret: it is compiled into the client bundle by
+ // design and marks first-party intent. With it set, a response to a
+ // request that does not carry it goes out with the marker stripped.
+ channelToken: 'playground-channel',
+ },
+})
diff --git a/packages/nuxt-typed-handler/playground/package.json b/packages/nuxt-typed-handler/playground/package.json
new file mode 100644
index 0000000..63ab031
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@dphonys/nuxt-typed-handler-playground",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "nuxt build"
+ },
+ "dependencies": {
+ "@dphonys/nuxt-typed-handler": "workspace:*",
+ "nuxt": "catalog:",
+ "zod": "catalog:"
+ }
+}
diff --git a/packages/nuxt-typed-handler/playground/request-typing.check.ts b/packages/nuxt-typed-handler/playground/request-typing.check.ts
new file mode 100644
index 0000000..64a5927
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/request-typing.check.ts
@@ -0,0 +1,258 @@
+import type { TypedRequestOptions } from '@dphonys/nuxt-typed-handler/types'
+import type { NitroFetchOptions } from 'nitropack/types'
+
+/**
+ * The Typed fetch family's request-side contract, re-pointed off the
+ * hand-written map in `test/types/request-routes.ts` and onto the **real**
+ * one: every row below reads `.nuxt/types/nuxt-typed-handler.d.ts` as this
+ * app's `nuxt prepare` wrote it, from the routes in `server/api/`.
+ *
+ * It lives in an app because that map only exists inside one, and it is
+ * compiler-asserted: `pnpm typecheck` runs `vue-tsc` over this project. Bare
+ * `@ts-expect-error` throughout - a line that compiles where it must not is
+ * itself an error (TS2578), so a green run means every row held.
+ */
+
+/**
+ * The package's own `test/types/assert.ts`, restated: the playground is a
+ * separate workspace, and reaching across into the package's test tree from
+ * an app would be a stranger dependency than these six lines.
+ */
+type Equal =
+ (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2
+ ? true
+ : false
+
+type Assert = T
+
+/** `/api/users` declares a body: required, typed as the wire sends it, closed. */
+export async function declaredBody(): Promise {
+ const _created = await $typedFetch('/api/users', {
+ method: 'post',
+ body: { name: 'Ada', email: 'ada@example.com' },
+ })
+ type _resp = Assert>
+
+ // @ts-expect-error - the declared body is required
+ await $typedFetch('/api/users', { method: 'post' })
+
+ await $typedFetch('/api/users', {
+ method: 'POST',
+ // @ts-expect-error - excess key on the declared body
+ body: { name: 'Ada', email: 'ada@example.com', extra: 1 },
+ })
+
+ await $typedFetch('/api/users', {
+ method: 'post',
+ // @ts-expect-error - a raw carrier is not the schema's input
+ body: '{"name":"Ada"}',
+ })
+}
+
+/** `/api/search` composes two query schemas: the wire satisfies both. */
+export async function tupleQuery(): Promise {
+ const _page = await $typedFetch('/api/search', {
+ query: { page: '2', sort: 'name' },
+ })
+ // `get` is the default method, and the route is keyed on it.
+ type _resp = Assert>
+
+ // The second element is all-optional; the first is not.
+ await $typedFetch('/api/search', { query: { page: '2' } })
+
+ // @ts-expect-error - `page` comes from the first element, and is required
+ await $typedFetch('/api/search', { query: { sort: 'name' } })
+
+ // @ts-expect-error - the composed query is closed over both elements
+ await $typedFetch('/api/search', { query: { page: '2', nope: 1 } })
+
+ await $typedFetch('/api/search', {
+ query: { page: '2' },
+ // @ts-expect-error - `body` is omitted on a branded `get`
+ body: { anything: 1 },
+ })
+
+ // @ts-expect-error - the output type (`page: number`) is not what the wire takes
+ await $typedFetch('/api/search', { query: { page: 2 } })
+}
+
+/** A `default` (method-less) handler answers every verb it is not keyed for. */
+export async function defaultHandler(): Promise {
+ const _got = await $typedFetch('/api/items')
+ type _default = Assert>
+
+ await $typedFetch('/api/items', { method: 'post', body: { qty: 1 } })
+ // @ts-expect-error - post on the default handler requires the declared body
+ await $typedFetch('/api/items', { method: 'post' })
+ // @ts-expect-error - excess key on the declared body
+ await $typedFetch('/api/items', { method: 'put', body: { qty: 1, x: 1 } })
+}
+
+/** A declared `body` is the schema's input, never a raw carrier. */
+export async function rawCarriersRejected(): Promise {
+ await $typedFetch('/api/users', {
+ method: 'post',
+ // @ts-expect-error - a FormData body
+ body: new FormData(),
+ })
+}
+
+/** A union-typed `method` distributes over the lookup, case and all. */
+export async function unionMethod(): Promise {
+ const method = Math.random() > 0.5 ? ('post' as const) : ('POST' as const)
+
+ await $typedFetch('/api/users', {
+ method,
+ body: { name: 'Ada', email: 'ada@example.com' },
+ })
+}
+
+/** `create` defaults are vanilla options: they never relax a call's requiredness. */
+export async function createdInstance(): Promise {
+ const api = $typedFetch.create({ headers: { 'x-any': 'thing' } })
+
+ // @ts-expect-error - `body` is still required per call
+ await api('/api/users', { method: 'post' })
+
+ await api('/api/users', {
+ method: 'post',
+ body: { name: 'Ada', email: 'ada@example.com' },
+ })
+
+ const viaCreate = await api.try('/api/users', {
+ method: 'post',
+ body: { name: 'Ada', email: 'ada@example.com' },
+ })
+
+ if (viaCreate.error) {
+ type _createTry = Assert<
+ Equal<
+ NonNullable<
+ typeof viaCreate.error.data
+ >['data']['__knownError__']['tag'],
+ 'user-exists' | 'validation-failed'
+ >
+ >
+ }
+}
+
+/** `.raw` shares the call's signature, and an explicit `T` still overrides. */
+export async function rawAndExplicitResponse(): Promise {
+ await $typedFetch.raw('/api/users', {
+ method: 'post',
+ // @ts-expect-error - typed exactly as the call is
+ body: {},
+ })
+
+ const _raw = await $typedFetch.raw('/api/users', {
+ method: 'post',
+ body: { name: 'Ada', email: 'ada@example.com' },
+ })
+ type _rawData = Assert<
+ Equal
+ >
+
+ const _custom = await $typedFetch<{ custom: true }>('/api/legacy')
+ type _customResp = Assert>
+}
+
+/** `params` is gone family-wide; every other vanilla key is untouched. */
+export async function optionsSurface(): Promise {
+ // @ts-expect-error - ofetch's deprecated alias is not a back door
+ await $typedFetch('/api/search', { params: { page: '2' } })
+ // @ts-expect-error - nor on an unbranded route
+ await $typedFetch('/api/legacy', { params: { a: 1 } })
+
+ await $typedFetch('/api/search', {
+ headers: { 'x-any': 'thing' },
+ query: { page: '2' },
+ })
+
+ type Headers_ = TypedRequestOptions<'/api/search', 'get'>['headers']
+ type _headersVanilla = Assert<
+ Equal['headers']>
+ >
+}
+
+/** A route that declares nothing types exactly as vanilla, key for key. */
+export async function vanillaDegradation(): Promise {
+ const _legacy = await $typedFetch('/api/legacy')
+ type _resp = Assert>
+
+ await $typedFetch('/api/legacy', { query: { anything: 1, goes: true } })
+ // `body` on `get` is vanilla's own on an unbranded route, so it stays.
+ await $typedFetch('/api/legacy', { method: 'get', body: 'x' })
+
+ type VanillaGet = TypedRequestOptions<'/api/legacy', 'get'>
+ type _getKeys = Assert<
+ Equal<
+ keyof VanillaGet,
+ Exclude, 'params'>
+ >
+ >
+ type _getBody = Assert<
+ Equal['body']>
+ >
+}
+
+/** An `errors`-only route declares no sources, so both stay vanilla. */
+export async function errorsOnlyRoute(): Promise {
+ await $typedFetch('/api/notes', {
+ method: 'post',
+ body: 'raw string',
+ query: { a: 1 },
+ })
+
+ type ErrorsOnlyPost = TypedRequestOptions<'/api/notes', 'post'>
+ type _bodyVanilla = Assert<
+ Equal['body']>
+ >
+ type _queryVanilla = Assert<
+ Equal['query']>
+ >
+}
+
+/** `.try` folds the failure into the result, typed from the errors map. */
+export async function tryResults(): Promise {
+ const both = await $typedFetch.try('/api/users', {
+ method: 'post',
+ body: { name: 'Ada', email: 'ada@example.com' },
+ })
+
+ if (both.error) {
+ const variant = both.error.data!.data.__knownError__
+ type _tags = Assert<
+ Equal
+ >
+
+ if (variant.tag === 'user-exists') {
+ type _payload = Assert>
+ }
+ } else {
+ // Narrowed by the sibling guard alone - no second check and no `!`.
+ type _data = Assert>
+ }
+
+ // A `validate`-only route can still only fail one way.
+ const validateOnly = await $typedFetch.try('/api/search', {
+ query: { page: 'nope' },
+ })
+
+ if (validateOnly.error) {
+ type _onlyVariant = Assert<
+ Equal<
+ NonNullable<
+ typeof validateOnly.error.data
+ >['data']['__knownError__']['tag'],
+ 'validation-failed'
+ >
+ >
+ }
+
+ // An unbranded route carries nothing to narrow on.
+ const unbranded = await $typedFetch.try('/api/legacy')
+
+ if (unbranded.error) {
+ type _untyped = Assert>
+ }
+}
diff --git a/packages/nuxt-typed-handler/playground/server/api/items.ts b/packages/nuxt-typed-handler/playground/server/api/items.ts
new file mode 100644
index 0000000..f5cfa59
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/server/api/items.ts
@@ -0,0 +1,10 @@
+import { itemUpdate } from '../validation/schemas'
+
+/**
+ * A method-less (`default`) handler: it answers every verb the route is not
+ * keyed for, and both maps key it under `default` rather than a method.
+ */
+export default defineTypedEventHandler(
+ { validate: { body: itemUpdate } },
+ (_event, { body }) => ({ qty: body.qty })
+)
diff --git a/packages/nuxt-typed-handler/playground/server/api/legacy.get.ts b/packages/nuxt-typed-handler/playground/server/api/legacy.get.ts
new file mode 100644
index 0000000..142d10d
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/server/api/legacy.get.ts
@@ -0,0 +1,2 @@
+/** Unbranded, and keyed anyway: what makes both generated lookups total. */
+export default defineEventHandler(() => ({ legacy: true }))
diff --git a/packages/nuxt-typed-handler/playground/server/api/notes.post.ts b/packages/nuxt-typed-handler/playground/server/api/notes.post.ts
new file mode 100644
index 0000000..7fe1353
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/server/api/notes.post.ts
@@ -0,0 +1,8 @@
+import { readRawBody } from 'h3'
+import { userErrors } from '../errors/users'
+
+/** An `errors`-only `POST`: the body is the handler's to read, or not. */
+export default defineTypedEventHandler(
+ { errors: userErrors.pick('user-not-found') },
+ async (event) => ({ reached: true, raw: (await readRawBody(event)) ?? null })
+)
diff --git a/packages/nuxt-typed-handler/playground/server/api/search.get.ts b/packages/nuxt-typed-handler/playground/server/api/search.get.ts
new file mode 100644
index 0000000..a36c054
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/server/api/search.get.ts
@@ -0,0 +1,10 @@
+import { pagination, sorting } from '../validation/schemas'
+
+/**
+ * A `validate`-only route with a composed (tuple) query: the parent's
+ * context, the umbrella's failure wire.
+ */
+export default defineTypedEventHandler(
+ { validate: { query: [pagination, sorting] } },
+ (_event, { query }) => ({ page: query.page, hits: [] as string[] })
+)
diff --git a/packages/nuxt-typed-handler/playground/server/api/users.post.ts b/packages/nuxt-typed-handler/playground/server/api/users.post.ts
new file mode 100644
index 0000000..1aa3d2b
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/server/api/users.post.ts
@@ -0,0 +1,14 @@
+import { userErrors } from '../errors/users'
+import { createUser } from '../validation/schemas'
+
+/** Both halves declared: a rejected body and a declared failure, one route. */
+export default defineTypedEventHandler(
+ { validate: { body: createUser }, errors: userErrors.pick('user-exists') },
+ (_event, { body, fail }) => {
+ if (body.email === 'taken@example.com') {
+ return fail('user-exists', { email: body.email })
+ }
+
+ return { created: body.name }
+ }
+)
diff --git a/packages/nuxt-typed-handler/playground/server/api/users/[id].get.ts b/packages/nuxt-typed-handler/playground/server/api/users/[id].get.ts
new file mode 100644
index 0000000..49eea89
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/server/api/users/[id].get.ts
@@ -0,0 +1,13 @@
+import { userErrors } from '../../errors/users'
+
+/** The errors parent's smoke: an `errors`-only route, byte for byte the parent's. */
+export default defineTypedEventHandler(
+ { errors: userErrors.pick('user-not-found') },
+ (event, { fail }) => {
+ const userId = event.context.params?.id ?? ''
+
+ if (userId === 'missing') return fail('user-not-found', { userId })
+
+ return { id: userId, name: `User ${userId}` }
+ }
+)
diff --git a/packages/nuxt-typed-handler/playground/server/errors/users.ts b/packages/nuxt-typed-handler/playground/server/errors/users.ts
new file mode 100644
index 0000000..8dc61e5
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/server/errors/users.ts
@@ -0,0 +1,5 @@
+/** The failures the user routes declare, shared so both spell them once. */
+export const userErrors = defineError({
+ 'user-not-found': { status: 404, payload: payload<{ userId: string }>() },
+ 'user-exists': { status: 409, payload: payload<{ email: string }>() },
+})
diff --git a/packages/nuxt-typed-handler/playground/server/tsconfig.json b/packages/nuxt-typed-handler/playground/server/tsconfig.json
new file mode 100644
index 0000000..b9ed69c
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/server/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../.nuxt/tsconfig.server.json"
+}
diff --git a/packages/nuxt-typed-handler/playground/server/validation/schemas.ts b/packages/nuxt-typed-handler/playground/server/validation/schemas.ts
new file mode 100644
index 0000000..52cd356
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/server/validation/schemas.ts
@@ -0,0 +1,25 @@
+import { z } from 'zod'
+
+/** A page number, coerced from the string the wire always carries. */
+export const pagination = z.object({
+ page: z
+ .string()
+ .regex(/^\d+$/, 'page must be a whole number')
+ .transform(Number),
+})
+
+/** What creating a user takes. */
+export const createUser = z.object({
+ name: z.string().min(1, 'name is required'),
+ email: z.string().email('email must be an address'),
+})
+
+/** The second half of the search query, composed with `pagination` as a tuple. */
+export const sorting = z.object({
+ sort: z.enum(['name', 'created']).optional(),
+})
+
+/** What updating an item takes; the `default` handler's declared body. */
+export const itemUpdate = z.object({
+ qty: z.number(),
+})
diff --git a/packages/nuxt-typed-handler/playground/tsconfig.json b/packages/nuxt-typed-handler/playground/tsconfig.json
new file mode 100644
index 0000000..4b34df1
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "./.nuxt/tsconfig.json"
+}
diff --git a/packages/nuxt-typed-handler/playground/turbo.json b/packages/nuxt-typed-handler/playground/turbo.json
new file mode 100644
index 0000000..79dff1c
--- /dev/null
+++ b/packages/nuxt-typed-handler/playground/turbo.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://turbo.build/schema.json",
+ "extends": ["//"],
+ "tasks": {
+ "typecheck": {
+ "dependsOn": []
+ }
+ }
+}
diff --git a/packages/nuxt-typed-handler/src/build/parent-types-paths.ts b/packages/nuxt-typed-handler/src/build/parent-types-paths.ts
new file mode 100644
index 0000000..7761d4f
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/build/parent-types-paths.ts
@@ -0,0 +1,80 @@
+import { resolveTypePaths } from '@nuxt/kit'
+import type { Nuxt } from '@nuxt/schema'
+import type { NitroConfig } from 'nitropack/types'
+import { fileURLToPath } from 'node:url'
+
+// The generated map augments the first and imports from the second; neither
+// resolves from an app that installed the umbrella alone.
+const PARENT_TYPES_SPECIFIERS = [
+ '@dphonys/nuxt-handler-errors/types',
+ '@dphonys/nuxt-handler-validation/types',
+] as const
+
+interface PathsCarrier {
+ compilerOptions?: { paths?: Record }
+}
+
+/**
+ * Map both parent `/types` specifiers, on every generated tsconfig, to the
+ * declaration each resolves to from this module's own location (`from` is
+ * its `import.meta.url`), so pnpm's nested layout is honoured.
+ * `typescript.hoist` cannot do this: it resolves from the app's `modulesDir`
+ * alone and silently drops what it cannot find there.
+ */
+export function addParentTypesPaths(nuxt: Nuxt, from: string): void {
+ // A directory, not the URL: handed a `file://` URL, `resolveTypePaths`
+ // silently searches from the process's working directory instead.
+ const searchPath = fileURLToPath(new URL('.', from))
+
+ // Resolved once for both hooks, failure included. `resolveTypePaths` rather
+ // than `resolvePath`: a `paths` entry has to name the declaration
+ // TypeScript loads for a subpath export, not the runtime file.
+ let declarations: Promise> | undefined
+
+ const resolveDeclarations = (): Promise> => {
+ declarations ??= resolveTypePaths(
+ [...PARENT_TYPES_SPECIFIERS],
+ [searchPath]
+ ).then((resolved) => {
+ const missing = PARENT_TYPES_SPECIFIERS.filter(
+ (specifier) => !resolved.some(([found]) => found === specifier)
+ )
+
+ if (missing.length > 0) {
+ throw new Error(
+ `[nuxt-typed-handler] could not resolve ${missing.join(', ')} from ${searchPath}. Both parents are exact-pinned dependencies of this package; reinstall it.`
+ )
+ }
+
+ return Object.fromEntries(
+ resolved.map(([specifier, path]) => [specifier, [path]])
+ )
+ })
+
+ return declarations
+ }
+
+ const write = async (...targets: PathsCarrier[]): Promise => {
+ const entries = await resolveDeclarations()
+
+ for (const target of targets) {
+ target.compilerOptions ??= {}
+ target.compilerOptions.paths ??= {}
+
+ for (const [specifier, paths] of Object.entries(entries)) {
+ target.compilerOptions.paths[specifier] = paths
+ }
+ }
+ }
+
+ nuxt.hook('prepare:types', ({ tsConfig, nodeTsConfig, sharedTsConfig }) =>
+ write(tsConfig, nodeTsConfig, sharedTsConfig)
+ )
+
+ nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => {
+ nitroConfig.typescript ??= {}
+ nitroConfig.typescript.tsConfig ??= {}
+
+ return write(nitroConfig.typescript.tsConfig)
+ })
+}
diff --git a/packages/nuxt-typed-handler/src/build/type-map.ts b/packages/nuxt-typed-handler/src/build/type-map.ts
new file mode 100644
index 0000000..1b62c8c
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/build/type-map.ts
@@ -0,0 +1,60 @@
+// The generated file as a value, kept out of `module.ts` so a suite can ask
+// for the real map - slots respelled in a test prove only the test.
+
+import {
+ emitMap,
+ emptyMap,
+ KNOWN_ERRORS_SLOT,
+} from '@dphonys/nuxt-handler-errors/internals/build'
+import type {
+ EmitMapSlot,
+ NitroPathOptions,
+} from '@dphonys/nuxt-handler-errors/internals/build'
+import type { NitroEventHandler } from 'nitropack/types'
+
+/**
+ * The specifier the request-inputs map augments. Exported so `setup()`'s
+ * `typescript.hoist.push(...)` cannot drift from the string actually emitted.
+ */
+export const TYPES_SPECIFIER = '@dphonys/nuxt-typed-handler/types'
+
+// No `Serialize`: the input *is* the wire shape by the author's intent, and
+// `query` must not be serialised.
+const REQUEST_INPUTS_SLOT: EmitMapSlot = {
+ interfaceName: 'KnownApiRequestInputs',
+ specifier: TYPES_SPECIFIER,
+ imports: [
+ { names: ['Simplify'], from: 'nitropack/types' },
+ {
+ names: ['RequestInputOfHandler'],
+ from: '@dphonys/nuxt-handler-validation/types',
+ },
+ ],
+ extract: (handlerType) => `Simplify>`,
+}
+
+const SLOTS: readonly EmitMapSlot[] = [KNOWN_ERRORS_SLOT, REQUEST_INPUTS_SLOT]
+
+export interface TypeMap {
+ /**
+ * Must live under `types/` - Nitro's `typesDir` - because every handler
+ * specifier the emitter computes is relative to it, and an unresolved
+ * `import('…')` in a `.d.ts` produces no diagnostic.
+ */
+ readonly filename: `${string}.d.ts`
+ /** What a build writes before Nitro exists: one empty interface per slot. */
+ readonly empty: string
+ readonly emit: (
+ handlers: readonly NitroEventHandler[],
+ nitroOptions: NitroPathOptions
+ ) => string
+}
+
+export function typeMap(name: string): TypeMap {
+ return {
+ filename: `types/${name}.d.ts`,
+ empty: emptyMap({ slots: SLOTS, generatedBy: name }),
+ emit: (handlers, nitroOptions) =>
+ emitMap(handlers, { nitroOptions, slots: SLOTS, generatedBy: name }),
+ }
+}
diff --git a/packages/nuxt-typed-handler/src/module.ts b/packages/nuxt-typed-handler/src/module.ts
new file mode 100644
index 0000000..1fffdbe
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/module.ts
@@ -0,0 +1,219 @@
+import {
+ addChannelStripErrorHandler,
+ addChannelToken,
+ normalizeChannelToken,
+ warnCustomErrorHandler,
+} from '@dphonys/nuxt-handler-errors/internals/build'
+import {
+ addImports,
+ addPlugin,
+ addServerImports,
+ addServerPlugin,
+ addTypeTemplate,
+ createResolver,
+ defineNuxtModule,
+ hasNuxtModule,
+ logger,
+ updateTemplates,
+} from '@nuxt/kit'
+import type { Nitro } from 'nitropack/types'
+import { addParentTypesPaths } from './build/parent-types-paths'
+import { typeMap, TYPES_SPECIFIER } from './build/type-map'
+
+export interface ModuleOptions {
+ /**
+ * The channel tag every checked call attaches, and the value the
+ * response-side stripper matches requests against. **A channel tag, not a
+ * secret**: it ships in the client bundle by design and authorises nothing.
+ *
+ * Defaults to `'nuxt-typed-handler'`, so gating is on out of the box. Set
+ * your own value to name your app's channel, or `false` to turn gating off
+ * entirely. (`false`, not `null`: the options merge treats `null` as
+ * "unset" and would silently restore the default.) Build-time: the value
+ * is baked into both bundles, so changing it is a rebuild.
+ */
+ channelToken: string | false
+}
+
+const NAME = 'nuxt-typed-handler'
+
+const TYPE_MAP = typeMap(NAME)
+
+const PARENTS = [
+ {
+ packageName: '@dphonys/nuxt-handler-errors',
+ moduleName: 'nuxt-handler-errors',
+ configKey: 'handlerErrors',
+ },
+ {
+ packageName: '@dphonys/nuxt-handler-validation',
+ moduleName: 'nuxt-handler-validation',
+ configKey: 'handlerValidation',
+ },
+] as const
+
+export default defineNuxtModule({
+ meta: {
+ name: NAME,
+ configKey: 'typedHandler',
+ // The ceiling is the only guard against the h3 v2 / Nitro 3 line.
+ compatibility: { nuxt: '>=4.5.1 <5.0.0' },
+ },
+ defaults: {
+ channelToken: NAME,
+ },
+ setup(options, nuxt) {
+ warnCustomErrorHandler(nuxt, NAME)
+
+ // Any value, `false` included: a parent's key configures nothing here, and
+ // `false` has nothing left to switch off.
+ for (const { configKey } of PARENTS) {
+ if (!Object.hasOwn(nuxt.options, configKey)) continue
+
+ logger.warn(
+ `[${NAME}] \`${configKey}\` in nuxt.config is ignored: this module replaces the parent it configured. Move \`channelToken\` under \`typedHandler\` and delete \`${configKey}\`.`
+ )
+ }
+
+ // The errors parent's app internals import `#app`, and Nuxt transpiles
+ // only what `modules` lists.
+ nuxt.options.build.transpile.push('@dphonys/nuxt-handler-errors')
+
+ // Only this module's own specifier is hoisted: `hoist` resolves from the
+ // app's `modulesDir`, where an umbrella-only install has no parent. The
+ // parents' `/types` go through `paths` instead.
+ nuxt.options.typescript.hoist.push(TYPES_SPECIFIER)
+ addParentTypesPaths(nuxt, import.meta.url)
+
+ // Named rather than scanned with `addServerImportsDir`: the parents'
+ // wrappers must not become auto-imports.
+ const resolver = createResolver(import.meta.url)
+ const serverEntry = resolver.resolve('./runtime/server/index')
+
+ addServerImports(
+ [
+ 'defineTypedEventHandler',
+ 'defineError',
+ 'payload',
+ 'recognizeKnownError',
+ 'recognizeValidationError',
+ ].map((name) => ({ name, from: serverEntry }))
+ )
+
+ // None of the composables use `addServerImports`: all five reach `#app`,
+ // which the Nitro build does not have.
+ const fetchComposables = resolver.resolve(
+ './runtime/app/composables/use-typed-fetch'
+ )
+ const asyncDataComposables = resolver.resolve(
+ './runtime/app/composables/use-typed-async-data'
+ )
+ const requestComposable = resolver.resolve(
+ './runtime/app/composables/use-request-typed-fetch'
+ )
+
+ addImports([
+ { name: 'useTypedFetch', from: fetchComposables },
+ { name: 'useLazyTypedFetch', from: fetchComposables },
+ { name: 'useTypedAsyncData', from: asyncDataComposables },
+ { name: 'useLazyTypedAsyncData', from: asyncDataComposables },
+ { name: 'useRequestTypedFetch', from: requestComposable },
+ ])
+
+ // `matchError` is deliberately not auto-imported: its callers include a
+ // consumer's `shared/` directory, where app-side auto-imports do not reach.
+
+ // Without this registration duplicate calls collapse onto one
+ // `useAsyncData` entry. `argumentLength: 3` is vanilla's own.
+ nuxt.options.optimization.keyedComposables.push(
+ { name: 'useTypedFetch', source: fetchComposables, argumentLength: 3 },
+ {
+ name: 'useLazyTypedFetch',
+ source: fetchComposables,
+ argumentLength: 3,
+ },
+ {
+ name: 'useTypedAsyncData',
+ source: asyncDataComposables,
+ argumentLength: 3,
+ },
+ {
+ name: 'useLazyTypedAsyncData',
+ source: asyncDataComposables,
+ argumentLength: 3,
+ }
+ )
+
+ // `$typedFetch` on both `globalThis`es. The app half is `client`-only:
+ // during SSR the one global is Nitro's.
+ addPlugin({
+ src: resolver.resolve('./runtime/app/plugins/typed-fetch.client'),
+ mode: 'client',
+ })
+ addServerPlugin(resolver.resolve('./runtime/server/plugins/typed-fetch'))
+
+ addServerPlugin(
+ resolver.resolve('./runtime/server/plugins/event-typed-fetch')
+ )
+
+ const channelToken = normalizeChannelToken(options.channelToken, NAME)
+ addChannelToken(nuxt, NAME, channelToken)
+ addChannelStripErrorHandler(
+ nuxt,
+ channelToken,
+ resolver.resolve('./runtime/server/handlers/channel-strip')
+ )
+
+ // Captured here and read by `getContents` - on a dev-server restart the
+ // current instance and the hooked one are not the same object.
+ let nitro: Nitro | undefined
+
+ // The context must name all three programs: passing a context at all opts
+ // out of everything it does not name.
+ addTypeTemplate(
+ {
+ filename: TYPE_MAP.filename,
+ getContents: () =>
+ nitro === undefined
+ ? TYPE_MAP.empty
+ : TYPE_MAP.emit(
+ [...nitro.scannedHandlers, ...nitro.options.handlers],
+ // Passed whole: `resolveNitroPath` reads arbitrary
+ // properties off it to expand `{{ }}` path templates.
+ nitro.options
+ ),
+ },
+ { nitro: true, nuxt: true, shared: true }
+ )
+
+ nuxt.hook('nitro:init', (instance) => {
+ nitro = instance
+
+ // `types:extend` fires inside Nitro's `writeTypes` after a fresh
+ // `scanHandlers`, so this map lands ahead of Nitro's own route types.
+ instance.hooks.hook('types:extend', async () => {
+ await updateTemplates({
+ filter: (template) => template.filename === TYPE_MAP.filename,
+ })
+ })
+ })
+
+ // A parent beside this module is a configuration error, not a warning.
+ // Both spellings: consumers list the package name, but a module passed as
+ // a value is known to kit by its `meta.name` alone.
+ nuxt.hook('modules:done', () => {
+ for (const { packageName, moduleName } of PARENTS) {
+ if (
+ !hasNuxtModule(packageName, nuxt) &&
+ !hasNuxtModule(moduleName, nuxt)
+ ) {
+ continue
+ }
+
+ throw new Error(
+ `[${NAME}] \`${packageName}\` is also registered in \`modules\`. @dphonys/nuxt-typed-handler replaces it: remove \`${packageName}\` (and uninstall it), then move any \`channelToken\` under \`typedHandler\`.`
+ )
+ }
+ })
+ },
+})
diff --git a/packages/nuxt-typed-handler/src/runtime/app/composables/use-request-typed-fetch.ts b/packages/nuxt-typed-handler/src/runtime/app/composables/use-request-typed-fetch.ts
new file mode 100644
index 0000000..0e26664
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/app/composables/use-request-typed-fetch.ts
@@ -0,0 +1,14 @@
+import { useRequestEvent } from '#app'
+import { $typedFetch } from '../../shared/typed-fetch'
+import type { TypedFetch } from '../../types/fetch'
+
+/**
+ * The request-bound typed fetch for SSR-safe imperative calls in app code -
+ * Nuxt's `useRequestFetch()`, mirrored. A call made while rendering forwards
+ * the incoming request's cookies and headers.
+ */
+export function useRequestTypedFetch(): TypedFetch {
+ if (import.meta.client) return $typedFetch
+
+ return useRequestEvent()?.$typedFetch || $typedFetch
+}
diff --git a/packages/nuxt-typed-handler/src/runtime/app/composables/use-typed-async-data.ts b/packages/nuxt-typed-handler/src/runtime/app/composables/use-typed-async-data.ts
new file mode 100644
index 0000000..464bfd9
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/app/composables/use-typed-async-data.ts
@@ -0,0 +1,28 @@
+import { wrapVanillaAsyncData } from '@dphonys/nuxt-handler-errors/internals/app'
+import { useAsyncData, useLazyAsyncData } from '#app'
+import type { UseTypedAsyncData } from '../../types/composables'
+
+/**
+ * Drop-in `useAsyncData` whose handler returns `.try` results instead of
+ * throwing: the handler's declared union lands typed on the `error` ref,
+ * `data` is the unwrapped success, and `matchError(error, …)` is the one read
+ * path.
+ *
+ * ```ts
+ * const { data, error } = await useTypedAsyncData('user', () =>
+ * $typedFetch.try(`/api/users/${id}`)
+ * )
+ * ```
+ */
+export const useTypedAsyncData = wrapVanillaAsyncData(
+ useAsyncData
+) as UseTypedAsyncData
+
+/**
+ * The lazy twin. Delegates to Nuxt's own `useLazyAsyncData` rather than
+ * passing `lazy: true`, so Nuxt's dev-mode data diagnostics tag the call
+ * correctly.
+ */
+export const useLazyTypedAsyncData = wrapVanillaAsyncData(
+ useLazyAsyncData
+) as UseTypedAsyncData
diff --git a/packages/nuxt-typed-handler/src/runtime/app/composables/use-typed-fetch.ts b/packages/nuxt-typed-handler/src/runtime/app/composables/use-typed-fetch.ts
new file mode 100644
index 0000000..657cbc9
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/app/composables/use-typed-fetch.ts
@@ -0,0 +1,29 @@
+import { configuredChannelToken } from '#nuxt-typed-handler/channel-token'
+import type { FetchWrapperOptions } from '@dphonys/nuxt-handler-errors/internals/app'
+import { wrapVanillaFetch } from '@dphonys/nuxt-handler-errors/internals/app'
+import { useFetch, useLazyFetch } from '#app'
+import type { UseTypedFetch } from '../../types/composables'
+
+// A getter: the alias is a live binding the unit double sets after the
+// composables are built, and the wrapper reads `token` on every call.
+const bound: FetchWrapperOptions = {
+ get token() {
+ return configuredChannelToken
+ },
+}
+
+/**
+ * Drop-in `useFetch` with the route's Request input typed on the options and
+ * its declared error union typed on the `error` ref: `data` is what it always
+ * was, and `matchError(error, …)` is the one read path.
+ */
+export const useTypedFetch = wrapVanillaFetch(useFetch, bound) as UseTypedFetch
+
+/**
+ * The lazy twin. Delegates to Nuxt's own `useLazyFetch` rather than passing
+ * `lazy: true`, so Nuxt's dev-mode data diagnostics tag the call correctly.
+ */
+export const useLazyTypedFetch = wrapVanillaFetch(
+ useLazyFetch,
+ bound
+) as UseTypedFetch
diff --git a/packages/nuxt-typed-handler/src/runtime/app/plugins/typed-fetch.client.ts b/packages/nuxt-typed-handler/src/runtime/app/plugins/typed-fetch.client.ts
new file mode 100644
index 0000000..ef9e03c
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/app/plugins/typed-fetch.client.ts
@@ -0,0 +1,6 @@
+import { defineNuxtPlugin } from '#app'
+import { $typedFetch } from '../../shared/typed-fetch'
+
+export default defineNuxtPlugin(() => {
+ globalThis.$typedFetch = $typedFetch
+})
diff --git a/packages/nuxt-typed-handler/src/runtime/server/handlers/channel-strip.ts b/packages/nuxt-typed-handler/src/runtime/server/handlers/channel-strip.ts
new file mode 100644
index 0000000..ab4c7f6
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/server/handlers/channel-strip.ts
@@ -0,0 +1,4 @@
+import { configuredChannelToken } from '#nuxt-typed-handler/channel-token'
+import { createChannelStripHandler } from '@dphonys/nuxt-handler-errors/internals/server'
+
+export default createChannelStripHandler(() => configuredChannelToken)
diff --git a/packages/nuxt-typed-handler/src/runtime/server/index.ts b/packages/nuxt-typed-handler/src/runtime/server/index.ts
new file mode 100644
index 0000000..1f8b4af
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/server/index.ts
@@ -0,0 +1,7 @@
+export {
+ defineError,
+ payload,
+ recognizeKnownError,
+} from '@dphonys/nuxt-handler-errors/server'
+export { recognizeValidationError } from '@dphonys/nuxt-handler-validation/server'
+export { defineTypedEventHandler } from './lib/typed-handler'
diff --git a/packages/nuxt-typed-handler/src/runtime/server/lib/on-invalid.ts b/packages/nuxt-typed-handler/src/runtime/server/lib/on-invalid.ts
new file mode 100644
index 0000000..a4091bb
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/server/lib/on-invalid.ts
@@ -0,0 +1,21 @@
+import { createKnownError } from '@dphonys/nuxt-handler-errors/internals/server'
+import type { OnInvalid } from '@dphonys/nuxt-handler-validation/internals/server'
+import { markValidationError } from '@dphonys/nuxt-handler-validation/internals/shared'
+import { RESERVED_TAG } from './reserved-tag'
+
+/**
+ * Every client-input rejection becomes the one built-in known error,
+ * `validation-failed` `400`, carrying both parents' markers so both
+ * recognizers answer.
+ */
+export const onInvalid: OnInvalid = (_source, issues) => {
+ const error = createKnownError(RESERVED_TAG, 400, { issues: [...issues] })
+
+ // Beside the marker too: what a client reads once the marker is stripped,
+ // at the path the validation parent documents. A second copy, so neither
+ // place shares an array with the other or the hook's input.
+ ;(error.data as Record).issues = [...issues]
+ markValidationError(error, issues)
+
+ throw error
+}
diff --git a/packages/nuxt-typed-handler/src/runtime/server/lib/reserved-tag.ts b/packages/nuxt-typed-handler/src/runtime/server/lib/reserved-tag.ts
new file mode 100644
index 0000000..9fb365f
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/server/lib/reserved-tag.ts
@@ -0,0 +1,16 @@
+import type { DeclaredError } from '@dphonys/nuxt-handler-errors/internals/server'
+
+/** The tag of the built-in variant; no umbrella route may declare it. */
+export const RESERVED_TAG = 'validation-failed'
+
+// The compile guard's answer for a JavaScript caller: thrown at declaration,
+// so the route never becomes servable.
+export function assertNoReservedTag(
+ declared: readonly DeclaredError[] | undefined
+): void {
+ if (declared?.some((entry) => entry.tag === RESERVED_TAG) !== true) return
+
+ throw new Error(
+ `[nuxt-typed-handler] The error tag "${RESERVED_TAG}" is reserved for the built-in validation variant. Rename the declared error.`
+ )
+}
diff --git a/packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts b/packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts
new file mode 100644
index 0000000..3366f13
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/server/lib/typed-handler.ts
@@ -0,0 +1,68 @@
+import {
+ createFail,
+ resolveDeclared,
+} from '@dphonys/nuxt-handler-errors/internals/server'
+import {
+ sourcePlan,
+ validatedContext,
+} from '@dphonys/nuxt-handler-validation/internals/server'
+import type { ValidatedContextOptions } from '@dphonys/nuxt-handler-validation/internals/server'
+import { defineEventHandler } from 'h3'
+import type { DefineTypedEventHandler } from '../../types/handler'
+import { onInvalid } from './on-invalid'
+import { assertNoReservedTag } from './reserved-tag'
+
+const VALIDATION_OPTIONS: ValidatedContextOptions = { onInvalid }
+
+/**
+ * Declare what a route validates and what it can fail with, and get both in
+ * the handler's second parameter: the validated sources, flat, plus `fail`
+ * scoped to the declared errors. Either half alone is valid.
+ *
+ * ```ts
+ * export default defineTypedEventHandler(
+ * { validate: { body: createUser }, errors: [...userErrors] },
+ * async (event, { body, fail }) => {
+ * if (await exists(body.email)) return fail('user-exists')
+ * return create(body)
+ * }
+ * )
+ * ```
+ *
+ * A rejected request answers the built-in `validation-failed` variant rather
+ * than the validation parent's own `400`; everything else about each half is
+ * the parent's, unchanged. Reading the body again with `readBody` yields h3's
+ * memoized unvalidated parse.
+ */
+export const defineTypedEventHandler: DefineTypedEventHandler = (
+ options,
+ handler
+) => {
+ // In this order, so each declaration fault reports with its owner's message.
+ const declared = options.errors ? resolveDeclared(options.errors) : undefined
+ assertNoReservedTag(declared)
+ const plan = options.validate ? sourcePlan(options.validate) : undefined
+ const fail = declared === undefined ? undefined : createFail(declared)
+
+ // The compile guard's answer for a JavaScript caller - `validate: {}` plans
+ // nothing, so it counts for nothing here either.
+ if ((plan === undefined || plan.length === 0) && fail === undefined) {
+ throw new Error(
+ '[nuxt-typed-handler] defineTypedEventHandler needs validate, errors, or both.'
+ )
+ }
+
+ // Cast because the loose record is typed at this seam and nowhere else.
+ const contextFor = (validated: Record): never =>
+ (fail === undefined ? validated : { ...validated, fail }) as never
+
+ // No validation call at all on a route that declares none: no body read,
+ // no await.
+ return defineEventHandler((event) =>
+ plan === undefined
+ ? handler(event, contextFor({}))
+ : validatedContext(event, plan, VALIDATION_OPTIONS).then((validated) =>
+ handler(event, contextFor(validated))
+ )
+ ) as never
+}
diff --git a/packages/nuxt-typed-handler/src/runtime/server/plugins/event-typed-fetch.ts b/packages/nuxt-typed-handler/src/runtime/server/plugins/event-typed-fetch.ts
new file mode 100644
index 0000000..ab6b92c
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/server/plugins/event-typed-fetch.ts
@@ -0,0 +1,14 @@
+import { configuredChannelToken } from '#nuxt-typed-handler/channel-token'
+import type { RawEventFetch } from '@dphonys/nuxt-handler-errors/internals/server'
+import { createCheckedEventFetch } from '@dphonys/nuxt-handler-errors/internals/server'
+import { defineNitroPlugin } from 'nitropack/runtime'
+import type { TypedEventFetch } from '../../types/fetch'
+
+export default defineNitroPlugin((nitroApp) => {
+ nitroApp.hooks.hook('request', (event) => {
+ event.$typedFetch = createCheckedEventFetch(
+ () => event.$fetch as RawEventFetch | undefined,
+ configuredChannelToken
+ ) as TypedEventFetch
+ })
+})
diff --git a/packages/nuxt-typed-handler/src/runtime/server/plugins/typed-fetch.ts b/packages/nuxt-typed-handler/src/runtime/server/plugins/typed-fetch.ts
new file mode 100644
index 0000000..36337f2
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/server/plugins/typed-fetch.ts
@@ -0,0 +1,6 @@
+import { defineNitroPlugin } from 'nitropack/runtime'
+import { $typedFetch } from '../../shared/typed-fetch'
+
+export default defineNitroPlugin(() => {
+ globalThis.$typedFetch = $typedFetch
+})
diff --git a/packages/nuxt-typed-handler/src/runtime/server/tsconfig.json b/packages/nuxt-typed-handler/src/runtime/server/tsconfig.json
new file mode 100644
index 0000000..0e35e64
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/server/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../../../.nuxt/tsconfig.server.json"
+}
diff --git a/packages/nuxt-typed-handler/src/runtime/shared/index.ts b/packages/nuxt-typed-handler/src/runtime/shared/index.ts
new file mode 100644
index 0000000..a1ab6db
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/shared/index.ts
@@ -0,0 +1 @@
+export * from '@dphonys/nuxt-handler-errors/shared'
diff --git a/packages/nuxt-typed-handler/src/runtime/shared/typed-fetch.ts b/packages/nuxt-typed-handler/src/runtime/shared/typed-fetch.ts
new file mode 100644
index 0000000..945b8c7
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/shared/typed-fetch.ts
@@ -0,0 +1,21 @@
+import { configuredChannelToken } from '#nuxt-typed-handler/channel-token'
+import type { CheckedFetchFactoryOptions } from '@dphonys/nuxt-handler-errors/internals/shared'
+import {
+ createCheckedFetch,
+ lazyGlobalFetch,
+} from '@dphonys/nuxt-handler-errors/internals/shared'
+import type { $TypedFetch } from '../types/fetch'
+
+// A getter, never spread: the alias is a live binding the unit double sets
+// after the global is built, and the factory reads `token` on every call.
+const bound: CheckedFetchFactoryOptions = {
+ get token() {
+ return configuredChannelToken
+ },
+}
+
+/** The value the module installs on `globalThis` - one object on every side. */
+export const $typedFetch = createCheckedFetch(
+ lazyGlobalFetch,
+ bound
+) as $TypedFetch
diff --git a/packages/nuxt-typed-handler/src/runtime/types/composables.ts b/packages/nuxt-typed-handler/src/runtime/types/composables.ts
new file mode 100644
index 0000000..5e9923c
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/types/composables.ts
@@ -0,0 +1,67 @@
+import type { UseCheckedAsyncData } from '@dphonys/nuxt-handler-errors/internals/app'
+import type { NitroFetchRequest } from 'nitropack/types'
+import type { AsyncData, UseFetchOptions } from 'nuxt/app'
+import type { MaybeRefOrGetter, Ref } from 'vue'
+import type {
+ DefaultMethod,
+ MethodArg,
+ Resp,
+ TypedErrorFor,
+ TypedSources,
+} from './fetch'
+
+// Nuxt does not export `ComputedOptions`; this is its definition verbatim -
+// the bare `Function` included, because narrowing it would stop matching what
+// vanilla's own option types accept.
+type ComputedOptions> = {
+ // eslint-disable-next-line ts/no-unsafe-function-type
+ [K in keyof T]: T[K] extends Function
+ ? T[K]
+ : ComputedOptions | MaybeRefOrGetter
+}
+
+type Reactive =
+ T extends Record
+ ? ComputedOptions | MaybeRefOrGetter
+ : MaybeRefOrGetter
+
+// Each typed source re-added the way vanilla types its own. A plain literal
+// is still excess-key checked; through `ref()` or a getter it is not - a
+// union target, and `ref()` infers its own type.
+type ReactiveSources = { [K in keyof O]: Reactive }
+
+/**
+ * Vanilla `useFetch`'s options for a route and method, with `body` and
+ * `query` typed from the route's declared schemas and `params` gone.
+ */
+export type UseTypedFetchOptions<
+ ResT,
+ ReqT extends NitroFetchRequest,
+ Method extends MethodArg,
+> = Omit<
+ UseFetchOptions,
+ 'body' | 'query' | 'params'
+> &
+ ReactiveSources>
+
+/**
+ * `useFetch` with the route's Request input typed on the options and its
+ * declared error union typed on the `error` ref.
+ */
+export interface UseTypedFetch {
+ <
+ ReqT extends NitroFetchRequest,
+ const Method extends MethodArg = DefaultMethod,
+ ResT = Resp,
+ >(
+ request: Ref | ReqT | (() => ReqT),
+ opts?: UseTypedFetchOptions
+ ): AsyncData | undefined>
+}
+
+/**
+ * `useAsyncData` whose handler returns `.try` results instead of throwing.
+ * The parent's signature exactly: the inner `$typedFetch.try` call types its
+ * own options, and its error union is what lands on the `error` ref.
+ */
+export type UseTypedAsyncData = UseCheckedAsyncData
diff --git a/packages/nuxt-typed-handler/src/runtime/types/fetch.ts b/packages/nuxt-typed-handler/src/runtime/types/fetch.ts
new file mode 100644
index 0000000..e1b71a8
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/types/fetch.ts
@@ -0,0 +1,166 @@
+import type {
+ KnownErrorFor,
+ TryResult,
+} from '@dphonys/nuxt-handler-errors/types'
+import type { RouterMethod } from 'h3'
+import type {
+ $Fetch,
+ AvailableRouterMethod,
+ NitroFetchOptions,
+ NitroFetchRequest,
+ TypedInternalResponse,
+} from 'nitropack/types'
+import type { RequestInputOfRoute } from './index'
+
+// Stack-depth rules, each one a `TS2321 Excessive stack depth` per
+// `InternalApi` key if broken: `M`'s default references only `R`; a method
+// derived from the options lives in an alias default, never in a signature
+// parameter's constraint; no type parameter appears in its own constraint.
+
+/** The methods a call may name for a route: Nitro's, in either case. */
+export type MethodArg =
+ | AvailableRouterMethod
+ | Uppercase>
+
+/** `get` when the route has one, else whatever it has - Nuxt's own rule. */
+export type DefaultMethod =
+ 'get' extends MethodArg ? 'get' : MethodArg
+
+// `R extends string` because `NitroFetchRequest` also admits a `Request`
+// object no route path can be read out of.
+type InputFor = R extends string
+ ? RequestInputOfRoute, RouterMethod>>
+ : never
+
+// Required only when the input cannot be omitted: an all-optional, `unknown`
+// or `any` input stays optional but typed.
+type Declared =
+ // eslint-disable-next-line ts/no-empty-object-type
+ {} extends I[K] ? { [P in K]?: I[K] } : { [P in K]-?: I[K] }
+
+/** ofetch's own typing of one key, for a source nobody declared. */
+type Vanilla = Pick, K>
+
+type QueryOption = [I] extends [never]
+ ? Vanilla<'query'>
+ : 'query' extends keyof I
+ ? Declared
+ : Vanilla<'query'>
+
+// A route declaring nothing keeps vanilla's `body` on every method; a
+// declaring route has none at all on `get` and `head`.
+type BodyOption = [I] extends [never]
+ ? Vanilla<'body'>
+ : Lowercase extends 'get' | 'head'
+ ? // eslint-disable-next-line ts/no-empty-object-type
+ {}
+ : 'body' extends keyof I
+ ? Declared
+ : Vanilla<'body'>
+
+/** The typed sources alone - what the composables re-add reactive. */
+export type TypedSources<
+ R extends NitroFetchRequest,
+ M extends MethodArg,
+> = QueryOption> & BodyOption, M>
+
+/**
+ * The options every member of the Typed fetch family takes for a route and
+ * method: vanilla's, with `body` and `query` typed from the route's declared
+ * schemas and ofetch's deprecated `params` alias gone for everyone.
+ */
+export type TypedRequestOptions<
+ R extends NitroFetchRequest,
+ M extends MethodArg,
+> = Omit<
+ NitroFetchOptions, AvailableRouterMethod>>,
+ 'method' | 'body' | 'query' | 'params'
+> & { method?: M } & TypedSources
+
+export type Resp = TypedInternalResponse<
+ R,
+ T,
+ Extract, RouterMethod>
+>
+
+/**
+ * The error one call can produce. The known-errors map already carries
+ * `validation-failed` for every validating route.
+ */
+export type TypedErrorFor = KnownErrorFor<
+ R,
+ Extract, RouterMethod>
+>
+
+/** The `.try` call: returns a {@link TryResult} instead of throwing. */
+export interface TypedFetchTry<
+ DefaultT = unknown,
+ DefaultR extends NitroFetchRequest = NitroFetchRequest,
+> {
+ <
+ T = DefaultT,
+ R extends NitroFetchRequest = DefaultR,
+ const M extends MethodArg = DefaultMethod,
+ >(
+ request: R,
+ opts?: TypedRequestOptions
+ ): Promise, TypedErrorFor>>
+}
+
+/**
+ * The minimal typed instance: the call plus `.try`. Every instance satisfies
+ * it - the global, a created instance, and the event-bound one.
+ */
+export interface TypedFetch<
+ DefaultT = unknown,
+ DefaultR extends NitroFetchRequest = NitroFetchRequest,
+> {
+ <
+ T = DefaultT,
+ R extends NitroFetchRequest = DefaultR,
+ const M extends MethodArg = DefaultMethod,
+ >(
+ request: R,
+ opts?: TypedRequestOptions
+ ): Promise>
+ try: TypedFetchTry
+}
+
+/** What `event.$typedFetch` is typed as: no `.raw`, `.create` or `.native`. */
+export type TypedEventFetch = TypedFetch
+
+// ofetch's `FetchOptions` and `FetchResponse`, indexed out of Nitro's own
+// signatures rather than imported from a package this module does not
+// depend on.
+type FetchDefaults = Parameters<$Fetch['create']>[0]
+type RawResponse = Omit>, '_data'> & {
+ _data?: T
+}
+
+/** The `$typedFetch` global: a full mirror of vanilla's namespace plus `.try`. */
+export interface $TypedFetch<
+ DefaultT = unknown,
+ DefaultR extends NitroFetchRequest = NitroFetchRequest,
+> extends TypedFetch {
+ /** Shares the call's signature; no `.try`, it already returns without throwing. */
+ raw: <
+ T = DefaultT,
+ R extends NitroFetchRequest = DefaultR,
+ const M extends MethodArg = DefaultMethod,
+ >(
+ request: R,
+ opts?: TypedRequestOptions
+ ) => Promise>>
+
+ /** ofetch's bare `fetch`, passed through untouched. */
+ native: typeof globalThis.fetch
+
+ /**
+ * Like `$fetch.create`: a derived instance with defaults, keeping `.try`.
+ * A default `query` never relaxes a call's own requiredness.
+ */
+ // Must return the *typed* interface, or `.try` vanishes one level down.
+ create: (
+ defaults: FetchDefaults
+ ) => $TypedFetch
+}
diff --git a/packages/nuxt-typed-handler/src/runtime/types/handler.ts b/packages/nuxt-typed-handler/src/runtime/types/handler.ts
new file mode 100644
index 0000000..82016a3
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/types/handler.ts
@@ -0,0 +1,135 @@
+import type { defineCheckedEventHandler } from '@dphonys/nuxt-handler-errors/server'
+import type {
+ CheckedEventHandler,
+ Fail,
+ KnownError,
+ KnownErrorsOf,
+ KnownVariant,
+} from '@dphonys/nuxt-handler-errors/types'
+import type {
+ RequestInput,
+ ValidatedContext,
+ ValidatedEventHandler,
+ ValidationIssue,
+ ValidationSchemas,
+ ValidationSchemasGuard,
+} from '@dphonys/nuxt-handler-validation/types'
+import type { EventHandlerRequest, EventHandlerResponse, H3Event } from 'h3'
+
+/** The built-in variant every validating route can fail with. */
+export interface ValidationFailed {
+ tag: 'validation-failed'
+ status: 400
+ issues: ValidationIssue[]
+}
+
+export type AnyKnownError = KnownError
+
+type HasValidate = [keyof S] extends [never]
+ ? false
+ : true
+
+type HasErrors> = [A[number]] extends [
+ never,
+]
+ ? false
+ : true
+
+/**
+ * The handler `defineTypedEventHandler` returns: an ordinary h3
+ * `EventHandler` carrying both parents' phantom slots, so each parent's
+ * extractor reads its own.
+ */
+// Extends rather than restates: the validation slot is keyed on a private
+// symbol.
+export interface TypedEventHandler<
+ Request extends EventHandlerRequest = EventHandlerRequest,
+ Response extends EventHandlerResponse = EventHandlerResponse,
+ Errors = never,
+ Input = never,
+>
+ extends
+ CheckedEventHandler,
+ ValidatedEventHandler {}
+
+/** The validated sources, flat, plus `fail` exactly when `errors` is declared. */
+export type TypedContext<
+ S extends ValidationSchemas,
+ A extends ReadonlyArray,
+> = ValidatedContext &
+ (HasErrors extends true
+ ? { fail: Fail> }
+ : // eslint-disable-next-line ts/no-empty-object-type
+ {})
+
+/** The declared union, plus the built-in variant when the route validates. */
+export type TypedErrors<
+ S extends ValidationSchemas,
+ A extends ReadonlyArray,
+> = KnownErrorsOf | (HasValidate extends true ? ValidationFailed : never)
+
+export type TypedHandlerFn<
+ S extends ValidationSchemas,
+ A extends ReadonlyArray,
+ Request extends EventHandlerRequest,
+ Response,
+> = (event: H3Event, ctx: TypedContext) => Response
+
+// Every guard below is a missing-property guard: an unsatisfiable property
+// naming the mistake, surfaced by the compiler at the options argument.
+
+/** Bare `{}` is a compile error: a route must declare something. */
+export type AtLeastOne<
+ S extends ValidationSchemas,
+ A extends ReadonlyArray,
+> =
+ HasValidate extends true
+ ? // eslint-disable-next-line ts/no-empty-object-type
+ {}
+ : HasErrors extends true
+ ? // eslint-disable-next-line ts/no-empty-object-type
+ {}
+ : { __declareSomething__: 'declare validate, errors, or both' }
+
+/** `validation-failed` belongs to the built-in variant on every umbrella route. */
+export type ReservedTagGuard> =
+ 'validation-failed' extends KnownErrorsOf['tag']
+ ? {
+ __reservedErrorTag__: 'validation-failed is reserved for the built-in variant'
+ }
+ : // eslint-disable-next-line ts/no-empty-object-type
+ {}
+
+// The errors parent's `ConflictGuard`, read off its wrapper's options type
+// because the parent's `/types` entry does not export the guard by name.
+type ConflictGuard> = Omit<
+ Parameters>[0],
+ 'errors'
+>
+
+export type TypedHandlerOptions<
+ S extends ValidationSchemas,
+ A extends ReadonlyArray,
+> = AtLeastOne &
+ ReservedTagGuard &
+ ConflictGuard & {
+ validate?: S & ValidationSchemasGuard
+ errors?: A
+ }
+
+// `Response` has no default type parameter on purpose: an explicit type
+// argument becomes an arity error instead of collapsing the success type.
+export interface DefineTypedEventHandler {
+ <
+ // `{}` is the "declared nothing" default: no key, so no source and no
+ // built-in variant.
+ // eslint-disable-next-line ts/no-empty-object-type
+ const S extends ValidationSchemas = {},
+ const A extends ReadonlyArray = [],
+ Response extends EventHandlerResponse = EventHandlerResponse,
+ Request extends EventHandlerRequest = EventHandlerRequest,
+ >(
+ options: TypedHandlerOptions,
+ handler: TypedHandlerFn
+ ): TypedEventHandler, RequestInput>
+}
diff --git a/packages/nuxt-typed-handler/src/runtime/types/index.ts b/packages/nuxt-typed-handler/src/runtime/types/index.ts
new file mode 100644
index 0000000..206f40e
--- /dev/null
+++ b/packages/nuxt-typed-handler/src/runtime/types/index.ts
@@ -0,0 +1,74 @@
+import type { RouterMethod } from 'h3'
+import type { MatchedRoutes } from 'nitropack/types'
+import type { $TypedFetch, TypedEventFetch } from './fetch'
+
+export type * from '@dphonys/nuxt-handler-errors/types'
+export type * from '@dphonys/nuxt-handler-validation/types'
+
+export type {
+ $TypedFetch,
+ TypedEventFetch,
+ TypedFetch,
+ TypedFetchTry,
+ TypedRequestOptions,
+} from './fetch'
+
+/**
+ * The generated map of every route's Request input - you never write to
+ * this. Keyed exactly like Nitro's `InternalApi`; the build-time emitter
+ * reopens it with `declare module`, and empty means no handler has declared
+ * anything yet.
+ */
+// Declared here, not re-exported: the emitted template augments this module
+// by its package specifier, and a `declare module` on a barrel that merely
+// re-exports an interface opens a second, unrelated one.
+export interface KnownApiRequestInputs {}
+
+/**
+ * A route's declared Request input from its path alone; `never` means
+ * "declares no sources" - the call site then types exactly as vanilla.
+ */
+// The `default` fallback is by presence rather than Nitro's on-`never` rule:
+// `never` is a legitimate value here.
+export type RequestInputOfRoute<
+ R extends string,
+ M extends RouterMethod | Uppercase = 'get',
+> =
+ MatchedRoutes extends infer Key
+ ? // Distributes over multiple matched keys; a route this map lacks answers
+ // `never` rather than `TS2536`.
+ Key extends keyof KnownApiRequestInputs
+ ? Lowercase extends keyof KnownApiRequestInputs[Key]
+ ? KnownApiRequestInputs[Key][Lowercase]
+ : 'default' extends keyof KnownApiRequestInputs[Key]
+ ? KnownApiRequestInputs[Key]['default']
+ : never
+ : never
+ : never
+
+export type {
+ AtLeastOne,
+ DefineTypedEventHandler,
+ ReservedTagGuard,
+ TypedContext,
+ TypedErrors,
+ TypedEventHandler,
+ TypedHandlerFn,
+ ValidationFailed,
+} from './handler'
+
+declare module 'h3' {
+ interface H3Event {
+ /** The event-bound typed fetch, forwarding the request's headers and cookies. */
+ $typedFetch: TypedEventFetch
+ }
+}
+
+declare global {
+ /**
+ * The typed fetch global - callable in a Nitro handler, in `