Concepts

Schemas are the types

The non-negotiable typing rule across the monorepo: every value-bearing type starts as a Valibot schema. The TypeScript type comes from v.InferOutput<typeof schema>.

import * as v from "valibot";

const userSchema = v.object({
  address: addressSchema,
  nickname: v.string(),
});

type User = v.InferOutput<typeof userSchema>;

That’s the whole convention. The schema is the single source of truth; the type is derived. You never write interface User { … } for wire-bearing data. You never write type User = { address: string; nickname: string }.

Parse at the boundary

The complement of schema-first typing: boundaries validate with parse, not safeParse.

function send(_params: unknown) {
  const params = v.parse(sendParamsSchema, _params); // throws on invalid
  // `params` is now `SendParams`, statically typed, runtime-validated
  return broadcast(params);
}

The underscore-prefix convention (_paramsparams) signals “loose at the door, validated inside.” Throws are the contract — invalid input is a developer error, not a recoverable failure mode. safeParse is reserved for places where you genuinely want to branch on validity (rare in this codebase; usually a smell).

Where it matters

This rule is enforced not just for prettiness but for three concrete reasons:

Wire safety. Every value crossing postMessage, chrome.runtime, or an HTTP boundary gets parsed. The wallet’s dispatcher in packages/wallet/src/utils/dispatch.ts parses every incoming envelope before it touches a handler. The transport layer parses every JSON-RPC response before it returns. There is no untyped wire data in the interior.

Single source of truth. When a standard updates its schema (EIP-7702’s authorization tuple changed shape multiple times during the draft phase), one edit at the Valibot definition flows through to the type, the parser, and any consumer. Hand-rolled type aliases would have created drift opportunities.

Composability. Primitive schemas live in @ethernauta/core (addressSchema, bytes32Schema, hash32Schema, uint256Schema, …). Feature packages compose them. Adding a new EIP doesn’t redeclare what an address is — it uses addressSchema. The catalog is small and stable; the compositions are large and ever-growing.

The forbidden shapes

These produce ratchet violations in CI (scripts/no-escape-hatches.sh):

// NO — interfaces for value-bearing data
interface User { address: string }

// NO — hand-rolled object types
type User = { address: string }

// NO — `as` to widen or coerce
const user = raw as User

// NO — escape hatches
// @ts-ignore
const x: any = somethingUntyped

// NO — redundant annotations the inference would have given you anyway
const reader: Reader = create_reader([chain1])

The recursive Valibot anchor and the irreducible mapped-tuple boundary in decode_function_result have documented exceptions tagged // allow-violation: <tag>. Everything else is fixable.

The schemas catalog

The primitives that compose everything else:

  • addressSchema — checksummed 0x + 40 hex chars.
  • bytes4Schema, bytes32Schema, bytes65Schema, bytesMax32Schema, etc.
  • hash32Schema — 32-byte hash.
  • uint8Schema, uint16Schema, … uint256Schema, uintSchema.
  • ratioSchema — 0–1 inclusive.

Full list in @ethernauta/core. If the right primitive is missing, add it instead of widening with any / unknown.

The type lifecycle

// 1. Schema is authored.
const sendParamsSchema = v.object({
  to: addressSchema,
  value: bytes32Schema,
  input: bytesSchema,
});

// 2. Type is derived.
type SendParams = v.InferOutput<typeof sendParamsSchema>;

// 3. Boundary parses, interior infers.
function eth_send_transaction(_params: unknown) {
  const params = v.parse(sendParamsSchema, _params);
  // params: SendParams, fully typed, fully validated
  return build_and_send(params);
}

// 4. Consumer imports the schema OR the type, whichever they need.
import { sendParamsSchema } from "@ethernauta/eth";
import type { SendParams } from "@ethernauta/eth";

The schema is the value; the type is the shadow.