Type-safe input/output gates around your handlers, powered by Zod.
r-gatekeeper sits at the boundary between “outside world” and “inside world” and makes sure:
- 🚫 No unexpected data comes in – inputs are validated before your handler runs.
- 🚫 No unexpected data leaks out – outputs are validated before they leave the gate.
- ✅ Your handler only deals with validated data.
- ✅ Errors are normalized as a small discriminated union (
GateResult).
It is intentionally small and focused: no DI container、no framework、just gates.
npm install r-gatekeeper zod
# or
yarn add r-gatekeeper zod
pnpm add r-gatekeeper zodr-gatekeeper is built around three ideas:
-
Schemas at the boundary
You describe the input/output of a handler with Zod schemas. -
Handlers work on validated data
Handlers never seeunknown. They receivez.infer<IN>and must returnz.infer<OUT>(or anError). -
Uniform result shape
Gates always return aGateResult<T>:
type GateResult<T> = { ok: true; data: T } | { ok: false; error: GateError }
type GateError =
| { kind: 'input'; error: ZodError } // invalid input
| { kind: 'output'; error: ZodError } // invalid output
| { kind: 'handler'; error: Error } // handler-level failureYou decide how to log / map / rethrow errors based on kind.
import { z } from 'zod'
import { withGate } from 'r-gatekeeper'
// 1. Define schemas for the boundary
const schemas = {
in: z.object({
id: z.string().uuid(),
}),
out: z.object({
id: z.string().uuid(),
name: z.string(),
}),
}
// 2. Implement your handler using validated types
const getUser = withGate(schemas, ({ id }) => {
// input is already validated: id is a UUID string
const user = findUserById(id) // e.g. from a repository
if (!user) {
return new Error('User not found')
}
return user
})
// 3. Use the gate at a boundary (e.g. HTTP handler)
async function httpHandler(req: Request): Promise<Response> {
const body = await req.json()
const result = getUser(body)
if (!result.ok) {
// You can branch on error kind
switch (result.error.kind) {
case 'input':
return new Response('Bad Request', { status: 400 })
case 'handler':
return new Response('Not Found', { status: 404 })
case 'output':
// output schema mismatch -> our side is wrong
console.error(result.error.error)
return new Response('Internal Server Error', { status: 500 })
}
}
return new Response(JSON.stringify(result.data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}All functions are fully typed and built on top of Zod.
import { withGate } from 'r-gatekeeper'
import { z } from 'zod'
const gate = withGate(
{
in: z.object({ value: z.number() }),
out: z.object({ doubled: z.number() }),
},
({ value }) => ({ doubled: value * 2 })
)
const result = gate({ value: 2 }) // value: unknown
// result: GateResult<{ doubled: number }>- Input type:
unknown
→ validated byschemas.in.safeParse - Handler input:
z.infer<typeof schemas.in> - Handler output:
z.infer<typeof schemas.out> | Error - Output type:
GateResult<z.infer<typeof schemas.out>>
Use this at outer boundaries where raw data flows in (HTTP, queues, CLIs, etc.).
import { withGateFixedIn } from 'r-gatekeeper'
const schemas = {
in: z.preprocess(value => (typeof value === 'string' ? JSON.parse(value) : value), z.object({ value: z.number() })),
out: z.object({ doubled: z.number() }),
}
const gate = withGateFixedIn(schemas, ({ value }) => ({ doubled: value * 2 }))
// accepts the Zod "input type", not unknown
const result = gate('{"value": 2}')- Input type:
z.input<IN_SCHEMA> - Handler input:
z.infer<IN_SCHEMA> - Output type:
GateResult<z.infer<OUT_SCHEMA>>
Use this when you want the function signature to expose the exact Zod input type (including preprocess / transform chains).
import { withGateAsync, withGateFixedInAsync } from 'r-gatekeeper'
const gate = withGateAsync(
{
in: z.object({ id: z.string() }),
out: z.object({ id: z.string(), name: z.string() }),
},
async ({ id }) => {
const user = await repo.findById(id)
if (!user) return new Error('User not found')
return user
}
)
const result = await gate({ id: '123' })
// result: Promise<GateResult<{ id: string; name: string }>>withGateAsync– same aswithGate, but:- Handler may return a value or
Error, or aPromiseof either. - Uses
safeParseAsyncfor input/output.
- Handler may return a value or
withGateFixedInAsync– async version ofwithGateFixedIn.
Helpers for narrowing GateResult と GateError:
import {
isGateResultOk,
isGateResultError,
isGateInputError,
isGateOutputError,
isGateHandlerError,
} from 'r-gatekeeper'
const result = gate(someInput)
if (isGateResultOk(result)) {
// result.data is available here
} else if (isGateResultError(result)) {
if (isGateInputError(result.error)) {
// schemas.in validation failed
} else if (isGateOutputError(result.error)) {
// schemas.out validation failed
} else if (isGateHandlerError(result.error)) {
// handler returned an Error
}
}Of course, you can also use a switch statement on the discriminated union if you prefer.
GateSchemaTypeis a thin alias aroundz.ZodType.- The library does not force
z.strict(). → Whether to use strict schemas or not is a design decision on the consumer side. - Handlers are not thrown from inside the library.
If you want to signal a handler-level failure, return an
Errorvalue (it will becomekind: 'handler'). If you want tothrow, do it at the outer boundary after inspecting theGateResult.
Typical scenarios where r-gatekeeper works well:
- Controller / Presenter layers in API servers.
- Server Actions / Route Handlers in frameworks like Next.js or Remix.
- Message queue consumers/producers and batch job boundaries.
- As a boundary in front of/behind other libraries (for example,
r-pipeline).
Use it when you don't want to change your inner business logic, but you do want to make the boundary between "outside" and "inside" explicit, validated, and safe.
This project was built collaboratively between human design and AI assistance.
Design and coding by risk
Design assistance and coding support by ChatGPT
Code review by ChatGPT and Cursor AI
Documentation generated by ChatGPT and Cursor AI
# Install dependencies
npm install
# Run tests
npm test
# Build the project
npm run build
# Run linting
npm run lint
# Format code
npm run formatMIT