# Overview import { Tab, Tabs } from "fumadocs-ui/components/tabs"; Zap Studio is a collection of framework-agnostic TypeScript packages for core app capabilities: typed HTTP calls, authorization policy checks, schema validation, and webhook processing. Our goal is to provide composable building blocks that stay portable across stacks while remaining strict about type safety and runtime correctness. Package Overview [#package-overview] | Package | What it covers | | ------------------------ | --------------------------------------------------------------- | | `@zap-studio/fetch` | Type-safe HTTP requests with schema-validated responses | | `@zap-studio/permit` | Declarative authorization policies and permission checks | | `@zap-studio/validation` | Standard Schema utilities and consistent validation errors | | `@zap-studio/webhooks` | Webhook routing, signature verification, and payload validation | Agent Skills [#agent-skills] We publish skills with AI-agent workflows in mind. * For manual installation, use the Vercel Skills CLI commands below. * We also use `@tanstack/intent` to ship `SKILL.md` files directly inside npm packages, so guidance stays versioned with each release. For official documentation, see [Vercel Skills](https://skills.sh/docs) and [TanStack Intent](https://tanstack.com/intent). ```bash npx skills add zap-studio/monorepo ``` ```bash pnpm dlx skills add zap-studio/monorepo ``` ```bash yarn dlx skills add zap-studio/monorepo ``` ```bash bunx skills add zap-studio/monorepo ``` For consumers mapping installed skills into agent config, run this after installing packages to automatically discover and install bundled skills from your installed dependencies: ```bash npx @tanstack/intent install ``` ```bash pnpm dlx @tanstack/intent install ``` ```bash yarn dlx @tanstack/intent install ``` ```bash bunx @tanstack/intent install ``` # App Icons Setting up proper icons ensures your application looks professional across all platforms and contexts. Desktop Icons [#desktop-icons] Desktop icons are used for the application window, taskbar, and installers on Windows, macOS, and Linux. Generate Icons [#generate-icons] Use the [Tauri Icon Generator](https://tauri.app/develop/icons/) to create icons for all platforms from a single source image: 1. **Prepare your source image** * Format: PNG * Size: 1024x1024 pixels minimum * Background: Transparent (recommended) 2. **Generate icons** ```bash turbo tauri -- icon path/to/your-icon.png ``` 3. **Output location** The generated icons will be placed in `src-tauri/icons/` with these files: * `32x32.png`, `128x128.png`, `128x128@2x.png` — Windows * `icon.icns` — macOS * `icon.ico` — Windows * `icon.png` — Linux The icon generator automatically creates all necessary sizes and formats. You only need to provide one high-quality source image. Manual Icon Placement [#manual-icon-placement] If you already have platform-specific icons, place them directly in `src-tauri/icons/`: ```text src-tauri/ └── icons/ ├── 32x32.png ├── 128x128.png ├── 128x128@2x.png ├── icon.icns (macOS) ├── icon.ico (Windows) └── icon.png (Linux, 512x512) ``` Updating Icons [#updating-icons] After changing icons: 1. **Clear cache** (if icons don't update immediately): ```bash rm -rf src-tauri/target ``` 2. **Rebuild the app**: ```bash turbo tauri -- build ``` Best Practices [#best-practices] 1. **Use high-resolution source images** — Start with at least 1024x1024px 2. **Keep it simple** — Icons should be recognizable at small sizes (16x16) 3. **Use transparency** — PNG with transparent backgrounds look better 4. **Test on all platforms** — Verify icons appear correctly on Windows, macOS, and Linux 5. **Consistent branding** — Use the same visual identity across desktop and web icons Troubleshooting [#troubleshooting] Icons not showing in built app [#icons-not-showing-in-built-app] Make sure icon paths in `src-tauri/tauri.conf.json` are correct: ```json { "tauri": { "bundle": { "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" ] } } } ``` # Autostart Local.ts can launch automatically when users log into their computer using the [Tauri autostart plugin](https://v2.tauri.app/plugin/autostart/). This is useful for apps that should always be running, like status monitors, chat clients, or sync tools. How It Works [#how-it-works] The autostart feature uses platform-specific mechanisms: | Platform | Method | | -------- | ---------------------------------------------------------------------- | | macOS | LaunchAgent in `~/Library/LaunchAgents/` | | Windows | Registry entry in `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` | | Linux | Desktop entry in `~/.config/autostart/` | The Tauri autostart plugin handles all platform differences automatically. Enabling Autostart [#enabling-autostart] From your React code: ```typescript import { enable, disable, isEnabled } from "@tauri-apps/plugin-autostart"; // Enable autostart await enable(); // Disable autostart await disable(); // Check if enabled const enabled = await isEnabled(); ``` Settings Integration [#settings-integration] Local.ts includes a "Launch at Login" toggle in Settings. The simplified implementation is the following: ```typescript const handleAutostartChange = async (enabled: boolean) => { if (enabled) { await enable(); } else { await disable(); } await updateSettings({ launchAtLogin: enabled }); }; ``` Removing Autostart [#removing-autostart] If you don't need autostart functionality: 1. **Remove the plugin** from `src-tauri/src/lib.rs`: ```diff - app.handle().plugin(tauri_plugin_autostart::init( - tauri_plugin_autostart::MacosLauncher::LaunchAgent, - None, - ))?; ``` 2. **Remove the dependency** from `src-tauri/Cargo.toml`: ```diff - [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] - tauri-plugin-autostart = "2" ``` 3. **Remove permissions** from `src-tauri/capabilities/default.json`: ```diff - "autostart:allow-enable", - "autostart:allow-disable", - "autostart:allow-is-enabled" ``` 4. **Remove the npm package**: ```bash pnpm remove @tauri-apps/plugin-autostart ``` 5. **Remove the setting** from the Settings page and database models. Check whether other dependencies exist inside the `[target.'cfg(any(...)'.dependencies]` section. If `tauri-plugin-autostart` is the only entry, remove the entire target-specific `[target.'cfg(...)'.dependencies]` section; otherwise delete only the `tauri-plugin-autostart = "2"` line from that section. # Code Quality Local.ts comes with a complete code quality setup using modern, fast tooling. This guide covers linting, formatting, testing, and the monorepo task runner. Ultracite [#ultracite] [Ultracite](https://www.ultracite.ai/) is a zero-configuration linter and formatter preset designed to enforce high code quality standards. It can unify linting and formatting using different engines, such as Biome, Prettier with ESLint, or the oxlint and oxfmt tools. In this project, we use the oxlint + oxfmt combo for fast, modern linting and formatting. Why Ultracite? [#why-ultracite] * **Fast** — Rust-based engine runs in milliseconds * **Zero-config** — Sensible defaults out of the box * **Unified** — Linting and formatting in one tool * **Auto-fix** — Most issues are automatically fixable Commands [#commands] ```bash # Format and fix all issues pnpm dlx ultracite fix # Check for issues without fixing pnpm dlx ultracite check # Diagnose setup issues pnpm dlx ultracite doctor ``` Or using Turborepo: ```bash turbo format # Fix issues turbo lint # Check issues ``` Configuration [#configuration] Ultracite works with oxlint under the hood. The configuration is in `.oxlintrc.json`: ```json { "$schema": "./node_modules/oxlint/configuration_schema.json", "extends": ["ultracite/oxlint/core", "ultracite/oxlint/react"] } ``` The presets include rules for: * TypeScript best practices * React hooks and accessibility * Modern JavaScript patterns * Security and performance Pre-commit Hooks [#pre-commit-hooks] Local.ts uses Lefthook to run Ultracite on staged files before each commit: ```yaml # lefthook.yml pre-commit: jobs: - run: pnpm exec ultracite fix glob: - "*.js" - "*.jsx" - "*.ts" - "*.tsx" - "*.json" - "*.jsonc" - "*.css" stage_fixed: true ``` This ensures all committed code passes linting and formatting checks. Testing with Vitest [#testing-with-vitest] Local.ts uses [Vitest](https://vitest.dev/) for testing. Vitest is a fast, Vite-native test runner with a Jest-compatible API. Running Tests [#running-tests] ```bash # Run tests once turbo test # Watch mode for development turbo test:watch # Generate coverage report turbo test:coverage # Open the UI test runner turbo test:ui ``` Configuration [#configuration-1] Testing is configured in `vite.config.ts`: ```typescript export default defineConfig({ // ... other config test: { coverage: { provider: "v8", reporter: ["text", "html"], }, }, }); ``` Writing Tests [#writing-tests] Create test files with the `.test.ts` or `.test.tsx` extension: ```typescript import { describe, it, expect } from "vitest"; describe("myFunction", () => { it("should return the correct value", () => { expect(myFunction(2)).toBe(4); }); }); ``` Coverage Reports [#coverage-reports] Coverage reports are generated in the `coverage/` directory: * `coverage/index.html` — Interactive HTML report * Terminal output shows summary Turborepo [#turborepo] [Turborepo](https://turborepo.com/) orchestrates tasks across the monorepo, handling both the frontend (Vite) and backend (Tauri/Rust) with unified commands and intelligent caching. Monorepo Structure [#monorepo-structure] Local.ts is structured as a pnpm workspace with two packages: ```yaml # pnpm-workspace.yaml packages: - . # Frontend (React/Vite) - ./src-tauri # Backend (Rust/Tauri) ``` This allows Turborepo to run tasks across both packages with a single command. Task Configuration [#task-configuration] Tasks are defined in `turbo.json`: ```json { "$schema": "https://turborepo.com/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": [ "dist/**", "src-tauri/target/release/**", "src-tauri/target/debug/**" ] }, "check": { "dependsOn": ["^check"] }, "dev": { "persistent": true, "cache": false }, "lint": { "dependsOn": ["^lint"] }, "test": { "dependsOn": ["^build"] }, "validate": { "dependsOn": ["build", "check", "lint", "test"] } } } ``` Key Concepts [#key-concepts] | Concept | Description | | ------------ | -------------------------------------------------------------- | | `dependsOn` | Run dependencies first (e.g., `^build` runs build in all deps) | | `outputs` | Files to cache for faster subsequent runs | | `persistent` | Keep running (for dev servers) | | `cache` | Whether to cache task outputs | Caching [#caching] Turborepo caches task outputs based on file inputs. When you run a task: 1. Turborepo hashes relevant source files 2. If the hash matches a previous run, it replays cached output 3. If not, it runs the task and caches the result This dramatically speeds up CI pipelines and repeated local builds. Available Commands [#available-commands] | Command | Description | | ---------------------- | ----------------------------------------- | | `turbo dev` | Start Vite dev server | | `turbo tauri -- dev` | Start full Tauri app with hot reload | | `turbo tauri -- build` | Build production app | | `turbo build` | Build frontend | | `turbo check` | TypeScript type checking | | `turbo lint` | Run Ultracite linter | | `turbo format` | Format code with Ultracite | | `turbo test` | Run Vitest tests | | `turbo test:coverage` | Generate coverage report | | `turbo validate` | Run all checks (build, check, lint, test) | The Validate Command [#the-validate-command] The `validate` task runs all quality checks in the correct order: ```json { "validate": { "dependsOn": ["build", "check", "lint", "test"] } } ``` Use it before committing or in CI: ```bash turbo validate ``` This ensures your code: * Builds successfully * Passes TypeScript checks * Passes linting rules * Passes all tests Cargo [#cargo] Local.ts includes a Rust backend in `src-tauri/` with comprehensive tooling for code quality. The Rust toolchain provides native tools for formatting, linting, type checking, testing, and coverage. Available Cargo Commands [#available-cargo-commands] | Command | Description | | ----------------- | ----------------------------------- | | `cargo fmt` | Format Rust code with rustfmt | | `cargo clippy` | Lint Rust code with Clippy | | `cargo check` | Type check without building | | `cargo test` | Run Rust unit and integration tests | | `cargo tarpaulin` | Generate code coverage reports | Turborepo Integration [#turborepo-integration] The `src-tauri/` directory includes a `package.json` that bridges Cargo commands to the Turborepo workflow. This allows unified commands across both TypeScript and Rust codebases. Here is a simplified example: ```json { "scripts": { "format": "cargo fmt", "lint": "cargo clippy", "check": "cargo check", "test": "cargo test" } } ``` Now you can run Cargo commands through Turborepo alongside your TypeScript tasks: ```bash # Format both TypeScript and Rust code turbo format # Lint both codebases turbo lint # Type check TypeScript and Rust turbo check # Run tests for both frontend and backend turbo test ``` Cargo-specific Workflows [#cargo-specific-workflows] For Rust-specific tasks, navigate to `src-tauri/` and use Cargo directly: ```bash cd src-tauri # Watch tests during development cargo watch -x test # Run tests with output cargo test -- --nocapture # Generate coverage report cargo tarpaulin --out Html # Fix clippy warnings automatically cargo clippy --fix ``` Tarpaulin Coverage [#tarpaulin-coverage] [Cargo Tarpaulin](https://github.com/xd009642/tarpaulin) generates code coverage for Rust tests: ```bash # Install tarpaulin cargo install cargo-tarpaulin # Generate HTML coverage report cargo tarpaulin --out Html # View in browser open tarpaulin-report.html ``` Coverage reports help identify untested code paths in your Rust backend. CI Integration [#ci-integration] Local.ts includes GitHub Actions workflows that run on every pull request to ensure code quality across the monorepo. Existing Workflows [#existing-workflows] | Workflow | File | Description | | --------- | ----------------------------------------------------------------------------------------- | ------------------------------------ | | **Check** | [check.yml](https://github.com/zap-studio/local.ts/blob/main/.github/workflows/check.yml) | Type checks TypeScript and Rust code | | **Lint** | [lint.yml](https://github.com/zap-studio/local.ts/blob/main/.github/workflows/lint.yml) | Runs oxlint linter on the monorepo | | **Test** | [test.yml](https://github.com/zap-studio/local.ts/blob/main/.github/workflows/test.yml) | Executes all test suites | | **Build** | [build.yml](https://github.com/zap-studio/local.ts/blob/main/.github/workflows/build.yml) | Builds the entire monorepo | Workflow Triggers [#workflow-triggers] All workflows: * Run on pull requests * Execute in parallel for faster feedback * Use pnpm for dependency management Leveraging Turborepo in CI [#leveraging-turborepo-in-ci] Turborepo caches build artifacts between runs, significantly reducing CI times after the first build: ```yaml # Your CI pipeline benefits from Turborepo's cache - name: Install dependencies run: pnpm install --frozen-lockfile - name: Validate run: pnpm turbo run validate ``` Remote Caching [#remote-caching] For team environments, enable [Turborepo Remote Caching](https://turborepo.com/repo/docs/core-concepts/remote-caching) to share cache across machines: ```bash turbo login turbo link ``` This allows team members and CI runners to benefit from each other's cached builds, dramatically speeding up workflows. # Database Local.ts uses [SQLite](https://sqlite.org/index.html) for persistent data storage with [Diesel ORM](https://diesel.rs/) for type-safe queries. All database operations run in Rust and are exposed to your React app via Tauri commands. How It Works [#how-it-works] The database system follows a layered architecture: 1. **Models** (`database/models/`) — Data structure definitions and type conversions 2. **Services** (`services/`) — Business logic and database operations 3. **Commands** (`commands/`) — Tauri command handlers that call services 4. **Frontend** — React code that invokes Tauri commands ```text Frontend (React) ↓ invoke() Commands (Tauri handlers) ↓ Services (Database operations) ↓ Models (Data structures) + Diesel ORM ↓ SQLite Database ``` When your app starts: 1. The database file is created in the app's data directory if it doesn't exist 2. Pending migrations are run automatically 3. A connection pool is initialized for efficient database access A **connection pool** is a set of reusable database connections that helps your app handle multiple queries efficiently. Local.ts uses [r2d2](https://docs.rs/r2d2/latest/r2d2/) for pooling, which manages connections so you don't need to open and close them for every request. Database Location [#database-location] The SQLite database file is stored in the platform-specific app data directory: | Platform | Location | | -------- | ------------------------------------------------------------- | | macOS | `~/Library/Application Support/{bundleIdentifier}/local.db` | | Windows | `C:\Users\{User}\AppData\Roaming\{bundleIdentifier}\local.db` | | Linux | `~/.local/share/{bundleIdentifier}/local.db` | Creating a New Table [#creating-a-new-table] Let's walk through adding a `users` table to your app. 1. Generate a Migration [#1-generate-a-migration] ```bash cd src-tauri diesel migration generate create_users ``` This creates a timestamped directory: ```text migrations/ └── 2024-01-01-000000_create_users/ ├── up.sql └── down.sql ``` 2. Write the SQL [#2-write-the-sql] In `up.sql`: ```sql CREATE TABLE users ( id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) ); ``` In `down.sql`: ```sql DROP TABLE users; ``` 3. Run the Migration [#3-run-the-migration] ```bash diesel migration run ``` This: * Creates the `users` table in your database * Auto-generates the schema in `src-tauri/src/database/schema.rs` Never edit `schema.rs` manually. It's regenerated automatically when you run migrations. 4. Create the Rust Model [#4-create-the-rust-model] Models define data structures and type conversions. Create `src-tauri/src/database/models/user.rs`: ```rust use diesel::prelude::*; use serde::{Deserialize, Serialize}; use crate::database::schema::users; #[derive(Debug, Clone, Queryable, Selectable, Serialize)] #[diesel(table_name = users)] pub struct User { pub id: i32, pub name: String, pub email: String, pub created_at: i64, } #[derive(Debug, Clone, Insertable, Deserialize)] #[diesel(table_name = users)] pub struct NewUser { pub name: String, pub email: String, } ``` Export it in `src-tauri/src/database/models/mod.rs`: ```rust pub mod user; pub use user::{User, NewUser}; ``` 5. Create the Service [#5-create-the-service] Services contain database operations. Create `src-tauri/src/services/user.rs`: ```rust use diesel::prelude::*; use diesel::SqliteConnection; use crate::database::DbError; use crate::database::models::{User, NewUser}; use crate::database::schema::users; pub fn list_users(conn: &mut SqliteConnection) -> Result, DbError> { users::table .load::(conn) .map_err(Into::into) } pub fn create_user( conn: &mut SqliteConnection, new_user: NewUser ) -> Result { diesel::insert_into(users::table) .values(&new_user) .execute(conn)?; users::table .order(users::id.desc()) .first(conn) .map_err(Into::into) } pub fn get_user( conn: &mut SqliteConnection, user_id: i32 ) -> Result, DbError> { users::table .find(user_id) .first(conn) .optional() .map_err(Into::into) } ``` Export it in `src-tauri/src/services/mod.rs`: ```rust pub mod user; ``` 6. Create Tauri Commands [#6-create-tauri-commands] Commands are thin wrappers that call services. Create `src-tauri/src/commands/user.rs`: ```rust use tauri::State; use crate::database::{DbPool, DbError}; use crate::database::models::NewUser; use crate::services::user; #[tauri::command] pub fn list_users(pool: State) -> Result, DbError> { let mut conn = pool.get()?; user::list_users(&mut conn) } #[tauri::command] pub fn create_user( pool: State, new_user: NewUser ) -> Result { let mut conn = pool.get()?; user::create_user(&mut conn, new_user) } #[tauri::command] pub fn get_user( pool: State, user_id: i32 ) -> Result, DbError> { let mut conn = pool.get()?; user::get_user(&mut conn, user_id) } ``` 7. Register the Commands [#7-register-the-commands] In `src-tauri/src/lib.rs`: ```rust .invoke_handler(tauri::generate_handler![ // ... existing commands commands::user::list_users, commands::user::create_user, commands::user::get_user, ]) ``` If you don't register your commands with Tauri using `invoke_handler`, your frontend won't be able to call them. Always ensure new commands are added here. 8. Call from React [#8-call-from-react] ```typescript import { invoke } from "@tauri-apps/api/core"; interface User { id: number; name: string; email: string; createdAt: number; } // List all users const users = await invoke("list_users"); // Create a new user const newUser = await invoke("create_user", { user: { name: "Alice", email: "alice@example.com" } }); // Get a specific user const user = await invoke("get_user", { userId: 1 }); ``` Common Queries [#common-queries] For more details and advanced query examples, check the [Diesel ORM documentation](https://diesel.rs/guides/). Modifying Existing Tables [#modifying-existing-tables] To add a column to an existing table: ```bash diesel migration generate add_avatar_to_users ``` In `up.sql`: ```sql ALTER TABLE users ADD COLUMN avatar_url TEXT; ``` In `down.sql`: ```sql ALTER TABLE users DROP COLUMN avatar_url; ``` Run the migration and update your Rust model to include the new field. Connection Pool [#connection-pool] The connection pool is initialized at startup in `src-tauri/src/database/mod.rs`: ```rust let pool = r2d2::Pool::builder() .max_size(10) .build(manager)?; ``` You can adjust `max_size` based on your app's concurrency needs. For most desktop apps, 10 connections is more than sufficient. # Distribution Once your app is ready, you will package and distribute it so users can install and run it. Tauri supports producing platform-native bundles and installers; this document gives a concise overview of the concepts and pointers to the official Tauri documentation for platform-specific details. Building for Production [#building-for-production] Use the Tauri build command to produce production bundles for one or more targets: ```bash # Build all configured bundles turbo tauri -- build ``` Build artifacts are produced under `src-tauri/target/release/bundle/` and typically include platform-appropriate formats. Configure what gets produced in your `tauri.conf.json` bundle section. * macOS: `.dmg`, `.app` * Windows: `.msi`, `.exe` * Linux: `.deb`, `.rpm`, `.AppImage` A minimal `bundle` configuration may look like: ```json { "bundle": { "active": true, "targets": "all", "identifier": "com.yourcompany.yourapp" } } ``` For full configuration options, see the Tauri distribution configuration docs. Code Signing [#code-signing] Code signing is the process of cryptographically signing your app binaries and installers so platforms and users can verify the publisher and integrity of the software. The goal is to establish publisher identity, enable notarization on macOS, and reduce warnings (e.g., Windows SmartScreen). * macOS: typically requires an Apple Developer account and notarization for broad distribution. * Windows: uses a code signing certificate from a trusted CA (or platform-specific signing services). * Linux: signing is less centralized; some package formats or distribution channels support signatures for verification. For platform-specific signing steps, certificates, and notarization details, follow the [Tauri signing guides](https://v2.tauri.app/distribute/sign/macos/). Automatic Updates [#automatic-updates] Automatic updates let your app check for, download, and apply new versions without users manually reinstalling. An update manifest or endpoint advertises the latest version, the updater checks that endpoint and verifies update integrity (signatures), then downloads and installs updates. Tauri provides an [updater plugin](https://v2.tauri.app/plugin/updater/) and patterns for hosting update metadata and artifacts. App Stores [#app-stores] If you plan to publish to platform app stores, be aware of their extra requirements: * macOS App Store: App Sandbox, entitlements, and App Store-specific packaging. * Microsoft Store: MSIX packaging and Partner Center submission. * Linux stores (Flathub, Snap Store, etc.): packaging formats and store submission processes differ. Check the [Tauri app stores](https://v2.tauri.app/distribute/) documentation and the target store's guidelines for the precise steps and policies. CI/CD [#cicd] We provide a GitHub Action workflow that automates the build and release process across all supported platforms. The workflow builds native bundles for macOS (both Apple Silicon and Intel), Windows, and Linux, then creates a draft GitHub release with the artifacts. Overview [#overview] The [publish workflow](https://github.com/zap-studio/local.ts/blob/main/.github/workflows/publish.yml) is triggered either manually via `workflow_dispatch` or automatically when pushing to the `release` branch. It uses a matrix strategy to build in parallel across different platforms: * **macOS** (both `aarch64-apple-darwin` for M1+ and `x86_64-apple-darwin` for Intel) * **Ubuntu 22.04** for Linux bundles * **Windows** for Windows installers Key Features [#key-features] * **Parallel builds** — All platform targets build simultaneously using GitHub's matrix strategy * **Caching** — Both pnpm and Rust dependencies are cached to speed up builds * **Draft releases** — Creates a draft GitHub release tagged as `app-v__VERSION__` with all platform artifacts * **Platform dependencies** — Automatically installs required system dependencies (e.g., WebKit libraries on Ubuntu) Using the Workflow [#using-the-workflow] 1. Push to the `release` branch or manually trigger the workflow 2. The workflow builds all platform bundles 3. A draft release is created with all artifacts attached 4. Review the draft release and publish when ready For more details on customizing this workflow or setting up your own CI/CD pipeline, see the [Tauri GitHub Pipelines guide](https://tauri.app/distribute/pipelines/github/). Learn More [#learn-more] For full, authoritative, and up-to-date instructions — including commands, certificates, keys, and security guidance — see the Tauri documentation: * [Tauri Distribution Overview](https://v2.tauri.app/distribute/) * [Tauri Signing Guides](https://v2.tauri.app/distribute/sign/) * [Tauri Updater Plugin](https://v2.tauri.app/plugin/updater/) * [Tauri GitHub Action](https://github.com/tauri-apps/tauri-action) # Getting Started This guide walks you through setting up Local.ts and running your first build. Prerequisites [#prerequisites] Before you begin, make sure you have the following installed: * **Node.js** (v18 or later) — [nodejs.org](https://nodejs.org/) * **Rust** — [rust-lang.org](https://www.rust-lang.org/tools/install) * **pnpm** (recommended) — [pnpm.io](https://pnpm.io/) * **Turbo** — [turborepo.com](https://turborepo.com/) For database migrations, optionally install Diesel CLI: ```bash cargo install diesel_cli ``` Installation [#installation] 1. Clone the Repository [#1-clone-the-repository] ```bash git clone https://github.com/zap-studio/local.ts.git my-app cd my-app ``` 2. Install Dependencies [#2-install-dependencies] ```bash pnpm install ``` This installs npm packages. 3. Run in Development Mode [#3-run-in-development-mode] ```bash turbo tauri -- dev ``` This starts the Vite development server with hot reload and opens your app in a native window. Changes to your React code will update instantly. Building for Production [#building-for-production] Create a production build for your current platform: ```bash turbo tauri -- build ``` The output will be in `src-tauri/target/release/bundle/` with platform-specific installers. Customizing Your App [#customizing-your-app] After cloning, update your app's name, version, and branding. See the [Project Identity](./project-identity.mdx) guide for a complete walkthrough. Available Run Scripts [#available-run-scripts] Local.ts includes several scripts to streamline your development workflow. | Command | Description | | ---------------------- | -------------------------------------------------------------- | | `turbo dev` | Start Vite dev server (frontend only) | | `turbo tauri -- dev` | Start full app with hot reload | | `turbo tauri -- build` | Build for production | | `turbo check` | TypeScript type checking | | `turbo lint` | Run linter | | `turbo format` | Format code | | `turbo test` | Run tests | | `turbo validate` | Run build, check, lint, and test to validate everything passes | The `validate` script is useful before committing or deploying — it ensures your code builds correctly and passes all quality checks in one command. # Overview A production-ready template for building cross-platform desktop applications with a local-first architecture. Your data stays on the user's device, always available offline, with native performance and a small bundle size. Built with **React**, **TypeScript**, **Tauri**, and **Rust**, Local.ts provides everything you need to ship a polished desktop app: persistent settings, system tray integration, notifications, database storage, and more. Since Local.ts relies heavily on Tauri, we highly recommend checking the [Tauri documentation](https://tauri.app/develop/) for deeper understanding of the platform and advanced customization options. Features [#features] * **Local-first** — Your data stays on the device, always available offline * **Cross-platform** — Build for macOS, Windows, Linux, iOS, and Android * **Lightweight** — Native performance with a small bundle size * **Secure** — Built-in Content Security Policy and Tauri's security model Built-in Functionality [#built-in-functionality] | Feature | Description | | ------------------------------------ | --------------------------------------------------------------- | | [Settings](./settings.mdx) | Persistent settings with theme, behavior, and developer options | | [System Tray](./system-tray.mdx) | Background operation with show/hide and quit actions | | [Notifications](./notifications.mdx) | Native notifications with permission handling | | [Database](./database.mdx) | SQLite with Diesel ORM and automatic migrations | | [Theming](./theming.mdx) | Light, dark, and system theme modes | | [Logging](./logging.mdx) | Multi-target logging to console, webview, and files | | [Window State](./window-state.mdx) | Remember window size and position across restarts | | [Autostart](./autostart.mdx) | Launch at login with user-configurable settings | | [Splash Screen](./splash-screen.mdx) | Elegant loading screen while app initializes | Quick Start [#quick-start] 1. Clone the Repository [#1-clone-the-repository] ```bash git clone https://github.com/zap-studio/local.ts.git my-app cd my-app ``` 2. Install Dependencies [#2-install-dependencies] ```bash pnpm install ``` 3. Run the Development Server [#3-run-the-development-server] ```bash pnpm tauri dev ``` Your app will open in a native window with hot reload enabled. Project Structure [#project-structure] ```text local.ts/ ├── src/ # Frontend React code │ ├── components/ # UI components │ ├── routes/ # TanStack Router pages │ ├── stores/ # Zustand state management │ ├── lib/ # Utilities and Tauri API wrappers │ └── constants/ # App configuration ├── src-tauri/ # Rust backend │ ├── src/ │ │ ├── commands/ # Tauri commands (API endpoints) │ │ ├── database/ # Diesel ORM models and schema │ │ ├── services/ # Business logic and database operations │ │ └── plugins/ # Plugin configurations │ └── migrations/ # Database migrations └── public/ # Static assets ``` # Logging Local.ts includes built-in logging powered by the [Tauri logging plugin](https://github.com/tauri-apps/tauri-plugin-log) and the basic Rust [log crate](https://docs.rs/log/latest/log/). Logs are sent to the console, browser devtools, and persistent files for debugging production issues. How Logging Works [#how-logging-works] The logging system outputs to three targets simultaneously: 1. **Console (stdout)** — For development, visible in the terminal 2. **Webview console** — Rust logs appear in browser devtools 3. **Log files** — Persistent logs stored on disk with automatic rotation Log Levels [#log-levels] | Level | Description | Use Case | | ------- | ------------------- | ------------------------------------ | | `error` | Critical failures | Database errors, crashes | | `warn` | Potential issues | Deprecated usage, recoverable errors | | `info` | General information | App started, user actions | | `debug` | Detailed debugging | Function calls, state changes | | `trace` | Very verbose | Request/response data, loops | Logging from Rust [#logging-from-rust] Use the standard Rust `log` macros: ```rust log::info!("Application started"); log::debug!("Processing user: {}", user_id); log::warn!("Deprecated API called"); log::error!("Failed to save settings: {}", error); ``` Logging from JavaScript [#logging-from-javascript] Import and use the Tauri log plugin: ```typescript import { info, debug, warn, error, trace } from "@tauri-apps/plugin-log"; await info("User clicked button"); await debug(`Processing item ${itemId}`); await warn("This feature is deprecated"); await error("Failed to fetch data"); ``` Log File Location [#log-file-location] Log files are stored in the platform-specific log directory: | Platform | Location | | -------- | ---------------------------------------------------------------- | | macOS | `~/Library/Logs/{bundleIdentifier}/logs.log` | | Windows | `C:\Users\{User}\AppData\Local\{bundleIdentifier}\logs\logs.log` | | Linux | `~/.local/share/{bundleIdentifier}/logs/logs.log` | Files rotate automatically at 50KB to prevent unbounded growth. Configuration [#configuration] The logging configuration is in `src-tauri/src/plugins/logging.rs`: ```rust use tauri_plugin_log::{Target, TargetKind, TimezoneStrategy}; pub fn build() -> tauri_plugin_log::Builder { tauri_plugin_log::Builder::new() .targets([ Target::new(TargetKind::Stdout), Target::new(TargetKind::Webview), Target::new(TargetKind::LogDir { file_name: Some("logs".to_string()), }), ]) .timezone_strategy(TimezoneStrategy::UseLocal) .max_file_size(50_000) } ``` Change File Rotation Size [#change-file-rotation-size] ```rust .max_file_size(100_000) // 100KB per file ``` Use UTC Timestamps [#use-utc-timestamps] ```rust .timezone_strategy(TimezoneStrategy::UseUtc) ``` Filter by Log Level [#filter-by-log-level] Set the minimum log level: ```rust .level(log::LevelFilter::Info) ``` Set different levels for specific modules: ```rust .level(log::LevelFilter::Info) .level_for("my_app::database", log::LevelFilter::Debug) ``` Log to Separate Files by Level [#log-to-separate-files-by-level] ```rust .targets([ Target::new(TargetKind::Stdout), Target::new(TargetKind::LogDir { file_name: Some("errors".to_string()) }) .filter(|metadata| metadata.level() == log::Level::Error), Target::new(TargetKind::LogDir { file_name: Some("all".to_string()) }), ]) ``` Disable Console Logging in Production [#disable-console-logging-in-production] ```rust let targets = if cfg!(debug_assertions) { vec![ Target::new(TargetKind::Stdout), Target::new(TargetKind::Webview), Target::new(TargetKind::LogDir { file_name: Some("logs".to_string()) }), ] } else { vec![ Target::new(TargetKind::LogDir { file_name: Some("logs".to_string()) }), ] }; tauri_plugin_log::Builder::new().targets(targets) ``` User-Configurable Logging [#user-configurable-logging] The Settings page includes toggles for logging and log level. Read these settings in your Rust code to adjust logging behavior: ```rust let settings = get_settings(&mut conn)?; if settings.enable_logging { log::info!("Detailed logging enabled at level: {:?}", settings.log_level); } ``` Refer to your settings module or database queries for the actual `get_settings` function signature. Viewing Logs [#viewing-logs] During Development [#during-development] Logs appear in your terminal when running `turbo tauri -- dev`. In Browser DevTools [#in-browser-devtools] Open devtools in your app window (Cmd+Option+I on macOS, F12 on Windows/Linux) to see Rust logs in the console. Reading Log Files [#reading-log-files] Navigate to the log directory for your platform and open the log file in any text editor. Removing Logging [#removing-logging] If you don't need logging: 1. **Delete the logging module** — Remove `src-tauri/src/plugins/logging.rs` 2. **Remove dependencies** from `src-tauri/Cargo.toml`: ```diff - tauri-plugin-log = "2" - log = "0.4" ``` 3. **Remove the plugin** from `src-tauri/src/lib.rs`: ```diff - .plugin(plugins::logging::build().build()) ``` 4. **Remove permissions** from `src-tauri/capabilities/default.json`: ```diff - "log:default" ``` 5. **Remove the npm package**: ```bash pnpm remove @tauri-apps/plugin-log ``` # Motivation Local.ts was created to solve a common problem: **building desktop apps with web technologies takes too long to set up right**. The Challenge [#the-challenge] Starting a new desktop app project typically means: * **Days of boilerplate** — Setting up Tauri with React, TypeScript, and Rust requires significant configuration * **Endless decisions** — State management, theming, database layer, testing setup... each choice takes time * **Missing patterns** — Most starters are "hello world" examples that don't show production patterns * **Fragmented docs** — Piecing together tutorials for each technology in the stack We wanted to skip straight to building features. The Rise of Homemade Tools [#the-rise-of-homemade-tools] AI is fundamentally changing how software gets built. With coding assistants and AI-powered development tools, **creating custom applications is now accessible to everyone** — not just professional developers. This shift will lead to a rise in homemade tools: | Trend | Implication | | -------------------------- | -------------------------------------------------------------- | | **Lower barrier to entry** | More people building personal tools instead of relying on SaaS | | **Data ownership** | Users want control over their data, not rental agreements | | **Custom workflows** | Generic tools can't match purpose-built solutions | When everyone can build their own tools, why trust your data to companies that might: * Sell your information * Get breached * Shut down and lose your data * Change pricing or terms **Local-first software puts you back in control.** Own Your Data, Own Your Backups [#own-your-data-own-your-backups] With local-first apps: * **Your data stays on your device** — No cloud servers to breach * **You control backups** — Export, sync, or replicate however you want * **No vendor lock-in** — SQLite files are portable and open * **Works forever** — Apps don't disappear when companies do Local.ts makes it easy to build these kinds of tools. Combined with AI assistants, you can create exactly the software you need — with the privacy guarantees you deserve. Why Local-First? [#why-local-first] Local.ts embraces a local-first architecture where your app's data lives on the user's device: | Benefit | Description | | ------------------- | --------------------------------------------------------------- | | **Privacy** | User data never leaves their machine unless they choose to sync | | **Offline-ready** | Works without internet connection | | **Performance** | No network latency — everything is instant | | **No server costs** | Ship apps without backend infrastructure | | **Reliability** | No dependencies on external services | Local-first doesn't mean "never online." You can add sync, cloud backups, or API integrations when needed. But starting local-first gives you a solid foundation. Why This Stack? [#why-this-stack] Every technology in Local.ts was chosen for a reason: Tauri v2 [#tauri-v2] * **Tiny binaries** — 10-20MB vs 150MB+ for Electron * **Native performance** — Uses system webview, not bundled Chromium * **Cross-platform** — macOS, Windows, and Linux from one codebase * **Security-focused** — Rust backend with fine-grained permissions React + TypeScript [#react--typescript] * **Familiar ecosystem** — Use the libraries you already know * **Type safety** — Catch errors at compile time * **Component model** — Build UIs from composable pieces * **Huge community** — Answers for every question Rust Backend [#rust-backend] * **Memory safety** — No null pointers, no data races * **Performance** — Native speed for file operations, database queries * **Reliability** — If it compiles, it usually works * **Growing ecosystem** — Excellent libraries for desktop development SQLite + Diesel [#sqlite--diesel] * **Embedded database** — No server to install or manage * **Battle-tested** — Powers billions of devices worldwide * **Type-safe queries** — Diesel catches SQL errors at compile time * **Migrations** — Version your schema with confidence What Local.ts Provides [#what-localts-provides] This isn't a minimal starter. It's a production-ready foundation: Core Features [#core-features] * **Settings system** — Persistent preferences with React integration * **Theming** — Light, dark, and system themes with CSS variables * **Database layer** — SQLite with migrations, models, services, and commands * **Logging** — Structured logs to files and console Desktop Integration [#desktop-integration] * **System tray** — Background operation with menu actions * **Notifications** — Native OS notifications * **Autostart** — Launch at login option * **Window state** — Remembers size and position Developer Experience [#developer-experience] * **Turborepo** — Orchestrated builds across TypeScript and Rust * **Ultracite** — Fast linting and formatting (oxlint + oxfmt) * **Vitest** — Modern testing with coverage * **Pre-commit hooks** — Automated quality checks Architecture [#architecture] * **Layered design** — Clear separation between frontend, commands, services, and models * **Type safety** — End-to-end types from Rust to React * **Extensible patterns** — Add features following established conventions Who Is This For? [#who-is-this-for] Local.ts is ideal for: * **Web developers** who want to build desktop apps without learning a new paradigm * **Teams** who need a consistent, well-documented starting point * **Solo developers** who want to ship desktop apps quickly * **AI-assisted builders** who want a solid foundation to extend with AI tools * **Anyone** who values user privacy and data ownership Getting Started [#getting-started] Ready to build your first local-first desktop app? Check out the [Getting Started guide](./getting-started.mdx) to create your first Local.ts app in minutes. # Notifications Local.ts includes native notification support with permission handling and user preferences. Thus, you can send system notifications that respect user settings and platform requirements. Understanding Notifications [#understanding-notifications] Notifications in Local.ts have two layers of control: 1. **System permissions** — The OS must grant notification access 2. **User preferences** — Users can disable notifications in Settings The notification helpers handle both automatically, so you can focus on when and what to notify. Sending Notifications [#sending-notifications] Basic Notification [#basic-notification] Use `notify()` to send a notification that respects user preferences: ```typescript import { notify } from "@/lib/tauri/notifications/send"; import { useSettings } from "@/stores/settings"; async function sendNotification() { const settings = useSettings.getState().settings; if (!settings) return; const sent = await notify({ title: "Task Complete", body: "Your export has finished successfully.", }, settings); if (sent) { console.log("Notification delivered"); } else { console.log("Notification blocked by settings or permissions"); } } ``` Critical Notifications [#critical-notifications] For urgent alerts that should bypass user preferences (but still require system permission): ```typescript import { notifyForced } from "@/lib/tauri/notifications/send"; async function sendCriticalAlert() { await notifyForced({ title: "Critical Error", body: "Your data could not be saved. Please try again.", }); } ``` Use `notifyForced` sparingly. Overusing it will frustrate users who have disabled notifications. Notification Options [#notification-options] Both `notify` and `notifyForced` accept the same options: ```typescript interface NotificationOptions { title: string; // Required: notification title body?: string; // Optional: notification body text icon?: string; // Optional: path to icon file } ``` Example with all options: ```typescript await notify({ title: "New Message", body: "You have a new message from Alice", icon: "icons/message.png", }, settings); ``` You can change this `NotificationOptions` interface however you want and modify the `notify` and `notifyForced` helpers to add more fields according to the [Tauri notification plugin options](https://v2.tauri.app/reference/javascript/notification/#sendnotification). Checking Permissions [#checking-permissions] Before showing notifications, ensure you have permission: ```typescript import { isPermissionGranted, requestPermission, } from "@tauri-apps/plugin-notification"; // Check current permission status const hasPermission = await isPermissionGranted(); // Request permission (shows system prompt if needed) const granted = await requestPermission(); if (granted) { console.log("Notifications enabled"); } ``` You can use the following convenient helper to ensure notification permission is granted (it checks and requests if needed): ```typescript // src/lib/tauri/notifications/permissions.ts import { isPermissionGranted, requestPermission, } from "@tauri-apps/plugin-notification"; /** * Ensure notification permission is granted. * Requests permission if not already granted. */ export async function ensureNotificationPermission(): Promise { const granted = await isPermissionGranted(); if (granted) { return "granted"; } return await requestPermission(); } ``` User Preferences [#user-preferences] The Settings page includes a notification toggle. When users enable notifications, permission is automatically requested: ```typescript import { requestPermission } from "@tauri-apps/plugin-notification"; // simplified implementation const handleNotificationChange = async (enabled: boolean) => { if (enabled) { const granted = await requestPermission(); if (granted !== "granted") { // Permission denied, don't enable notifications return; } } }; ``` How It Works [#how-it-works] The `notify` function implements this logic: ```typescript export async function notify( options: NotificationOptions, settings: Settings ): Promise { // 1. Check user preferences if (!settings.enableNotifications) { return false; } // 2. Ensure system permission const granted = await ensureNotificationPermission(); if (granted !== "granted") { return false; } // 3. Send the notification sendNotification({ title: options.title, body: options.body, icon: options.icon, }); return true; } ``` Removing Notifications [#removing-notifications] If you don't need notification functionality: 1. **Remove the plugin** from `src-tauri/src/lib.rs`: ```diff - .plugin(tauri_plugin_notification::init()) ``` 2. **Remove the dependency** from `src-tauri/Cargo.toml`: ```diff - tauri-plugin-notification = "2" ``` 3. **Remove permissions** from `src-tauri/capabilities/default.json`: ```diff - "notification:default" ``` 4. **Remove the npm package**: ```bash pnpm remove @tauri-apps/plugin-notification ``` 5. **Delete the notifications module** — Remove `src/lib/tauri/notifications/` # Project Identity After cloning Local.ts, you'll want to update several files to match your project's name, description, and branding. Files to Update [#files-to-update] | File | Fields to Update | | --------------------------- | ------------------------------------------- | | `package.json` | `name`, `version`, `description` | | `index.html` | `title` | | `src/constants/index.ts` | `APP_TITLE` | | `src-tauri/Cargo.toml` | `name`, `version`, `description`, `authors` | | `src-tauri/tauri.conf.json` | `productName`, `version`, `identifier` | | `splash.html` | App name and description text | Step-by-Step Guide [#step-by-step-guide] 1. Update package.json [#1-update-packagejson] ```json { "name": "my-awesome-app", "version": "1.0.0", "description": "A brief description of your app" } ``` 2. Update index.html [#2-update-indexhtml] ```html My Awesome App ``` 3. Update App Constants [#3-update-app-constants] In `src/constants/index.ts`: ```typescript export const APP_TITLE = "My Awesome App"; ``` This constant is used throughout the frontend for window titles and UI elements. 4. Update Cargo.toml [#4-update-cargotoml] In `src-tauri/Cargo.toml`: ```toml [package] name = "my-awesome-app" version = "1.0.0" description = "A brief description of your app" authors = ["Your Name "] ``` 5. Update Tauri Configuration [#5-update-tauri-configuration] In `src-tauri/tauri.conf.json`: ```json { "productName": "My Awesome App", "version": "1.0.0", "identifier": "com.yourcompany.myawesomeapp" } ``` The `identifier` should be a unique reverse-domain identifier for your app. This is used for: * macOS bundle identifier * Windows application ID * Linux desktop file 6. Update Splash Screen [#6-update-splash-screen] In `splash.html`, update the visible text: ```html

