# Zagora > Build type-safe, error-safe functions that never throw. Full TypeScript inference with tuple arguments, sync/async awareness, and typed error handling. ## Async Support \[Sync and async procedure inference] Zagora dynamically infers whether a procedure is sync or async based on your handler and schemas. ### Automatic Inference #### Sync Handler = Sync Result ```js import { z } from 'zod'; import { zagora } from 'zagora'; const add = zagora() .input(z.tuple([z.number(), z.number()])) .handler((_, a, b) => a + b) // Sync handler .callable(); const result = add(1, 2); // ZagoraResult (not Promise!) console.log(result.data); // 3 ``` #### Async Handler = Async Result ```js const fetchUser = zagora() .input(z.string()) .handler(async (_, id) => { // Async handler const res = await fetch(`/api/users/${id}`); return res.json(); }) .callable(); const result = await fetchUser('123'); // Promise> console.log(result.data); ``` ### Why This Matters Unlike oRPC/tRPC where everything is async, Zagora preserves synchronicity: ```js // oRPC/tRPC - always async, even for sync operations const result = await add({ a: 1, b: 2 }); // Zagora - sync when possible const result = add(1, 2); // No await needed! ``` This is crucial for: * Library APIs that should be sync * Performance-sensitive code * Simpler call sites ### Async Schemas If your schema has async validation, the procedure becomes async: ```js const createUser = zagora() .input(z.object({ email: z.string().refine( async (email) => await isUniqueEmail(email), 'Email already exists' ) })) .handler((_, input) => input) // Sync handler .callable(); // Must await due to async schema validation const result = await createUser({ email: 'test@example.com' }); ``` :::warning TypeScript may not require `await` due to StandardSchema limitations. Always await when using async schemas. ::: ### Async Cache If your cache adapter has async methods, the procedure becomes async: ```js const proc = zagora() .cache(redisCache) // has async .has() and .get() .input(z.string()) .handler((_, input) => expensiveSync(input)) .callable(); // Must await due to async cache const result = await proc('key'); ``` ### Mixed Sync/Async You can have both sync and async procedures in the same codebase: ```js // Sync - for simple operations const validate = zagora() .input(z.string().email()) .handler((_, email) => ({ valid: true, email })) .callable(); // Async - for I/O operations const sendEmail = zagora() .input(z.object({ to: z.string(), body: z.string() })) .handler(async (_, { to, body }) => { await emailService.send(to, body); return { sent: true }; }) .callable(); // Use them appropriately const validation = validate('test@example.com'); // Sync const sent = await sendEmail({ to: 'test@example.com', body: 'Hi' }); // Async ``` ### Promise Return Type If your handler returns a Promise explicitly, it's async: ```js const fetchData = zagora() .input(z.string()) .handler((_, url) => { return fetch(url).then(r => r.json()); // Returns Promise }) .callable(); const result = await fetchData('/api'); ``` ### Type Inference TypeScript correctly infers the result type: ```js const syncProc = zagora() .input(z.number()) .handler((_, n) => n * 2) .callable(); const asyncProc = zagora() .input(z.number()) .handler(async (_, n) => n * 2) .callable(); // TypeScript infers: type SyncResult = ReturnType; // ZagoraResult type AsyncResult = ReturnType; // Promise> ``` ### Next Steps * [Caching & Memoization](/docs/caching) - Add caching to procedures * [Procedures](/docs/procedures) - Overview of the builder API ## Auto-Callable Mode \[Simplified API without .callable()] Auto-callable mode lets you skip the `.callable()` step for a cleaner API. ### Enabling Auto-Callable Pass `autoCallable: true` to the zagora config: ```js import { z } from 'zod'; import { zagora } from 'zagora'; const greet = zagora({ autoCallable: true }) .input(z.string()) .handler((_, name) => `Hello, ${name}!`); // No .callable() needed! const result = greet('World'); // { ok: true, data: 'Hello, World!' } ``` ### Comparison #### Standard Mode ```js const add = zagora() .input(z.tuple([z.number(), z.number()])) .handler((_, a, b) => a + b) .callable(); // Required add(1, 2); ``` #### Auto-Callable Mode ```js const add = zagora({ autoCallable: true }) .input(z.tuple([z.number(), z.number()])) .handler((_, a, b) => a + b); // No .callable() add(1, 2); ``` ### Combining with disableOptions For the cleanest API, combine with `disableOptions`: ```js const add = zagora({ autoCallable: true, disableOptions: true }) .input(z.tuple([z.number(), z.number()])) .handler((a, b) => a + b); // No options object! add(1, 2); // { ok: true, data: 3 } ``` Compare the handler signatures: ```js // Standard .handler((options, a, b) => a + b) // disableOptions: true .handler((a, b) => a + b) ``` ### Providing Runtime Options With auto-callable, provide runtime options differently: #### Context ```js const proc = zagora({ autoCallable: true }) .context({ db: defaultDb }) // Initial context here .input(z.string()) .handler(({ context }, id) => context.db.find(id)); ``` #### Env Vars ```js const proc = zagora({ autoCallable: true }) .env( z.object({ API_KEY: z.string() }), process.env as any // Pass as second argument to .env() ) .handler(({ env }) => env.API_KEY); ``` #### Cache ```js const cache = new Map(); const proc = zagora({ autoCallable: true }) .cache(cache) // Provide at definition time .input(z.string()) .handler((_, input) => expensiveOperation(input)); ``` ### Trade-offs #### Advantages * Cleaner API for simple procedures * Less boilerplate * Natural function-like exports #### Disadvantages * No runtime override of context/cache/env * Must provide all dependencies at definition time * Less flexibility for testing (can't swap dependencies) ### When to Use **Use auto-callable when:** * Building simple utility functions * Dependencies are known at definition time * You want the cleanest possible API **Use standard mode when:** * You need to override context per-call * Testing with mock dependencies * Different environments need different config ### Pattern: Library Export ```js // lib/validators.ts import { z } from 'zod'; import { zagora } from 'zagora'; const za = zagora({ autoCallable: true, disableOptions: true }); export const validateEmail = za .input(z.string().email()) .handler((email) => ({ valid: true, normalized: email.toLowerCase() })); export const validatePhone = za .input(z.string().regex(/^\+?[1-9]\d{1,14}$/)) .handler((phone) => ({ valid: true, normalized: phone.replace(/\D/g, '') })); // Usage import { validateEmail, validatePhone } from './lib/validators'; const email = validateEmail('Test@Example.com'); const phone = validatePhone('+1-555-123-4567'); ``` ### Next Steps * [Handler Options](/docs/handler-options) - Understanding the options object * [Procedures](/docs/procedures) - Full builder API reference ## Caching & Memoization \[Avoid redundant computations] Zagora supports caching via a simple adapter interface, allowing you to memoize expensive operations. ### Basic Caching Pass a Map or compatible cache to `.cache()`: ```js import { z } from 'zod'; import { zagora } from 'zagora'; const cache = new Map(); const expensiveCalc = zagora() .cache(cache) .input(z.number()) .handler((_, n) => { console.log('Computing...'); return fibonacci(n); }) .callable(); expensiveCalc(40); // "Computing..." - cache miss expensiveCalc(40); // No log - cache hit! ``` ### Cache Key Composition Cache keys are computed from: * Input values * Input/output/error schemas * Handler function body This means: ```js // Same input, same handler = cache hit expensiveCalc(40); expensiveCalc(40); // Hit! // Different input = cache miss expensiveCalc(41); // Miss // Changed handler = all cache invalidated ``` ### Runtime Cache Override Provide cache at call time via `.callable()`: ```js const calc = zagora() .input(z.number()) .handler((_, n) => fibonacci(n)) .callable({ cache: requestCache }); // Or create multiple callables with different caches const prodCalc = calc.callable({ cache: redisCache }); const testCalc = calc.callable({ cache: new Map() }); ``` ### Cache Adapter Interface Any object with `has`, `get`, and `set` methods works: ```js interface CacheAdapter { has(key: K): boolean | Promise; get(key: K): V | undefined | Promise; set(key: K, value: V): void | Promise; } ``` #### Sync Cache (Map) ```js const cache = new Map(); ``` #### Async Cache (Redis-like) ```js const redisCache = { async has(key) { return await redis.exists(key); }, async get(key) { const value = await redis.get(key); return value ? JSON.parse(value) : undefined; }, async set(key, value) { await redis.set(key, JSON.stringify(value)); } }; ``` :::warning Async cache methods make the procedure async. Always `await` the result. ::: ### Cache with Errors Failed executions are not cached: ```js const fetchUser = zagora() .cache(cache) .input(z.string()) .errors({ NOT_FOUND: z.object({ id: z.string() }) }) .handler(async ({ errors }, id) => { const user = await db.find(id); if (!user) throw errors.NOT_FOUND({ id }); return user; }) .callable(); fetchUser('missing'); // NOT_FOUND error - not cached fetchUser('missing'); // Will try again (not cached) fetchUser('exists'); // Success - cached fetchUser('exists'); // Cache hit! ``` ### Cache Error Handling If the cache adapter throws, you get an `UNKNOWN_ERROR`: ```js const brokenCache = { has() { throw new Error('Cache failed'); }, get() { throw new Error('Cache failed'); }, set() { throw new Error('Cache failed'); } }; const proc = zagora() .cache(brokenCache) .handler(() => 'result') .callable(); const result = proc(); // result.error.kind === 'UNKNOWN_ERROR' // result.error.cause === Error('Cache failed') ``` ### Pattern: Request-Scoped Cache Use per-request caches to avoid leaking between requests: ```js const getUser = zagora() .input(z.string()) .handler(async (_, id) => db.findUser(id)); // In request handler app.get('/user/:id', (req, res) => { const requestCache = new Map(); const proc = getUser.callable({ cache: requestCache }); // Multiple calls in same request share cache const user1 = await proc(req.params.id); const user2 = await proc(req.params.id); // Cache hit // Cache is garbage collected after request }); ``` ### Pattern: TTL Cache Implement time-based expiration: ```js function createTTLCache(ttlMs) { const cache = new Map(); return { has(key) { const entry = cache.get(key); if (!entry) return false; if (Date.now() > entry.expiresAt) { cache.delete(key); return false; } return true; }, get(key) { const entry = cache.get(key); if (!entry || Date.now() > entry.expiresAt) return undefined; return entry.value; }, set(key, value) { cache.set(key, { value, expiresAt: Date.now() + ttlMs }); } }; } const proc = zagora() .cache(createTTLCache(60000)) // 1 minute TTL .handler(() => expensiveOperation()) .callable(); ``` ### Next Steps * [Async Support](/docs/async-support) - Sync vs async procedures * [Context Management](/docs/context) - Dependency injection ## Context Management \[Pass shared dependencies to handlers] Context allows you to inject dependencies like databases, loggers, or configuration into your handlers. ### Setting Initial Context Use `.context()` to define initial context: ```js import { z } from 'zod'; import { zagora } from 'zagora'; const getUser = zagora() .context({ db: myDatabase, logger: console }) .input(z.string()) .handler(({ context }, userId) => { context.logger.log('Fetching user:', userId); return context.db.findUser(userId); }) .callable(); ``` ### Runtime Context Override Override or extend context at call site via `.callable()`: ```js const getUser = zagora() .context({ db: productionDb }) .input(z.string()) .handler(({ context }, userId) => context.db.findUser(userId)) .callable(); // Use production db (from initial context) getUser('123'); // Override with test db const testGetUser = getUser.callable({ context: { db: testDb } }); testGetUser('123'); ``` ### Context Merging Runtime context is deep-merged with initial context: ```js const proc = zagora() .context({ db: myDb, config: { timeout: 5000 } }) .handler(({ context }) => { console.log(context.db); // myDb console.log(context.config); // { timeout: 5000 } console.log(context.extra); // 'value' }) .callable({ context: { extra: 'value' } }); // Handler sees merged context: // { db: myDb, config: { timeout: 5000 }, extra: 'value' } ``` ### TypeScript Inference Context is fully typed: ```js const proc = zagora() .context({ db: myDatabase }) .handler(({ context }) => { context.db; // typeof myDatabase context.other; // TypeScript error! }) .callable(); ``` With runtime context: ```js const proc = zagora() .context({ db: myDatabase }) .handler(({ context }) => { context.db; // typeof myDatabase context.logger; // Logger (from runtime) }) .callable({ context: { logger: myLogger } }); ``` ### Pattern: Dependency Injection Use context for clean dependency injection: ```js // Define procedure with dependencies const createUser = zagora() .input(z.object({ name: z.string(), email: z.string() })) .handler(async ({ context }, input) => { const user = await context.userService.create(input); await context.emailService.sendWelcome(user.email); context.analytics.track('user_created', { userId: user.id }); return user; }); // Production const prodCreateUser = createUser.callable({ context: { userService: new UserService(prodDb), emailService: new EmailService(sendgrid), analytics: new Analytics(mixpanel) } }); // Testing const testCreateUser = createUser.callable({ context: { userService: mockUserService, emailService: mockEmailService, analytics: mockAnalytics } }); ``` ### Pattern: Request Context Pass request-specific data: ```js const getResource = zagora() .input(z.string()) .handler(({ context }, resourceId) => { // Check permissions using request context if (!context.user.canAccess(resourceId)) { throw new Error('Forbidden'); } return context.db.find(resourceId); }); // In request handler app.get('/resource/:id', (req, res) => { const proc = getResource.callable({ context: { user: req.user, db: req.db } }); const result = proc(req.params.id); // ... }); ``` ### Context Without Input Context works without input: ```js const getCurrentUser = zagora() .context({ auth: authService }) .handler(({ context }) => { return context.auth.getCurrentUser(); }) .callable(); ``` ### Next Steps * [Handler Options](/docs/handler-options) - Full options object reference * [Environment Variables](/docs/env-vars) - Type-safe env var access ## Default Values \[Auto-fill missing arguments] Zagora automatically applies schema defaults at runtime, ensuring handlers always receive complete data. ### How Defaults Work When you define a schema with `.default()`, Zagora fills in missing values: ```js import { z } from 'zod'; import { zagora } from 'zagora'; const createPost = zagora() .input(z.object({ title: z.string(), published: z.boolean().default(false), views: z.number().default(0) })) .handler((_, input) => { // input.published: boolean (not boolean | undefined!) // input.views: number (not number | undefined!) return input; }) .callable(); createPost({ title: 'Hello' }); // { ok: true, data: { title: 'Hello', published: false, views: 0 } } ``` ### Defaults in Tuple Arguments Defaults work seamlessly with tuple inputs: ```js const greet = zagora() .input(z.tuple([ z.string(), // Required z.string().default('Hello'), // Default greeting z.number().default(1) // Default count ])) .handler((_, name, greeting, count) => { // greeting: string (never undefined!) // count: number (never undefined!) return `${greeting} ${name}!`.repeat(count); }) .callable(); greet('World'); // 'Hello World!' greet('World', 'Hi'); // 'Hi World!' greet('World', 'Hey', 3); // 'Hey World!Hey World!Hey World!' ``` ### Default vs Optional These behave differently: ```js // Default - value is guaranteed z.number().default(0) // Handler receives: number (never undefined) // Optional - value may be undefined z.number().optional() // Handler receives: number | undefined ``` Choose defaults when you need a guaranteed value: ```js const paginate = zagora() .input(z.object({ page: z.number().default(1), // Always has a value limit: z.number().default(10), // Always has a value filter: z.string().optional() // May be undefined })) .handler((_, { page, limit, filter }) => { // No need to check if page/limit exist const offset = (page - 1) * limit; return { offset, limit, filter }; }) .callable(); ``` ### Dynamic Defaults Use functions for dynamic default values: ```js const createRecord = zagora() .input(z.object({ name: z.string(), createdAt: z.date().default(() => new Date()), id: z.string().default(() => crypto.randomUUID()) })) .handler((_, input) => input) .callable(); createRecord({ name: 'Test' }); // { name: 'Test', createdAt: Date, id: 'uuid-...' } ``` ### Nested Defaults Defaults work at any nesting level: ```js const createUser = zagora() .input(z.object({ name: z.string(), settings: z.object({ theme: z.string().default('light'), notifications: z.object({ email: z.boolean().default(true), push: z.boolean().default(false) }).default({}) }).default({}) })) .handler((_, input) => input) .callable(); createUser({ name: 'Alice' }); // { // name: 'Alice', // settings: { // theme: 'light', // notifications: { email: true, push: false } // } // } ``` ### TypeScript Behavior Zagora correctly infers non-undefined types for defaults: ```js const proc = zagora() .input(z.tuple([ z.string(), z.number().default(10) ])) .handler((_, name, count) => { // name: string // count: number (NOT number | undefined) // TypeScript knows count is always defined return name.repeat(count); }) .callable(); ``` ### Defaults in Output Schemas Defaults also work in output schemas: ```js const getUser = zagora() .input(z.string()) .output(z.object({ name: z.string(), role: z.string().default('user') })) .handler((_, id) => { // Can return without role, default applies return { name: 'Alice' }; }) .callable(); ``` ### Next Steps * [Tuple Arguments](/docs/tuple-arguments) - Multiple function arguments * [Input Validation](/docs/input-validation) - All input types ## Environment Variables \[Type-safe env var validation] Zagora lets you validate environment variables with the same schema system used for inputs and outputs. ### Basic Usage Use `.env()` to define and validate environment variables: ```js import { z } from 'zod'; import { zagora } from 'zagora'; const apiCall = zagora() .env(z.object({ API_KEY: z.string().min(1), API_URL: z.string().url(), TIMEOUT: z.coerce.number().default(5000) })) .input(z.string()) .handler(({ env }, endpoint) => { // env.API_KEY: string // env.API_URL: string // env.TIMEOUT: number return fetch(`${env.API_URL}${endpoint}`, { headers: { 'Authorization': `Bearer ${env.API_KEY}` }, signal: AbortSignal.timeout(env.TIMEOUT) }); }) .callable({ env: process.env as any }); ``` ### Providing Env at Runtime Pass environment variables via `.callable()`: ```js const proc = zagora() .env(z.object({ DATABASE_URL: z.string() })) .handler(({ env }) => connectToDatabase(env.DATABASE_URL)) .callable({ env: process.env as any }); ``` :::tip Use `as any` when passing `process.env` or `import.meta.env` because they have different types than your schema. This is intentional—it forces explicit validation. ::: ### Coercion Use `.coerce` to convert string env vars to proper types: ```js .env(z.object({ PORT: z.coerce.number(), // "3000" -> 3000 DEBUG: z.coerce.boolean(), // "true" -> true TIMEOUT: z.coerce.number().default(5000) })) ``` ### Default Values Defaults work as expected: ```js .env(z.object({ NODE_ENV: z.enum(['development', 'production']).default('development'), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), MAX_RETRIES: z.coerce.number().default(3) })) ``` ### Optional Env Vars Use `.optional()` for truly optional variables: ```js .env(z.object({ API_KEY: z.string(), // Required SECONDARY_KEY: z.string().optional() // Optional })) .handler(({ env }) => { // env.API_KEY: string // env.SECONDARY_KEY: string | undefined }) ``` ### Env Validation Errors Invalid env vars result in a `VALIDATION_ERROR`: ```js const proc = zagora() .env(z.object({ PORT: z.coerce.number().min(1).max(65535) })) .handler(({ env }) => env.PORT) .callable({ env: { PORT: 'invalid' } }); const result = proc(); // result.ok === false // result.error.kind === 'VALIDATION_ERROR' ``` ### With autoCallable When using `autoCallable`, provide env vars as the second argument to `.env()`: ```js const proc = zagora({ autoCallable: true }) .env( z.object({ API_KEY: z.string() }), process.env as any // Second argument: runtime env ) .input(z.string()) .handler(({ env }, input) => { return fetch(`/api/${input}`, { headers: { 'X-API-Key': env.API_KEY } }); }); // Direct call - no .callable() needed proc('users'); ``` ### Without disableOptions :::warning When `disableOptions: true` is set, the handler does NOT receive the `env` object. Don't use `.env()` with `disableOptions`. ::: ### Pattern: Config Procedure Create a config-loading procedure: ```js const getConfig = zagora() .env(z.object({ DATABASE_URL: z.string(), REDIS_URL: z.string().optional(), JWT_SECRET: z.string().min(32), PORT: z.coerce.number().default(3000), NODE_ENV: z.enum(['development', 'staging', 'production']).default('development') })) .handler(({ env }) => ({ database: { url: env.DATABASE_URL }, redis: env.REDIS_URL ? { url: env.REDIS_URL } : null, auth: { secret: env.JWT_SECRET }, server: { port: env.PORT }, env: env.NODE_ENV })) .callable({ env: process.env as any }); const config = getConfig(); if (config.ok) { startServer(config.data); } ``` ### Pattern: Per-Environment Config ```js const createApiClient = zagora() .env(z.object({ API_URL: z.string().url(), API_KEY: z.string() })) .input(z.string()) .handler(({ env }, endpoint) => { return fetch(`${env.API_URL}${endpoint}`, { headers: { 'Authorization': `Bearer ${env.API_KEY}` } }); }); // Production const prodClient = createApiClient.callable({ env: { API_URL: 'https://api.production.com', API_KEY: prodKey } }); // Staging const stagingClient = createApiClient.callable({ env: { API_URL: 'https://api.staging.com', API_KEY: stagingKey } }); ``` ### Limitations * Async schema validation for env vars is **not supported** * Env vars are validated once at `.callable()` time, not per-call ### Next Steps * [Handler Options](/docs/handler-options) - Full options reference * [Context Management](/docs/context) - Dependency injection ## Error Type Guards \[Narrow error types with utilities] Zagora provides type guard utilities to help narrow error types in your code. ### Available Guards Import guards from `zagora/errors`: ```js import { isValidationError, isInternalError, isDefinedError, isZagoraError } from 'zagora/errors'; ``` ### isValidationError Check if an error is a validation error (input, output, or error payload validation failed): ```js const result = myProcedure(invalidInput); if (!result.ok) { if (isValidationError(result.error)) { // result.error.kind === 'VALIDATION_ERROR' console.log(result.error.issues); console.log(result.error.key); // undefined or error key if error helper validation failed } } ``` ### isInternalError Check if an error is an unknown/internal error (unhandled exception in handler): ```js if (!result.ok) { if (isInternalError(result.error)) { // result.error.kind === 'UNKNOWN_ERROR' console.log(result.error.message); console.log(result.error.cause); } } ``` ### isDefinedError Check if an error is one of your defined error types: ```js const proc = zagora() .errors({ NOT_FOUND: z.object({ id: z.string() }), FORBIDDEN: z.object({ reason: z.string() }) }) .handler(/* ... */) .callable(); const result = proc(); if (!result.ok) { if (isDefinedError(result.error)) { // result.error.kind is 'NOT_FOUND' | 'FORBIDDEN' switch (result.error.kind) { case 'NOT_FOUND': console.log(result.error.id); break; case 'FORBIDDEN': console.log(result.error.reason); break; } } } ``` ### isZagoraError Check if an error is any Zagora error type: ```js if (!result.ok) { if (isZagoraError(result.error)) { // It's a Zagora-managed error (not a raw throw) console.log(result.error.kind); } } ``` ### Pattern: Error Handler Utility Create a reusable error handler: ```js import { isValidationError, isInternalError } from 'zagora/errors'; function handleError(error) { if (isValidationError(error)) { return { status: 400, body: { message: 'Invalid input', issues: error.issues } }; } if (isInternalError(error)) { console.error('Internal error:', error.cause); return { status: 500, body: { message: 'Internal server error' } }; } // Defined error return { status: 422, body: { code: error.kind, ...error } }; } const result = myProcedure(input); if (!result.ok) { return handleError(result.error); } ``` ### TypeScript Narrowing The guards work with TypeScript's control flow analysis: ```js const result = proc(); if (!result.ok) { const error = result.error; if (isValidationError(error)) { // TypeScript knows: error.kind === 'VALIDATION_ERROR' error.issues; // OK } else if (isInternalError(error)) { // TypeScript knows: error.kind === 'UNKNOWN_ERROR' error.cause; // OK } else { // TypeScript knows: it's a defined error error.kind; // 'NOT_FOUND' | 'FORBIDDEN' | ... } } ``` ### Next Steps * [Typed Errors](/docs/typed-errors) - Define custom error types * [Context Management](/docs/context) - Pass dependencies to handlers ## Getting Started \[Introduction to Zagora] Zagora enables building type-safe and error-safe procedures that encapsulate business logic with robust validation, error handling, and context management. ### What is Zagora? Zagora is a minimalist TypeScript library for creating type-safe, error-safe functions that never throw. Unlike oRPC or tRPC, Zagora focuses on producing pure functions—no network layer, no routers, just regular TypeScript functions with superpowers. ### Key Features * **Full Type Inference** - Complete TypeScript inference across inputs, outputs, errors, and context * **Never Throws** - Every function returns `{ ok, data, error }` for predictable execution * **Tuple Arguments** - Define multiple function arguments with per-argument validation * **Sync & Async** - Dynamic inference based on handler behavior * **StandardSchema** - Works with Zod, Valibot, ArkType, and any compliant validator ### Installation ```bash bun add zagora ``` ### Quick Start ```js import { z } from 'zod'; import { zagora } from 'zagora'; const greet = zagora() .input(z.string()) .handler((_, name) => `Hello, ${name}!`) .callable(); const result = greet('World'); if (result.ok) { console.log(result.data); // "Hello, World!" } ``` ## Handler Options \[The options object reference] The handler's first argument is an options object containing context, errors, and other utilities. ### Options Structure ```js .handler((options, ...inputs) => { const { context, // Merged context object errors, // Typed error helpers (if .errors() defined) env // Validated environment variables (if .env() defined) } = options; // Your logic here }) ``` ### context The merged context from `.context()` and `.callable({ context })`: ```js const proc = zagora() .context({ db: myDb }) .input(z.string()) .handler(({ context }, id) => { return context.db.find(id); }) .callable({ context: { logger: console } }); // Handler receives: { db: myDb, logger: console } ``` ### errors Typed error helper functions when `.errors()` is defined: ```js const proc = zagora() .errors({ NOT_FOUND: z.object({ id: z.string() }), FORBIDDEN: z.object({ reason: z.string() }) }) .handler(({ errors }, id) => { throw errors.NOT_FOUND({ id }); // or throw errors.FORBIDDEN({ reason: 'No access' }); }) .callable(); ``` Each helper creates a throwable error that Zagora catches and returns as a typed result. ### env Validated environment variables when `.env()` is defined: ```js const proc = zagora() .env(z.object({ API_KEY: z.string(), TIMEOUT: z.coerce.number().default(5000) })) .handler(({ env }) => { console.log(env.API_KEY); // string console.log(env.TIMEOUT); // number }) .callable({ env: process.env }); ``` ### Disabling Options Use `disableOptions: true` to omit the options object entirely: ```js const add = zagora({ disableOptions: true }) .input(z.tuple([z.number(), z.number()])) .handler((a, b) => a + b) // No options object! .callable(); ``` :::warning When `disableOptions` is enabled, you cannot use: * `context` (not accessible in handler) * `errors` (not accessible in handler) * `env` (not accessible in handler) ::: ### Options with Different Input Types #### Object Input ```js .input(z.object({ name: z.string() })) .handler((options, input) => { // input: { name: string } }) ``` #### Tuple Input (Spread) ```js .input(z.tuple([z.string(), z.number()])) .handler((options, name, age) => { // name: string, age: number }) ``` #### Primitive Input ```js .input(z.string()) .handler((options, input) => { // input: string }) ``` #### Array Input ```js .input(z.array(z.number())) .handler((options, numbers) => { // numbers: number[] }) ``` #### No Input ```js .handler((options) => { // No input arguments }) ``` ### Pattern: Destructuring Common pattern for cleaner handlers: ```js .handler(({ context, errors, env }, input) => { const { db, logger } = context; logger.info('Processing:', input); const result = db.query(input); if (!result) { throw errors.NOT_FOUND({ id: input }); } return result; }) ``` ### Next Steps * [Context Management](/docs/context) - Deep dive into context * [Environment Variables](/docs/env-vars) - Type-safe env vars * [Auto-Callable Mode](/docs/auto-callable) - Simplified API ## Input Validation \[Validate arguments with schemas] Input validation ensures your procedures receive correctly typed and validated data at runtime. ### Basic Input Define an input schema using any StandardSchema-compliant validator: ```js import { z } from 'zod'; import { zagora } from 'zagora'; const greet = zagora() .input(z.string()) .handler((_, name) => `Hello, ${name}!`) .callable(); greet('Alice'); // { ok: true, data: 'Hello, Alice!' } greet(123); // { ok: false, error: { kind: 'VALIDATION_ERROR', ... } } ``` ### Object Inputs For structured data, use object schemas: ```js const createUser = zagora() .input(z.object({ name: z.string().min(1), email: z.string().email(), age: z.number().optional() })) .handler((_, input) => { // input: { name: string, email: string, age?: number } return { id: '123', ...input }; }) .callable(); createUser({ name: 'Alice', email: 'alice@example.com' }); ``` ### Tuple Inputs (Multiple Arguments) Use tuple schemas to define multiple arguments with per-argument validation: ```js const add = zagora() .input(z.tuple([z.number(), z.number()])) .handler((_, a, b) => a + b) // Arguments are spread .callable(); add(5, 10); // { ok: true, data: 15 } ``` With defaults and optionals: ```js const greet = zagora() .input(z.tuple([ z.string(), // Required z.number().default(18), // Optional with default z.string().optional() // Optional (undefined allowed) ])) .handler((_, name, age, title) => { // name: string // age: number (never undefined due to default!) // title: string | undefined return `${title || 'User'} ${name}, age ${age}`; }) .callable(); greet('Alice'); // "User Alice, age 18" greet('Bob', 25); // "User Bob, age 25" greet('Carol', 30, 'Dr.'); // "Dr. Carol, age 30" ``` ### Array Inputs For variable-length inputs of the same type: ```js const sum = zagora() .input(z.array(z.number())) .handler((_, numbers) => { return numbers.reduce((a, b) => a + b, 0); }) .callable(); sum([1, 2, 3, 4, 5]); // { ok: true, data: 15 } ``` ### Validation Errors When input validation fails, you get a structured error: ```js const result = createUser({ name: '', email: 'invalid' }); if (!result.ok && result.error.kind === 'VALIDATION_ERROR') { console.log(result.error.issues); // [ // { path: ['name'], message: 'String must contain at least 1 character(s)' }, // { path: ['email'], message: 'Invalid email' } // ] } ``` ### Using Different Validators Zagora works with any StandardSchema validator: #### Valibot ```js import * as v from 'valibot'; import { zagora } from 'zagora'; const greet = zagora() .input(v.string()) .handler((_, name) => `Hello, ${name}!`) .callable(); ``` #### ArkType ```js import { type } from 'arktype'; import { zagora } from 'zagora'; const greet = zagora() .input(type('string')) .handler((_, name) => `Hello, ${name}!`) .callable(); ``` ### No Input Procedures can omit input entirely: ```js const getTime = zagora() .handler(() => new Date().toISOString()) .callable(); getTime(); // { ok: true, data: '2024-01-15T10:30:00.000Z' } ``` ### Next Steps * [Output Validation](/docs/output-validation) - Validate return values * [Tuple Arguments](/docs/tuple-arguments) - Deep dive into multiple arguments * [Default Values](/docs/default-values) - Auto-fill missing arguments ## Installation \[How to install Zagora] Zagora works with any StandardSchema-compliant validator like Zod, Valibot, or ArkType. ### Install Zagora :::code-group ```bash [bun] bun add zagora ``` ```bash [npm] npm install zagora ``` ```bash [pnpm] pnpm add zagora ``` ```bash [yarn] yarn add zagora ``` ::: ### Install a Validator You'll need a StandardSchema-compliant validation library. Choose one: :::code-group ```bash [Zod] bun add zod ``` ```bash [Valibot] bun add valibot ``` ```bash [ArkType] bun add arktype ``` ::: ### Verify Installation Create a simple procedure to verify everything works: ```js import { z } from 'zod'; import { zagora } from 'zagora'; const greet = zagora() .input(z.string()) .handler((_, name) => `Hello, ${name}!`) .callable(); const result = greet('World'); console.log(result); // { ok: true, data: 'Hello, World!', error: undefined } ``` ### TypeScript Configuration Zagora is written in TypeScript and provides full type inference out of the box. No additional configuration is required, but ensure your `tsconfig.json` has `strict` mode enabled for the best experience: ```json { "compilerOptions": { "strict": true, "moduleResolution": "bundler" } } ``` ### Next Steps Now that you have Zagora installed, check out the [Quick Start](/docs/quick-start) guide to build your first procedure. ## Never-Throwing Guarantees \[Procedures never crash your process] Zagora procedures are guaranteed to never throw exceptions. Every outcome—success or failure—is returned as a structured result. ### The Guarantee No matter what happens inside a procedure, you always get a `ZagoraResult`: ```js import { z } from 'zod'; import { zagora } from 'zagora'; const riskyOperation = zagora() .input(z.string()) .handler((_, input) => { throw new Error('Something went wrong!'); }) .callable(); // This does NOT throw - it returns an error result const result = riskyOperation('test'); console.log(result.ok); // false console.log(result.error.kind); // 'UNKNOWN_ERROR' console.log(result.error.cause); // Error: Something went wrong! ``` ### What Gets Captured #### Handler Exceptions Any error thrown in the handler becomes an `UNKNOWN_ERROR`: ```js .handler(() => { throw new Error('Oops'); }) // => { ok: false, error: { kind: 'UNKNOWN_ERROR', message: 'Oops', cause: Error } } ``` #### Validation Failures Invalid inputs become `VALIDATION_ERROR`: ```js const proc = zagora() .input(z.number()) .handler((_, n) => n * 2) .callable(); proc('not a number'); // => { ok: false, error: { kind: 'VALIDATION_ERROR', issues: [...] } } ``` #### Typed Errors Your defined errors are captured and typed: ```js const proc = zagora() .errors({ NOT_FOUND: z.object({ id: z.string() }) }) .handler(({ errors }) => { throw errors.NOT_FOUND({ id: '123' }); }) .callable(); const result = proc(); // => { ok: false, error: { kind: 'NOT_FOUND', id: '123' } } ``` #### Syntax Errors Even syntax errors are captured: ```js .handler(() => { const obj = null; return obj.property; // TypeError: Cannot read property of null }) // => { ok: false, error: { kind: 'UNKNOWN_ERROR', cause: TypeError } } ``` #### Async Rejections Promise rejections are captured: ```js .handler(async () => { const response = await fetch('/failing-endpoint'); if (!response.ok) throw new Error('Request failed'); }) // => { ok: false, error: { kind: 'UNKNOWN_ERROR', ... } } ``` ### Result Structure #### Success ```js { ok: true, data: T, // Your return value error: undefined } ``` #### Failure ```js { ok: false, data: undefined, error: { kind: 'VALIDATION_ERROR' | 'UNKNOWN_ERROR' | YourErrorKinds, message: string, // ... additional fields based on error kind } } ``` ### Why Never-Throwing? #### Predictable Control Flow ```js // Traditional - must remember to try/catch try { const user = getUser(id); processUser(user); } catch (e) { // What type is e? Unknown! handleError(e); } // Zagora - explicit error handling const result = getUser(id); if (result.ok) { processUser(result.data); } else { // result.error is fully typed handleError(result.error); } ``` #### No Accidental Crashes ```js // Traditional - unhandled rejection can crash process await riskyAsyncOperation(); // Might throw! // Zagora - always safe const result = await riskyAsyncOperation(); // Never throws if (!result.ok) { console.error('Failed:', result.error); } ``` #### Typed Errors ```js if (!result.ok) { switch (result.error.kind) { case 'NOT_FOUND': // TypeScript knows the shape console.log(result.error.id); break; case 'VALIDATION_ERROR': console.log(result.error.issues); break; case 'UNKNOWN_ERROR': console.log(result.error.cause); break; } } ``` ### Comparison with Other Approaches #### vs try/catch ```js // try/catch - error type is unknown try { const result = dangerousOperation(); } catch (e) { // e is `unknown` - must cast or check } // Zagora - error is fully typed const result = safeOperation(); if (!result.ok) { result.error.kind; // Typed! } ``` #### vs neverthrow ```js // neverthrow - requires unwrapping const result = await operation(); const value = result.unwrap(); // Throws if error! // Zagora - direct access const result = operation(); if (result.ok) { result.data; // Direct access, no unwrap } ``` #### vs Effect.ts ```js // Effect - complex API const program = Effect.tryPromise({ try: () => operation(), catch: (e) => new CustomError(e) }); await Effect.runPromise(program); // Zagora - simple const result = operation(); ``` ### Edge Cases #### Cache Failures ```js const brokenCache = { has() { throw new Error('Cache failed'); }, // ... }; const result = proc(); // => { ok: false, error: { kind: 'UNKNOWN_ERROR', cause: Error('Cache failed') } } ``` #### Invalid Error Payloads ```js throw errors.NOT_FOUND({ wrongField: 'value' }); // => { ok: false, error: { kind: 'VALIDATION_ERROR', key: 'NOT_FOUND', ... } } ``` ### Next Steps * [Typed Errors](/docs/typed-errors) - Define structured errors * [Error Type Guards](/docs/error-guards) - Narrow error types ## Output Validation \[Ensure return values match schemas] Output validation verifies that your handler returns correctly shaped data, catching bugs before they reach consumers. ### Basic Output Define an output schema to validate return values: ```js import { z } from 'zod'; import { zagora } from 'zagora'; const getUser = zagora() .input(z.string()) .output(z.object({ id: z.string(), name: z.string(), email: z.string().email() })) .handler((_, id) => { return { id, name: 'Alice', email: 'alice@example.com' }; }) .callable(); ``` ### Why Validate Outputs? Output validation catches handler bugs at runtime: ```js const buggyHandler = zagora() .output(z.object({ count: z.number() })) .handler(() => { return { count: 'not a number' }; // Bug! }) .callable(); const result = buggyHandler(); // { ok: false, error: { kind: 'VALIDATION_ERROR', ... } } ``` Without output validation, this bug would propagate to consumers. ### Output Type Inference TypeScript infers the output type from the schema: ```js const getUser = zagora() .output(z.object({ id: z.string(), name: z.string() })) .handler(() => ({ id: '1', name: 'Alice' })) .callable(); const result = getUser(); if (result.ok) { result.data.id; // string result.data.name; // string } ``` ### Optional Output Output validation is optional. Without it, the handler's return type is inferred: ```js const greet = zagora() .input(z.string()) .handler((_, name) => `Hello, ${name}!`) .callable(); // TypeScript infers: result.data is string ``` ### Transforming Outputs Use schema transformations to modify output: ```js const getUser = zagora() .input(z.string()) .output( z.object({ name: z.string(), createdAt: z.date() }).transform(user => ({ ...user, displayName: user.name.toUpperCase() })) ) .handler((_, id) => ({ name: 'alice', createdAt: new Date() })) .callable(); const result = getUser('123'); if (result.ok) { console.log(result.data.displayName); // 'ALICE' } ``` ### Async Output Validation Output schemas can be async (with caveats): ```js const createUser = zagora() .input(z.object({ email: z.string() })) .output( z.object({ email: z.string() }).refine( async (data) => await isUniqueEmail(data.email), 'Email already exists' ) ) .handler((_, input) => input) .callable(); // Must await even if handler is sync const result = await createUser({ email: 'test@example.com' }); ``` :::warning When using async schemas, always `await` the result even if TypeScript suggests it's not needed. This is a StandardSchema limitation. ::: ### Output vs Handler Return Type The output schema takes precedence for type inference: ```js const proc = zagora() .output(z.object({ name: z.string() })) .handler(() => { // Handler could return anything, but output schema // ensures consumers only see { name: string } return { name: 'Alice', secret: 'hidden' }; }) .callable(); const result = proc(); if (result.ok) { result.data.name; // OK result.data.secret; // TypeScript error! Not in output schema } ``` ### Next Steps * [Typed Errors](/docs/typed-errors) - Define structured error responses * [Procedures](/docs/procedures) - Overview of the builder API ## Procedures \[The building blocks of Zagora] A procedure is a type-safe, error-safe function built with the fluent builder API. Procedures validate inputs, execute handlers, and return predictable results. ### Anatomy of a Procedure ```js import { z } from 'zod'; import { zagora } from 'zagora'; const myProcedure = zagora() // 1. Create instance .input(z.string()) // 2. Define input schema .output(z.number()) // 3. Define output schema (optional) .errors({ /* ... */ }) // 4. Define error schemas (optional) .handler((options, input) => { // 5. Implement logic return input.length; }) .callable(); // 6. Create callable function ``` ### The Builder Chain Each method returns a new builder instance, allowing fluent chaining: | Method | Purpose | Required | | -------------------- | ------------------------------ | ------------------------------- | | `.input(schema)` | Validate inputs | No (but recommended) | | `.output(schema)` | Validate outputs | No | | `.errors(map)` | Define typed errors | No | | `.context(initial)` | Set initial context | No | | `.env(schema)` | Validate environment variables | No | | `.cache(adapter)` | Enable memoization | No | | `.handler(fn)` | Implement business logic | **Yes** | | `.callable(options)` | Create the function | **Yes** (unless `autoCallable`) | ### Handler Signature The handler receives an options object and the validated input(s): ```js // With object/primitive input .handler((options, input) => { const { context, errors } = options; return processInput(input); }) // With tuple input (spread arguments) .handler((options, arg1, arg2, arg3) => { return arg1 + arg2 + arg3; }) ``` ### Calling a Procedure Procedures are called like regular functions: ```js const greet = zagora() .input(z.string()) .handler((_, name) => `Hello, ${name}!`) .callable(); // Direct call const result = greet('World'); // Check result if (result.ok) { console.log(result.data); // "Hello, World!" } ``` ### Procedure Types #### Sync Procedures When the handler is synchronous, the procedure returns `ZagoraResult`: ```js const add = zagora() .input(z.tuple([z.number(), z.number()])) .handler((_, a, b) => a + b) .callable(); const result = add(1, 2); // ZagoraResult ``` #### Async Procedures When the handler is async or returns a Promise, the procedure returns `Promise>`: ```js const fetchData = zagora() .input(z.string()) .handler(async (_, url) => { const res = await fetch(url); return res.json(); }) .callable(); const result = await fetchData('/api'); // Promise> ``` ### Without Input Procedures can have no input: ```js const getTimestamp = zagora() .handler(() => Date.now()) .callable(); const result = getTimestamp(); // No arguments needed ``` ### With Runtime Options Pass context, cache, or env at call time: ```js const query = zagora() .input(z.string()) .handler(({ context }, sql) => context.db.query(sql)) .callable({ context: { db: productionDb } }); // Or override at call site by creating a new callable const testQuery = query.callable({ context: { db: testDb } }); ``` ### Next Steps * [Input Validation](/docs/input-validation) - Define and validate inputs * [Output Validation](/docs/output-validation) - Ensure return values match schemas * [Typed Errors](/docs/typed-errors) - Create structured error responses ## Quick Start \[Build your first procedure] This guide walks you through creating type-safe, error-safe procedures with Zagora. ### Your First Procedure A procedure is a type-safe function with validated inputs and predictable outputs: ```js import { z } from 'zod'; import { zagora } from 'zagora'; const addNumbers = zagora() .input(z.tuple([z.number(), z.number()])) .output(z.number()) .handler((_, a, b) => a + b) .callable(); const result = addNumbers(5, 10); if (result.ok) { console.log(result.data); // 15 } ``` ### Understanding the Result Every procedure returns a `ZagoraResult` with a discriminated union: ```js const result = addNumbers(5, 10); if (result.ok) { // Success path console.log(result.data); // number console.log(result.error); // undefined } else { // Error path console.log(result.data); // undefined console.log(result.error.kind); // 'VALIDATION_ERROR' | 'UNKNOWN_ERROR' | ... } ``` ### Input Validation Invalid inputs are caught at runtime and returned as validation errors: ```js const result = addNumbers('not', 'numbers'); if (!result.ok) { console.log(result.error.kind); // 'VALIDATION_ERROR' console.log(result.error.issues); // Array of validation issues } ``` ### Adding Typed Errors Define custom errors with schemas for strongly-typed error handling: ```js const divide = zagora() .input(z.tuple([z.number(), z.number()])) .output(z.number()) .errors({ DIVISION_BY_ZERO: z.object({ dividend: z.number() }) }) .handler(({ errors }, a, b) => { if (b === 0) { throw errors.DIVISION_BY_ZERO({ dividend: a }); } return a / b; }) .callable(); const result = divide(10, 0); if (!result.ok && result.error.kind === 'DIVISION_BY_ZERO') { console.log(result.error.dividend); // 10 } ``` ### Using Context Pass shared dependencies through context: ```js const getUser = zagora() .input(z.string()) .handler(({ context }, userId) => { return context.db.findUser(userId); }) .callable({ context: { db: myDatabase } }); ``` ### Async Procedures Zagora automatically infers sync vs async based on your handler: ```js const fetchUser = zagora() .input(z.string()) .handler(async (_, userId) => { const response = await fetch(`/api/users/${userId}`); return response.json(); }) .callable(); // Returns Promise> const result = await fetchUser('123'); ``` ### Next Steps * Learn about [Procedures](/docs/procedures) in depth * Explore [Tuple Arguments](/docs/tuple-arguments) for natural function signatures * See [Typed Errors](/docs/typed-errors) for robust error handling ## Tuple Arguments \[Natural function signatures with multiple arguments] Zagora's tuple argument support lets you define procedures with multiple arguments that feel like native TypeScript functions. ### Basic Tuple Input Use `z.tuple([...])` to define multiple arguments: ```js import { z } from 'zod'; import { zagora } from 'zagora'; const add = zagora() .input(z.tuple([z.number(), z.number()])) .handler((_, a, b) => a + b) .callable(); // Call like a normal function add(5, 10); // { ok: true, data: 15 } ``` Compare to oRPC/tRPC style: ```js // Zagora - natural function signature add(5, 10); // oRPC/tRPC - always object input add({ a: 5, b: 10 }); ``` ### Argument Spreading Tuple elements are spread as handler arguments: ```js const greet = zagora() .input(z.tuple([z.string(), z.string(), z.number()])) .handler((_, firstName, lastName, age) => { return `Hello, ${firstName} ${lastName}! You are ${age}.`; }) .callable(); greet('John', 'Doe', 30); // { ok: true, data: 'Hello, John Doe! You are 30.' } ``` ### Default Values Use `.default()` to make arguments optional with defaults: ```js const createUser = zagora() .input(z.tuple([ z.string(), // name - required z.number().default(18), // age - optional, defaults to 18 z.string().default('user') // role - optional, defaults to 'user' ])) .handler((_, name, age, role) => { // age: number (never undefined!) // role: string (never undefined!) return { name, age, role }; }) .callable(); createUser('Alice'); // { name: 'Alice', age: 18, role: 'user' } createUser('Bob', 25); // { name: 'Bob', age: 25, role: 'user' } createUser('Carol', 30, 'admin'); // { name: 'Carol', age: 30, role: 'admin' } ``` ### Optional Arguments Use `.optional()` for truly optional arguments: ```js const greet = zagora() .input(z.tuple([ z.string(), // name - required z.string().optional() // title - optional (can be undefined) ])) .handler((_, name, title) => { // title: string | undefined return title ? `${title} ${name}` : name; }) .callable(); greet('Alice'); // 'Alice' greet('Bob', 'Dr.'); // 'Dr. Bob' ``` ### Mixed Required, Default, and Optional Combine all three patterns: ```js const search = zagora() .input(z.tuple([ z.string(), // query - required z.number().default(1), // page - default 1 z.number().default(10), // limit - default 10 z.string().optional() // sortBy - optional ])) .handler((_, query, page, limit, sortBy) => { // query: string // page: number (never undefined) // limit: number (never undefined) // sortBy: string | undefined return db.search(query, { page, limit, sortBy }); }) .callable(); search('typescript'); // page=1, limit=10, sortBy=undefined search('typescript', 2); // page=2, limit=10, sortBy=undefined search('typescript', 1, 20); // page=1, limit=20, sortBy=undefined search('typescript', 1, 10, 'relevance'); // page=1, limit=10, sortBy='relevance' ``` ### Per-Argument Validation Each tuple element is validated independently: ```js const register = zagora() .input(z.tuple([ z.string().min(3).max(20), // username: 3-20 chars z.string().email(), // email: valid email z.number().min(13).max(120) // age: 13-120 ])) .handler((_, username, email, age) => { return { username, email, age }; }) .callable(); // Validation error points to specific argument const result = register('ab', 'invalid', 10); // result.error.issues: // [ // { path: [0], message: 'String must contain at least 3 character(s)' }, // { path: [1], message: 'Invalid email' }, // { path: [2], message: 'Number must be greater than or equal to 13' } // ] ``` ### TypeScript Inference Full type inference for all arguments: ```js const fn = zagora() .input(z.tuple([ z.string(), z.number().default(0), z.boolean().optional() ])) .handler((_, a, b, c) => { // a: string // b: number (not number | undefined!) // c: boolean | undefined }) .callable(); // TypeScript knows the call signatures: fn('hello'); // OK fn('hello', 5); // OK fn('hello', 5, true); // OK fn(); // Error: missing required argument fn('hello', 'wrong'); // Error: string not assignable to number ``` ### Next Steps * [Default Values](/docs/default-values) - More on auto-filling defaults * [Input Validation](/docs/input-validation) - All input types ## Typed Errors \[Define structured error responses] Zagora lets you define error schemas upfront, giving you strongly-typed error helpers inside handlers and typed error responses for consumers. ### Defining Errors Use the `.errors()` method with an object mapping error kinds to schemas: ```js import { z } from 'zod'; import { zagora } from 'zagora'; const getUser = zagora() .input(z.string()) .errors({ NOT_FOUND: z.object({ userId: z.string(), message: z.string() }), UNAUTHORIZED: z.object({ requiredRole: z.string() }) }) .handler(({ errors }, userId) => { if (!currentUser) { throw errors.UNAUTHORIZED({ requiredRole: 'user' }); } const user = db.find(userId); if (!user) { throw errors.NOT_FOUND({ userId, message: 'User not found' }); } return user; }) .callable(); ``` ### Error Keys Must Be Uppercase Error keys represent error "kinds" and must be UPPERCASE: ```js // Good .errors({ NOT_FOUND: z.object({ id: z.string() }), RATE_LIMITED: z.object({ retryAfter: z.number() }) }) // Bad - TypeScript will error .errors({ notFound: z.object({ id: z.string() }) // Must be uppercase! }) ``` ### Using Error Helpers Inside the handler, `errors` provides typed helper functions: ```js .handler(({ errors }, input) => { // errors.NOT_FOUND() - creates NOT_FOUND error // errors.UNAUTHORIZED() - creates UNAUTHORIZED error throw errors.NOT_FOUND({ userId: '123', message: 'Gone' }); }) ``` The helper validates the error payload at runtime: ```js // This will cause a VALIDATION_ERROR because retryAfter should be number throw errors.RATE_LIMITED({ retryAfter: 'invalid' }); ``` ### Consuming Typed Errors Consumers can narrow error types using the `kind` property: ```js const result = getUser('123'); if (!result.ok) { switch (result.error.kind) { case 'NOT_FOUND': console.log(result.error.userId); // string console.log(result.error.message); // string break; case 'UNAUTHORIZED': console.log(result.error.requiredRole); // string break; case 'VALIDATION_ERROR': console.log(result.error.issues); // Schema.Issue[] break; case 'UNKNOWN_ERROR': console.log(result.error.cause); // unknown break; } } ``` ### Built-in Error Kinds Zagora includes two built-in error kinds: #### VALIDATION\_ERROR Returned when input, output, or error payload validation fails: ```js { kind: 'VALIDATION_ERROR', message: 'Validation failed', issues: [{ path: ['email'], message: 'Invalid email' }], key?: 'NOT_FOUND' // Present if error helper validation failed } ``` #### UNKNOWN\_ERROR Returned when an untyped error is thrown: ```js .handler(() => { throw new Error('Something went wrong'); }) // Results in: { kind: 'UNKNOWN_ERROR', message: 'Something went wrong', cause: Error('Something went wrong') } ``` ### Error Validation Error payloads are validated at runtime. Invalid payloads become `VALIDATION_ERROR`: ```js const proc = zagora() .errors({ RATE_LIMITED: z.object({ retryAfter: z.number() }) }) .handler(({ errors }) => { // Wrong type for retryAfter throw errors.RATE_LIMITED({ retryAfter: 'soon' }); }) .callable(); const result = proc(); // result.error.kind === 'VALIDATION_ERROR' // result.error.key === 'RATE_LIMITED' ``` ### Strict Error Schemas Use `.strict()` to reject unknown properties: ```js .errors({ NOT_FOUND: z.object({ id: z.string() }).strict() }) // This will fail validation: throw errors.NOT_FOUND({ id: '123', extra: 'field' }); ``` ### Next Steps * [Error Type Guards](/docs/error-guards) - Narrow error types with utilities * [Never-Throwing Guarantees](/docs/never-throwing) - How errors are always captured ## Why Zagora? \[The case for type-safe, error-safe functions] Zagora fills a gap in the TypeScript ecosystem: type-safe functions that never throw, with matching compile-time and runtime behavior, without the complexity of full RPC frameworks or functional programming libraries. ### The Problem #### Plain TypeScript Falls Short TypeScript gives you compile-time types, but no runtime guarantees: ```js function getUser(id: string): User { const user = db.find(id); if (!user) throw new Error('Not found'); // Runtime bomb return user; } // Caller has no idea this can throw const user = getUser('123'); // May crash your app ``` #### RPC Frameworks Are Overkill for Libraries oRPC and tRPC are excellent for APIs, but they come with baggage for library building: * Always async (even for sync operations) * Require router infrastructure (even for server-side calls) * Single object inputs only (`{ name, age }` not `name, age`) * Designed for API documentation and HTTP semantics **However:** When you do need routers, middleware, or HTTP integration, Zagora makes it easy. See [Building Routers](/advanced/building-routers) for patterns including authenticated procedures and framework integration. You can also [generate OpenAPI specs](/advanced/openapi) using StandardSchema helpers. #### Functional Libraries Have Steep Learning Curves neverthrow and Effect.ts solve the error problem, but demand buy-in: * neverthrow requires monadic operations (`.map()`, `.unwrap()`) * Effect.ts requires learning an entirely new paradigm * Neither includes validation—you still need Zod separately ### Zagora's Approach Zagora gives you the best of all worlds: #### 1. Never-Throwing Execution Absolute guarantee that the consumer's process/server will NEVER crash, great for long running processes and observability (OTel) ```js const result = getUser('123'); // Always safe to access if (result.ok) { console.log(result.data); } else { console.log(result.error.kind); } ``` #### 2. Full Type Inference Even if the user is goold ol' JavaScript, they will get complete intellisense, auto-completion, and validation. ```js const getUser = zagora() .input(z.tuple([z.string(), z.number().default(18)])) .output(z.object({ name: z.string(), age: z.number() })) .handler((_, name, age) => ({ name, age })) .callable(); // TypeScript knows: // - First arg is string (required) // - Second arg is number (optional, defaults to 18) // - Returns { name: string, age: number } ``` #### 3. Just Functions ```js // Export directly from your library export const createUser = zagora() .input(z.object({ name: z.string() })) .handler((_, input) => db.create(input)) .callable(); // Consumers call it like any function import { createUser } from 'your-library'; const result = createUser({ name: 'Alice' }); ``` #### 4. Sync If Needed Produced functions can be synchronous if needed. They look, feel, and act as regular functions - with compile-time type safety and runtime validation. ```js // Sync handler = sync result const add = zagora() .input(z.tuple([z.number(), z.number()])) .handler((_, a, b) => a + b) .callable(); const result = add(1, 2); // ZagoraResult (not Promise) // Async handler = async result const fetch = zagora() .input(z.string()) .handler(async (_, url) => await fetchData(url)) .callable(); const result = await fetch('/api'); // Promise ``` ### When to Use Zagora **Use Zagora when:** * Building libraries or SDKs with type-safe APIs * Creating internal tooling with validated inputs * You want error-safe functions without forced async everywhere * You need sync procedures (not everything is async) * You prefer natural function signatures (`fn(a, b)` not `fn({ a, b })`) * You want runtime validation of error payloads, not just compile-time **Need APIs too?** Zagora works great for HTTP APIs - see [Building Routers](/advanced/building-routers) for middleware patterns, context injection, and framework integration. You can also [generate OpenAPI specs](/advanced/openapi). **Use oRPC/tRPC when:** * You need built-in OpenAPI spec generation (oRPC) * You want tight Next.js integration (tRPC) * You need batching and request deduplication * Your entire team is already using these patterns **Use Effect.ts when:** * You want a complete effect system * You're building a large application with complex dependency injection * Your team is comfortable with functional programming ### Summary | Need | Solution | | --------------------------- | ---------- | | Type-safe library functions | **Zagora** | | Client-server APIs | oRPC/tRPC | | Full effect system | Effect.ts | | Simple error wrapping | neverthrow | Zagora is "just functions" with runtime validation, typed errors, and predictable results. No paradigm shift required. ## vs neverthrow / Effect.ts \[Comparison with functional libraries] Zagora, neverthrow, and Effect.ts all solve the "exceptions are bad" problem, but with different approaches. **Philosophy**: Zagora gives you error-safe results without functional programming overhead. The `neverthrow` requires monadic operations, Effect.ts requires learning an entirely new paradigm. Zagora is "just functions" with predictable results. ### Feature Comparison | Feature | Zagora | neverthrow | Effect.ts | | ---------------------------- | --------------------- | ---------------------- | ------------------------ | | Result unwrapping | Not needed | Required (`.unwrap()`) | Complex API | | Learning curve | Minimal | Low | Very steep | | Validation included | Yes (StandardSchema) | No | Yes (own system) | | Error schema support | Yes (typed helpers) | No | Yes (different approach) | | Functional programming focus | No | Yes | Yes (heavy FP) | | Type inference | Full | Good | Excellent | | Bundle size | Tiny | Small | Large | | Primary philosophy | Practical type-safety | Monadic errors | Full effect system | ### Key Differences #### 1. Result Access **Zagora:** Direct access via discriminated union ```js const result = getUser('123'); if (result.ok) { console.log(result.data); // Direct access } else { console.log(result.error); // Direct access } ``` **neverthrow:** Requires unwrapping or monadic operations ```js const result = getUser('123'); // Option 1: unwrap (throws if error!) const user = result.unwrap(); // Option 2: match result.match( (user) => console.log(user), (error) => console.error(error) ); // Option 3: map chain result .map(user => user.name) .mapErr(err => new Error(err.message)); ``` **Effect.ts:** Complex API with generators or pipe ```js const program = pipe( getUser('123'), Effect.map(user => user.name), Effect.catchTag('NotFound', () => Effect.succeed('Unknown')) ); await Effect.runPromise(program); ``` #### 2. Validation **Zagora:** Built-in via StandardSchema ```js const createUser = zagora() .input(z.object({ email: z.string().email(), age: z.number().min(18) })) .handler((_, input) => input) .callable(); // Invalid input -> VALIDATION_ERROR createUser({ email: 'bad', age: 10 }); ``` **neverthrow:** No validation, just error wrapping ```js // Must validate separately const validated = emailSchema.safeParse(input); if (!validated.success) { return err(new ValidationError(validated.error)); } return ok(createUser(validated.data)); ``` **Effect.ts:** Own validation system (Schema) ```js import { Schema } from '@effect/schema'; const User = Schema.struct({ email: Schema.string.pipe(Schema.email), age: Schema.number.pipe(Schema.greaterThan(17)) }); ``` #### 3. Error Typing **Zagora:** Schema-validated typed errors ```js const proc = zagora() .errors({ NOT_FOUND: z.object({ id: z.string() }), FORBIDDEN: z.object({ reason: z.string() }) }) .handler(({ errors }) => { throw errors.NOT_FOUND({ id: '123' }); }); // Error is validated AND typed if (result.error.kind === 'NOT_FOUND') { result.error.id; // string, guaranteed } ``` **neverthrow:** Custom error classes ```js class NotFoundError extends Error { constructor(public id: string) { super('Not found'); } } // No runtime validation return err(new NotFoundError('123')); ``` **Effect.ts:** Tagged errors with `Effect.fail` ```js class NotFound { readonly _tag = 'NotFound'; constructor(public id: string) {} } Effect.fail(new NotFound('123')); ``` #### 4. Learning Curve **Zagora:** Minimal - just functions with results ```js const fn = zagora().handler(() => 'result').callable(); const result = fn(); if (result.ok) use(result.data); ``` **neverthrow:** Low - learn monadic operations ```js // Need to understand: ok, err, Result, map, mapErr, match, andThen, unwrap... ``` **Effect.ts:** Very steep - new paradigm ```js // Need to understand: Effect, Layer, Runtime, Fiber, pipe, generators, do notation... ``` ### Code Comparison #### Simple Operation **Zagora:** ```js const divide = zagora() .input(z.tuple([z.number(), z.number()])) .errors({ DIVISION_BY_ZERO: z.object({}) }) .handler(({ errors }, a, b) => { if (b === 0) throw errors.DIVISION_BY_ZERO({}); return a / b; }) .callable(); const result = divide(10, 2); if (result.ok) console.log(result.data); ``` **neverthrow:** ```js const divide = (a: number, b: number): Result => { if (b === 0) return err(new DivisionByZero()); return ok(a / b); }; divide(10, 2).match( value => console.log(value), error => console.error(error) ); ``` **Effect.ts:** ```js const divide = (a: number, b: number) => b === 0 ? Effect.fail(new DivisionByZero()) : Effect.succeed(a / b); await pipe( divide(10, 2), Effect.match({ onSuccess: value => console.log(value), onFailure: error => console.error(error) }), Effect.runPromise ); ``` ### When to Use Each #### Use Zagora When * You want error-safe functions without learning new paradigms * You need input/output validation built-in * You prefer direct result access over unwrapping * You want typed errors with runtime validation #### Use neverthrow When * You're comfortable with monadic operations * You want a lightweight error-handling library * Validation is handled separately * You like functional composition #### Use Effect.ts When * You want a complete effect system * You need advanced features (fibers, layers, retries) * Your team is comfortable with functional programming * You're building a large application with complex requirements ### Philosophy **Zagora:** Practical type-safety with minimal ceremony. Just functions with validated inputs, typed errors, and predictable results. **neverthrow:** Monadic error handling for JavaScript. Learn a few concepts, apply them consistently. **Effect.ts:** A full effect system bringing Scala ZIO / Haskell IO patterns to TypeScript. Maximum power, maximum complexity. ## vs Plain TypeScript \[Why use Zagora over plain functions] **Philosophy**: Plain TypeScript offers types but no runtime guarantees. Zagora bridges the gap with runtime validation while maintaining full type inference. You get the ergonomics of pure functions with the safety of schema validation. Here's what Zagora adds. ### Feature Comparison | Aspect | Zagora | Plain TypeScript | | ------------------------- | -------------------------- | ----------------------- | | Runtime validation | Yes (StandardSchema) | Manual implementation | | Compile-time types | Yes (full inference) | Manual type annotations | | Error handling | Structured `{ ok, error }` | try/catch or manual | | Default values at runtime | Automatic from schema | Manual implementation | | Tuple argument spreading | Built-in | Not applicable | | Caching/memoization | Built-in adapter | Manual implementation | | Context injection | Built-in | Manual prop drilling | ### Key Differences #### 1. Runtime Validation **Plain TypeScript:** Types exist only at compile-time ```js function createUser(data: { name: string; age: number }) { // Types say this is safe, but at runtime... return { id: '1', ...data }; } // TypeScript is happy, but runtime disaster awaits createUser(JSON.parse(untrustedInput)); // Could be anything! ``` **Zagora:** Validates at runtime ```js const createUser = zagora() .input(z.object({ name: z.string().min(1), age: z.number().min(0) })) .handler((_, data) => ({ id: '1', ...data })) .callable(); // Safe even with untrusted input createUser(JSON.parse(untrustedInput)); // If invalid: { ok: false, error: { kind: 'VALIDATION_ERROR', ... } } ``` #### 2. Error Handling **Plain TypeScript:** Unpredictable exceptions ```js function getUser(id: string): User { const user = db.find(id); if (!user) throw new Error('Not found'); // Caller might forget to catch return user; } // Easy to forget error handling const user = getUser('123'); // Might crash! ``` **Zagora:** Predictable results ```js const getUser = zagora() .input(z.string()) .errors({ NOT_FOUND: z.object({ id: z.string() }) }) .handler(({ errors }, id) => { const user = db.find(id); if (!user) throw errors.NOT_FOUND({ id }); return user; }) .callable(); // Caller must handle both cases const result = getUser('123'); if (result.ok) { // use result.data } else { // handle result.error } ``` #### 3. Default Values **Plain TypeScript:** Manual defaults ```js function greet( name: string, greeting: string = 'Hello', punctuation: string = '!' ) { return `${greeting}, ${name}${punctuation}`; } ``` **Zagora:** Schema-driven defaults ```js const greet = zagora() .input(z.tuple([ z.string(), z.string().default('Hello'), z.string().default('!') ])) .handler((_, name, greeting, punctuation) => { return `${greeting}, ${name}${punctuation}`; }) .callable(); // Same behavior, but with validation too greet('World'); // "Hello, World!" ``` #### 4. Type Inference **Plain TypeScript:** Manual annotations ```js interface UserInput { name: string; email: string; age?: number; } interface User { id: string; name: string; email: string; age: number; } function createUser(input: UserInput): User { return { id: crypto.randomUUID(), name: input.name, email: input.email, age: input.age ?? 18 }; } ``` **Zagora:** Inferred from schemas ```js const createUser = zagora() .input(z.object({ name: z.string(), email: z.string().email(), age: z.number().default(18) })) .output(z.object({ id: z.string(), name: z.string(), email: z.string(), age: z.number() })) .handler((_, input) => ({ id: crypto.randomUUID(), ...input })) .callable(); // Types are inferred from schemas - no manual interface definitions ``` #### 5. Dependency Injection **Plain TypeScript:** Prop drilling or class patterns ```js class UserService { constructor( private db: Database, private logger: Logger, private cache: Cache ) {} async getUser(id: string) { this.logger.log('Fetching user'); // ... } } ``` **Zagora:** Built-in context ```js const getUser = zagora() .context({ db: myDb, logger: myLogger }) .input(z.string()) .handler(({ context }, id) => { context.logger.log('Fetching user'); return context.db.find(id); }) .callable(); // Override for testing const testGetUser = getUser.callable({ context: { db: mockDb, logger: mockLogger } }); ``` ### What You Get with Zagora 1. **Runtime safety** - Invalid data is caught before it causes problems 2. **Predictable errors** - Never crash from unhandled exceptions 3. **Type inference** - Define once in schema, get types everywhere 4. **Less boilerplate** - Defaults, validation, and error handling built-in 5. **Testability** - Easy dependency injection via context ### What Plain TypeScript Is Better For * Simple utility functions with no validation needs * Performance-critical hot paths (Zagora adds overhead) * When you're already using another validation solution * One-off scripts where robustness isn't critical ### Philosophy Plain TypeScript gives you types but trusts your code to be correct. Zagora adds a runtime safety net with validation, structured errors, and predictable results. Choose Zagora when reliability and type-safety at runtime matter. ## vs oRPC / tRPC \[Comparison with RPC frameworks] Zagora and RPC frameworks like oRPC and tRPC have different design goals. Here's an honest comparison. **Philosophy**: Zagora focuses on building low-level, composable functions for libraries, and optionally backends or APIs. oRPC and tRPC are designed primarily for API endpoints with network communication. Choose Zagora when you need type-safe functions without the network overhead. ### Feature Comparison | Feature | Zagora | oRPC | tRPC | | ------------------------ | ----------------------- | ------------------------------- | ------------------------------- | | Sync procedure support | Yes (dynamic inference) | No (always async) | No (always async) | | Tuple/multiple arguments | Yes (`z.tuple([...])`) | No (single object) | No (single object) | | Network layer required | No | Optional (server-side callable) | Optional (server-side callable) | | Router concept | Optional (DIY) | Required | Required | | Middleware system | No (external/DIY) | Yes | Yes | | Typed errors | Yes (schema-validated) | Yes (compile-time) | Partial | | Error payload validation | Yes (runtime) | No | No | | StandardSchema support | Yes | Yes | Partial | | OpenAPI generation | DIY (see guide) | Built-in | Plugin | | Bundle size | Minimal | Moderate | Moderate | | Primary use case | Libraries & functions | APIs & backends | APIs & backends | ### Key Differences #### 1. Sync vs Async **Zagora:** Infers sync/async from handler - useful for performance-critical paths ```js // Sync procedure - no await needed const add = zagora() .input(z.tuple([z.number(), z.number()])) .handler((_, a, b) => a + b) .callable(); const result = add(1, 2); // Immediate result, not a Promise ``` **oRPC/tRPC:** Always async, even for trivial operations ```js // Must always await, even for sync logic const result = await add.mutate({ a: 1, b: 2 }); ``` #### 2. Function Signature **Zagora:** Natural function signatures with tuple arguments - ideal for SDKs and libraries ```js // Call like a normal function getUser('alice', 25); sendEmail('to@example.com', 'Subject', 'Body'); // Great for third-party consumers and SDK building ``` **oRPC/tRPC:** Single object input - works well for APIs, less natural for libraries ```js // Always object input getUser({ name: 'alice', age: 25 }); sendEmail({ to: 'to@example.com', subject: 'Subject', body: 'Body' }); ``` #### 3. Router Requirements **Zagora:** Routers are optional - export procedures directly or [build your own patterns](/advanced/building-routers) ```js // Export directly - it's just a function export const createUser = zagora() .input(schema) .handler(handler) .callable(); // Consumer imports and calls import { createUser } from 'your-library'; createUser({ name: 'Alice' }); ``` **oRPC/tRPC:** Routers are required, even for server-side usage ```js // Router structure required const appRouter = router({ createUser: publicProcedure .input(schema) .mutation(handler) }); // Even server-side calls go through the router ``` #### 4. Error Handling **Zagora:** Schema-validated typed errors with runtime validation ```js const proc = zagora() .errors({ NOT_FOUND: z.object({ id: z.string() }), // Schema validated at runtime! RATE_LIMIT: z.object({ retryAfter: z.number() }), }) .handler(({ errors }) => { throw errors.NOT_FOUND({ id: '123' }); }); // Invalid error payload = VALIDATION_ERROR throw errors.NOT_FOUND({ wrongField: 'x' }); // Caught at runtime! ``` **oRPC:** Typed errors (compile-time), but HTTP-oriented with some quirks ```js // oRPC has typed errors, but they're HTTP-focused throw new ORPCError('NOT_FOUND', { message: 'User not found' }); // Error shape is not schema-validated at runtime ``` **tRPC:** Error classes without schema validation ```js throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found', // No schema validation of error shape }); ``` #### 5. Server-Side Usage Both oRPC and tRPC support "server-side clients" for calling procedures without network overhead. However, they still require the router infrastructure. **Zagora:** No special setup needed - procedures are just functions ```js // Direct call in server handlers app.post('/users', async (c) => { const result = await createUser(await c.req.json()); return result.ok ? c.json(result.data) : c.json(result.error, 400); }); ``` **oRPC/tRPC:** Server-side clients require router context ```js // Still need router + context setup const caller = appRouter.createCaller(context); const result = await caller.createUser(input); ``` #### 6. OpenAPI / API Generation **oRPC:** Built-in OpenAPI spec generation - excellent for public APIs **tRPC:** OpenAPI available via plugin (trpc-openapi) **Zagora:** DIY approach using StandardSchema - see [Generating OpenAPI](/advanced/openapi) for patterns using `toJSONSchema` helpers ### When to Use Each #### Use Zagora When * Building libraries or SDKs with type-safe APIs * You want natural function signatures (`fn(a, b)` not `fn({ a, b })`) * You need sync procedures for performance-critical paths * You want error payloads validated at runtime, not just compile-time * You prefer minimal dependencies and DIY patterns * You're building internal tooling or utility functions See [Building Routers](/advanced/building-routers) for HTTP integration, middleware patterns, and authenticated procedures. #### Use oRPC When * Building client-server APIs with HTTP/WebSocket * You need OpenAPI spec generation out of the box * You want built-in router and middleware system * You're building a public API that needs documentation * Everything is already async anyway #### Use tRPC When * Building full-stack TypeScript apps (Next.js, etc.) * You want tight framework integration * You need batching and deduplication * Your team is already familiar with tRPC patterns ### Migration Path #### From tRPC to Zagora (for library code) **Before (tRPC-style):** ```js export const userRouter = router({ getUser: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { return db.users.find(input.id); }), }); ``` **After (Zagora):** ```js export const getUser = zagora() .input(z.string()) // Simpler - just the ID .handler(async (_, id) => { return db.users.find(id); }) .callable(); // Usage - natural function call const result = await getUser('123'); ``` ### Philosophy **Zagora's philosophy:** Build low-level, composable functions for libraries. No assumptions about network, routing, or application architecture. Just type-safe functions with predictable results. When you need routers or HTTP integration, [build exactly what you need](/advanced/building-routers). **oRPC's philosophy:** Provide a complete, type-safe API solution with OpenAPI generation, middleware, and server-side calling. Best for teams building documented APIs. **tRPC's philosophy:** End-to-end type safety for full-stack TypeScript apps. Best for teams using Next.js or similar frameworks where client and server share types. Choose the tool that matches your use case. Zagora excels at library building and internal tooling; oRPC/tRPC excel at API development. ## vs Standalone Validators \[Why use Zagora over Zod/Valibot alone] **Philosophy**: Standalone validators are great for data validation but require boilerplate for function composition. Zagora provides an ergonomic layer that unifies input/output/error validation with handler definition in a cohesive, type-safe API. ### Feature Comparison | Aspect | Zagora | Zod/Valibot alone | | ---------------------- | ----------------------------------- | ----------------------------------------- | | Fluent builder pattern | Yes (`.input().output().handler()`) | Manual composition | | Unified result shape | Yes (`{ ok, data, error }`) | `.parse()` throws, `.safeParse()` returns | | Typed error helpers | Yes (from schema definitions) | No | | Handler definition | Integrated | Separate from validation | | Multiple arguments | Yes (tuple schemas spread) | Manual handling | | Context injection | Built-in | Not applicable | | Caching integration | Built-in | Manual | | Env vars validation | Built-in | Manual setup | ### Key Differences #### 1. Building Functions **Zod alone:** Validation separate from logic ```js import { z } from 'zod'; const inputSchema = z.object({ name: z.string(), age: z.number() }); const outputSchema = z.object({ greeting: string }); function createGreeting(input: z.infer) { const validated = inputSchema.parse(input); // Throws on error! return { greeting: `Hello ${validated.name}, age ${validated.age}` }; } // Or with safeParse function createGreetingSafe(input: unknown) { const result = inputSchema.safeParse(input); if (!result.success) { return { success: false, error: result.error }; } return { success: true, data: { greeting: `Hello ${result.data.name}` } }; } ``` **Zagora:** All-in-one builder ```js const createGreeting = zagora() .input(z.object({ name: z.string(), age: z.number() })) .output(z.object({ greeting: z.string() })) .handler((_, { name, age }) => ({ greeting: `Hello ${name}, age ${age}` })) .callable(); // Unified API, always safe const result = createGreeting({ name: 'Alice', age: 25 }); ``` #### 2. Error Handling **Zod alone:** Different patterns for safe vs unsafe ```js // Option 1: throws try { const data = schema.parse(input); } catch (e) { if (e instanceof z.ZodError) { // handle validation error } } // Option 2: safeParse const result = schema.safeParse(input); if (result.success) { // use result.data } else { // use result.error } ``` **Zagora:** Consistent result shape ```js const result = proc(input); if (result.ok) { result.data; // Your data } else { result.error; // Always { kind, message, ... } } ``` #### 3. Typed Errors **Zod alone:** No typed error helpers ```js // Must define and throw errors manually class NotFoundError extends Error { constructor(public id: string) { super('Not found'); } } function getUser(id: string) { const user = db.find(id); if (!user) throw new NotFoundError(id); return user; } ``` **Zagora:** Schema-validated error helpers ```js const getUser = zagora() .input(z.string()) .errors({ NOT_FOUND: z.object({ id: z.string(), message: z.string() }) }) .handler(({ errors }, id) => { const user = db.find(id); if (!user) { throw errors.NOT_FOUND({ id, message: 'User not found' }); } return user; }) .callable(); // Error payload is validated at runtime! ``` #### 4. Multiple Arguments **Zod alone:** Manual handling ```js const argsSchema = z.tuple([z.string(), z.number(), z.boolean()]); function myFunc(...args: z.infer) { const [name, age, active] = argsSchema.parse(args); // ... } ``` **Zagora:** Automatic spreading ```js const myFunc = zagora() .input(z.tuple([z.string(), z.number(), z.boolean()])) .handler((_, name, age, active) => { // Arguments are automatically spread and typed }) .callable(); myFunc('Alice', 25, true); // Natural call signature ``` #### 5. Context and Dependencies **Zod alone:** Manual prop drilling ```js function createUser( input: CreateUserInput, db: Database, logger: Logger ) { logger.log('Creating user'); return db.create(input); } ``` **Zagora:** Built-in context ```js const createUser = zagora() .context({ db: myDb, logger: myLogger }) .input(createUserSchema) .handler(({ context }, input) => { context.logger.log('Creating user'); return context.db.create(input); }) .callable(); ``` ### Code Comparison #### Complete Example **Zod alone:** ```js import { z } from 'zod'; const userInput = z.object({ name: z.string(), email: z.string().email() }); const userOutput = z.object({ id: z.string(), name: z.string(), email: z.string() }); type CreateUserResult = | { success: true; data: z.infer } | { success: false; error: z.ZodError | Error }; function createUser(input: unknown): CreateUserResult { const parsed = userInput.safeParse(input); if (!parsed.success) { return { success: false, error: parsed.error }; } try { const user = { id: crypto.randomUUID(), ...parsed.data }; const validated = userOutput.parse(user); return { success: true, data: validated }; } catch (e) { return { success: false, error: e as Error }; } } ``` **Zagora:** ```js import { z } from 'zod'; import { zagora } from 'zagora'; const createUser = zagora() .input(z.object({ name: z.string(), email: z.string().email() })) .output(z.object({ id: z.string(), name: z.string(), email: z.string() })) .handler((_, input) => ({ id: crypto.randomUUID(), ...input })) .callable(); ``` ### When to Use Each #### Use Zod/Valibot Alone When * You only need data validation (not function building) * You're validating API responses or form data * You have an existing error handling strategy * You want minimal dependencies #### Use Zagora When * You're building functions or procedures * You want unified input/output/error handling * You need typed error helpers * You want natural function signatures with multiple arguments * You need context injection or caching ### Philosophy Zod/Valibot are validation libraries—they validate data. Zagora is a procedure builder that uses validation libraries under the hood to create type-safe, error-safe functions. Zagora doesn't replace Zod—it's built on top of StandardSchema validators to provide an ergonomic function-building layer. ## Error Types \[Error type reference] Zagora provides a structured error system with built-in and user-defined error types. ### Import ```js import { isValidationError, isInternalError, isDefinedError, isZagoraError } from 'zagora/errors'; ``` ### Built-in Error Types #### VALIDATION\_ERROR Returned when input, output, or error payload validation fails. ```js { kind: 'VALIDATION_ERROR', message: string, issues: Array<{ path: (string | number)[], message: string }>, key?: string // Present if error helper validation failed } ``` **Causes:** * Invalid input to procedure * Invalid output from handler * Invalid payload to error helper **Example:** ```js const result = proc({ email: 'invalid' }); if (!result.ok && result.error.kind === 'VALIDATION_ERROR') { console.log(result.error.issues); // [{ path: ['email'], message: 'Invalid email' }] } ``` #### UNKNOWN\_ERROR Returned when an unhandled exception occurs in the handler. ```js { kind: 'UNKNOWN_ERROR', message: string, cause: unknown // The original error } ``` **Causes:** * Throwing a non-Zagora error in handler * Runtime errors (TypeError, ReferenceError, etc.) * Cache adapter failures **Example:** ```js const result = proc(); if (!result.ok && result.error.kind === 'UNKNOWN_ERROR') { console.log(result.error.message); console.log(result.error.cause); // Original Error object } ``` ### User-Defined Error Types Define custom errors with `.errors()`: ```js const proc = zagora() .errors({ NOT_FOUND: z.object({ id: z.string(), resource: z.string() }), RATE_LIMITED: z.object({ retryAfter: z.number() }), FORBIDDEN: z.object({ requiredRole: z.string() }) }) .handler(({ errors }) => { throw errors.NOT_FOUND({ id: '123', resource: 'User' }); }) .callable(); ``` The result error will have the shape: ```js { kind: 'NOT_FOUND', id: '123', resource: 'User' } ``` ### Type Guards #### isValidationError(error) Check if error is a validation error: ```js if (!result.ok && isValidationError(result.error)) { // result.error.kind === 'VALIDATION_ERROR' result.error.issues; // Available } ``` #### isInternalError(error) Check if error is an unknown/internal error: ```js if (!result.ok && isInternalError(result.error)) { // result.error.kind === 'UNKNOWN_ERROR' result.error.cause; // Available } ``` #### isDefinedError(error) Check if error is one of your defined error types: ```js if (!result.ok && isDefinedError(result.error)) { // result.error.kind is one of your defined kinds switch (result.error.kind) { case 'NOT_FOUND': result.error.id; // string result.error.resource; // string break; } } ``` #### isZagoraError(error) Check if error is any Zagora error type: ```js if (!result.ok && isZagoraError(result.error)) { result.error.kind; // Always available } ``` ### Error Hierarchy ``` ZagoraError ├── VALIDATION_ERROR (built-in) ├── UNKNOWN_ERROR (built-in) └── [User-Defined Errors] ├── NOT_FOUND ├── FORBIDDEN └── ... (your custom error kinds) ``` ### TypeScript Types ```js import type { ValidationError, UnknownError, DefinedError, ZagoraError } from 'zagora/types'; type ValidationError = { kind: 'VALIDATION_ERROR'; message: string; issues: Array<{ path: (string | number)[], message: string }>; key?: string; }; type UnknownError = { kind: 'UNKNOWN_ERROR'; message: string; cause: unknown; }; type DefinedError = { kind: keyof TErrors; // ... properties from error schema }; ``` ### See Also * [Typed Errors](/docs/typed-errors) - Defining custom errors * [Error Type Guards](/docs/error-guards) - Using type guards * [ZagoraResult Type](/api/result-type) - Full result type ## Instance Methods \[Builder API reference] The `ZagoraBuilder` provides a fluent API for defining procedures. ### .input(schema) Define the input validation schema. ```js .input(z.string()) .input(z.object({ name: z.string() })) .input(z.tuple([z.string(), z.number()])) .input(z.array(z.number())) ``` **Parameter:** Any StandardSchema-compliant schema\ **Returns:** `ZagoraBuilder` (chainable) #### Tuple Spreading Tuple inputs are spread as handler arguments: ```js .input(z.tuple([z.string(), z.number()])) .handler((_, name, age) => { /* name: string, age: number */ }) ``` ### .output(schema) Define the output validation schema. ```js .output(z.object({ id: z.string(), name: z.string() })) ``` **Parameter:** Any StandardSchema-compliant schema\ **Returns:** `ZagoraBuilder` (chainable) Output is validated after the handler returns. ### .errors(map) Define typed error schemas. ```js .errors({ NOT_FOUND: z.object({ id: z.string() }), FORBIDDEN: z.object({ reason: z.string() }) }) ``` **Parameter:** Object mapping UPPERCASE error kinds to schemas\ **Returns:** `ZagoraBuilder` (chainable) The handler receives typed error helpers: ```js .handler(({ errors }) => { throw errors.NOT_FOUND({ id: '123' }); }) ``` ### .context(initial) Set initial context values. ```js .context({ db: myDatabase, logger: console }) ``` **Parameter:** Object with initial context values\ **Returns:** `ZagoraBuilder` (chainable) Context is merged with runtime context from `.callable()`. ### .env(schema, runtime?) Define environment variable schema. ```js .env(z.object({ API_KEY: z.string(), TIMEOUT: z.coerce.number().default(5000) })) ``` **Parameters:** * `schema` - StandardSchema for env vars * `runtime` (optional) - Runtime env vars (for `autoCallable` mode) **Returns:** `ZagoraBuilder` (chainable) With autoCallable: ```js zagora({ autoCallable: true }) .env(schema, process.env as any) ``` ### .cache(adapter) Set cache adapter for memoization. ```js .cache(new Map()) .cache(redisAdapter) ``` **Parameter:** Object with `has`, `get`, `set` methods\ **Returns:** `ZagoraBuilder` (chainable) Cache interface: ```js interface CacheAdapter { has(key: K): boolean | Promise; get(key: K): V | undefined | Promise; set(key: K, value: V): void | Promise; } ``` ### .handler(fn) Define the handler function. ```js // With options .handler((options, input) => { const { context, errors, env } = options; return result; }) // With tuple input .handler((options, arg1, arg2) => arg1 + arg2) // With disableOptions .handler((input) => input.toUpperCase()) ``` **Parameter:** Handler function\ **Returns:** `ZagoraBuilder` (chainable) or callable function (if `autoCallable`) #### Handler Signature Standard mode: ```js (options: { context, errors?, env? }, ...inputs) => Result ``` With `disableOptions`: ```js (...inputs) => Result ``` ### .callable(options?) Create the callable function. ```js .callable() .callable({ context: { db: testDb } }) .callable({ cache: requestCache }) .callable({ env: process.env as any }) ``` **Parameter (optional):** Runtime options * `context` - Override/extend context * `cache` - Override cache adapter * `env` - Provide environment variables **Returns:** Callable procedure function #### Return Type The returned function has signature: ```js (...inputs) => ZagoraResult // or (...inputs) => Promise> ``` ### Method Chaining All methods return the builder, allowing fluent chaining: ```js const proc = zagora() .context({ db }) .env(envSchema) .input(inputSchema) .output(outputSchema) .errors(errorMap) .cache(cache) .handler(handlerFn) .callable(runtimeOptions); ``` ### Optional Methods Only `.handler()` is required. All other methods are optional: ```js // Minimal procedure const proc = zagora() .handler(() => 'Hello') .callable(); ``` ### See Also * [zagora(config)](/api/zagora) - Create builder instance * [Error Types](/api/error-types) - Error type reference * [ZagoraResult Type](/api/result-type) - Result type reference ## ZagoraResult Type \[Result type reference] Every Zagora procedure returns a `ZagoraResult`—a discriminated union representing success or failure. ### Type Definition ```js type ZagoraResult = | { ok: true; data: TData; error: undefined } | { ok: false; data: undefined; error: ZagoraError }; ``` ### Success Result When `ok` is `true`, the procedure succeeded: ```js { ok: true, data: TData, // Your return value error: undefined // Always undefined on success } ``` **Example:** ```js const result = greet('World'); if (result.ok) { console.log(result.data); // 'Hello, World!' console.log(result.error); // undefined } ``` ### Error Result When `ok` is `false`, the procedure failed: ```js { ok: false, data: undefined, // Always undefined on error error: ZagoraError // Typed error object } ``` **Example:** ```js const result = divide(10, 0); if (!result.ok) { console.log(result.data); // undefined console.log(result.error); // { kind: 'DIVISION_BY_ZERO', ... } } ``` ### Type Narrowing TypeScript narrows the type based on `ok`: ```js const result = proc(input); if (result.ok) { // TypeScript knows: // - result.data is TData // - result.error is undefined result.data.someProperty; // OK } else { // TypeScript knows: // - result.data is undefined // - result.error is ZagoraError result.error.kind; // OK } ``` ### Async Results Async procedures return `Promise`: ```js const asyncProc = zagora() .handler(async () => fetchData()) .callable(); const result = await asyncProc(); // result: ZagoraResult ``` ### Generic Parameters #### TData The success data type, inferred from: 1. Output schema (if defined) 2. Handler return type (otherwise) ```js // From output schema zagora() .output(z.object({ id: z.string() })) .handler(() => ({ id: '123' })) // TData = { id: string } // From handler return zagora() .handler(() => ({ id: '123', name: 'test' })) // TData = { id: string, name: string } ``` #### TErrors The union of possible error types: ```js zagora() .errors({ NOT_FOUND: z.object({ id: z.string() }), FORBIDDEN: z.object({ reason: z.string() }) }) // TErrors = // | { kind: 'NOT_FOUND', id: string } // | { kind: 'FORBIDDEN', reason: string } // | ValidationError // | UnknownError ``` ### Utility Types #### Extracting Data Type ```js import type { ZagoraResult } from 'zagora/types'; type Result = ZagoraResult<{ id: string }, never>; // Extract data type type Data = Extract['data']; // Data = { id: string } ``` #### Extracting Error Type ```js type ErrorType = Extract['error']; ``` ### Pattern: Result Handling #### Basic Pattern ```js const result = proc(input); if (result.ok) { return result.data; } else { throw new Error(result.error.message); } ``` #### Switch on Error Kind ```js if (!result.ok) { switch (result.error.kind) { case 'NOT_FOUND': return { status: 404, body: result.error }; case 'FORBIDDEN': return { status: 403, body: result.error }; case 'VALIDATION_ERROR': return { status: 400, body: result.error.issues }; case 'UNKNOWN_ERROR': console.error(result.error.cause); return { status: 500, body: 'Internal error' }; } } ``` #### Generic Result Handler ```js function handleResult(result: ZagoraResult): T { if (result.ok) { return result.data; } // Log and re-throw as appropriate for your app console.error('Procedure failed:', result.error); throw new Error(`${result.error.kind}: ${result.error.message}`); } const data = handleResult(proc(input)); ``` ### See Also * [Error Types](/api/error-types) - All error types * [Never-Throwing Guarantees](/docs/never-throwing) - Why results never throw ## zagora(config) \[Create a Zagora instance] The `zagora` function creates a builder instance for defining type-safe procedures. ### Import ```js import { zagora } from 'zagora'; ``` ### Signature ```js function zagora(config?: ZagoraConfig): ZagoraBuilder ``` ### Config Options #### autoCallable Skip the `.callable()` step and return a callable function directly from `.handler()`. ```js const proc = zagora({ autoCallable: true }) .input(z.string()) .handler((_, name) => `Hello, ${name}!`); // No .callable() needed proc('World'); ``` **Type:** `boolean`\ **Default:** `false` #### disableOptions Omit the options object from the handler signature. ```js const proc = zagora({ disableOptions: true }) .input(z.tuple([z.number(), z.number()])) .handler((a, b) => a + b) // No options object .callable(); ``` **Type:** `boolean`\ **Default:** `false` :::warning When `disableOptions` is `true`, the handler cannot access `context`, `errors`, or `env`. ::: ### Combining Options ```js const za = zagora({ autoCallable: true, disableOptions: true }); const add = za .input(z.tuple([z.number(), z.number()])) .handler((a, b) => a + b); add(1, 2); // { ok: true, data: 3 } ``` ### Return Value Returns a `ZagoraBuilder` instance with the following methods: | Method | Description | | ------------------------ | ------------------------------- | | `.input(schema)` | Define input validation schema | | `.output(schema)` | Define output validation schema | | `.errors(map)` | Define typed error schemas | | `.context(initial)` | Set initial context | | `.env(schema, runtime?)` | Define env var schema | | `.cache(adapter)` | Set cache adapter | | `.handler(fn)` | Define the handler function | | `.callable(options?)` | Create the callable function | ### Examples #### Basic Usage ```js const greet = zagora() .input(z.string()) .handler((_, name) => `Hello, ${name}!`) .callable(); ``` #### With All Options ```js const createUser = zagora() .context({ db: myDatabase }) .env(z.object({ API_KEY: z.string() })) .input(z.object({ name: z.string(), email: z.string() })) .output(z.object({ id: z.string(), name: z.string() })) .errors({ ALREADY_EXISTS: z.object({ email: z.string() }) }) .cache(new Map()) .handler(async ({ context, errors, env }, input) => { const existing = await context.db.findByEmail(input.email); if (existing) { throw errors.ALREADY_EXISTS({ email: input.email }); } return context.db.create(input); }) .callable({ env: process.env as any }); ``` #### Reusable Instance ```js const za = zagora({ autoCallable: true, disableOptions: true }); export const validators = { email: za.input(z.string().email()).handler((email) => email.toLowerCase()), phone: za.input(z.string()).handler((phone) => phone.replace(/\D/g, '')), url: za.input(z.string().url()).handler((url) => new URL(url).href) }; ``` ### See Also * [Instance Methods](/api/methods) - All builder methods * [Procedures](/docs/procedures) - Building procedures ## Best Practices \[Guidelines for using Zagora effectively] These best practices help you get the most out of Zagora while avoiding common pitfalls. ### Procedure Design #### Define Output Schemas for Public APIs Always define output schemas for procedures that are part of your public API: ```js // Good - output is guaranteed const getUser = zagora() .input(z.string()) .output(z.object({ id: z.string(), name: z.string(), email: z.string() })) .handler((_, id) => db.findUser(id)) .callable(); // Avoid - consumers don't know the shape const getUser = zagora() .input(z.string()) .handler((_, id) => db.findUser(id)) .callable(); ``` #### Use Tuple Inputs for Natural APIs Prefer tuple inputs for better developer experience: ```js // Good - natural function signature const sendEmail = zagora() .input(z.tuple([z.string(), z.string(), z.string()])) .handler((_, to, subject, body) => /* ... */) .callable(); sendEmail('to@example.com', 'Subject', 'Body'); // Less ergonomic const sendEmail = zagora() .input(z.object({ to: z.string(), subject: z.string(), body: z.string() })) .handler((_, input) => /* ... */) .callable(); sendEmail({ to: 'to@example.com', subject: 'Subject', body: 'Body' }); ``` #### Define All Error Cases Upfront Enumerate all possible error cases: ```js const createUser = zagora() .input(userSchema) .errors({ EMAIL_EXISTS: z.object({ email: z.string() }), INVALID_DOMAIN: z.object({ domain: z.string() }), RATE_LIMITED: z.object({ retryAfter: z.number() }) }) .handler(({ errors }, input) => { // Handle each case explicitly }) .callable(); ``` ### Error Handling #### Always Handle Both Paths Never ignore the error case: ```js // Good const result = getUser(id); if (result.ok) { return result.data; } else { logger.error('Failed to get user', result.error); throw new HttpError(500); } // Bad - ignoring error case const result = getUser(id); return result.data; // undefined if error! ``` #### Use Error Type Guards Narrow error types with guards: ```js import { isValidationError, isDefinedError } from 'zagora/errors'; if (!result.ok) { if (isValidationError(result.error)) { return { status: 400, body: { issues: result.error.issues } }; } if (isDefinedError(result.error)) { return { status: 422, body: { code: result.error.kind } }; } // Unknown error logger.error(result.error.cause); return { status: 500 }; } ``` #### Create Error Handler Utilities Centralize error handling: ```js function handleProcedureError(error: ZagoraError) { if (isValidationError(error)) { return { status: 400, code: 'VALIDATION_ERROR', issues: error.issues }; } switch (error.kind) { case 'NOT_FOUND': return { status: 404, code: error.kind, ...error }; case 'FORBIDDEN': return { status: 403, code: error.kind, ...error }; default: logger.error('Unhandled error', error); return { status: 500, code: 'INTERNAL_ERROR' }; } } ``` ### Context Management #### Use Context for Dependencies Inject dependencies through context, not closures: ```js // Good - testable via context override const getUser = zagora() .context({ db: defaultDb }) .input(z.string()) .handler(({ context }, id) => context.db.find(id)) .callable(); // Test with mock const testGetUser = getUser.callable({ context: { db: mockDb } }); // Avoid - hard to test const db = productionDb; const getUser = zagora() .input(z.string()) .handler((_, id) => db.find(id)) // Closure over db .callable(); ``` #### Keep Context Focused Don't put everything in context: ```js // Good - focused context .context({ db, logger }) // Avoid - kitchen sink .context({ db, logger, config, utils, helpers, constants, ... }) ``` ### Performance #### Use Caching Strategically Cache expensive operations: ```js const expensiveQuery = zagora() .cache(new Map()) // Or Redis, etc. .input(z.string()) .handler((_, query) => { // Expensive database aggregation return db.aggregate(query); }) .callable(); ``` #### Prefer Sync When Possible Don't make things async unnecessarily: ```js // Good - sync when possible const validate = zagora() .input(z.string().email()) .handler((_, email) => ({ valid: true, normalized: email.toLowerCase() })) .callable(); const result = validate(email); // Immediate result // Avoid - unnecessary async const validate = zagora() .input(z.string().email()) .handler(async (_, email) => ({ valid: true, normalized: email.toLowerCase() })) .callable(); const result = await validate(email); // Forced to await ``` ### Code Organization #### Group Related Procedures Organize by domain: ```js // users/procedures.ts export const createUser = zagora()... export const getUser = zagora()... export const updateUser = zagora()... // posts/procedures.ts export const createPost = zagora()... export const getPost = zagora()... ``` #### Create Reusable Instances Share configuration: ```js // lib/zagora.ts export const za = zagora({ autoCallable: true, disableOptions: true }); // Usage import { za } from './lib/zagora'; export const validate = za.input(z.string()).handler((s) => s.trim()); ``` ### Testing #### Test All Code Paths Cover success, validation errors, and custom errors: ```js describe('createUser', () => { it('creates user successfully', () => { /* ... */ }); it('returns VALIDATION_ERROR for invalid email', () => { /* ... */ }); it('returns EMAIL_EXISTS for duplicate', () => { /* ... */ }); it('returns RATE_LIMITED when throttled', () => { /* ... */ }); }); ``` #### Mock via Context Use context for dependency injection in tests: ```js const testProc = createUser.callable({ context: { db: mockDb, email: mockEmailService } }); ``` ### Common Pitfalls #### Don't Forget `.callable()` ```js // Wrong - returns builder, not function const proc = zagora().input(z.string()).handler((_, s) => s); proc('test'); // Error: proc is not a function // Right const proc = zagora().input(z.string()).handler((_, s) => s).callable(); proc('test'); // Works ``` #### Await Async Results ```js // Wrong - result is Promise const result = asyncProc(input); console.log(result.data); // undefined // Right const result = await asyncProc(input); console.log(result.data); // actual data ``` #### Handle Async Schema Quirks ```js // With async schemas, always await const proc = zagora() .input(z.string().refine(async (s) => checkAsync(s))) .handler((_, s) => s) .callable(); // Always await, even if TypeScript doesn't require it const result = await proc('test'); ``` ## Building Routers \[DIY routing patterns with Zagora] Zagora doesn't include routers by design—it focuses on individual procedures. But you can easily build your own routing layer when needed. ### Why No Built-in Router? Zagora's philosophy is "just functions." Routers are application-level concerns that vary by use case: * HTTP APIs need path-based routing * CLI tools need command-based routing * Libraries don't need routing at all By not bundling a router, Zagora stays minimal and lets you use patterns that fit your architecture. ### Pattern: Simple Object Router Group procedures in an object: ```js import { z } from 'zod'; import { zagora } from 'zagora'; // Define procedures const getUser = zagora() .input(z.string()) .handler(async (_, id) => db.users.find(id)) .callable(); const createUser = zagora() .input(z.object({ name: z.string(), email: z.string() })) .handler(async (_, input) => db.users.create(input)) .callable(); const deleteUser = zagora() .input(z.string()) .handler(async (_, id) => db.users.delete(id)) .callable(); // Group as router export const userRouter = { get: getUser, create: createUser, delete: deleteUser }; // Usage const user = await userRouter.get('123'); const newUser = await userRouter.create({ name: 'Alice', email: 'alice@test.com' }); ``` ### Pattern: Nested Routers Compose routers hierarchically: ```js export const userRouter = { get: getUser, create: createUser, profile: { get: getUserProfile, update: updateUserProfile } }; export const postRouter = { list: listPosts, get: getPost, create: createPost }; export const appRouter = { users: userRouter, posts: postRouter }; // Usage await appRouter.users.get('123'); await appRouter.users.profile.update({ bio: 'Hello' }); await appRouter.posts.list(); ``` ### Pattern: Shared Context Factory Create procedures with shared context: ```js function createRouter(context) { const za = zagora().context(context); return { getUser: za .input(z.string()) .handler(({ context }, id) => context.db.users.find(id)) .callable(), createUser: za .input(z.object({ name: z.string() })) .handler(({ context }, input) => context.db.users.create(input)) .callable() }; } // Production const prodRouter = createRouter({ db: productionDb }); // Testing const testRouter = createRouter({ db: mockDb }); ``` ### Pattern: HTTP Router Integration Integrate with Express, Hono, or any HTTP framework: ```js import { Hono } from 'hono'; const app = new Hono(); // Wrap Zagora procedure for HTTP function wrapProcedure(proc) { return async (c) => { const input = await c.req.json(); const result = await proc(input); if (result.ok) { return c.json(result.data); } switch (result.error.kind) { case 'VALIDATION_ERROR': return c.json({ error: result.error.issues }, 400); case 'NOT_FOUND': return c.json({ error: 'Not found' }, 404); default: return c.json({ error: 'Internal error' }, 500); } }; } app.post('/users', wrapProcedure(createUser)); app.get('/users/:id', async (c) => { const result = await getUser(c.req.param('id')); // ... handle result }); ``` ### Pattern: Type-Safe Router Helper Create a typed router helper: ```js type Router = { [K in keyof T]: T[K] extends (...args: any[]) => any ? T[K] : Router; }; function createTypedRouter>(routes: T): Router { return routes; } const router = createTypedRouter({ users: { get: getUser, create: createUser }, posts: { list: listPosts } }); // Fully typed router.users.get('123'); ``` ### Pattern: Middleware-like Composition Compose procedures with shared logic like authentication: ```js import { z } from 'zod'; import { zagora } from 'zagora'; // Define a base with shared error types const authErrors = { UNAUTHORIZED: z.object({ message: z.string() }), FORBIDDEN: z.object({ message: z.string(), requiredRole: z.string() }), }; // Auth wrapper - checks for authenticated user in context function withAuth(procedureBuilder) { return procedureBuilder .errors(authErrors) .handler(async ({ context, errors }, ...args) => { if (!context.user) { throw errors.UNAUTHORIZED({ message: 'Authentication required' }); } // Original handler logic here }); } // Role-based auth wrapper function withRole(role) { return (procedureBuilder) => { return procedureBuilder .errors(authErrors) .handler(async ({ context, errors }, ...args) => { if (!context.user) { throw errors.UNAUTHORIZED({ message: 'Authentication required' }); } if (context.user.role !== role) { throw errors.FORBIDDEN({ message: `Role '${role}' required`, requiredRole: role }); } // Original handler logic here }); }; } // Auth wrapper function authedProcedure(proc) { return zagora() .input(proc.inputSchema) .handler(async ({ context }, ...args) => { if (!context.user) { throw new Error('Unauthorized'); } return proc(...args); }) .callable({ context: { user: getCurrentUser(), db } }); } // Apply to procedures const protectedGetUser = authedProcedure(getUser); ``` #### Using in HTTP Handlers ```js import { Hono } from 'hono'; const app = new Hono(); // Inject user from request into context app.post('/api/users', async (c) => { const user = await getUserFromToken(c.req.header('Authorization')); const result = await createUser.callable({ context: { user, db } })(await c.req.json()); if (result.ok) { return c.json(result.data, 201); } // Map error kinds to HTTP status codes const statusMap = { VALIDATION_ERROR: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, }; return c.json(result.error, statusMap[result.error.kind] || 500); }); ``` #### Factory Pattern for Authenticated Procedures ```js // Create a factory for authenticated procedures function createAuthenticatedProcedure(db) { return zagora() .errors({ UNAUTHORIZED: z.object({ message: z.string() }), }) .context({ db }); } const db = prodDatabase; // Use it consistently const getProfile = createAuthenticatedProcedure(db) .input(z.object({})) .handler(async ({ context, errors }) => { if (!context.user) { throw errors.UNAUTHORIZED({ message: 'Login required' }); } return context.db.users.find(context.user.id); }) .callable(); const updateProfile = createAuthenticatedProcedure(db) .input(z.object({ name: z.string(), bio: z.string().optional() })) .handler(async ({ context, errors }, input) => { if (!context.user) { throw errors.UNAUTHORIZED({ message: 'Login required' }); } return context.db.users.update(context.user.id, input); }) .callable(); ``` ### When to Build Routers **Do build routers when:** * You have many related procedures * You need shared context across procedures * You're integrating with HTTP frameworks * You want hierarchical organization **Don't build routers when:** * You're building a library (just export procedures) * You have few procedures * Each procedure is independent ### Comparison with oRPC/tRPC Unlike oRPC/tRPC where routers are required: ```js // tRPC - router required const appRouter = router({ users: router({ get: publicProcedure.input(z.string()).query(...) }) }); ``` Zagora routers are optional and simple: ```js // Zagora - just objects const userRouter = { get: getUser, create: createUser }; // Or no router at all export { getUser, createUser }; ``` ### Next Steps * **[Generating OpenAPI](/advanced/openapi)** - Create API documentation from your procedures * **[Testing Procedures](/advanced/testing)** - How to test Zagora procedures effectively * **[Best Practices](/advanced/best-practices)** - Guidelines for production usage ## Generating OpenAPI \[API documentation from Zagora procedures] Zagora doesn't include built-in OpenAPI generation, but you can easily create OpenAPI specs using native JSON Schema conversion. Modern validators like Zod v4, Valibot, and ArkType all provide built-in conversion - no extra libraries needed. ### Why DIY OpenAPI? Zagora focuses on being minimal and unopinionated. Rather than bundling OpenAPI generation (which adds dependencies and constraints), we show you how to build exactly what you need using standard tools. **Benefits:** * Use any OpenAPI version (3.0, 3.1) * Customize output format * Integrate with your existing tooling * No extra dependencies in Zagora core ### StandardJSONSchema The [StandardSchema](https://standardschema.dev) project includes a **StandardJSONSchema** specification that defines a standard interface for JSON Schema conversion. Libraries implementing this spec expose `~standard.jsonSchema.input()` and `~standard.jsonSchema.output()` methods. This means you can write generic conversion code that works with any compliant validator: ```js // Generic JSON Schema extraction (works with any StandardJSONSchema-compliant library) function toJsonSchema(schema, options = { target: 'draft-2020-12' }) { if (schema['~standard']?.jsonSchema) { return schema['~standard'].jsonSchema.output(options); } throw new Error('Schema does not support JSON Schema conversion'); } ``` ### Converting Schemas to JSON Schema #### With Zod v4 Zod v4 includes native JSON Schema conversion via `z.toJSONSchema()`: ```js import { z } from 'zod'; const userSchema = z.object({ name: z.string().min(1), email: z.email(), age: z.int().min(0).optional(), }); // Native Zod v4 conversion const jsonSchema = z.toJSONSchema(userSchema); // { // type: 'object', // properties: { // name: { type: 'string', minLength: 1 }, // email: { type: 'string', format: 'email' }, // age: { type: 'integer', minimum: 0 } // }, // required: ['name', 'email'], // additionalProperties: false // } // Target specific JSON Schema version z.toJSONSchema(userSchema, { target: 'openapi-3.0' }); z.toJSONSchema(userSchema, { target: 'draft-07' }); z.toJSONSchema(userSchema, { target: 'draft-2020-12' }); ``` See the [Zod JSON Schema docs](https://zod.dev/json-schema) for full options including handling unrepresentable types, cycles, and metadata. #### With Valibot Valibot provides `@valibot/to-json-schema`: ```js import * as v from 'valibot'; import { toJsonSchema } from '@valibot/to-json-schema'; const userSchema = v.object({ name: v.pipe(v.string(), v.minLength(1)), email: v.pipe(v.string(), v.email()), age: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))), }); const jsonSchema = toJsonSchema(userSchema); ``` #### With ArkType ArkType has built-in `toJsonSchema()`: ```js import { type } from 'arktype'; const userSchema = type({ name: 'string>0', email: 'email', 'age?': 'integer>=0', }); const jsonSchema = userSchema.toJsonSchema(); ``` ### Pattern: Example OpenAPI Generator (for Zod v4+) Create a utility that extracts schemas from Zagora procedures. Zagora stores all metadata in `procedure["~zagora"]`: ```js import { z } from 'zod'; // Helper to extract OpenAPI operation from procedure metadata function procedureToOperation(name, procedure, options = {}) { const { method = 'post', path, description, tags = [] } = options; // Access Zagora's internal metadata const meta = procedure["~zagora"]; const inputSchema = meta.inputSchema; const outputSchema = meta.outputSchema; const errorsMap = meta.errorsMap; const operation = { operationId: name, description, tags, }; // Add request body if procedure has input schema if (inputSchema) { operation.requestBody = { required: true, content: { 'application/json': { schema: z.toJSONSchema(inputSchema, { target: 'openapi-3.0' }), }, }, }; } // Add response schemas operation.responses = { '200': { description: 'Successful response', content: { 'application/json': { schema: outputSchema ? z.toJSONSchema(outputSchema, { target: 'openapi-3.0' }) : { type: 'object' }, }, }, }, '400': { description: 'Validation error', content: { 'application/json': { schema: { type: 'object', properties: { kind: { type: 'string', enum: ['VALIDATION_ERROR'] }, message: { type: 'string' }, issues: { type: 'array' }, }, }, }, }, }, }; // Add custom error responses if (errorsMap) { for (const [errorKind, errorSchema] of Object.entries(errorsMap)) { const statusCode = getStatusCodeForError(errorKind); const errorJsonSchema = z.toJSONSchema(errorSchema, { target: 'openapi-3.0' }); operation.responses[statusCode] = { description: errorKind, content: { 'application/json': { schema: { type: 'object', properties: { kind: { type: 'string', enum: [errorKind] }, ...errorJsonSchema.properties, }, }, }, }, }; } } return { [path]: { [method]: operation } }; } function getStatusCodeForError(kind) { const mapping = { NOT_FOUND: '404', UNAUTHORIZED: '401', FORBIDDEN: '403', RATE_LIMIT: '429', CONFLICT: '409', }; return mapping[kind] || '400'; } ``` ### Pattern: Router to OpenAPI Generate a complete OpenAPI spec from a router object: ```js function routerToOpenAPI(router, options = {}) { const { title = 'API', version = '1.0.0', description = '', servers = [{ url: 'http://localhost:3000' }], } = options; const spec = { openapi: '3.1.0', info: { title, version, description }, servers, paths: {}, components: { schemas: {} }, }; // Walk the router and extract procedures function walkRouter(obj, prefix = '') { for (const [key, value] of Object.entries(obj)) { const path = `${prefix}/${key}`; // Check if it's a Zagora procedure by looking for the ~zagora metadata if (typeof value === 'function' && value["~zagora"].inputSchema) { const operation = procedureToOperation(key, value, { path, method: 'post', tags: [prefix.split('/')[1] || 'default'], }); Object.assign(spec.paths, operation); } else if (typeof value === 'object') { // Nested router walkRouter(value, path); } } } walkRouter(router); return spec; } // Usage const openAPISpec = routerToOpenAPI(appRouter, { title: 'My API', version: '1.0.0', description: 'API built with Zagora', }); // Write to file import { writeFileSync } from 'fs'; writeFileSync('openapi.json', JSON.stringify(openAPISpec, null, 2)); ``` ### Accessing Procedure Metadata Zagora stores all procedure metadata in the `["~zagora"]` property. Here's what's available: ```js // After building a procedure (before .callable()) const builder = zagora() .input(z.object({ id: z.string() })) .output(z.object({ name: z.string() })) .errors({ NOT_FOUND: z.object({ id: z.string() }) }) .handler(async (_, input) => { /* ... */ }); // Access metadata from the builder const meta = builder["~zagora"]; meta.inputSchema; // The input schema meta.outputSchema; // The output schema meta.errorsMap; // Map of error schemas { NOT_FOUND: schema, ... } meta.envVarsMapSchema; // Environment variables schema meta.cacheAdapter; // Cache adapter if set meta.initialContext; // Initial context if set meta.handler; // The handler function ``` This metadata is preserved on the callable procedure too, making it easy to introspect for documentation generation. For example: ```js const meta = builder["~zagora"]; const someProc = builder.callable({ context: { foo: 123 }}); const someProcMeta = someProc['~zagora']; someProcMeta.inputSchema; // same as meta.inputSchema someProcMeta.outputSchema; // same as meta.outputSchema // and so on ``` ### Pattern: HTTP Framework Integration Combine with your HTTP framework for automatic route generation: ```js import { Hono } from 'hono'; import { swaggerUI } from '@hono/swagger-ui'; const app = new Hono(); // Generate spec from router const spec = routerToOpenAPI(appRouter, { title: 'My API' }); // Serve OpenAPI spec app.get('/openapi.json', (c) => c.json(spec)); // Serve Swagger UI app.get('/docs', swaggerUI({ url: '/openapi.json' })); // Auto-generate routes from router function mountRouter(app, router, prefix = '') { for (const [key, value] of Object.entries(router)) { const path = `${prefix}/${key}`; if (typeof value === 'function') { app.post(path, async (c) => { const input = await c.req.json(); const result = await value(input); if (result.ok) { return c.json(result.data); } const status = getStatusCodeForError(result.error.kind); return c.json(result.error, parseInt(status)); }); } else if (typeof value === 'object') { mountRouter(app, value, path); } } } mountRouter(app, appRouter); ``` ### Complete Example ```js import { z } from 'zod'; import { zagora } from 'zagora'; import { Hono } from 'hono'; // Define procedures const createUser = zagora() .input(z.object({ name: z.string().min(1), email: z.email(), })) .output(z.object({ id: z.string(), name: z.string(), email: z.string(), })) .errors({ CONFLICT: z.object({ message: z.string() }), }) .handler(async ({ errors }, input) => { if (await db.users.exists({ email: input.email })) { throw errors.CONFLICT({ message: 'Email already registered' }); } return db.users.create(input); }) .callable(); const getUser = zagora() .input(z.object({ id: z.string() })) .output(z.object({ id: z.string(), name: z.string(), email: z.string(), })) .errors({ NOT_FOUND: z.object({ id: z.string() }), }) .handler(async ({ errors }, { id }) => { const user = await db.users.find(id); if (!user) throw errors.NOT_FOUND({ id }); return user; }) .callable(); // Group into router const userRouter = { create: createUser, get: getUser, }; const appRouter = { users: userRouter, }; // Generate OpenAPI spec using Zod v4's native conversion const spec = routerToOpenAPI(appRouter, { title: 'User API', version: '1.0.0', }); // Serve with Hono const app = new Hono(); app.get('/openapi.json', (c) => c.json(spec)); mountRouter(app, appRouter); export default app; ``` ### When to Use This Pattern **Good for:** * Internal APIs that need documentation * Generating client SDKs from specs * Teams familiar with OpenAPI tooling * Gradual migration from other frameworks **Consider oRPC instead when:** * You need production-ready OpenAPI generation out of the box * You want contract-first development * You need advanced OpenAPI features (callbacks, links, etc.) Zagora's DIY approach gives you full control over the output, but requires more setup. For complex public APIs with extensive documentation needs, dedicated API frameworks may be more appropriate. ## Testing Procedures \[How to test Zagora procedures] Zagora procedures are regular functions, making them easy to test. Here are patterns and best practices. ### Basic Testing Test procedures like any function: ```js import { describe, it, expect } from 'vitest'; import { z } from 'zod'; import { zagora } from 'zagora'; const add = zagora() .input(z.tuple([z.number(), z.number()])) .handler((_, a, b) => a + b) .callable(); describe('add', () => { it('adds two numbers', () => { const result = add(2, 3); expect(result.ok).toBe(true); expect(result.data).toBe(5); }); it('returns validation error for invalid input', () => { const result = add('a', 'b'); expect(result.ok).toBe(false); expect(result.error.kind).toBe('VALIDATION_ERROR'); }); }); ``` ### Testing with Context Override context for testing: ```js const getUser = zagora() .context({ db: productionDb }) .input(z.string()) .handler(({ context }, id) => context.db.find(id)) .callable(); describe('getUser', () => { it('returns user from database', async () => { // Create mock database const mockDb = { find: vi.fn().mockResolvedValue({ id: '123', name: 'Alice' }) }; // Create test version with mock const testGetUser = getUser.callable({ context: { db: mockDb } }); const result = await testGetUser('123'); expect(result.ok).toBe(true); expect(result.data).toEqual({ id: '123', name: 'Alice' }); expect(mockDb.find).toHaveBeenCalledWith('123'); }); }); ``` ### Testing Errors Test both success and error cases: ```js const divide = zagora() .input(z.tuple([z.number(), z.number()])) .errors({ DIVISION_BY_ZERO: z.object({ dividend: z.number() }) }) .handler(({ errors }, a, b) => { if (b === 0) throw errors.DIVISION_BY_ZERO({ dividend: a }); return a / b; }) .callable(); describe('divide', () => { it('divides numbers', () => { const result = divide(10, 2); expect(result.ok).toBe(true); expect(result.data).toBe(5); }); it('returns error for division by zero', () => { const result = divide(10, 0); expect(result.ok).toBe(false); expect(result.error.kind).toBe('DIVISION_BY_ZERO'); expect(result.error.dividend).toBe(10); }); it('returns validation error for invalid input', () => { const result = divide('ten', 2); expect(result.ok).toBe(false); expect(result.error.kind).toBe('VALIDATION_ERROR'); }); }); ``` ### Testing Async Procedures Use async/await in tests: ```js const fetchUser = zagora() .input(z.string()) .handler(async (_, id) => { const res = await fetch(`/api/users/${id}`); return res.json(); }) .callable(); describe('fetchUser', () => { it('fetches user data', async () => { // Mock fetch global.fetch = vi.fn().mockResolvedValue({ json: () => Promise.resolve({ id: '123', name: 'Alice' }) }); const result = await fetchUser('123'); expect(result.ok).toBe(true); expect(result.data.name).toBe('Alice'); }); }); ``` ### Testing with Type Guards Use error type guards in tests: ```js import { isValidationError, isDefinedError } from 'zagora/errors'; it('returns validation error for bad input', () => { const result = proc(invalidInput); expect(result.ok).toBe(false); expect(isValidationError(result.error)).toBe(true); if (isValidationError(result.error)) { expect(result.error.issues).toHaveLength(1); expect(result.error.issues[0].path).toEqual(['email']); } }); ``` ### Snapshot Testing Snapshot test result shapes: ```js it('returns expected shape', () => { const result = createUser({ name: 'Alice', email: 'alice@test.com' }); expect(result.ok).toBe(true); expect(result.data).toMatchSnapshot(); }); it('returns expected error shape', () => { const result = createUser({ name: '', email: 'invalid' }); expect(result.ok).toBe(false); expect(result.error).toMatchSnapshot(); }); ``` ### Integration Testing Test procedure chains: ```js describe('user workflow', () => { const mockDb = createMockDb(); const context = { db: mockDb }; const createUser = createUserProc.callable({ context }); const getUser = getUserProc.callable({ context }); const deleteUser = deleteUserProc.callable({ context }); it('creates, retrieves, and deletes user', async () => { // Create const createResult = await createUser({ name: 'Alice' }); expect(createResult.ok).toBe(true); const userId = createResult.data.id; // Retrieve const getResult = await getUser(userId); expect(getResult.ok).toBe(true); expect(getResult.data.name).toBe('Alice'); // Delete const deleteResult = await deleteUser(userId); expect(deleteResult.ok).toBe(true); // Verify deleted const getAgain = await getUser(userId); expect(getAgain.ok).toBe(false); expect(getAgain.error.kind).toBe('NOT_FOUND'); }); }); ``` ### Test Helpers Create test utilities: ```js // test-utils.ts export function expectSuccess(result: ZagoraResult): T { expect(result.ok).toBe(true); if (!result.ok) throw new Error('Expected success'); return result.data; } export function expectError( result: ZagoraResult, kind: string ): T { expect(result.ok).toBe(false); if (result.ok) throw new Error('Expected error'); expect(result.error.kind).toBe(kind); return result.error; } // Usage it('creates user', async () => { const result = await createUser({ name: 'Alice' }); const user = expectSuccess(result); expect(user.name).toBe('Alice'); }); it('rejects invalid email', async () => { const result = await createUser({ name: 'Alice', email: 'bad' }); const error = expectError(result, 'VALIDATION_ERROR'); expect(error.issues[0].path).toContain('email'); }); ``` ### Testing Best Practices 1. **Test both success and error paths** 2. **Mock dependencies via context** 3. **Use type guards for error assertions** 4. **Create test utilities for common patterns** 5. **Test edge cases (empty input, max values, etc.)** 6. **Test validation errors specifically** ## Type Safety Guarantees \[How Zagora ensures type correctness] Zagora provides comprehensive type safety across inputs, outputs, errors, context, and more. Here's how it works. ### Type Inference Sources #### From Input Schema ```js const proc = zagora() .input(z.object({ name: z.string(), age: z.number().optional() })) .handler((_, input) => { // input is typed as: // { name: string, age?: number | undefined } }); ``` #### From Output Schema ```js const proc = zagora() .output(z.object({ id: z.string(), createdAt: z.date() })) .handler(() => ({ id: '123', createdAt: new Date() })); // Result type: ZagoraResult<{ id: string, createdAt: Date }> ``` #### From Error Schemas ```js const proc = zagora() .errors({ NOT_FOUND: z.object({ id: z.string() }), FORBIDDEN: z.object({ reason: z.string() }) }) .handler(({ errors }) => { // errors.NOT_FOUND and errors.FORBIDDEN are typed }); // Error type includes all defined kinds ``` ### Tuple Argument Inference Tuple schemas spread into handler arguments with full type inference: ```js const proc = zagora() .input(z.tuple([ z.string(), // arg1: string z.number().default(10), // arg2: number (not number | undefined!) z.boolean().optional() // arg3: boolean | undefined ])) .handler((_, arg1, arg2, arg3) => { // TypeScript knows exact types }); ``` #### Default Values When a schema has `.default()`, the type is **not optional**: ```js .input(z.tuple([ z.number().default(0) // Handler receives: number (not number | undefined) ])) ``` This is different from `.optional()`: ```js .input(z.tuple([ z.number().optional() // Handler receives: number | undefined ])) ``` ### Result Type Narrowing The `ok` property enables TypeScript narrowing: ```js const result = proc(input); if (result.ok) { // TypeScript knows: result.data; // TData (your output type) result.error; // undefined } else { // TypeScript knows: result.data; // undefined result.error; // ZagoraError (union of all error types) } ``` ### Error Kind Narrowing Switch on `error.kind` for type-safe error handling: ```js if (!result.ok) { switch (result.error.kind) { case 'NOT_FOUND': // TypeScript knows: result.error has NOT_FOUND schema shape result.error.id; break; case 'VALIDATION_ERROR': // TypeScript knows: result.error has issues array result.error.issues; break; case 'UNKNOWN_ERROR': // TypeScript knows: result.error has cause result.error.cause; break; } } ``` ### Context Type Safety Context is fully typed from both sources: ```js const proc = zagora() .context({ db: myDatabase }) // Initial context .handler(({ context }) => { context.db; // typeof myDatabase context.logger; // Logger (from runtime) }) .callable({ context: { logger: myLogger } }); // Runtime context ``` ### Env Type Safety Environment variables are typed from schema: ```js const proc = zagora() .env(z.object({ API_KEY: z.string(), PORT: z.coerce.number() })) .handler(({ env }) => { env.API_KEY; // string env.PORT; // number (coerced from string) }); ``` ### Type Tests Zagora includes dedicated type-level tests to ensure type inference works correctly. See `test/types-testing.test.ts` in the repository. Example type test: ```js // Verify tuple spreading preserves types const proc = zagora() .input(z.tuple([z.string(), z.number().default(5)])) .handler((_, a, b) => { // Type assertions expectTypeOf(a).toEqualTypeOf(); expectTypeOf(b).toEqualTypeOf(); // Not number | undefined! }); ``` ### Edge Cases #### Async Schema Inference When using async schemas, TypeScript may not require `await`. Always await anyway: ```js const proc = zagora() .input(z.string().refine(async (val) => checkAsync(val))) .handler((_, input) => input); // Always await with async schemas const result = await proc('test'); ``` #### Handler Return Type vs Output Schema If both are present, output schema takes precedence: ```js const proc = zagora() .output(z.object({ name: z.string() })) .handler(() => ({ name: 'Alice', secret: 'hidden' // Not in output schema })); // result.data type is { name: string }, not { name: string, secret: string } ``` ### Best Practices 1. **Define output schemas** for public APIs to guarantee return shape 2. **Use tuple inputs** for natural function signatures 3. **Define all error kinds** upfront for complete error typing 4. **Enable strict mode** in tsconfig.json for best type safety 5. **Run type tests** to verify type inference in your procedures