validation.md

August 22, 2026 ยท View on GitHub

Your method signature says id: string. Nothing stops a peer from sending an array, an object with a $ne property, or a callback that arrives as an RpcStub. TypeScript is erased long before the call does, so at runtime that signature is a comment.

capnweb-validate makes it enforceable. Mark a class with @validateRpc(), and at build time a bundler plugin (or the CLI) reads the resolved TypeScript types and injects a validator for each method, which runs before your code does:

@validateRpc()
export class Api extends RpcTarget {
  // A caller sending anything but a string now gets an error, not your method body.
  getUser(id: string) {
    return this.db.find(id);
  }
}

You do not write a schema. The types you already wrote are the schema.

:::note If a validation decorator is left untransformed, it throws a configuration error rather than silently running without validation. You cannot accidentally ship an unvalidated service. :::

Does this mean defining my API twice?

No, and this is the reason capnweb-validate exists in this shape.

"Schemaless" means the library needs no schema: Cap'n Web forwards whatever call you make without being told about it in advance. It does not mean you have no contract. Your contract is the TypeScript interface, used on both ends. The only problem is that TypeScript is erased before your code ever meets a hostile input.

There are two honest ways to close that gap without writing the interface out twice:

  • Generate the validators from the types. That is what capnweb-validate does: the TypeScript signature stays the single source of truth and the runtime check is derived from it at build time.
  • Generate the types from the validators. Schema libraries like Zod infer TypeScript types from the schema object, so you write the schema and get the types for free. ArkType, typia and ts-runtime-checks occupy similar territory, the last two also transforming TypeScript types directly into checks.

Either way you write one description of the boundary, not two. What you must not do is write only the TypeScript and assume it is doing something at runtime.

Install

Two packages, or one if you are on Workers RPC:

npm install capnweb capnweb-validate

Workers RPC users can install capnweb-validate without installing capnweb. The root package has no runtime dependency on capnweb; Cap'n Web-specific helpers live under capnweb-validate/capnweb.

There is no TypeScript peer dependency to satisfy. Reading your resolved types needs a compiler with the JavaScript API, so the package depends on typescript (>=5.7.0 <7) directly and uses that copy. This is what keeps the transform working in a TypeScript 7 (tsgo) workspace, where the compiler your editor and tsc use no longer exposes that API.

Server usage

Decorate the class you expose. Every call that arrives is checked against the method's declared parameter types before your code runs:

import { newWorkersRpcResponse, RpcTarget } from 'capnweb';
import { validateRpc } from 'capnweb-validate';

type User = { id: string; name: string };

@validateRpc()
export class Api extends RpcTarget {
  async authenticate(sessionToken: string): Promise<User> {
    // ...
  }
}

export default {
  async fetch(request: Request, env: Env) {
    return newWorkersRpcResponse(request, new Api());
  },
};

@validateRpc() validates calls on class instances, so it works with Cap'n Web, Workers WorkerEntrypoint, and Workers DurableObject services.

With no explicit type argument, the RPC surface is the class's public string-named methods and RPC-readable getters/properties, matching Cap'n Web dispatch. implements SomeInterface can sharpen matching signatures, but it does not hide extra public class methods. Keep local-only helpers private or symbol-named.

An explicit @validateRpc<SomeInterface>() makes SomeInterface the RPC surface. Public class methods outside that interface are rejected over RPC.

Client usage

Client-side stub validation is explicit. Wrap a client stub with validateStub<T>() when the caller wants return values and pipelined calls checked against a concrete surface:

import { newHttpBatchRpcSession } from 'capnweb';
import { validateStub } from 'capnweb-validate';

import type { Api } from './worker';

export const api = validateStub<Api>(newHttpBatchRpcSession<Api>('/rpc'));

validateStub<T>() validates resolved return values on the caller side. It does not validate outgoing arguments; the receiver validates those on arrival.

Wiring it into your build

Bundler plugins

import capnwebValidate from 'capnweb-validate/vite';    // or
import capnwebValidate from 'capnweb-validate/rollup';  // or
import capnwebValidate from 'capnweb-validate/webpack'; // or
import capnwebValidate from 'capnweb-validate/rspack';  // or
import capnwebValidate from 'capnweb-validate/esbuild'; // or
import capnwebValidate from 'capnweb-validate/farm';