My Awesome App

Loading your awesome experience...

``` Bundle Identifier [#bundle-identifier] The bundle identifier in `tauri.conf.json` is important for: | Platform | Usage | | -------- | ---------------------------------- | | macOS | Bundle ID, code signing, App Store | | Windows | Application User Model ID | | Linux | Desktop entry file name | Choose a unique identifier following reverse-domain notation. Here are some examples: * `com.yourcompany.appname` — For company apps * `dev.yourname.appname` — For personal projects * `io.github.username.appname` — For open source projects Version Management [#version-management] Keep versions synchronized across all configuration files. When releasing a new version, update: 1. `package.json` 2. `src-tauri/Cargo.toml` 3. `src-tauri/tauri.conf.json` Consider using a tool like [changesets](https://github.com/changesets/changesets) to manage versioning across the monorepo. # Settings Local.ts includes a complete settings system with persistent storage, type-safe APIs, and a pre-built Settings page. Understanding the Settings System [#understanding-the-settings-system] Settings in Local.ts flow through a layered architecture: 1. **Models** (`database/models/settings.rs`) — Data structures and type definitions 2. **Services** (`services/settings.rs`) — Database operations (get/update settings) 3. **Commands** (`commands/settings.rs`) — Tauri handlers that call services 4. **Frontend** — Zustand store and React hooks When you update a setting, it flows from the frontend through commands and services to the database, then syncs back to the UI. Available Settings [#available-settings] | Category | Setting | Description | Default | | ------------- | -------------------- | -------------------------- | -------- | | Appearance | Theme | Light, dark, or system | `system` | | | Sidebar Expanded | Keep sidebar open | `true` | | Behavior | Show in System Tray | Display tray icon | `true` | | | Launch at Login | Auto-start with system | `false` | | Notifications | Enable Notifications | Allow native notifications | `true` | | Developer | Enable Logging | Record application logs | `true` | | | Log Level | Minimum severity to log | `info` | Reading Settings [#reading-settings] Use the `useSettings` hook to access settings in your components: ```typescript import { useSettings } from "@/stores/settings"; function MyComponent() { const settings = useSettings((state) => state.settings); if (!settings) return null; return (

Current theme: {settings.theme}

Logging enabled: {settings.enableLogging ? "Yes" : "No"}

); } ``` Updating Settings [#updating-settings] Call `updateSettings` with a partial settings object. Only the fields you provide will be updated: ```typescript import { useSettings } from "@/stores/settings"; function ThemeToggle() { const updateSettings = useSettings((state) => state.updateSettings); const switchToDark = async () => { await updateSettings({ theme: "dark" }); }; return ; } ``` You can update multiple settings at once: ```typescript await updateSettings({ theme: "light", enableLogging: true, logLevel: "debug", }); ``` Accessing Settings Outside React [#accessing-settings-outside-react] For non-React code, access the store directly: ```typescript import { useSettings } from "@/stores/settings"; // Get current settings const settings = useSettings.getState().settings; // Update settings await useSettings.getState().updateSettings({ theme: "dark" }); ``` TypeScript Types [#typescript-types] Settings have full TypeScript support. Import the types for use in your code: ```typescript import type { Settings, SettingsUpdate, Theme, LogLevel } from "@/lib/tauri/settings/types"; // Theme: "light" | "dark" | "system" // LogLevel: "error" | "warn" | "info" | "debug" | "trace" function processSettings(settings: Settings) { console.log(settings.theme); } ``` The `Settings` interface: ```typescript interface Settings { theme: Theme; sidebarExpanded: boolean; showInTray: boolean; launchAtLogin: boolean; enableLogging: boolean; logLevel: LogLevel; enableNotifications: boolean; } ``` Adding New Settings [#adding-new-settings] To add a new setting, you need to update both the backend and frontend. 1. Create a Database Migration [#1-create-a-database-migration] Generate a new migration: ```bash cd src-tauri diesel migration generate add_my_setting ``` Write the SQL in `up.sql`: ```sql ALTER TABLE settings ADD COLUMN my_setting INTEGER NOT NULL DEFAULT 0; ``` And the rollback in `down.sql`: ```sql ALTER TABLE settings DROP COLUMN my_setting; ``` Run the migration: ```bash diesel migration run ``` 2. Update the Rust Models [#2-update-the-rust-models] In `src-tauri/src/database/models/settings.rs`, add the field to each struct: ```rust // In SettingsRow pub struct SettingsRow { // ... existing fields pub my_setting: i32, } // In Settings pub struct Settings { // ... existing fields pub my_setting: bool, } // In SettingsUpdate pub struct SettingsUpdate { // ... existing fields pub my_setting: Option, } // In SettingsChangeset pub struct SettingsChangeset { // ... existing fields pub my_setting: Option, } ``` Update the `from_row` and `From` implementations to handle the new field. 3. Update TypeScript Types [#3-update-typescript-types] In `src/lib/tauri/settings/types.ts`: ```typescript export interface Settings { // ... existing fields mySetting: boolean; } ``` 4. Add UI Controls [#4-add-ui-controls] Add a toggle or input in `src/routes/settings.tsx` for users to modify the setting. # Sidebar Navigation Local.ts includes a pre-made sidebar component that's entirely customizable. It exists to help you start building faster, but you can modify or replace it completely to match your app's needs. Customizing Sidebar Links [#customizing-sidebar-links] The sidebar navigation is configured in `src/constants/sidebar.ts`. This file exports two arrays that define the items shown at the top and bottom of the sidebar. Basic Structure [#basic-structure] ```typescript import { Home, Settings } from "lucide-react"; import type { SidebarItem } from "@/components/ui/sidebar/sidebar-nav-item"; export const SIDEBAR_TOP_ITEMS: SidebarItem[] = [ { icon: Home, label: "Home", href: "/" }, ]; export const SIDEBAR_BOTTOM_ITEMS: SidebarItem[] = [ { icon: Settings, label: "Settings", href: "/settings" }, ]; ``` Each `SidebarItem` has three properties: | Property | Type | Description | | -------- | ------------ | ------------------------------------------------ | | `icon` | `LucideIcon` | A Lucide React icon component | | `label` | `string` | Display name shown in the sidebar | | `href` | `string` | Route path (must match a route in `src/routes/`) | Adding New Links [#adding-new-links] To add a new sidebar item: 1. Import the Icon [#1-import-the-icon] Choose an icon from [Lucide Icons](https://lucide.dev/icons/) and import it: ```typescript import { Home, Settings, Users, Activity } from "lucide-react"; ``` 2. Add the Item [#2-add-the-item] Add the new item to either `SIDEBAR_TOP_ITEMS` or `SIDEBAR_BOTTOM_ITEMS`: ```typescript export const SIDEBAR_TOP_ITEMS: SidebarItem[] = [ { icon: Home, label: "Home", href: "/" }, { icon: Activity, label: "Dashboard", href: "/dashboard" }, ]; ``` 3. Create the Corresponding Route [#3-create-the-corresponding-route] Make sure the route exists in your `src/routes/` directory. For example, for `/dashboard`, create: ``` src/routes/dashboard.tsx ``` The sidebar automatically highlights the active route based on the current URL. Ordering Items [#ordering-items] Items appear in the order they're defined in the array: * **Top items** — Primary navigation links (top of sidebar) * **Bottom items** — Secondary actions like Settings (bottom of sidebar) Using Different Icons [#using-different-icons] Local.ts uses [Lucide React](https://lucide.dev/) for icons. You can: 1. **Browse available icons** at [lucide.dev/icons](https://lucide.dev/icons/) 2. **Import any icon** using the PascalCase name: ```typescript import { FileText, Calendar, Mail, Bell } from "lucide-react"; ``` Customizing the Sidebar Component [#customizing-the-sidebar-component] The sidebar component itself is located at `src/components/sidebar.tsx`. You can modify: * **Styling** — Adjust colors, spacing, and animations * **Layout** — Change the sidebar width or positioning * **Behavior** — Add tooltips, badges, or collapse functionality * **Structure** — Reorganize or add new sections The sidebar is just a React component. You're free to completely rewrite it or use a different navigation pattern if needed. Removing Items [#removing-items] Simply delete items you don't need: ```typescript export const SIDEBAR_TOP_ITEMS: SidebarItem[] = [ { icon: Home, label: "Home", href: "/" }, // { icon: Users, label: "Team", href: "/team" }, // removed ]; ``` TypeScript Support [#typescript-support] The `SidebarItem` type provides full TypeScript support. If you need to add custom properties, extend the type in `src/components/ui/sidebar/sidebar-nav-item.tsx`: ```typescript export interface SidebarItem { icon: LucideIcon; label: string; href: string; badge?: number; // Optional badge count disabled?: boolean; // Optional disabled state } ``` # Splash Screen Local.ts includes a splash screen that displays while your app initializes. This creates a polished first impression and prevents users from seeing an empty window during startup. How It Works [#how-it-works] The splash screen uses Tauri's multi-window feature: 1. **On launch** — The splash screen window shows immediately while the main window stays hidden 2. **During initialization** — Your app loads settings, connects to the database, and sets up stores 3. **When ready** — The splash closes and the main window appears This ensures users see a branded loading experience instead of a blank window. Window Configuration [#window-configuration] The windows are defined in `src-tauri/tauri.conf.json`: ```json { "windows": [ { "title": "Local.ts", "label": "main", "visible": false, "width": 1280, "height": 720, "center": true }, { "title": "Loading...", "label": "splashscreen", "url": "splash.html", "width": 1280, "height": 720, "center": true, "decorations": false } ] } ``` Note that `main` has `visible: false` so it stays hidden until initialization completes. Closing the Splash Screen [#closing-the-splash-screen] The `close_splashscreen` command in Rust closes the splash and shows the main window: ```rust #[tauri::command] pub fn close_splashscreen(window: tauri::Window) { if let Some(splash) = window.get_webview_window("splashscreen") { let _ = splash.close(); } if let Some(main) = window.get_webview_window("main") { let _ = main.show(); let _ = main.set_focus(); } } ``` This is called from the React `StoreInitializer` component after all initialization completes: ```typescript useEffect(() => { const init = async () => { try { await initializeSettings(); initializeTheme(); setIsInitialized(true); // Close splash screen and show main window await invoke("close_splashscreen"); } catch (err) { console.error("Failed to initialize:", err); setError(err); // Still close splash on error to show error UI await invoke("close_splashscreen").catch(console.error); } }; init(); }, []); ``` Customizing the Splash Screen [#customizing-the-splash-screen] To customize the splash screen, modify `splash.html` located at your project root. Here is the default template: ```html Loading...

