Protovalidate (ECMAScript)
Runtime validation for Protobuf messages, driven by buf.validate rule annotations in your .proto schema.
Repository Health
Technical Analysis
protovalidate-es is the ECMAScript implementation of Protovalidate, the semantic validation library for Protocol Buffers. It reads buf.validate annotations directly from your .proto files — standard rules like string.email, uint32.lte, and repeated.min_items — and compiles them into fast, hand-written TypeScript checks, falling back to Common Expression Language (CEL) evaluation for anything the native fast path doesn’t cover.
Beyond the standard rule set, it supports custom per-field and per-message CEL expressions for cross-field logic, oneof-based conditional requirements, and a Standard Schema V1 adapter so the same validator plugs into any tool that speaks the Standard Schema interface. It succeeds protoc-gen-validate as the next generation of Protobuf validation for TypeScript and JavaScript projects built on @bufbuild/protobuf.
What You Get
- A createValidator() factory producing a reusable Validator with configurable failFast, legacyRequired, and custom regex-matcher options
- Native, hand-written implementations of the standard buf.validate field rules (string, numeric, bytes, list, map) that skip CEL entirely for the common cases
- A CEL-based fallback engine (via @bufbuild/cel) for any rule not covered by the native fast path, plus full support for custom message- and field-level CEL expressions
- Structured Violation objects with machine-readable field/rule paths, convertible to and from the buf.validate.Violations Protobuf message
- A Standard Schema V1 adapter (createStandardSchema / createStandardSchemaInit) for drop-in use with any tool that consumes the Standard Schema interface
Common Use Cases
- Validating API request/response messages generated by protoc-gen-es before they reach business logic
- Enforcing cross-field invariants (e.g. “last_name is required if first_name is set”) via custom CEL expressions on a message
- Rejecting malformed gRPC/Connect payloads at the edge of a service using rules co-located with the .proto schema
- Wiring Protobuf messages into form libraries or other Standard-Schema-aware tooling without writing a second validation layer
Under The Hood
Architecture protovalidate-es separates compile-time planning from runtime evaluation: Planner (src/planner.ts) walks a Protobuf DescMessage once, reading buf.validate field/message/oneof options via getOption(), and builds a cached tree of Eval<T> nodes (EvalMany, EvalField, EvalListItems, EvalMapEntries, EvalOneofRequired) keyed per message type in a messageCache Map, so repeated validation of the same message type skips replanning entirely. Rule evaluation for a given field routes through Planner.rules(), which first asks native/index.ts’s tryBuildNative() whether a hand-written TypeScript check can handle the field’s rules, and only falls through to EvalStandardRulesCel / EvalExtendedRulesCel (backed by CelManager in src/cel.ts, which wraps @bufbuild/cel’s celEnv/parse/plan and caches compiled rule sets by message typeName) for anything the native dispatcher doesn’t claim. Violations accumulate into a Cursor (src/cursor.ts) that either fails fast or collects everything, and CelManager.resetNow() runs after each top-level validate() call to keep the memoized “now” CEL binding scoped correctly. This plan-once/evaluate-many design, split across a native fast path and a CEL fallback, is the core architectural decision the package is built around.
Tech Stack The package is TypeScript-only (91.8% of the repo by bytes), built on @bufbuild/protobuf’s reflect API (reflect(), usedTypes(), createMutableRegistry) for schema-driven traversal and @bufbuild/cel for the fallback CEL interpreter, with @bufbuild/cel/ext supplying the standard string function library. It lives in an npm/turborepo workspace (turbo.json, package.json workspaces) alongside protovalidate-testing, protovalidate-bench, example, and upstream packages; the protovalidate package itself builds dual CJS/ESM output via two separate tsc invocations (build:cjs, build:esm) and verifies the resulting package exports with @arethetypeswrong/cli. Protobuf code generation runs through buf generate against a buf.gen.yaml/buf.yaml pair. Linting and formatting are handled by Biome rather than ESLint/Prettier, and tests run via Node’s built-in test runner through tsx —test.
Code Quality Test coverage is substantial and colocated with source: validator.test.ts (648 lines), lib.test.ts (1,475 lines), rules.test.ts (428 lines), plus dedicated suites for error.ts, eval.ts, cursor.ts, and standard-schema.ts, totaling roughly 3,400 lines of tests against the package’s own source. Error handling is explicit and typed — CompilationError, RuntimeError, and ValidationError are distinct Error subclasses rather than generic throws, and Validator.validate() catches by instance type to route into a discriminated ValidationResult union instead of ever swallowing an exception silently. Naming is consistent (Eval<T> node classes prefixed by evaluation shape: EvalField, EvalListItems, EvalMapEntries), the codebase is strict TypeScript throughout, Biome enforces lint/format in CI, and package exports are checked with arethetypeswrong to catch type/export mismatches before publish.
API Design The public surface is deliberately small: createValidator(options) returns a single validate(schema, message) method whose result is a three-way discriminated union (valid/invalid/error) rather than a thrown exception for the common case, and createStandardSchema/createStandardSchemaInit wrap that same validator behind the community Standard Schema V1 interface so it drops into any tool that already consumes Zod/Valibot-shaped schemas without a bespoke adapter. Configuration is limited to five well-scoped options (registry, failFast, regexMatch, legacyRequired, disableNativeRules), and the regexMatch hook is a notable ergonomic/security choice: ECMAScript regex can’t guarantee RE2’s linear-time behavior, so the library lets callers swap in an RE2 engine to close a ReDoS gap rather than silently accepting the risk. Violations carry structured, path-addressable data (Violation.field, Violation.rule as Path arrays) instead of only a message string, so downstream code can build custom error UIs without string-parsing.