export default {
  plugins: [capnwebValidate()],
};

The plugin transforms matching modules in memory; your source files are not modified on disk.

CLI

Wrangler does not expose a bundler plugin hook. For Wrangler, CI, or any flow that needs transformed files on disk:

capnweb-validate build --out .capnweb-validate
OptionMeaning
--out <dir>Where to write the transformed source tree. Required.
--tsconfig <path>Defaults to ./tsconfig.json.
--cwd <dir>Defaults to process.cwd().

Point the downstream build tool at the generated entry under --out.

Opting out per method

@skipRpcValidation() exempts one method from an otherwise validated class:

import { RpcTarget } from 'capnweb';
import { skipRpcValidation, validateRpc } from 'capnweb-validate';

@validateRpc()
class Api extends RpcTarget {
  @skipRpcValidation()
  unsafe(payload: unknown): unknown {
    return payload;
  }
}

The method still goes through Cap'n Web normally. This only disables capnweb-validate validation for that method.

Validation errors

Failures throw TypeError, so they keep their standard error type when crossing RPC boundaries. The message includes the failing path, expected type, and actual type.

BoundaryFailureHow it surfaces
Client stubBad resolved returnThe returned promise rejects.
Server targetBad incoming argumentThe server throws and the caller observes an RPC rejection.

Type coverage

The supported set matches Cap'n Web's published wire format: every type Cap'n Web guarantees can travel over RPC also has a precise build-time validator. That includes primitives and literal types, arrays, tuples, Map/Set, plain object shapes, unions, Record/index signatures, Promise<T> returns, and the RPC-compatible built-ins (Date, ArrayBuffer, typed arrays, Error subclasses, Blob, streams, URL, Headers, Request, Response). Pass-by-reference values are validated as stubs: functions, RpcStub<T>, RpcPromise<T>, RpcTarget subclasses, and Workers Fetcher<T>.

These are rejected at build time so you find out before the first RPC call:

TypeReason
WeakMapNot a supported RPC validation type.
WeakSetNot a supported RPC validation type.
SharedArrayBufferNot a supported RPC validation type.
FileUse a Blob or Uint8Array instead.

Overloaded methods are passed through unvalidated with a warning. Validating against one signature would reject valid calls to the others. Collapse the overloads into a single signature with union parameters, or use @skipRpcValidation() to silence the warning.

For generics, the transform emits one validator at the class declaration, so it cannot specialize per-new-expression. Use an explicit surface such as @validateRpc<Cursor<string>>() when the type arguments are known at the decorator site. An unconstrained type parameter defaults to any with a warning; a constrained one validates against its constraint.

Schema evolution

A validator built from one version of your types will eventually meet a peer built from another. Additive changes go through; changes that would let an unchecked value reach your code do not:

ChangeResult
Extra argumentAllowed
Extra object propertyAllowed
Extra index-signature keyAllowed
New optional parameter or propertyAllowed
Missing required parameter or propertyRefused
Renamed or retyped memberRefused
Changed tuple length, no rest elementRefused
New union memberRefused
New methodRefused

To remove a required member, make it optional in one release and delete it in a later one, so no build ever requires something a peer has already stopped sending.

"Allowed" is not the same as "visible". Extra arguments are dropped before the method runs, so an implementation cannot read an argument no validator checked:

// spec generated from: greet(name: string)
greet(name: string, ...rest: unknown[]) {
  // rest is always empty
}

// spec generated from: sum(label: string, ...values: number[])
sum(label: string, ...values: number[]) {
  // gets every argument, each one validated
}

Truncation only applies where the spec declares its parameters. A client-side spec omits args entirely, so nothing is dropped there. Extra object properties, by contrast, are forwarded to the implementation unvalidated; an index signature is the exception, since it validates every property outside the declared ones.

Keep strictNullChecks on. Without it TypeScript erases null from your types, and the generated validator will refuse a null that a peer built with the flag on considers perfectly valid.

Full details are in the capnweb-validate README.