Local.ts

A starter kit for building local-first applications

``` Changing Splash Window Size [#changing-splash-window-size] In `tauri.conf.json`: ```json { "label": "splashscreen", "url": "splash.html", "width": 400, "height": 300, "center": true, "decorations": false } ``` Setting `decorations: false` removes the window title bar for a cleaner look. Error Handling with Retry [#error-handling-with-retry] If initialization fails (for example, a database connection error), the `StoreInitializer` component displays an error screen with a retry button instead of leaving users stuck on the splash screen. The component tracks error state and provides a retry mechanism: ```typescript const [error, setError] = useState(null); const handleRetry = async () => { setError(null); setIsInitialized(false); try { await initializeSettings(); initializeTheme(); setIsInitialized(true); } catch (err) { console.error("Retry failed:", err); setError(err instanceof Error ? err : new Error("Unknown error")); } }; if (error) { return ; } ``` The error UI component shows a clear message and retry button: ```typescript function InitializationError({ error, onRetry }: InitializationErrorProps) { return (

Initialization Error

Failed to load application settings

{error.message}

); } ``` This ensures users always have a path forward, even when something goes wrong during app startup. Removing the Splash Screen [#removing-the-splash-screen] If you prefer to show the main window immediately: 1. **Update `tauri.conf.json`**: ```diff "windows": [ { "title": "Local.ts", "label": "main", - "visible": false, + "visible": true, "width": 1280, "height": 720 }, - { - "title": "Loading...", - "label": "splashscreen", - "url": "splash.html", - "width": 1280, - "height": 720 - } ] ``` 2. **Delete `splash.html`** 3. **Remove the window command** — Delete `src-tauri/src/commands/window.rs` 4. **Update commands module** — Remove the export from `src-tauri/src/commands/mod.rs` 5. **Unregister the command** from `src-tauri/src/lib.rs`: ```diff .invoke_handler(tauri::generate_handler![ commands::settings::get_app_settings, commands::settings::update_app_settings, commands::settings::set_tray_visible, - commands::window::close_splashscreen, ]) ``` 6. **Remove the invoke call** from `src/components/store-initializer.tsx`: ```diff - await invoke("close_splashscreen"); ``` Learn More [#learn-more] Learn more about [Tauri splash screens](https://v2.tauri.app/learn/splashscreen/) by checking the official documentation. # System Tray The system tray lets your app run in the background and provides quick access to common actions. Local.ts includes a fully configured system tray with show/hide controls and settings integration. How It Works [#how-it-works] The system tray provides: * **Tray icon** — Your app icon appears in the system tray (menu bar on macOS, system tray on Windows/Linux) * **Right-click menu** — Show, Hide, and Quit options * **Left-click behavior** — Clicking the icon shows and focuses the main window * **Settings integration** — Users can toggle tray visibility from the Settings page Using the System Tray [#using-the-system-tray] The tray is automatically set up when your app starts. Users can control its visibility from Settings. Toggle Tray Visibility [#toggle-tray-visibility] From your React code: ```typescript import { setTrayVisible } from "@/lib/tauri/settings"; // Show the tray icon await setTrayVisible(true); // Hide the tray icon await setTrayVisible(false); ``` Check Current Visibility [#check-current-visibility] The tray visibility is stored in settings: ```typescript import { useSettings } from "@/stores/settings"; function TrayStatus() { const settings = useSettings((state) => state.settings); return (

Tray is {settings?.showInTray ? "visible" : "hidden"}

); } ``` Customizing the Menu [#customizing-the-menu] The tray menu is defined in `src-tauri/src/plugins/system_tray.rs`. To add or modify menu items: ```rust use tauri::menu::{Menu, MenuItem}; pub fn setup(app: &App, pool: &DbPool) -> Result<(), Box> { // Create menu items let show_i = MenuItem::with_id(app, "show", "Show", true, None::<&str>)?; let hide_i = MenuItem::with_id(app, "hide", "Hide", true, None::<&str>)?; let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; // Build the menu let menu = Menu::with_items(app, &[&show_i, &hide_i, &quit_i])?; // ... rest of setup } ``` Handling Menu Events [#handling-menu-events] Menu events are handled in the `on_menu_event` callback: ```rust .on_menu_event(|app, event| match event.id.as_ref() { "show" => { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); } } "hide" => { if let Some(window) = app.get_webview_window("main") { let _ = window.hide(); } } "quit" => { app.exit(0); } "my_custom_action" => { // Handle your custom action } _ => {} }) ``` Customizing Click Behavior [#customizing-click-behavior] The left-click behavior shows and focuses the main window: ```rust .on_tray_icon_event(|tray, event| { if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { let app = tray.app_handle(); if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); } } }) ``` To open a specific page on click, emit an event or call a command from this handler. Changing the Tray Icon [#changing-the-tray-icon] The tray uses your app's default icon. To use a different icon: ```rust let tray = TrayIconBuilder::new() .icon(app.default_window_icon().unwrap().clone()) // ... rest of builder .build(app)?; ``` Removing the System Tray [#removing-the-system-tray] If you don't need tray functionality: 1. **Delete the module** — Remove `src-tauri/src/plugins/system_tray.rs` 2. **Remove the tray-icon feature** from `src-tauri/Cargo.toml`: ```diff - tauri = { version = "2", features = ["tray-icon"] } + tauri = { version = "2", features = [] } ``` 3. **Remove setup and command** from `src-tauri/src/lib.rs`: ```diff - plugins::system_tray::setup(app, &pool)?; ``` ```diff .invoke_handler(tauri::generate_handler![ commands::settings::get_app_settings, commands::settings::update_app_settings, - commands::settings::set_tray_visible, ]) ``` 4. **Update the plugins module** — Remove the export from `src-tauri/src/plugins/mod.rs` Learn More [#learn-more] * [Tauri System Tray Documentation](https://v2.tauri.app/learn/system-tray/) — Official Tauri menu and tray icon documentation # Theming Local.ts includes a complete theming system with light, dark, and system modes. The theme is persisted in settings and automatically applied on app launch. How Theming Works [#how-theming-works] The theme system has two stores working together: 1. **Settings Store** — Persists the user's theme preference (`light`, `dark`, or `system`) 2. **Theme Store** — Manages the resolved theme and applies it to the DOM When the theme is `system`, the app listens for OS theme changes and updates automatically. Theme Values [#theme-values] | Value | Description | | -------- | ------------------------------------- | | `light` | Always use light mode | | `dark` | Always use dark mode | | `system` | Match the operating system preference | Reading the Theme [#reading-the-theme] Use the `useTheme` hook to access theme state: ```typescript import { useTheme } from "@/stores/theme"; function ThemeIndicator() { // The user's preference: "light" | "dark" | "system" const theme = useTheme((state) => state.theme); // The actual applied theme: "light" | "dark" const resolvedTheme = useTheme((state) => state.resolvedTheme); return (

Preference: {theme}

Applied: {resolvedTheme}

); } ``` Changing the Theme [#changing-the-theme] Use `setTheme` to update the theme. It automatically persists to settings: ```typescript import { useTheme } from "@/stores/theme"; function ThemeSwitcher() { const setTheme = useTheme((state) => state.setTheme); const theme = useTheme((state) => state.theme); return (
); } ``` How Theme Application Works [#how-theme-application-works] The theme store applies themes by adding or removing the `dark` class on the document root: ```typescript function getSystemTheme(): "light" | "dark" { if (typeof window === "undefined") return "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } function applyThemeToDOM(theme: Theme): "light" | "dark" { const resolved = theme === "system" ? getSystemTheme() : theme; const root = document.documentElement; if (resolved === "dark") { root.classList.add("dark"); } else { root.classList.remove("dark"); } return resolved; } ``` This works with Tailwind CSS's dark mode variant. Style dark mode with the `dark:` prefix: ```css .card { background: white; } .dark .card { background: #1a1a1a; } ``` Or with Tailwind utilities: ```html
Content
``` The following line has already been included in your `styles/globals.css`: ```css @custom-variant dark (&:where(.dark, .dark *)); ``` This defines a custom CSS variant called `dark`. It allows you to target any element with the `dark` class, or any of its descendants, for dark mode styling. For additional details, refer to [Tailwind's dark mode guide](https://tailwindcss.com/docs/dark-mode). System Theme Detection [#system-theme-detection] The theme store listens for system theme changes: ```typescript export const useTheme = create((set) => ({ // ... initialize: () => { const settings = useSettings.getState().settings; if (settings) { const resolved = applyThemeToDOM(settings.theme); set({ theme: settings.theme, resolvedTheme: resolved }); } const handleChange = () => { const currentTheme = useTheme.getState().theme; if (currentTheme === "system") { const resolved = applyThemeToDOM("system"); set({ resolvedTheme: resolved }); } }; // Listen for system theme changes const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); mediaQuery.addEventListener("change", handleChange); }, })); ``` When the OS theme changes and the user has selected `system`, the app updates instantly. CSS Variables [#css-variables] Local.ts uses CSS variables for colors, defined in `src/styles/globals.css`. You can customize the color palette: ```css :root { --background: 0 0% 100%; --foreground: 240 10% 3.9%; --primary: 240 5.9% 10%; /* ... more variables */ } .dark { --background: 240 10% 3.9%; --foreground: 0 0% 98%; --primary: 0 0% 98%; /* ... dark mode overrides */ } ``` Then use them in your styles: ```css .my-component { background: hsl(var(--background)); color: hsl(var(--foreground)); } ``` Accessing Theme Outside React [#accessing-theme-outside-react] To access Zustand store state outside of React components, use the following approach: ```typescript import { useTheme } from "@/stores/theme"; // Get current theme const { theme, resolvedTheme } = useTheme.getState(); // Check if dark mode const isDark = resolvedTheme === "dark"; ``` # Window State Local.ts uses the [Tauri window state plugin](https://v2.tauri.app/plugin/window-state/) to automatically remember your window size, position, and state across app restarts. This creates a polished desktop experience where users don't have to resize their window every time they open the app. What Gets Saved [#what-gets-saved] The window state plugin tracks: | Property | Description | | ----------- | -------------------------------------- | | Size | Window width and height | | Position | Window X and Y coordinates | | Maximized | Whether the window is maximized | | Fullscreen | Whether the window is in fullscreen | | Decorations | Whether window decorations are visible | How It Works [#how-it-works] Initialization [#initialization] The plugin is initialized during app setup and restores state for all windows: ```rust // src-tauri/src/lib.rs .setup(|app| { plugins::logging::init(app); // Initialize window state plugin #[cfg(desktop)] plugins::window_state::init(app)?; // ... other setup code Ok(()) }) ``` The `init` function registers the plugin and restores saved state. Saving State [#saving-state] Window state is saved automatically when the window closes: ```rust // src-tauri/src/lib.rs .on_window_event(|window, event| { #[cfg(desktop)] if let tauri::WindowEvent::CloseRequested { .. } = event { plugins::window_state::on_close_requested(window); } }) ``` The `on_close_requested` function handles the save operation. Customizing What's Saved [#customizing-whats-saved] You can choose which properties to persist by modifying the `StateFlags` in the plugin module: ```rust // src-tauri/src/plugins/window_state.rs pub fn init(app: &App) -> Result<(), Box> { // ... plugin registration ... // Only restore size and position let flags = StateFlags::SIZE | StateFlags::POSITION; for (label, window) in windows { if let Err(err) = window.restore_state(flags) { log::warn!("Failed to restore state for window '{}': {}", label, err); } } Ok(()) } pub fn on_close_requested(window: &Window) { // Only save size and position let flags = StateFlags::SIZE | StateFlags::POSITION; if let Err(err) = window.app_handle().save_window_state(flags) { log::warn!( "Failed to save window state for '{}': {}", window.label(), err ); } } ``` Available flags: | Flag | What It Saves | | ------------------------- | ------------------ | | `StateFlags::SIZE` | Window dimensions | | `StateFlags::POSITION` | Window location | | `StateFlags::MAXIMIZED` | Maximized state | | `StateFlags::VISIBLE` | Visibility state | | `StateFlags::DECORATIONS` | Window decorations | | `StateFlags::FULLSCREEN` | Fullscreen state | | `StateFlags::all()` | All of the above | Default Window Size [#default-window-size] Set default dimensions in `src-tauri/tauri.conf.json` for first launch before any state is saved: ```json { "windows": [{ "title": "Local.ts", "label": "main", "visible": false, "width": 1280, "height": 720, "center": true }] } ``` These defaults are used only on first launch. Error Handling [#error-handling] The window state plugin uses proper error handling with logging: * **Restore failures** are logged as warnings but don't prevent the app from starting * **Save failures** are logged as warnings to help debug state persistence issues * Window labels are included in error messages for easier troubleshooting This ensures that state management issues won't crash your application. Saving State Periodically [#saving-state-periodically] Instead of (or in addition to) saving on close, you can save state periodically by adding a function to the plugin: ```rust // src-tauri/src/plugins/window_state.rs use std::time::Duration; pub fn start_periodic_save(app: &App) { let app_handle = app.handle().clone(); std::thread::spawn(move || { loop { std::thread::sleep(Duration::from_secs(300)); // 5 minutes if let Err(err) = app_handle.save_window_state(StateFlags::all()) { log::warn!("Failed to save window state periodically: {}", err); } } }); } ``` Then call it from your setup: ```rust // src-tauri/src/lib.rs .setup(|app| { #[cfg(desktop)] { plugins::window_state::init(app)?; plugins::window_state::start_periodic_save(app); } Ok(()) }) ``` This protects against state loss if the app crashes. The spawned thread runs for the application's entire lifetime — it will only exit when the application terminates, so make sure any resources accessed from that thread are safe to use for the duration of the process. Removing Window State [#removing-window-state] If you don't need window state persistence: 1. **Remove the plugin module** at `src-tauri/src/plugins/window_state.rs` 2. **Remove the initialization** from `src-tauri/src/lib.rs`: ```diff - #[cfg(desktop)] - plugins::window_state::init(app)?; ``` 3. **Remove the window event handler** from `src-tauri/src/lib.rs`: ```diff - .on_window_event(|window, event| { - #[cfg(desktop)] - if let tauri::WindowEvent::CloseRequested { .. } = event { - plugins::window_state::on_close_requested(window); - } - }) ``` 4. **Remove the dependency** from `src-tauri/Cargo.toml`: ```diff - tauri-plugin-window-state = "2" ``` 5. **Remove permissions** from `src-tauri/capabilities/default.json`: ```diff - "window-state:default" ``` # API Methods The `api` object provides convenient methods for common HTTP verbs. All methods require a schema for validation and return fully typed, validated data. Overview [#overview] The `api` object includes methods for the five most common HTTP verbs: | Method | Use Case | | -------------- | ------------------------------------- | | `api.get()` | Retrieve data (users, products, etc.) | | `api.post()` | Create new resources | | `api.put()` | Replace entire resources | | `api.patch()` | Partially update resources | | `api.delete()` | Remove resources | All methods automatically: * Set `Content-Type: application/json` for request bodies * JSON-stringify objects passed as `body` * Validate responses against your schema * Provide full TypeScript type inference api.get() [#apiget] Retrieves data from the server. Use for fetching resources. ```typescript api.get(url, schema, options?) ``` Example: Fetching a User Profile [#example-fetching-a-user-profile] ```typescript import { z } from "zod"; import { api } from "@zap-studio/fetch"; const UserProfileSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), avatar: z.string().url().nullable(), bio: z.string().optional(), joinedAt: z.string(), }); async function getUserProfile(userId: string) { const profile = await api.get( `https://api.example.com/users/${userId}`, UserProfileSchema ); return profile; // Type: { id: string; name: string; email: string; avatar: string | null; bio?: string; joinedAt: string } } ``` Example: Fetching with Query Parameters [#example-fetching-with-query-parameters] ```typescript const ProductListSchema = z.object({ products: z.array( z.object({ id: z.string(), name: z.string(), price: z.number(), }) ), total: z.number(), page: z.number(), perPage: z.number(), }); async function searchProducts(query: string, page = 1) { const url = new URL("https://api.example.com/products"); url.searchParams.set("q", query); url.searchParams.set("page", String(page)); url.searchParams.set("limit", "20"); return api.get(url.toString(), ProductListSchema); } // Usage const results = await searchProducts("laptop", 2); console.log(`Found ${results.total} products`); ``` api.post() [#apipost] Creates new resources on the server. The request body is automatically JSON-stringified. ```typescript api.post(url, schema, options?) ``` Example: Creating a New User [#example-creating-a-new-user] ```typescript const CreateUserResponseSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), createdAt: z.string(), }); async function createUser(name: string, email: string, password: string) { const user = await api.post( "https://api.example.com/users", CreateUserResponseSchema, { body: { name, email, password, }, } ); console.log(`User ${user.id} created at ${user.createdAt}`); return user; } ``` Example: Submitting a Form [#example-submitting-a-form] ```typescript const SubmissionSchema = z.object({ id: z.string(), status: z.enum(["pending", "approved", "rejected"]), submittedAt: z.string(), }); async function submitContactForm(data: { name: string; email: string; message: string; }) { return api.post("https://api.example.com/contact", SubmissionSchema, { body: data, }); } // Usage const submission = await submitContactForm({ name: "Jane Doe", email: "jane@example.com", message: "Hello! I have a question about your product.", }); if (submission.status === "pending") { console.log("Your message has been received!"); } ``` api.put() [#apiput] Replaces an entire resource. Use when you want to update all fields. ```typescript api.put(url, schema, options?) ``` Example: Updating User Settings [#example-updating-user-settings] ```typescript const UserSettingsSchema = z.object({ userId: z.string(), theme: z.enum(["light", "dark", "system"]), language: z.string(), notifications: z.object({ email: z.boolean(), push: z.boolean(), sms: z.boolean(), }), updatedAt: z.string(), }); type UserSettings = z.infer; async function updateSettings( userId: string, settings: Omit ) { return api.put( `https://api.example.com/users/${userId}/settings`, UserSettingsSchema, { body: settings, } ); } // Usage: replaces ALL settings const updated = await updateSettings("user-123", { theme: "dark", language: "en-US", notifications: { email: true, push: true, sms: false, }, }); ``` api.patch() [#apipatch] Partially updates a resource. Use when you only want to change specific fields. ```typescript api.patch(url, schema, options?) ``` Example: Updating Profile Fields [#example-updating-profile-fields] ```typescript const ProfileSchema = z.object({ id: z.string(), name: z.string(), bio: z.string().nullable(), website: z.string().url().nullable(), updatedAt: z.string(), }); async function updateProfile( userId: string, updates: Partial<{ name: string; bio: string; website: string }> ) { return api.patch( `https://api.example.com/users/${userId}/profile`, ProfileSchema, { body: updates, } ); } // Usage: only updates the bio field const profile = await updateProfile("user-123", { bio: "Software engineer passionate about TypeScript", }); ``` Example: Toggling a Feature [#example-toggling-a-feature] ```typescript const FeatureToggleSchema = z.object({ feature: z.string(), enabled: z.boolean(), updatedAt: z.string(), }); async function toggleFeature( userId: string, feature: string, enabled: boolean ) { return api.patch( `https://api.example.com/users/${userId}/features/${feature}`, FeatureToggleSchema, { body: { enabled }, } ); } ``` api.delete() [#apidelete] Removes a resource from the server. ```typescript api.delete(url, schema, options?) ``` Example: Deleting a Post [#example-deleting-a-post] ```typescript const DeleteResponseSchema = z.object({ success: z.boolean(), deletedAt: z.string(), }); async function deletePost(postId: string) { const result = await api.delete( `https://api.example.com/posts/${postId}`, DeleteResponseSchema ); if (result.success) { console.log(`Post deleted at ${result.deletedAt}`); } return result; } ``` Example: Removing from Cart [#example-removing-from-cart] ```typescript const CartSchema = z.object({ id: z.string(), items: z.array( z.object({ productId: z.string(), quantity: z.number(), }) ), total: z.number(), }); async function removeFromCart(cartId: string, productId: string) { // Returns the updated cart return api.delete( `https://api.example.com/carts/${cartId}/items/${productId}`, CartSchema ); } ``` Request Options [#request-options] All methods accept an optional `options` parameter: | Option | Type | Default | Description | | ------------------------ | ------------------------------------------- | ------- | ------------------------------------------- | | `body` | `object \| BodyInit` | — | Request body (auto-stringified for objects) | | `headers` | `HeadersInit` | — | Additional request headers | | `searchParams` | `URLSearchParams \| Record` | — | Query parameters | | `throwOnFetchError` | `boolean` | `true` | Throw `FetchError` on non-2xx responses | | `throwOnValidationError` | `boolean` | `true` | Throw `ValidationError` on schema failures | Plus all standard [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) options. Real-World Example: Blog API Client [#real-world-example-blog-api-client] ```typescript import { z } from "zod"; import { createFetch } from "@zap-studio/fetch"; // Schemas const PostSchema = z.object({ id: z.string(), title: z.string(), content: z.string(), authorId: z.string(), published: z.boolean(), createdAt: z.string(), updatedAt: z.string(), }); const PostListSchema = z.object({ posts: z.array(PostSchema), total: z.number(), hasMore: z.boolean(), }); const CommentSchema = z.object({ id: z.string(), postId: z.string(), authorId: z.string(), content: z.string(), createdAt: z.string(), }); // Create API client const { api } = createFetch({ baseURL: "https://api.myblog.com/v1", headers: { Authorization: `Bearer ${getAuthToken()}`, }, }); // Blog API functions export const blogApi = { // Get all posts async getPosts(page = 1, limit = 10) { return api.get("/posts", PostListSchema, { searchParams: { page: String(page), limit: String(limit) }, }); }, // Get single post async getPost(id: string) { return api.get(`/posts/${id}`, PostSchema); }, // Create post async createPost(data: { title: string; content: string }) { return api.post("/posts", PostSchema, { body: data }); }, // Update post async updatePost( id: string, data: Partial<{ title: string; content: string; published: boolean }> ) { return api.patch(`/posts/${id}`, PostSchema, { body: data }); }, // Delete post async deletePost(id: string) { return api.delete(`/posts/${id}`, z.object({ success: z.boolean() })); }, // Add comment async addComment(postId: string, content: string) { return api.post(`/posts/${postId}/comments`, CommentSchema, { body: { content }, }); }, }; // Usage const { posts, hasMore } = await blogApi.getPosts(); const newPost = await blogApi.createPost({ title: "My First Post", content: "Hello, world!", }); await blogApi.updatePost(newPost.id, { published: true }); ``` Note on Raw Responses [#note-on-raw-responses] The `api.*` methods always require a schema for validation. If you need raw responses without validation, use [`$fetch`](./fetch-function.mdx) directly. # Factory Pattern The `createFetch` function allows you to create pre-configured fetch instances with base URLs and default headers. This is ideal for building API clients that need consistent configuration across requests. Why Use createFetch? [#why-use-createfetch] When building applications, you often make requests to the same API with the same headers (authentication, content type, etc.). Instead of repeating this configuration: ```typescript // Without createFetch - repetitive const user = await $fetch("https://api.example.com/users/1", UserSchema, { headers: { Authorization: "Bearer token", "X-API-Key": "key" }, }); const posts = await $fetch("https://api.example.com/posts", PostsSchema, { headers: { Authorization: "Bearer token", "X-API-Key": "key" }, }); ``` You can configure once and reuse: ```typescript // With createFetch - clean and DRY const { api } = createFetch({ baseURL: "https://api.example.com", headers: { Authorization: "Bearer token", "X-API-Key": "key" }, }); const user = await api.get("/users/1", UserSchema); const posts = await api.get("/posts", PostsSchema); ``` Basic Usage [#basic-usage] ```typescript import { z } from "zod"; import { createFetch } from "@zap-studio/fetch"; const { $fetch, api } = createFetch({ baseURL: "https://api.example.com", headers: { Authorization: "Bearer your-token", "X-API-Key": "your-api-key", }, }); const UserSchema = z.object({ id: z.number(), name: z.string(), }); // Use relative paths - baseURL is prepended automatically const user = await api.get("/users/1", UserSchema); // POST with auto-stringified body const newUser = await api.post("/users", UserSchema, { body: { name: "John Doe" }, }); ``` Factory Options [#factory-options] | Option | Type | Default | Description | | ------------------------ | ------------------------------------------- | ------- | ----------------------------------------------------- | | `baseURL` | `string` | `""` | Base URL prepended to relative paths only | | `headers` | `HeadersInit` | - | Default headers included in all requests | | `searchParams` | `URLSearchParams \| Record` | - | Default query params included in all requests | | `throwOnFetchError` | `boolean` | `true` | Throw `FetchError` on non-2xx responses | | `throwOnValidationError` | `boolean` | `true` | Throw `ValidationError` on schema validation failures | URL Handling [#url-handling] Relative URLs [#relative-urls] Relative URLs are automatically prefixed with the `baseURL`: ```typescript const { api } = createFetch({ baseURL: "https://api.example.com" }); // Fetches https://api.example.com/users const users = await api.get("/users", UsersSchema); // Leading slash is optional const user = await api.get("users/1", UserSchema); ``` Absolute URLs [#absolute-urls] Absolute URLs (starting with `http://`, `https://`, or `//`) ignore the `baseURL`: ```typescript const { api } = createFetch({ baseURL: "https://api.example.com" }); // Fetches https://other-api.com/data (ignores baseURL) const data = await api.get("https://other-api.com/data", DataSchema); ``` Header Merging [#header-merging] Default headers from the factory are merged with per-request headers. Per-request headers take precedence: ```typescript const { api } = createFetch({ baseURL: "https://api.example.com", headers: { Authorization: "Bearer default-token", "Content-Type": "application/json", }, }); // This request will have: // - Authorization: Bearer override-token (overridden) // - Content-Type: application/json (from defaults) // - X-Custom: value (new header) const user = await api.get("/users/1", UserSchema, { headers: { Authorization: "Bearer override-token", "X-Custom": "value", }, }); ``` Search Params Merging [#search-params-merging] Default search params are merged with per-request and URL params: ```typescript const { api } = createFetch({ baseURL: "https://api.example.com", searchParams: { locale: "en", page: "1" }, }); // Final URL: /users?locale=en&page=2&q=alex // - locale: en (from defaults) // - page: 2 (overridden by per-request) // - q: alex (new param) const user = await api.get("/users", UserSchema, { searchParams: { page: "2", q: "alex" }, }); ``` Priority order: 1. **Factory defaults** — lowest priority 2. **URL params** — override factory defaults 3. **Per-request params** — highest priority Real-World Examples [#real-world-examples] Multiple API Clients [#multiple-api-clients] Create separate clients for different APIs in your application: ```typescript import { z } from "zod"; import { createFetch } from "@zap-studio/fetch"; // GitHub API client const github = createFetch({ baseURL: "https://api.github.com", headers: { Authorization: `token ${process.env.GITHUB_TOKEN}`, Accept: "application/vnd.github.v3+json", }, }); // Stripe API client const stripe = createFetch({ baseURL: "https://api.stripe.com/v1", headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`, }, }); // Internal API client const internal = createFetch({ baseURL: process.env.API_URL, headers: { "X-Internal-Key": process.env.INTERNAL_API_KEY, }, }); // Schemas const RepoSchema = z.object({ id: z.number(), name: z.string(), full_name: z.string(), private: z.boolean(), }); const CustomerSchema = z.object({ id: z.string(), email: z.string(), name: z.string().nullable(), }); // Usage const repo = await github.api.get("/repos/owner/repo", RepoSchema); const customer = await stripe.api.get("/customers/cus_123", CustomerSchema); ``` Configuring Error Behavior [#configuring-error-behavior] You can configure default error throwing behavior at the factory level: ```typescript // Never throw validation errors by default const { api } = createFetch({ baseURL: "https://api.example.com", throwOnValidationError: false, }); // All requests return Result objects instead of throwing const result = await api.get("/users/1", UserSchema); if (result.issues) { console.error("Validation failed:", result.issues); } else { console.log("Success:", result.value); } ``` You can still override this per-request: ```typescript // Override to throw for this specific request const user = await api.get("/users/1", UserSchema, { throwOnValidationError: true, }); ``` Return Type [#return-type] `createFetch` returns an object with both `$fetch` and `api`: ```typescript const { $fetch, api } = createFetch({ baseURL: "https://api.example.com", }); // Use api.* for convenience methods const user = await api.get("/users/1", UserSchema); // Use $fetch for raw responses or more control const response = await $fetch("/health"); console.log("Status:", response.status); ``` # Error Handling `fetch` provides two specialized error classes for granular error handling: `FetchError` for HTTP errors and `ValidationError` for schema validation failures. Why Custom Error Classes? [#why-custom-error-classes] When fetching data, many things can go wrong: * **Network errors** — Connection failed, timeout, DNS issues * **HTTP errors** — 404 Not Found, 401 Unauthorized, 500 Server Error * **Validation errors** — API returned data that doesn't match your schema Custom error classes let you handle each case appropriately: ```typescript try { const user = await api.get("/users/1", UserSchema); } catch (error) { if (error instanceof FetchError) { // HTTP error - check status code } else if (error instanceof ValidationError) { // Data doesn't match schema } else { // Network or other error } } ``` Importing Error Classes [#importing-error-classes] ```typescript import { FetchError, ValidationError } from "@zap-studio/fetch/errors"; ``` FetchError [#fetcherror] Thrown when the HTTP response status is not ok (non-2xx status codes) and `throwOnFetchError` is `true` (default). Properties [#properties] | Property | Type | Description | | ---------- | ---------- | ------------------------------ | | `name` | `string` | Always `"FetchError"` | | `message` | `string` | Error message with status info | | `status` | `number` | HTTP status code | | `response` | `Response` | The full Response object | Basic Example [#basic-example] ```typescript import { api } from "@zap-studio/fetch"; import { FetchError } from "@zap-studio/fetch/errors"; try { const user = await api.get("/api/users/999", UserSchema); } catch (error) { if (error instanceof FetchError) { console.error(`HTTP Error ${error.status}: ${error.message}`); // Access the full response const body = await error.response.text(); console.error("Response body:", body); // Handle specific status codes if (error.status === 404) { console.log("User not found"); } else if (error.status === 401) { console.log("Unauthorized - please log in"); } } } ``` ValidationError [#validationerror] Thrown when schema validation fails and `throwOnValidationError` is `true` (default). Properties [#properties-1] | Property | Type | Description | | --------- | -------------------------- | -------------------------------- | | `name` | `string` | Always `"ValidationError"` | | `message` | `string` | JSON-formatted validation issues | | `issues` | `StandardSchemaV1.Issue[]` | Array of validation issues | Issue Structure [#issue-structure] Each issue follows the Standard Schema format: ```typescript interface Issue { message: string; // Human-readable error message path?: PropertyKey[]; // Path to the invalid field } ``` Basic Example [#basic-example-1] ```typescript import { api } from "@zap-studio/fetch"; import { ValidationError } from "@zap-studio/fetch/errors"; try { const user = await api.get("/api/users/1", UserSchema); } catch (error) { if (error instanceof ValidationError) { console.error("Validation failed!"); for (const issue of error.issues) { const path = issue.path?.join(".") ?? "root"; console.error(` - ${path}: ${issue.message}`); } } } ``` Combined Error Handling [#combined-error-handling] Handle both error types in a single try-catch: ```typescript import { z } from "zod"; import { api } from "@zap-studio/fetch"; import { FetchError, ValidationError } from "@zap-studio/fetch/errors"; const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email(), }); type FetchResult = | { success: true; data: T } | { success: false; error: string; code: string }; async function safeGetUser(id: string): Promise>> { try { const data = await api.get(`/api/users/${id}`, UserSchema); return { success: true, data }; } catch (error) { if (error instanceof FetchError) { if (error.status === 404) { return { success: false, error: "User not found", code: "NOT_FOUND" }; } if (error.status === 401) { return { success: false, error: "Please log in", code: "UNAUTHORIZED" }; } return { success: false, error: `Server error: ${error.status}`, code: "SERVER_ERROR" }; } if (error instanceof ValidationError) { return { success: false, error: "Invalid data received from server", code: "VALIDATION_ERROR", }; } return { success: false, error: "Network error", code: "NETWORK_ERROR" }; } } // Usage const result = await safeGetUser("123"); if (result.success) { console.log(`Hello, ${result.data.name}!`); } else { console.error(`[${result.code}] ${result.error}`); } ``` Disabling Error Throwing [#disabling-error-throwing] You can disable automatic error throwing to handle errors manually. Disabling FetchError [#disabling-fetcherror] ```typescript const response = await $fetch("/api/users/999", { throwOnFetchError: false, }); // Check status manually if (!response.ok) { console.log("Request failed:", response.status); } ``` Disabling ValidationError [#disabling-validationerror] When disabled, validation returns a Result object instead of throwing: ```typescript const result = await $fetch("/api/users/1", UserSchema, { throwOnValidationError: false, }); if (result.issues) { // Validation failed console.error("Validation issues:", result.issues); } else { // Validation succeeded console.log("User:", result.value); } ``` Result Type [#result-type] When `throwOnValidationError` is `false`, the return type is a Standard Schema Result: ```typescript type Result = | { value: T; issues?: undefined } | { value?: undefined; issues: Issue[] }; ``` Best Practices [#best-practices] 1. **Always handle both error types** when making API calls 2. **Use specific error handlers** for different status codes 3. **Log validation issues** to help debug API response changes 4. **Consider disabling throws** for expected error cases (like 404s) 5. **Re-throw unexpected errors** to avoid silently swallowing issues 6. **Parse error responses** to get structured error information from APIs # Using $fetch The `$fetch` function provides more control over your requests. You can use it with or without schema validation, making it flexible for different use cases. When to Use $fetch [#when-to-use-fetch] Use `$fetch` instead of `api.*` methods when you need: * Raw `Response` objects for headers, status codes, or streaming * Non-JSON responses (binary files, text, HTML) * Conditional validation based on response status * More control over the request/response cycle With Schema Validation [#with-schema-validation] When you pass a schema, `$fetch` validates the response and returns typed data: ```typescript import { z } from "zod"; import { $fetch } from "@zap-studio/fetch"; const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), }); const user = await $fetch("https://api.example.com/users/1", UserSchema, { headers: { Authorization: "Bearer token", }, }); // user is typed as { id: number; name: string; email: string } console.log(user.name); ``` Without Schema Validation [#without-schema-validation] When you don't pass a schema, `$fetch` returns the raw `Response` object: ```typescript import { $fetch } from "@zap-studio/fetch"; const response = await $fetch("https://api.example.com/users/1"); // response is a standard Response object console.log(response.status); // 200 console.log(response.headers); // Headers object const data = await response.json(); // Manual parsing ``` Function Signatures [#function-signatures] `$fetch` has two overloaded signatures: ```typescript // With schema - returns validated data async function $fetch( resource: string, schema: TSchema, options?: ExtendedRequestInit ): Promise>; // Without schema - returns raw Response async function $fetch( resource: string, options?: ExtendedRequestInit ): Promise; ``` Extended Request Options [#extended-request-options] | Option | Type | Default | Description | | ------------------------ | ------------------------------------------- | ------- | ----------------------------------------------------- | | `body` | `BodyInit \| JsonValue` | - | Request body (auto-stringified for plain JSON values) | | `searchParams` | `URLSearchParams \| Record` | - | Query parameters | | `throwOnFetchError` | `boolean` | `true` | Throw `FetchError` on non-2xx responses | | `throwOnValidationError` | `boolean` | `true` | Throw `ValidationError` on schema validation failures | Plus all standard [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) options. Examples [#examples] POST with JSON body [#post-with-json-body] ```typescript const user = await $fetch("https://api.example.com/users", UserSchema, { method: "POST", body: { name: "John Doe", email: "john@example.com", }, }); ``` GET with query parameters [#get-with-query-parameters] ```typescript const url = new URL("https://api.example.com/users"); url.searchParams.set("page", "1"); url.searchParams.set("limit", "10"); const users = await $fetch(url.toString(), UsersSchema); ``` Handling non-JSON responses [#handling-non-json-responses] ```typescript const response = await $fetch("https://api.example.com/file.pdf"); if (response.ok) { const blob = await response.blob(); // Handle the binary data } ``` Custom headers [#custom-headers] ```typescript const user = await $fetch("https://api.example.com/users/1", UserSchema, { headers: { Authorization: "Bearer token", "Accept-Language": "en-US", "X-Request-ID": crypto.randomUUID(), }, }); ``` # Overview A type-safe fetch wrapper with Standard Schema validation. Why fetch? [#why-fetch] When fetching data from APIs, TypeScript can't verify that the response matches your expected type. You end up with unsafe type assertions that can cause runtime errors. **Before:** ```typescript const response = await fetch("/api/users/1"); const data = await response.json(); const user = data as User; // 😱 Unsafe type assertion // If the API returns { name: "John" } instead of { id: 1, name: "John" }, // your app breaks at runtime with no warning ``` **After:** ```typescript import { api } from "@zap-studio/fetch"; const user = await api.get("/api/users/1", UserSchema); // ✨ Typed, validated, and safe! // If the API response doesn't match UserSchema, you get a clear error ``` Features [#features] * **Type-safe requests** with automatic type inference * **Runtime validation** using Standard Schema (Zod, Valibot, ArkType, etc.) * **Convenient API methods** (GET, POST, PUT, PATCH, DELETE) * **Factory pattern** for creating pre-configured instances with base URLs * **Custom error handling** with FetchError and ValidationError classes * **Full TypeScript support** with zero configuration Installation [#installation] npm yarn pnpm bun deno ```bash npm install @zap-studio/fetch ``` ```bash yarn add @zap-studio/fetch ``` ```bash pnpm add @zap-studio/fetch ``` ```bash bun add @zap-studio/fetch ``` ```bash deno add jsr:@zap-studio/fetch ``` Quick Start [#quick-start] Let's build a type-safe API client for a user management system. 1. Define Your Schema [#1-define-your-schema] First, define the shape of your API responses using any Standard Schema library: ```typescript import { z } from "zod"; // Define the user schema const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), role: z.enum(["admin", "user", "guest"]), createdAt: z.string().transform((s) => new Date(s)), }); type User = z.infer; ``` 2. Make Type-Safe Requests [#2-make-type-safe-requests] ```typescript import { api } from "@zap-studio/fetch"; // Fetch a single user - fully typed and validated const user = await api.get("https://api.example.com/users/1", UserSchema); console.log(user.name); // TypeScript knows this is a string console.log(user.createdAt); // TypeScript knows this is a Date ``` 3. Handle Errors Gracefully [#3-handle-errors-gracefully] ```typescript import { FetchError, ValidationError } from "@zap-studio/fetch/errors"; try { const user = await api.get("https://api.example.com/users/1", UserSchema); console.log(`Hello, ${user.name}!`); } catch (error) { if (error instanceof FetchError) { // HTTP error (404, 500, etc.) console.error(`API error: ${error.status}`); } else if (error instanceof ValidationError) { // Response didn't match schema console.error("Invalid API response:", error.issues); } } ``` # Validation `fetch` uses [Standard Schema](https://standardschema.dev/) for runtime validation, which means it works with any schema library that implements the Standard Schema specification. Why Validation Matters [#why-validation-matters] APIs change. Without runtime validation, you might get data that doesn't match your TypeScript types, causing subtle bugs that are hard to track down: ```typescript // Without validation const user = await fetch("/api/users/1").then((r) => r.json()) as User; // What if the API returns { id: "123" } instead of { id: 123 }? // TypeScript thinks id is a number, but it's actually a string! user.id + 1; // "1231" instead of 124 😱 ``` With `fetch`, you get runtime validation that catches these issues immediately: ```typescript // With validation const user = await api.get("/api/users/1", UserSchema); // If the API returns { id: "123" }, you get a ValidationError // instead of silent type mismatch ``` Supported Schema Libraries [#supported-schema-libraries] Any library implementing Standard Schema v1 is supported: * [Zod](https://zod.dev/) — The most popular TypeScript-first schema library * [Valibot](https://valibot.dev/) — Smaller bundle size alternative * [ArkType](https://arktype.io/) — 1:1 TypeScript syntax * [TypeBox](https://github.com/sinclairzx81/typebox) — JSON Schema compatible * And more... Using Different Schema Libraries [#using-different-schema-libraries] Zod [#zod] ```typescript import { z } from "zod"; import { api } from "@zap-studio/fetch"; const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), createdAt: z.string().datetime(), }); const user = await api.get("/users/1", UserSchema); ``` Valibot [#valibot] ```typescript import * as v from "valibot"; import { api } from "@zap-studio/fetch"; const UserSchema = v.object({ id: v.number(), name: v.string(), email: v.pipe(v.string(), v.email()), createdAt: v.pipe(v.string(), v.isoTimestamp()), }); const user = await api.get("/users/1", UserSchema); ``` ArkType [#arktype] ```typescript import { type } from "arktype"; import { api } from "@zap-studio/fetch"; const UserSchema = type({ id: "number", name: "string", email: "email", createdAt: "string", }); const user = await api.get("/users/1", UserSchema); ``` The standardValidate Helper [#the-standardvalidate-helper] For standalone validation needs, use the `standardValidate` helper: ```typescript import { standardValidate } from "@zap-studio/fetch/validator"; import { z } from "zod"; const UserSchema = z.object({ id: z.number(), name: z.string(), }); // Throwing validation (default) const user = await standardValidate(UserSchema, data, true); // Returns validated data or throws ValidationError // Non-throwing validation const result = await standardValidate(UserSchema, data, false); // Returns { value: T } or { issues: Issue[] } if (result.issues) { console.error("Validation failed:", result.issues); } else { console.log("Valid user:", result.value); } ``` isStandardSchema Type Guard [#isstandardschema-type-guard] Check if a value is a Standard Schema: ```typescript import { isStandardSchema } from "@zap-studio/fetch/validator"; const schema = z.object({ name: z.string() }); if (isStandardSchema(schema)) { // TypeScript knows schema is StandardSchemaV1 const result = schema["~standard"].validate(data); } ``` Validation Flow [#validation-flow] When you pass a schema to `$fetch` or `api.*` methods: 1. The HTTP request is made 2. The response JSON is parsed 3. The data is validated against your schema 4. If valid, the typed data is returned 5. If invalid and `throwOnValidationError: true`, a `ValidationError` is thrown 6. If invalid and `throwOnValidationError: false`, a Result object is returned ```typescript // Simplified internal flow const response = await fetch(url, options); const rawData = await response.json(); const validatedData = await standardValidate(schema, rawData, throwOnValidationError); return validatedData; ``` Handling Validation Results [#handling-validation-results] Throwing Mode (Default) [#throwing-mode-default] ```typescript import { ValidationError } from "@zap-studio/fetch/errors"; try { const user = await api.get("/users/1", UserSchema); // user is fully typed: { id: number; name: string; email: string; } } catch (error) { if (error instanceof ValidationError) { for (const issue of error.issues) { console.error(`${issue.path?.join(".")}: ${issue.message}`); } } } ``` Non-Throwing Mode [#non-throwing-mode] ```typescript const result = await api.get("/users/1", UserSchema, { throwOnValidationError: false, }); if (result.issues) { result.issues.forEach((issue) => { console.error(`${issue.path?.join(".")}: ${issue.message}`); }); } else { console.log("User name:", result.value.name); } ``` Type Inference [#type-inference] Types are automatically inferred from your schema: ```typescript const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), roles: z.array(z.enum(["admin", "user"])), }); const user = await api.get("/users/1", UserSchema); // TypeScript infers: // { // id: number; // name: string; // email: string; // roles: ("admin" | "user")[]; // } ``` Best Practices [#best-practices] 1. Define Schemas Once, Reuse Everywhere [#1-define-schemas-once-reuse-everywhere] ```typescript // schemas/user.ts import { z } from "zod"; export const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), createdAt: z.string().transform((s) => new Date(s)), }); export type User = z.infer; // api/users.ts import { api } from "@zap-studio/fetch"; import { UserSchema, type User } from "@/schemas/user"; export async function getUser(id: number): Promise { return api.get(`/users/${id}`, UserSchema); } ``` 2. Use Strict Schemas [#2-use-strict-schemas] Validate exactly what you expect: ```typescript // Too loose - accepts any extra fields const LooseSchema = z.object({ id: z.number() }); // Better - rejects unknown fields const StrictSchema = z.object({ id: z.number() }).strict(); ``` 3. Handle Validation Errors Gracefully [#3-handle-validation-errors-gracefully] APIs can change unexpectedly: ```typescript async function getUser(id: string) { try { return await api.get(`/users/${id}`, UserSchema); } catch (error) { if (error instanceof ValidationError) { // Log for debugging but don't crash console.error("API response changed:", error.issues); return null; } throw error; } } ``` 4. Use Schema Transforms [#4-use-schema-transforms] Parse and transform data in your schema: ```typescript const DateSchema = z.string().transform((s) => new Date(s)); const PriceSchema = z.number().transform((cents) => ({ cents, dollars: cents / 100, formatted: `$${(cents / 100).toFixed(2)}`, })); ``` 5. Compose Schemas [#5-compose-schemas] Build complex schemas from simpler parts: ```typescript const AddressSchema = z.object({ street: z.string(), city: z.string(), country: z.string(), }); const UserSchema = z.object({ id: z.string(), name: z.string(), address: AddressSchema.optional(), }); const OrderSchema = z.object({ id: z.string(), user: UserSchema, shippingAddress: AddressSchema, billingAddress: AddressSchema.optional(), }); ``` # Conditions Conditions are the building blocks for creating complex authorization rules. `permit` provides combinators to compose simple conditions into powerful, readable policies. Understanding Conditions [#understanding-conditions] A condition is a function that evaluates to `true` or `false`: ```typescript type ConditionFn = ( context: TContext, action: TAction, resource: TResource ) => boolean; ``` Conditions are used with `when()` to create policy rules: ```typescript when((ctx, action, resource) => /* condition logic */) ``` Condition Combinators [#condition-combinators] and() [#and] The `and()` combinator creates a condition that passes only when **all** conditions are true. It short-circuits on the first `false` value. ```typescript import { and } from "@zap-studio/permit"; and(...conditions) ``` Example: Multiple Requirements [#example-multiple-requirements] ```typescript import { createPolicy, when, and } from "@zap-studio/permit"; // User must be authenticated AND be the owner AND post must be published const canEditPublishedPost = and( (ctx) => ctx.user !== null, (ctx, _, post) => ctx.user?.id === post.authorId, (_, __, post) => post.status === "published" ); const policy = createPolicy({ resources, actions, rules: { post: { edit: when(canEditPublishedPost), }, }, }); ``` or() [#or] The `or()` combinator creates a condition that passes when **any** condition is true. It short-circuits on the first `true` value. ```typescript import { or } from "@zap-studio/permit"; or(...conditions) ``` Example: Multiple Access Paths [#example-multiple-access-paths] ```typescript import { createPolicy, when, or } from "@zap-studio/permit"; // User can access if they're the owner OR an admin OR explicitly shared const canAccess = or( (ctx, _, doc) => ctx.user?.id === doc.ownerId, (ctx) => ctx.user?.role === "admin", (ctx, _, doc) => doc.sharedWith.includes(ctx.user?.id ?? "") ); const policy = createPolicy({ resources, actions, rules: { document: { read: when(canAccess), }, }, }); ``` not() [#not] The `not()` combinator negates a condition. ```typescript import { not } from "@zap-studio/permit"; not(condition) ``` Example: Exclusion Rules [#example-exclusion-rules] ```typescript import { createPolicy, when, not, and } from "@zap-studio/permit"; // Cannot interact with your own content const isNotOwner = not((ctx, _, resource) => ctx.user?.id === resource.authorId); // Content is not archived const isNotArchived = not((_, __, resource) => resource.status === "archived"); const policy = createPolicy({ resources, actions, rules: { post: { // Can like any post except your own like: when(isNotOwner), // Can comment on posts that aren't archived comment: when(isNotArchived), // Can report posts that: you don't own AND aren't already reported by you report: when( and( isNotOwner, (ctx, _, post) => !post.reportedBy.includes(ctx.user?.id ?? "") ) ), }, }, }); ``` has() [#has] The `has()` helper creates a condition that checks if a context property equals a specific value. ```typescript import { has } from "@zap-studio/permit"; has(key, value) ``` Example: Simple Property Checks [#example-simple-property-checks] ```typescript import { createPolicy, when, has, or } from "@zap-studio/permit"; type AppContext = { role: "guest" | "user" | "admin"; isVerified: boolean; plan: "free" | "pro" | "enterprise"; }; const policy = createPolicy({ resources, actions, rules: { settings: { // Only admins can access settings read: when(has("role", "admin")), }, billing: { // Only verified users can access billing read: when(has("isVerified", true)), }, export: { // Pro or Enterprise users can export use: when(or(has("plan", "pro"), has("plan", "enterprise"))), }, }, }); ``` Combining Combinators [#combining-combinators] Combinators can be nested to create complex conditions: ```typescript import { createPolicy, when, and, or, not } from "@zap-studio/permit"; const policy = createPolicy({ resources, actions, rules: { document: { // Can edit if: // (owner OR admin) AND (not archived) AND (not locked OR is admin) edit: when( and( or( (ctx, _, doc) => ctx.user?.id === doc.ownerId, (ctx) => ctx.user?.role === "admin" ), not((_, __, doc) => doc.status === "archived"), or( not((_, __, doc) => doc.isLocked), (ctx) => ctx.user?.role === "admin" ) ) ), }, }, }); ``` Extracting Reusable Conditions [#extracting-reusable-conditions] For cleaner code, extract conditions into named functions: ```typescript import { and, or, not } from "@zap-studio/permit"; import type { ConditionFn } from "@zap-studio/permit/types"; type DocContext = { user: { id: string; role: string } | null; }; type Document = { ownerId: string; status: string; isLocked: boolean; }; // Reusable conditions const isOwner: ConditionFn = (ctx, _, doc) => ctx.user?.id === doc.ownerId; const isAdmin: ConditionFn = (ctx) => ctx.user?.role === "admin"; const isArchived: ConditionFn = (_, __, doc) => doc.status === "archived"; const isLocked: ConditionFn = (_, __, doc) => doc.isLocked; // Composed conditions const canEdit = and( or(isOwner, isAdmin), not(isArchived), or(not(isLocked), isAdmin) ); // Use in policy const policy = createPolicy({ resources, actions, rules: { document: { edit: when(canEdit), }, }, }); ``` Best Practices [#best-practices] 1. **Name your conditions** — Extract complex logic into well-named functions 2. **Keep conditions simple** — Each condition should check one thing 3. **Use short-circuit evaluation** — Put fast/common checks first in `and()`/`or()` 4. **Avoid side effects** — Conditions should be pure functions 5. **Document complex logic** — Add comments explaining business rules # Creating Policies A policy is the core building block of `permit`. It defines what actions users can perform on your application's resources based on the current context. Understanding Policies [#understanding-policies] Think of a policy as a rulebook that answers questions like: * "Can this user edit this blog post?" * "Can a guest view this private document?" * "Can an admin delete any comment?" A policy consists of three parts: 1. **Resources** — The things being protected (posts, comments, users) 2. **Actions** — What can be done with resources (read, write, delete) 3. **Rules** — The logic that determines if an action is allowed We recommend defining resources and actions in a centralized location to ensure consistency and type safety. Defining Resources [#defining-resources] Resources are defined using [Standard Schema](https://standardschema.dev/), which means you can use Zod, Valibot, ArkType, or any compatible library. Using Zod [#using-zod] ```typescript import { z } from "zod"; import type { Resources } from "@zap-studio/permit/types"; const resources = { post: z.object({ id: z.string(), authorId: z.string(), title: z.string(), visibility: z.enum(["public", "private", "draft"]), createdAt: z.date(), }), comment: z.object({ id: z.string(), postId: z.string(), authorId: z.string(), content: z.string(), }), user: z.object({ id: z.string(), email: z.string().email(), role: z.enum(["guest", "user", "admin"]), }), } satisfies Resources; ``` Using Valibot [#using-valibot] ```typescript import * as v from "valibot"; import type { Resources } from "@zap-studio/permit/types"; const resources = { post: v.object({ id: v.string(), authorId: v.string(), visibility: v.picklist(["public", "private"]), }), } satisfies Resources; ``` Using ArkType [#using-arktype] ```typescript import { type } from "arktype"; import type { Resources } from "@zap-studio/permit/types"; const resources = { post: type({ id: "string", authorId: "string", visibility: "'public' | 'private'", }), } satisfies Resources; ``` You should use `satisfies` to ensure type safety and consistency. Learn more about the [TypeScript `satisfies` operator](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator). Defining Actions [#defining-actions] Actions specify what operations are allowed on each resource. Define them as readonly arrays: ```typescript import type { Actions } from "@zap-studio/permit/types"; const actions = { post: ["read", "write", "delete", "publish"], comment: ["read", "write", "delete"], user: ["read", "update", "delete", "ban"], } as const satisfies Actions; ``` Use `as const` to ensure TypeScript infers literal types for actions. This enables autocomplete and type checking when writing rules. Learn more about the [TypeScript `as const` operator](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions). Understanding Context [#understanding-context] Context represents the runtime information available when checking permissions. This typically includes the current user, but can contain anything relevant to authorization: ```typescript type AppContext = { user: { id: string; role: "guest" | "user" | "admin"; permissions: string[]; organizationId: string; } | null; request?: { ip: string; userAgent: string; }; timestamp: Date; }; ``` Context is passed to `policy.can()` at runtime and is available in all your rule functions. Context is just a type that represents the runtime information available when checking permissions. It can be anything relevant to your application's authorization needs. Providing context will make your experience way better. Believe me, it's worth it! Creating a Policy [#creating-a-policy] Finally, a policy defines the rules that govern access to resources for each action. It uses the resources and actions defined earlier. Use `createPolicy()` to create a policy: ```typescript import { createPolicy, allow, deny, when } from "@zap-studio/permit"; const policy = createPolicy({ resources, actions, rules: { post: { read: allow(), write: when((ctx, _action, post) => ctx.user?.id === post.authorId), delete: deny(), publish: when((ctx) => ctx.user?.role === "admin"), }, comment: { read: allow(), write: when((ctx) => ctx.user !== null), delete: when((ctx, _action, comment) => ctx.user?.id === comment.authorId), }, }, }); ``` Checking Permissions [#checking-permissions] The policy object provides a `can()` method to check if an action is allowed. It takes the `context`, `action`, `resourceType`, and `resource` as parameters and returns a `Promise` indicating whether the action is allowed. ```typescript policy.can(context, action, resourceType, resource): Promise ``` Parameters [#parameters] | Parameter | Type | Description | | -------------- | ---------- | ---------------------------------------------- | | `context` | `TContext` | The current context (user, request, etc.) | | `action` | `string` | The action to check (e.g., "read", "write") | | `resourceType` | `string` | The type of resource (e.g., "post", "comment") | | `resource` | `object` | The actual resource being accessed | Example: Blog Post Authorization [#example-blog-post-authorization] Here is a complete example for a blog application: ```typescript // Scenario: User trying to edit a blog post const context: AppContext = { user: { id: "user-123", role: "user", permissions: [], organizationId: "org-1" }, timestamp: new Date(), }; const post = { id: "post-456", authorId: "user-123", // Same as context.user.id (so user is the author) title: "My First Post", visibility: "public" as const, createdAt: new Date(), }; // The user is the author, so this returns true according to the policy const canEdit = await policy.can(context, "write", "post", post); console.log(canEdit); // true // A different user trying to edit const otherContext: AppContext = { user: { id: "user-789", role: "user", permissions: [], organizationId: "org-1" }, timestamp: new Date(), }; const canOtherEdit = await policy.can(otherContext, "write", "post", post); console.log(canOtherEdit); // false ``` Real-World Example: E-commerce Store [#real-world-example-e-commerce-store] Here's another complete example for an e-commerce application: ```typescript import { z } from "zod"; import { createPolicy, allow, deny, when, or } from "@zap-studio/permit"; import type { Resources, Actions } from "@zap-studio/permit/types"; // Define resources const resources = { product: z.object({ id: z.string(), sellerId: z.string(), price: z.number(), status: z.enum(["draft", "published", "archived"]), }), order: z.object({ id: z.string(), customerId: z.string(), sellerId: z.string(), status: z.enum(["pending", "paid", "shipped", "delivered"]), }), review: z.object({ id: z.string(), productId: z.string(), customerId: z.string(), rating: z.number().min(1).max(5), }), } satisfies Resources; const actions = { product: ["read", "create", "update", "delete", "publish"], order: ["read", "create", "update", "cancel"], review: ["read", "create", "update", "delete"], } as const satisfies Actions; type StoreContext = { user: { id: string; role: "customer" | "seller" | "admin"; } | null; }; const storePolicy = createPolicy({ resources, actions, rules: { product: { // Anyone can read published products read: when((_, __, product) => product.status === "published"), // Only sellers can create products create: when((ctx) => ctx.user?.role === "seller"), // Sellers can update their own products update: when( (ctx, _, product) => ctx.user?.role === "seller" && ctx.user.id === product.sellerId ), // Only admins can delete products delete: when((ctx) => ctx.user?.role === "admin"), // Sellers can publish their own products publish: when( (ctx, _, product) => ctx.user?.role === "seller" && ctx.user.id === product.sellerId ), }, order: { // Customers see their orders, sellers see orders for their products read: when( or( (ctx, _, order) => ctx.user?.id === order.customerId, (ctx, _, order) => ctx.user?.id === order.sellerId ) ), // Only authenticated customers can create orders create: when((ctx) => ctx.user?.role === "customer"), // Sellers can update order status update: when((ctx, _, order) => ctx.user?.id === order.sellerId), // Customers can cancel pending orders cancel: when( (ctx, _, order) => ctx.user?.id === order.customerId && order.status === "pending" ), }, review: { // Anyone can read reviews read: allow(), // Customers can create reviews create: when((ctx) => ctx.user?.role === "customer"), // Customers can update their own reviews update: when((ctx, _, review) => ctx.user?.id === review.customerId), // Customers can delete their reviews, admins can delete any delete: when( or( (ctx, _, review) => ctx.user?.id === review.customerId, (ctx) => ctx.user?.role === "admin" ) ), }, }, }); // Usage const product = { id: "prod-1", sellerId: "seller-123", price: 99.99, status: "published" as const, }; const customerContext: StoreContext = { user: { id: "customer-456", role: "customer" }, }; console.log(await storePolicy.can(customerContext, "read", "product", product)); // true console.log(await storePolicy.can(customerContext, "update", "product", product)); // false ``` Best Practices [#best-practices] 1. **Keep resources focused** — Each resource should represent a single domain entity 2. **Use descriptive action names** — "publish" is clearer than "update-status" 3. **Include all relevant data in context** — Don't fetch additional data inside rules 4. **Start with `deny()` by default** — Be explicit about what's allowed 5. **Test your policies** — Write unit tests for critical authorization rules # Error Handling `permit` provides utilities for handling errors and ensuring exhaustive type checking in your authorization logic. PolicyError [#policyerror] The `PolicyError` class is a custom error type for policy-related errors. Use it to distinguish authorization errors from other application errors. ```typescript import { PolicyError } from "@zap-studio/permit/errors"; ``` Properties [#properties] | Property | Type | Description | | --------- | -------- | ----------------------- | | `name` | `string` | Always `"PolicyError"` | | `message` | `string` | Error description | | `stack` | `string` | Stack trace (inherited) | Creating Policy Errors [#creating-policy-errors] ```typescript import { PolicyError } from "@zap-studio/permit/errors"; // Throw when an authorization check fails throw new PolicyError("User is not authorized to delete this resource"); // Throw for invalid policy configuration throw new PolicyError("Unknown resource type: 'invalid'"); // Throw for missing context throw new PolicyError("User context is required for this action"); ``` Catching Policy Errors [#catching-policy-errors] ```typescript import { PolicyError } from "@zap-studio/permit/errors"; async function deletePost(postId: string, context: AppContext) { try { const post = await getPost(postId); if (!(await policy.can(context, "delete", "post", post))) { throw new PolicyError("Not authorized to delete this post"); } await db.posts.delete(postId); return { success: true }; } catch (error) { if (error instanceof PolicyError) { // Handle authorization errors return { success: false, error: error.message, code: "FORBIDDEN" }; } // Re-throw unexpected errors throw error; } } ``` assertNever [#assertnever] The `assertNever()` helper ensures exhaustive type checking in TypeScript. It causes a compile-time error if a switch statement or if-else chain doesn't handle all possible cases. ```typescript import { assertNever } from "@zap-studio/permit/helpers"; ``` How It Works [#how-it-works] `assertNever()` accepts a value of type `never`. If TypeScript can prove that a value could reach `assertNever()`, it means you've missed a case. Example: Exhaustive Action Handling [#example-exhaustive-action-handling] ```typescript import { assertNever } from "@zap-studio/permit/helpers"; type Action = "read" | "write" | "delete"; function getPermissionLevel(action: Action): number { switch (action) { case "read": return 1; case "write": return 2; case "delete": return 3; default: // TypeScript error if we forget a case return assertNever(action); } } ``` If you add a new action without updating the switch: ```typescript type Action = "read" | "write" | "delete" | "archive"; // Added "archive" function getPermissionLevel(action: Action): number { switch (action) { case "read": return 1; case "write": return 2; case "delete": return 3; default: // TypeScript ERROR: Argument of type 'string' is not assignable to parameter of type 'never'. return assertNever(action); } } ``` Runtime Behavior [#runtime-behavior] If `assertNever()` is reached at runtime (e.g., due to type assertions or JavaScript calling the function), it throws an error: ```typescript import { assertNever } from "@zap-studio/permit/helpers"; // This would throw: Error: Unexpected value: unknown const badValue = "unknown" as never; assertNever(badValue); ``` Best Practices [#best-practices] 1. **Use `PolicyError` for authorization failures** — Makes it easy to distinguish from other errors 2. **Catch errors at boundaries** — Handle `PolicyError` in middleware or API handlers 3. **Include context in error messages** — "Not authorized to delete post-123" is better than "Forbidden" 4. **Use `assertNever` for exhaustive checks** — Especially when handling actions or resource types 5. **Log denied attempts** — Track authorization failures for security monitoring # Overview Authorization logic often ends up scattered across your codebase — buried in route handlers, middleware, and components. This makes it hard to maintain, test, and audit. Every application needs to answer one question: **"Can this user do this action on this resource?"** Most developers handle this with scattered `if` statements — checking roles in route handlers, ownership in services, and permissions in components. This approach creates problems: * **Duplicated logic** — The same checks written in multiple places * **Hard to audit** — No single source of truth for "who can do what" * **Easy to forget** — New endpoints might miss authorization entirely * **Difficult to test** — Authorization is tangled with business logic `permit` solves this by letting you define all your authorization rules in one place, with full type safety and composable building blocks. Why permit? [#why-permit] **Before:** ```typescript // Authorization logic scattered everywhere app.delete("/posts/:id", async (req, res) => { const post = await getPost(req.params.id); const user = req.user; // 😱 Logic duplicated across routes if (user.role !== "admin" && post.authorId !== user.id) { return res.status(403).json({ error: "Forbidden" }); } await deletePost(post.id); res.json({ success: true }); }); ``` **After:** ```typescript import { createPolicy, when, or, hasRole } from "@zap-studio/permit"; // ✨ Centralized, declarative authorization const policy = createPolicy({ resources, actions, rules: { post: { delete: when( or( hasRole("admin"), (ctx, _, post) => ctx.user.id === post.authorId ) ), }, }, }); // Clean route handler app.delete("/posts/:id", async (req, res) => { const post = await getPost(req.params.id); if (!(await policy.can(req.context, "delete", "post", post))) { return res.status(403).json({ error: "Forbidden" }); } await deletePost(post.id); res.json({ success: true }); }); ``` Features [#features] * **Declarative policies** — Define authorization rules in one place * **Type-safe** — Full TypeScript inference for resources, actions, and context * **Standard Schema validation** — Validates resources at runtime using your schemas * **Composable conditions** — Build complex rules with `and()`, `or()`, `not()` * **Role hierarchies** — Support inherited permissions with `hasRole()` * **Policy merging** — Combine multiple policies with different strategies * **Framework agnostic** — Works with Express, Hono, Elysia, Fastify, Next.js, TanStack Start or any framework Installation [#installation] npm yarn pnpm bun deno ```bash npm install @zap-studio/permit ``` ```bash yarn add @zap-studio/permit ``` ```bash pnpm add @zap-studio/permit ``` ```bash bun add @zap-studio/permit ``` ```bash deno add jsr:@zap-studio/permit ``` Quick Start [#quick-start] Let's build authorization for a simple blog where users can read public posts, but only authors can edit their own posts. 1. Define Your Resources [#1-define-your-resources] First, define the shape of your resources using any Standard Schema library. You can re-use existing schemas or create new ones. ```typescript import { z } from "zod"; import type { Resources, Actions } from "@zap-studio/permit/types"; const resources = { post: z.object({ id: z.string(), authorId: z.string(), visibility: z.enum(["public", "private"]), }), } satisfies Resources; const actions = { post: ["read", "write", "delete"], } as const satisfies Actions; ``` For instance, in the above example, we define a `post` resource that includes an `id`, `authorId`, and `visibility`. The associated actions for this resource are `read`, `write`, or `delete`. Indeed, all resources should be defined with a set of actions that are relevant to the resource. 2. Create Your Policy [#2-create-your-policy] Then, we can define the authorization rules for each resource and action. For that, we need to provide a `context` object that contains information about the current user and their role. ```typescript import { createPolicy, allow, when, or } from "@zap-studio/permit"; type AppContext = { user: { id: string; role: "guest" | "user" | "admin" } | null; }; const policy = createPolicy({ resources, actions, rules: { post: { // Anyone can read public posts, authors can read their private posts read: when( or( (_ctx, _action, post) => post.visibility === "public", (ctx, _action, post) => ctx.user?.id === post.authorId ) ), // Only authors can write their posts write: when((ctx, _, post) => ctx.user?.id === post.authorId), // Only authors can delete their posts delete: when((ctx, _, post) => ctx.user?.id === post.authorId), }, }, }); ``` The above policy defines the authorization rules for the `post` resource. It allows anyone to read public posts and authors to read their private posts. Only authors can write or delete their posts. 3. Check Permissions [#3-check-permissions] Finally, you can use `policy.can()` to check if an action is allowed: ```typescript const post = { id: "post-1", authorId: "user-123", visibility: "public" as const, }; const context: AppContext = { user: { id: "user-456", role: "user" }, }; // Check if the user can read this post if (await policy.can(context, "read", "post", post)) { console.log("Access granted!"); } else { console.log("Access denied."); } ``` Using this approach, you get an awesome way to manage access control in your application. Everything is type-safe, easy to understand, and centralized. # Merging Policies As your application grows, you may want to organize authorization logic into separate policies. `permit` provides two strategies for merging policies: deny-overrides and allow-overrides. Why Merge Policies? [#why-merge-policies] Merging policies is useful when: * **Separation of concerns** — Keep domain-specific rules in separate files * **Layered security** — Apply base rules that can be tightened or relaxed * **Feature flags** — Enable/disable features by adding or removing policies * **Multi-tenancy** — Combine organization policies with application policies mergePolicies() — Deny-Overrides Strategy [#mergepolicies--deny-overrides-strategy] The `mergePolicies()` function combines policies using a deny-overrides strategy. An action is allowed only if **all** policies allow it. ```typescript import { mergePolicies } from "@zap-studio/permit"; const mergedPolicy = mergePolicies(policy1, policy2, policy3); ``` Think of it as an AND operation: `policy1 AND policy2 AND policy3` Behavior [#behavior] | Policy 1 | Policy 2 | Result | | -------- | -------- | -------- | | allow | allow | allow | | allow | deny | **deny** | | deny | allow | **deny** | | deny | deny | deny | Example: Base + Restrictive Policy [#example-base--restrictive-policy] ```typescript import { z } from "zod"; import { createPolicy, when, mergePolicies } from "@zap-studio/permit"; import type { Resources, Actions } from "@zap-studio/permit/types"; const resources = { document: z.object({ id: z.string(), ownerId: z.string(), classification: z.enum(["public", "internal", "confidential"]), }), } satisfies Resources; const actions = { document: ["read", "write", "delete"], } as const satisfies Actions; type AppContext = { user: { id: string; clearanceLevel: number } | null; }; // Base policy: standard access rules const basePolicy = createPolicy({ resources, actions, rules: { document: { read: when((ctx, _, doc) => doc.classification === "public" || ctx.user?.id === doc.ownerId ), write: when((ctx, _, doc) => ctx.user?.id === doc.ownerId), delete: when((ctx, _, doc) => ctx.user?.id === doc.ownerId), }, }, }); // Security policy: additional clearance requirements const securityPolicy = createPolicy({ resources, actions, rules: { document: { read: when((ctx, _, doc) => { if (doc.classification === "confidential") { return (ctx.user?.clearanceLevel ?? 0) >= 3; } if (doc.classification === "internal") { return (ctx.user?.clearanceLevel ?? 0) >= 1; } return true; }), write: when((ctx, _, doc) => { if (doc.classification === "confidential") { return (ctx.user?.clearanceLevel ?? 0) >= 3; } return true; }), delete: when((ctx, _, doc) => { if (doc.classification === "confidential") { return (ctx.user?.clearanceLevel ?? 0) >= 4; } return true; }), }, }, }); // Merge: both policies must allow const policy = mergePolicies(basePolicy, securityPolicy); // Usage const confidentialDoc = { id: "doc-1", ownerId: "user-123", classification: "confidential" as const, }; // Owner with low clearance: base allows, security denies → DENIED const lowClearanceOwner: AppContext = { user: { id: "user-123", clearanceLevel: 1 }, }; console.log(await policy.can(lowClearanceOwner, "read", "document", confidentialDoc)); // false // Owner with high clearance: both allow → ALLOWED const highClearanceOwner: AppContext = { user: { id: "user-123", clearanceLevel: 3 }, }; console.log(await policy.can(highClearanceOwner, "read", "document", confidentialDoc)); // true ``` mergePoliciesAny() — Allow-Overrides Strategy [#mergepoliciesany--allow-overrides-strategy] The `mergePoliciesAny()` function combines policies using an allow-overrides strategy. An action is allowed if **any** policy allows it. ```typescript import { mergePoliciesAny } from "@zap-studio/permit"; const mergedPolicy = mergePoliciesAny(policy1, policy2, policy3); ``` Think of it as an OR operation: `policy1 OR policy2 OR policy3` Behavior [#behavior-1] | Policy 1 | Policy 2 | Result | | -------- | -------- | --------- | | allow | allow | allow | | allow | deny | **allow** | | deny | allow | **allow** | | deny | deny | deny | Example: Multiple Access Paths [#example-multiple-access-paths] ```typescript import { z } from "zod"; import { createPolicy, when, mergePoliciesAny } from "@zap-studio/permit"; import type { Resources, Actions } from "@zap-studio/permit/types"; const resources = { file: z.object({ id: z.string(), ownerId: z.string(), isPublic: z.boolean(), sharedWith: z.array(z.string()), teamId: z.string().nullable(), }), } satisfies Resources; const actions = { file: ["read", "write", "delete"], } as const satisfies Actions; type FileContext = { user: { id: string; teamIds: string[] } | null; }; // Owner access policy const ownerPolicy = createPolicy({ resources, actions, rules: { file: { read: when((ctx, _, file) => ctx.user?.id === file.ownerId), write: when((ctx, _, file) => ctx.user?.id === file.ownerId), delete: when((ctx, _, file) => ctx.user?.id === file.ownerId), }, }, }); // Public access policy const publicPolicy = createPolicy({ resources, actions, rules: { file: { read: when((_, __, file) => file.isPublic), write: when(() => false), delete: when(() => false), }, }, }); // Shared access policy const sharedPolicy = createPolicy({ resources, actions, rules: { file: { read: when((ctx, _, file) => file.sharedWith.includes(ctx.user?.id ?? "")), write: when((ctx, _, file) => file.sharedWith.includes(ctx.user?.id ?? "")), delete: when(() => false), }, }, }); // Team access policy const teamPolicy = createPolicy({ resources, actions, rules: { file: { read: when((ctx, _, file) => file.teamId !== null && (ctx.user?.teamIds.includes(file.teamId) ?? false) ), write: when((ctx, _, file) => file.teamId !== null && (ctx.user?.teamIds.includes(file.teamId) ?? false) ), delete: when(() => false), }, }, }); // Any of these policies can grant access const filePolicy = mergePoliciesAny(ownerPolicy, publicPolicy, sharedPolicy, teamPolicy); // Usage const file = { id: "file-1", ownerId: "user-owner", isPublic: false, sharedWith: ["user-shared"], teamId: "team-1", }; const sharedUser: FileContext = { user: { id: "user-shared", teamIds: [] }, }; console.log(await filePolicy.can(sharedUser, "read", "file", file)); // true (via sharedPolicy) console.log(await filePolicy.can(sharedUser, "write", "file", file)); // true (via sharedPolicy) console.log(await filePolicy.can(sharedUser, "delete", "file", file)); // false (no policy allows) ``` Combining Both Strategies [#combining-both-strategies] You can combine `mergePolicies` and `mergePoliciesAny` for complex scenarios: ```typescript import { mergePolicies, mergePoliciesAny } from "@zap-studio/permit"; // Access layer: multiple paths to access const accessPolicy = mergePoliciesAny(ownerPolicy, sharedPolicy, publicPolicy); // Security layer: must pass all security checks const securedPolicy = mergePolicies(auditPolicy, compliancePolicy, ratePolicy); // Final policy: must have access AND pass security const finalPolicy = mergePolicies(accessPolicy, securedPolicy); ``` Visualization [#visualization] ```text ┌─────────────┐ │ finalPolicy │ └──────┬──────┘ │ AND (mergePolicies) ┌─────────────┴─────────────┐ │ │ ┌──────┴──────┐ ┌──────┴───────┐ │accessPolicy │ │securedPolicy │ └──────┬──────┘ └──────┬───────┘ │ OR │ AND ┌───────┼───────┐ ┌───────┼───────┐ │ │ │ │ │ │ owner shared public audit compliance rate ``` Empty Policy Arrays [#empty-policy-arrays] * Calling `mergePolicies()` without any policies returns `false` (denies by default). * Calling `mergePoliciesAny()` without any policies also returns `false` (denies by default). ```typescript const emptyAnd = mergePolicies(); // Always denies const emptyOr = mergePoliciesAny(); // Always denies ``` Best Practices [#best-practices] 1. **Name policies descriptively** — `securityPolicy`, `ownerAccessPolicy`, `compliancePolicy` 2. **Keep policies focused** — Each policy should handle one concern 3. **Document merge order** — Explain why policies are merged in a specific order 4. **Test merged policies** — Ensure the combined behavior is correct 5. **Consider performance** — Fewer policies means fewer checks # Policy Rules Policy rules are functions that determine whether an action is allowed or denied. `permit` provides three rule builders: `allow()`, `deny()`, and `when()`. Understanding Rules [#understanding-rules] Every rule in your policy must return a **decision**: either `"allow"` or `"deny"`. Rules receive three arguments: 1. **context** — The current user/request context 2. **action** — The action being performed (e.g., "read", "write") 3. **resource** — The resource being accessed allow() [#allow] The `allow()` function creates a rule that always permits the action, regardless of context or resource. ```typescript import { allow } from "@zap-studio/permit"; ``` When to Use [#when-to-use] Use `allow()` for actions that should be available to everyone: * Public content (published blog posts, product listings) * Health check endpoints * Public API documentation Example: Public Content [#example-public-content] ```typescript import { createPolicy, allow, when } from "@zap-studio/permit"; const policy = createPolicy({ resources, actions, rules: { post: { // Anyone can read posts (we'll refine this with conditions later) read: allow(), }, documentation: { // API docs are always public read: allow(), }, }, }); ``` deny() [#deny] The `deny()` function creates a rule that always blocks the action. No context or resource can override this. ```typescript import { deny } from "@zap-studio/permit"; ``` When to Use [#when-to-use-1] Use `deny()` for: * Temporarily disabled features * Actions reserved for future implementation * Hard blocks that should never be bypassed Example: Disabled Features [#example-disabled-features] ```typescript import { createPolicy, allow, deny, when } from "@zap-studio/permit"; const policy = createPolicy({ resources, actions, rules: { post: { read: allow(), write: when((ctx, _, post) => ctx.user?.id === post.authorId), // Permanently archived posts cannot be deleted delete: deny(), }, legacyFeature: { // This feature is deprecated and disabled use: deny(), }, }, }); ``` when() [#when] The `when()` function creates a conditional rule. It takes a condition function and allows the action only if the condition returns `true`. ```typescript import { when } from "@zap-studio/permit"; when((context, action, resource) => boolean) ``` Condition Function [#condition-function] The condition function receives: | Parameter | Type | Description | | ---------- | ----------- | ---------------------------- | | `context` | `TContext` | Current user/request context | | `action` | `string` | The action being performed | | `resource` | `TResource` | The resource being accessed | It must return a `boolean`: * `true` → action is allowed * `false` → action is denied Example: Owner-Only Access [#example-owner-only-access] ```typescript import { createPolicy, when } from "@zap-studio/permit"; const policy = createPolicy({ resources, actions, rules: { post: { // Only the author can edit their post write: when((ctx, _, post) => ctx.user?.id === post.authorId), // Only the author can delete their post delete: when((ctx, _, post) => ctx.user?.id === post.authorId), }, }, }); ``` Using the Action Parameter [#using-the-action-parameter] The `action` parameter becomes useful when you create reusable condition functions that handle multiple actions differently. This lets you share logic across actions while still customizing behavior: ```typescript import { createPolicy, when } from "@zap-studio/permit"; import type { ConditionFn } from "@zap-studio/permit/types"; type PostAction = "read" | "write" | "delete"; type Post = { id: string; authorId: string; visibility: "public" | "private" }; // Reusable condition that behaves differently based on action const canAccessPost: ConditionFn = (ctx, action, post) => { // Anyone can read public posts if (action === "read" && post.visibility === "public") { return true; } // For write/delete (or private reads), must be the author return ctx.user?.id === post.authorId; }; const policy = createPolicy({ resources, actions, rules: { post: { // Same function handles all three actions with different logic read: when(canAccessPost), write: when(canAccessPost), delete: when(canAccessPost), }, }, }); ``` This pattern is useful when actions share similar logic but need slight variations — you define the condition once and reuse it across multiple actions. Combining Rules [#combining-rules] Rules can be combined with condition combinators. See the [Conditions](./conditions.mdx) page for details on using `and()`, `or()`, and `not()`. Best Practices [#best-practices] 1. **Start restrictive** — Use `deny()` as the default, then explicitly allow 2. **Keep conditions pure** — Don't perform side effects in condition functions 3. **Avoid async operations** — Conditions must be synchronous; fetch data before checking 4. **Use descriptive variable names** — `isOwner`, `isMember`, `hasAccess` 5. **Extract complex conditions** — Create reusable condition functions for clarity # Role-Based Access Control Role-Based Access Control (RBAC) is a common authorization pattern where permissions are assigned to roles, and users are assigned roles. `permit` provides utilities for implementing RBAC with support for role hierarchies. Understanding RBAC [#understanding-rbac] In RBAC, instead of assigning permissions directly to users, you: 1. Define **roles** (e.g., "guest", "user", "admin") 2. Assign **permissions** to roles (e.g., admin can delete posts) 3. Assign **roles** to users (e.g., Alice is an admin) This simplifies permission management — when you need to change what admins can do, you update the role's permissions once instead of updating every admin user. Simple Role Checks [#simple-role-checks] The `hasRole()` function checks if the current user has a specific role: ```typescript import { hasRole } from "@zap-studio/permit"; hasRole(role) ``` Context Requirements [#context-requirements] Your context must include a `role` property that can be: * A single role: `{ role: "admin" }` * An array of roles: `{ role: ["user", "moderator"] }` Example: Basic RBAC [#example-basic-rbac] ```typescript import { z } from "zod"; import { createPolicy, when, hasRole, or } from "@zap-studio/permit"; import type { Resources, Actions } from "@zap-studio/permit/types"; const resources = { post: z.object({ id: z.string(), authorId: z.string(), status: z.enum(["draft", "published", "archived"]), }), user: z.object({ id: z.string(), email: z.string(), }), } satisfies Resources; const actions = { post: ["read", "write", "delete", "publish"], user: ["read", "update", "delete", "ban"], } as const satisfies Actions; type Role = "guest" | "user" | "moderator" | "admin"; type AppContext = { user: { id: string } | null; role: Role; }; const policy = createPolicy({ resources, actions, rules: { post: { // Guests can read published posts read: when((ctx, _, post) => post.status === "published" || ctx.role === "user" ), // Users can write their own posts write: when((ctx, _, post) => ctx.role === "user" && ctx.user?.id === post.authorId ), // Moderators and admins can delete any post delete: when( or( hasRole("moderator"), hasRole("admin") ) ), // Only admins can publish publish: when(hasRole("admin")), }, user: { // Users can read their own profile, admins can read any read: when( or( (ctx, _, user) => ctx.user?.id === user.id, hasRole("admin") ) ), // Users can update their own profile update: when((ctx, _, user) => ctx.user?.id === user.id), // Only admins can delete users delete: when(hasRole("admin")), // Moderators and admins can ban users ban: when( or( hasRole("moderator"), hasRole("admin") ) ), }, }, }); ``` For now, `hasRole()` returns a `ConditionFn`, which can be used in `when()` conditions. Thus, it doesn't return a boolean value directly. Prefer using `role` directly in conditions. Role Hierarchies [#role-hierarchies] Often, higher-level roles inherit permissions from lower-level roles. For example, an admin should have all permissions that a user has, plus admin-specific ones. Defining a Role Hierarchy [#defining-a-role-hierarchy] A role hierarchy maps each role to an array of roles it inherits from: ```typescript import type { RoleHierarchy } from "@zap-studio/permit/types"; type Role = "guest" | "user" | "moderator" | "admin"; const roleHierarchy: RoleHierarchy = { guest: [], // No inherited roles user: ["guest"], // Users inherit guest permissions moderator: ["user"], // Moderators inherit user permissions admin: ["moderator"], // Admins inherit moderator permissions }; // Permission inheritance chain: // admin → moderator → user → guest ``` Using hasRole with Hierarchy [#using-hasrole-with-hierarchy] Pass the hierarchy as the second argument to `hasRole()`: ```typescript import { hasRole } from "@zap-studio/permit"; // Without hierarchy: only matches exact role hasRole("user") // Only true if role === "user" // With hierarchy: matches role or any role that inherits from it hasRole("user", roleHierarchy) // True for "user", "moderator", or "admin" ``` Multiple Roles [#multiple-roles] Users can have multiple roles. The context can include an array of roles: ```typescript type AppContext = { user: { id: string } | null; role: Role[]; // Array of roles }; const context: AppContext = { user: { id: "user-1" }, role: ["user", "beta-tester"], // User has both roles }; ``` `hasRole()` checks if any of the user's roles match (or inherit from) the required role. Example: Multiple Roles [#example-multiple-roles] ```typescript import { z } from "zod"; import { createPolicy, when, hasRole, or } from "@zap-studio/permit"; import type { Resources, Actions, RoleHierarchy } from "@zap-studio/permit/types"; const resources = { betaFeature: z.object({ id: z.string(), name: z.string(), }), supportTicket: z.object({ id: z.string(), userId: z.string(), priority: z.enum(["low", "normal", "high", "urgent"]), }), } satisfies Resources; const actions = { betaFeature: ["access"], supportTicket: ["create", "escalate"], } as const satisfies Actions; type Role = "user" | "beta-tester" | "premium" | "support-agent" | "admin"; const roleHierarchy: RoleHierarchy = { user: [], "beta-tester": ["user"], premium: ["user"], "support-agent": ["user"], admin: ["premium", "support-agent"], // Admin inherits from multiple roles }; type MultiRoleContext = { user: { id: string } | null; role: Role[]; }; const multiRolePolicy = createPolicy({ resources, actions, rules: { betaFeature: { // Only beta testers can access beta features access: when(hasRole("beta-tester", roleHierarchy)), }, supportTicket: { // Any authenticated user can create tickets create: when(hasRole("user", roleHierarchy)), // Only premium users or support agents can escalate escalate: when( or( hasRole("premium", roleHierarchy), hasRole("support-agent", roleHierarchy) ) ), }, }, }); // User with multiple roles const betaPremiumUser: MultiRoleContext = { user: { id: "u1" }, role: ["user", "beta-tester", "premium"], }; const ticket = { id: "t1", userId: "u1", priority: "normal" as const }; const feature = { id: "f1", name: "New Dashboard" }; console.log(await multiRolePolicy.can(betaPremiumUser, "access", "betaFeature", feature)); // true (has beta-tester) console.log(await multiRolePolicy.can(betaPremiumUser, "escalate", "supportTicket", ticket)); // true (has premium) ``` Collecting Inherited Roles [#collecting-inherited-roles] The `collectInheritedRoles()` function returns all roles a user has, including inherited ones: ```typescript import { collectInheritedRoles } from "@zap-studio/permit"; collectInheritedRoles(roles, hierarchy) ``` Example: Role Inspection [#example-role-inspection] ```typescript import { collectInheritedRoles } from "@zap-studio/permit"; import type { RoleHierarchy } from "@zap-studio/permit/types"; type Role = "guest" | "user" | "moderator" | "admin"; const hierarchy: RoleHierarchy = { guest: [], user: ["guest"], moderator: ["user"], admin: ["moderator"], }; // Get all roles for an admin const adminRoles = collectInheritedRoles(["admin"], hierarchy); console.log(adminRoles); // Set { "admin", "moderator", "user", "guest" } // Get all roles for a moderator const modRoles = collectInheritedRoles(["moderator"], hierarchy); console.log(modRoles); // Set { "moderator", "user", "guest" } // Multiple input roles const mixedRoles = collectInheritedRoles(["user", "beta-tester"], { ...hierarchy, "beta-tester": [], }); console.log(mixedRoles); // Set { "user", "guest", "beta-tester" } ``` Best Practices [#best-practices] 1. **Keep hierarchies simple** — Deep hierarchies are hard to reason about 2. **Document role permissions** — Maintain a clear mapping of what each role can do 3. **Use meaningful role names** — "editor" is clearer than "level-3" 4. **Avoid circular dependencies** — Role A should not inherit from role B if B inherits from A 5. **Test edge cases** — Especially when users have multiple roles # Concepts Before using the helpers, this page gives you the foundation: what validation is, why modern apps need it, what schemas are, and why Standard Schema was created. Why Validation Matters In Modern Apps [#why-validation-matters-in-modern-apps] Modern apps constantly process unknown input: * HTTP request bodies * query params * webhooks * environment variables * database payloads * third-party API responses TypeScript only checks types at build time. It does **not** protect runtime input by itself. Without runtime validation: * invalid data can silently enter your app * assumptions break deeper in business logic * bugs are harder to debug because failures happen far from the input boundary Validation fixes this by checking data at runtime, early, and explicitly. What Is A Schema? [#what-is-a-schema] A schema is a formal definition of what valid data should look like. Example contract for a user payload: * `id` must be a number * `email` must be a valid email string * `name` is required Think of a schema as the source of truth for input expectations. What Is Validation, Exactly? [#what-is-validation-exactly] Validation means comparing real input against that schema contract. The result is usually one of two outcomes: * input is valid -> you get parsed/validated data * input is invalid -> you get structured issues describing what failed This pattern makes error handling deterministic and easier to maintain. A Bit Of History [#a-bit-of-history] Before Zod [#before-zod] Before TypeScript-first schema libraries became popular, teams often used: * manual `if` checks * custom validator utilities * JSON Schema tooling (often separate from app types) These approaches worked, but many codebases suffered from duplication and drift between runtime validation and TypeScript types. What Zod Solved [#what-zod-solved] Zod popularized a TypeScript-first workflow where: * schemas live in application code * runtime validation and type inference are connected * developer experience is much better That was a major productivity jump for many teams. The New Problem After That [#the-new-problem-after-that] As the ecosystem evolved, multiple strong schema libraries emerged (Zod, Valibot, ArkType, and others), often adopted differently across frameworks, teams, and packages. This created a new interoperability problem: * shared packages had to branch by library * return/error shapes differed * cross-team consistency got harder Why Standard Schema Was Built [#why-standard-schema-was-built] Standard Schema provides a common validation interface across compatible libraries. Instead of hard-coding behavior for one validator, package code can target one shared contract. That is the key idea behind `validation`: one consistent helper surface over Standard Schema-compatible libraries. Standard Schema is a shared initiative across major validation libraries and ecosystem maintainers, including libraries like [Zod](https://zod.dev/), [Valibot](https://valibot.dev/), and [ArkType](https://arktype.io/). # Create Validators When the same schema is reused many times, creating a validator function once makes code cleaner and easier to share. Why Create Validator Functions? [#why-create-validator-functions] Use create helpers when you want: * a reusable validator for a specific schema * separation between schema setup and runtime calls * consistent behavior across multiple modules createStandardValidator [#createstandardvalidator] Creates an async validator that works with both sync and async schemas. ```ts import { createStandardValidator } from "@zap-studio/validation"; const validateUser = createStandardValidator(userSchema); const result = await validateUser(input); if (result.issues) { // invalid } else { // result.value } const user = await validateUser(input, { throwOnError: true }); ``` Use it when: * your call sites are async * schema behavior might become async over time * you want one reusable validator instance createSyncStandardValidator [#createsyncstandardvalidator] Creates a sync-only validator. ```ts import { createSyncStandardValidator } from "@zap-studio/validation"; const validateUser = createSyncStandardValidator(userSchema); const result = validateUser(input); const user = validateUser(input, { throwOnError: true }); ``` Use it when: * code must stay synchronous * schemas are guaranteed sync Important behavior: * throws if the schema validates asynchronously Which Create Helper Should I Use? [#which-create-helper-should-i-use] * Prefer `createStandardValidator` for default package/app code. * Use `createSyncStandardValidator` in strict sync contexts only. * Pair create helpers with `standardValidate`/`standardValidateSync` depending on whether you prefer reusable functions or direct one-shot validation calls. # Getting Started This page is a quick recap to install `validation` and get started as fast as possible. For deeper details and advanced usage, check the other pages in this section. Install [#install] npm yarn pnpm bun deno ```bash npm install @zap-studio/validation ``` ```bash yarn add @zap-studio/validation ``` ```bash pnpm add @zap-studio/validation ``` ```bash bun add @zap-studio/validation ``` ```bash deno add jsr:@zap-studio/validation ``` Quick Start [#quick-start] Use `standardValidate` to validate data against a Standard Schema-compatible schema. ```ts import { standardValidate } from "@zap-studio/validation"; const user = await standardValidate(userSchema, input, { throwOnError: true, }); ``` Next, we introduce the concepts so you build a solid understanding of what validation is, why modern applications need it, what schemas are, and how all of this fits together. # Handling Errors Validation errors are part of normal application behavior. API payloads can be malformed, form inputs can be incomplete, and external data can drift from expected formats. In most apps, handling these failures clearly is critical for reliability, UX, and observability. `validation` helps by exposing a shared `ValidationError` class. Instead of handling each schema library error shape differently, you can catch one predictable error type and access normalized Standard Schema `issues`. Why We Created ValidationError [#why-we-created-validationerror] Different validation libraries expose different error shapes. That makes shared package logic harder to standardize. `ValidationError` gives you: * one predictable error class to catch * direct access to Standard Schema `issues` * consistent behavior across libraries and packages When Is It Thrown? [#when-is-it-thrown] It is thrown by: * `standardValidate(..., { throwOnError: true })` * `standardValidateSync(..., { throwOnError: true })` * `createStandardValidator(schema)(..., { throwOnError: true })` * `createSyncStandardValidator(schema)(..., { throwOnError: true })` How To Catch It? [#how-to-catch-it] ```ts import { standardValidate, ValidationError } from "@zap-studio/validation"; try { const user = await standardValidate(userSchema, input, { throwOnError: true, }); // use user } catch (error) { if (error instanceof ValidationError) { console.error("Validation failed", error.issues); return; } throw error; } ``` When To Use ValidationError? [#when-to-use-validationerror] Use throwing mode + `ValidationError` when: * invalid input should interrupt control flow immediately * your layer already handles exceptions (API handlers, service boundaries) * you want centralized error handling Use non-throwing mode when: * validation failures are expected and part of normal logic * you want explicit branching on `issues` without exceptions For example, in form workflows you often return field errors instead of throwing: ```ts import { standardValidate } from "@zap-studio/validation"; const result = await standardValidate(emailSchema, formData, { throwOnError: false, }); if (result.issues) { return { ok: false, fieldErrors: result.issues }; } return { ok: true, value: result.value }; ``` # How to Validate This page explains how to validate data against a schema. `validation` provides: * an async helper that works with both async and sync schemas * a sync helper that only works with synchronous schemas * reusable validator factories that accept the same `throwOnError` options If you are new to schema validation, read [Concepts](./concepts) first. standardValidate [#standardvalidate] Use `standardValidate` as the default choice. It works with: * synchronous schemas * asynchronous schemas So one API covers both cases. Both `throwOnError` modes exist so you can match different control-flow styles: * throw immediately in imperative flows where invalid input should stop execution * handle errors as data in functional or pipeline-based flows Throwing on Error [#throwing-on-error] ```ts import { standardValidate } from "@zap-studio/validation"; const user = await standardValidate(userSchema, input, { throwOnError: true, }); ``` * returns parsed value on success * throws `ValidationError` on failure Handle Errors Manually [#handle-errors-manually] Use this mode when you want full control over branching, logging, and error mapping without exceptions. ```ts const result = await standardValidate(userSchema, input, { throwOnError: false, }); if (result.issues) { // handle issues } else { // use result.value } ``` standardValidateSync [#standardvalidatesync] Use `standardValidateSync` only when validation must remain synchronous end-to-end. Typical cases include limited or constrained runtimes where you explicitly need sync-only execution paths. ```ts import { standardValidateSync } from "@zap-studio/validation"; const result = standardValidateSync(userSchema, input); ``` This is useful in sync-only code paths (e.g. deterministic in-memory checks). Important behavior: * if schema validation is async, `standardValidateSync` throws Prefer the async helper (`standardValidate`) by default. It is the most compatible option because it supports both sync and async schemas. Which One Should I Use? [#which-one-should-i-use] | Helper | Supports sync schemas | Supports async schemas | Returns | Best use case | | ---------------------- | --------------------- | ---------------------- | ------------------- | ------------------------------------- | | `standardValidate` | Yes | Yes | `Promise<...>` | Default choice for most applications | | `standardValidateSync` | Yes | No | direct value/result | Strict sync-only runtime requirements | * Use `standardValidate` by default for compatibility and simpler call sites. * Use `standardValidateSync` only when synchronous behavior is a hard requirement. Reusable Validators [#reusable-validators] If you reuse one schema across many calls, prefer: * `createStandardValidator` * `createSyncStandardValidator` The returned validator functions support the same `throwOnError` modes as `standardValidate` and `standardValidateSync`. # Overview `validation` is a small package that makes validation workflows consistent across any [Standard Schema](https://standardschema.dev/schema) compatible library. What Is Standard Schema? [#what-is-standard-schema] Different teams choose different validators: * [Zod](https://zod.dev/) * [ArkType](https://arktype.io/) * [Valibot](https://valibot.dev/) This is usually fine until you need shared infrastructure across teams and packages. Without a common contract, every library has different parsing APIs, return shapes, and error formats. **Standard Schema** defines a shared interface so those libraries can be consumed through one consistent protocol. That means your app or package logic can stay stable even when schema implementations differ. Why We Created validation? [#why-we-created-validation] At Zap Studio, multiple packages need runtime validation (`fetch`, `permit`, and others). We wanted one internal validation flow that works with any Standard Schema-compatible library. So `validation` provides: * a single validation helper surface * an `isStandardSchema` guard for unknown values * sync and async-safe validation options * a shared `ValidationError` type * reusable validator factories This lets us keep package behavior consistent and avoid library-specific branching. Before [#before] Without a shared API, you usually end up writing separate validation branches for each schema library used across teams or packages. That approach is easy to miss in edge cases, hard to keep exhaustive over time, and prone to inconsistent error handling. ```ts if (validatorLib === "zod") { const result = userSchema.safeParse(input); if (!result.success) throw result.error; return result.data; } if (validatorLib === "valibot") { const result = v.safeParse(userSchema, input); if (!result.success) throw result.issues; return result.output; } ``` After [#after] With `validation`, you call one helper regardless of the underlying Standard Schema-compatible library. The flow is simpler, easier to reason about, and much easier to keep consistent across packages. ```ts import { standardValidate } from "@zap-studio/validation"; const user = await standardValidate(userSchema, input, { throwOnError: true, }); ``` Let's now learn how to validate against schemas and handle errors with this package. # Schema Guard `isStandardSchema` is a runtime guard that checks whether a value implements the Standard Schema interface. Use it when schema values are dynamic, external, or typed as `unknown`. Why It Exists [#why-it-exists] In static code, TypeScript types usually ensure you pass a schema to validation helpers. At runtime, you may still receive unknown values from plugins, shared registries, or configuration. `isStandardSchema` helps you: * prevent invalid values from being passed to validation helpers * branch safely before validation * keep runtime behavior predictable in dynamic systems Basic Usage [#basic-usage] ```ts import { isStandardSchema } from "@zap-studio/validation"; if (isStandardSchema(schemaLike)) { // schemaLike is now typed as StandardSchemaV1 } ``` Guard Before Validation [#guard-before-validation] ```ts import { isStandardSchema, standardValidate } from "@zap-studio/validation"; export async function validateIfPossible( schemaLike: unknown, input: unknown ) { if (!isStandardSchema(schemaLike)) { return { ok: false as const, reason: "not-a-schema" as const }; } const result = await standardValidate(schemaLike, input, { throwOnError: false, }); if (result.issues) { return { ok: false as const, reason: "invalid-input" as const, issues: result.issues }; } return { ok: true as const, value: result.value }; } ``` When To Use It [#when-to-use-it] * schema objects come from external modules or user configuration * your API accepts flexible `unknown` inputs * you build framework/package boundaries and want explicit runtime checks If schema values are always strongly typed as `StandardSchemaV1`, you usually do not need this guard. # Adapters `@zap-studio/webhooks` is framework-agnostic by design. It does not ship framework-specific adapters. Instead, it exposes a small adapter contract so you can integrate with your framework in a predictable way. Why this design: * no framework lock-in * smaller package surface * consumers control runtime details (raw body parsing, response writing) Why BaseAdapter [#why-baseadapter] `BaseAdapter` exists to avoid rewriting the same plumbing in every app: * normalize framework request -> `NormalizedRequest` * call `router.handle` * map `NormalizedResponse` -> framework response You only implement request/response mapping, while `handleWebhook()` orchestration is shared. Conceptually: * adapter = transport layer * router = webhook application layer Example [#example] ```ts import { BaseAdapter } from "@zap-studio/webhooks/adapters/base"; import type { NormalizedRequest, NormalizedResponse, } from "@zap-studio/webhooks/types"; class MyHttpAdapter extends BaseAdapter { async toNormalizedRequest(req: any): Promise { return { method: req.method, path: req.url, headers: new Headers(req.headers), rawBody: req.rawBody, }; } async toFrameworkResponse( res: any, normalized: NormalizedResponse ): Promise { res.statusCode = normalized.status; res.end( typeof normalized.body === "string" ? normalized.body : JSON.stringify(normalized.body) ); return res; } } ``` Usage [#usage] ```ts const adapter = new MyHttpAdapter(); const webhookHandler = adapter.handleWebhook(router); // Plug webhookHandler into your framework route ``` # Getting Started 1. Install [#1-install] You need the router package and a Standard Schema-compatible validator. npm yarn pnpm bun deno ```bash npm install @zap-studio/webhooks zod ``` ```bash yarn add @zap-studio/webhooks zod ``` ```bash pnpm add @zap-studio/webhooks zod ``` ```bash bun add @zap-studio/webhooks zod ``` ```bash deno add jsr:@zap-studio/webhooks npm:zod ``` 2. Create router [#2-create-router] `createWebhookRouter` configures global behavior once (prefix, verification, hooks). ```ts import { createWebhookRouter } from "@zap-studio/webhooks"; const router = createWebhookRouter({ prefix: "/webhooks/", }); ``` 3. Register route with schema [#3-register-route-with-schema] Each route binds: * a route key * a validation schema * a typed handler The handler payload type is inferred from the schema output. ```ts import { z } from "zod"; router.register("payments/succeeded", { schema: z.object({ id: z.string(), amount: z.number().positive(), currency: z.string().length(3), }), handler: async ({ payload, ack }) => { // payload inferred from schema return ack({ status: 200, body: `processed ${payload.id}` }); }, }); ``` 4. Handle request [#4-handle-request] Your framework layer should convert incoming requests into `NormalizedRequest` and call: ```ts const response = await router.handle(normalizedReq); ``` Why this design: * the router stays framework-agnostic * adapters remain thin and reusable * request validation and dispatch stay consistent across runtimes If you need reusable framework integration, see [Adapters](./adapters). # Guides This page shows provider-specific patterns you can adapt directly. The important idea: verification and payload validation are separate concerns. Verification checks authenticity. Schema checks shape. GitHub (HMAC signature) [#github-hmac-signature] GitHub uses HMAC signatures. This is the simplest case for `createHmacVerifier`. ```ts import { createWebhookRouter } from "@zap-studio/webhooks"; import { createHmacVerifier } from "@zap-studio/webhooks/verify"; import { z } from "zod"; const router = createWebhookRouter({ verify: createHmacVerifier({ headerName: "x-hub-signature-256", secret: process.env.GITHUB_WEBHOOK_SECRET!, }), }); router.register("github/push", { schema: z.object({ ref: z.string(), repository: z.object({ full_name: z.string() }), }), handler: async ({ payload, ack }) => { console.log(payload.repository.full_name, payload.ref); return ack(); }, }); ``` Stripe (provider-specific verification) [#stripe-provider-specific-verification] Stripe uses its own signed payload format, so verification should use the Stripe SDK. This is why the package accepts a custom `verify` function. ```ts import Stripe from "stripe"; import { createWebhookRouter } from "@zap-studio/webhooks"; import { z } from "zod"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const router = createWebhookRouter({ verify: async (req) => { const signature = req.headers.get("stripe-signature"); if (!signature) throw new Error("Missing Stripe signature"); stripe.webhooks.constructEvent( req.rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET! ); }, }); router.register("stripe/payment_intent.succeeded", { schema: z.object({ id: z.string(), object: z.literal("event"), type: z.literal("payment_intent.succeeded"), }), handler: async ({ payload, ack }) => { console.log("Stripe event:", payload.id); return ack({ status: 200 }); }, }); ``` Production checklist [#production-checklist] * keep `rawBody` untouched before verification * fail closed when signature header is missing * validate payload schema even after signature passes * keep handlers idempotent (providers retry on failures) # Overview `@zap-studio/webhooks` is a schema-first router for inbound webhooks. It centralizes the parts of webhook handling that are easy to get wrong: * path matching * payload validation * signature verification * lifecycle hooks * normalized responses Why schema-first [#why-schema-first] Your schema is the single source of truth for: * runtime validation * inferred payload types in handlers This package behaves this way to remove drift between “TypeScript types” and “actual payload checks”. If a provider payload changes, validation fails early instead of silently breaking business logic. Matching behavior [#matching-behavior] Concept [#concept] A route key is matched from the request path after normalization. Path matching is exact after normalization: 1. Parse pathname from full URL if needed. 2. Require configured prefix (default `/webhooks/`). 3. Strip prefix. 4. Match exact route key. Why this behavior [#why-this-behavior] * Exact matching keeps routing predictable and avoids surprising wildcard collisions. * Prefix requirement prevents webhook routes from colliding with normal app routes. * URL parsing allows adapters to pass either full URLs or plain paths consistently. Example: * registered key: `"github/push"` * request path: `"/webhooks/github/push"` * normalized key: `"github/push"` # Lifecycle Hooks Hooks are shared interception points around route execution. Why they exist: * keep handlers focused on business logic * centralize logging, metrics, and error policy * avoid copy/pasting cross-cutting concerns into every route Global hooks [#global-hooks] Configure once in `createWebhookRouter` options: ```ts import { createWebhookRouter } from "@zap-studio/webhooks"; const router = createWebhookRouter({ before: (req) => { console.log("incoming", req.path); }, after: (_req, res) => { console.log("status", res.status); }, onError: (error) => ({ status: 500, body: { error: error.message }, }), }); ``` Route-level hooks [#route-level-hooks] Route hooks are useful when one endpoint needs extra behavior beyond global hooks. ```ts import { z } from "zod"; router.register("github/push", { schema: z.object({ ref: z.string() }), before: (req) => { console.log("route-before", req.path); }, handler: async ({ ack }) => ack({ status: 200 }), after: (_req, res) => { console.log("route-after", res.status); }, }); ``` Hook order [#hook-order] Understanding order matters when composing observability and validation behavior. For successful requests: 1. global `before` 2. route `before` 3. `verify` 4. validation 5. handler 6. route `after` 7. global `after` For errors: * `onError` is called and may return a custom response. Why `onError` returns a response: * gives you one place to standardize error shape/status * lets you map provider/framework errors consistently # Verification `verify` is the authenticity gate for incoming requests. Concept: * `verify` runs before schema validation and before the route handler. * If `verify` throws, request handling stops. Why: * prevents untrusted payloads from reaching business logic * avoids spending resources validating/processing forged requests HMAC helper [#hmac-helper] The package exports `createHmacVerifier` from `@zap-studio/webhooks/verify`. ```ts import { createWebhookRouter } from "@zap-studio/webhooks"; import { createHmacVerifier } from "@zap-studio/webhooks/verify"; const router = createWebhookRouter({ verify: createHmacVerifier({ headerName: "x-hub-signature-256", secret: process.env.GITHUB_WEBHOOK_SECRET!, algo: "sha256", // optional }), }); ``` `createHmacVerifier`: * reads the signature from the configured header * computes HMAC from `req.rawBody` * compares signatures in constant time Why constant-time comparison: * avoids timing side channels when comparing signatures Custom verification [#custom-verification] For providers with custom signing, pass your own `verify` function. ```ts import Stripe from "stripe"; import { createWebhookRouter } from "@zap-studio/webhooks"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const router = createWebhookRouter({ verify: async (req) => { const signature = req.headers.get("stripe-signature"); if (!signature) { throw new Error("Missing Stripe signature"); } stripe.webhooks.constructEvent( req.rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET! ); }, }); ``` Use custom verification when a provider: * uses a non-HMAC signature format * requires SDK-specific verification logic * includes timestamp or replay-protection checks