diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..ed19792 Binary files /dev/null and b/.DS_Store differ diff --git a/serializer/package.json b/serializer/package.json index e39920a..4ca818c 100644 --- a/serializer/package.json +++ b/serializer/package.json @@ -10,6 +10,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", "bench": "vitest bench --run", diff --git a/serializer/plugin/codegen.ts b/serializer/plugin/codegen.ts index 82b542f..22e94b3 100644 --- a/serializer/plugin/codegen.ts +++ b/serializer/plugin/codegen.ts @@ -670,7 +670,20 @@ function emitDec(schema: AnySchema, ctx: Ctx): { pre: string; expr: string } { const BARE_IDENT = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; -export function compileObject(schema: ObjectSchema): CodegenResult { +export interface CompileOptions { + /** + * If set, the generated decoder builds the result with + * `Object.create(${boundProtoExpr})` and assigns fields one by one, so the + * decoded value is an instance of the class whose prototype this expression + * resolves to. When omitted, the decoder returns a plain object literal. + */ + boundProtoExpr?: string; +} + +export function compileObject( + schema: ObjectSchema, + options: CompileOptions = {}, +): CodegenResult { const encCtx = new Ctx('enc'); const decCtx = new Ctx('dec'); @@ -681,23 +694,35 @@ export function compileObject(schema: ObjectSchema): CodegenResult { } const encodeBody = encSeg.build(); - // Decoder body: emit as inline object literal in return statement. - // For fields whose inner.expr is already a bare identifier (declared via inner.pre), - // skip the wrapping `const tmp = expr;` and use the identifier directly. + // Decoder body. For fields whose inner.expr is already a bare identifier + // (declared via inner.pre), skip the wrapping `const tmp = expr;`. let pre = ''; - const props: string[] = []; + const pairs: Array<{ key: string; expr: string; rawName: string }> = []; for (const fname of Object.keys(schema.fields)) { const inner = emitDec(schema.fields[fname]!, decCtx); + let expr: string; if (inner.pre !== '' && BARE_IDENT.test(inner.expr)) { pre += inner.pre; - props.push(`${JSON.stringify(fname)}: ${inner.expr}`); + expr = inner.expr; } else { const tmp = decCtx.fresh(`f_${sanitize(fname)}`); pre += `${inner.pre} const ${tmp} = ${inner.expr};`; - props.push(`${JSON.stringify(fname)}: ${tmp}`); + expr = tmp; } + pairs.push({ key: JSON.stringify(fname), expr, rawName: fname }); + } + + let decodeBody: string; + if (options.boundProtoExpr) { + // Class-bound: build via Object.create(proto) + sequential assigns so the + // result satisfies `instanceof` and inherits prototype methods. + const out = decCtx.fresh('out'); + const assigns = pairs.map((p) => `${out}[${p.key}] = ${p.expr};`).join(''); + decodeBody = `${pre} const ${out} = Object.create(${options.boundProtoExpr}); ${assigns} r.pos = pos; return ${out};`; + } else { + const literal = pairs.map((p) => `${p.key}: ${p.expr}`).join(', '); + decodeBody = `${pre} r.pos = pos; return { ${literal} };`; } - const decodeBody = `${pre} r.pos = pos; return { ${props.join(', ')} };`; const deps = new Map(); for (const [k, v] of encCtx.deps) deps.set(k, v); @@ -708,7 +733,13 @@ export function compileObject(schema: ObjectSchema): CodegenResult { return { encodeBody, decodeBody, deps, closure }; } -export function compileUnion(schema: UnionSchema): CodegenResult { +export function compileUnion( + schema: UnionSchema, + _options: CompileOptions = {}, +): CodegenResult { + // Class-bound unions are not yet supported (each variant would need its own + // prototype binding). For now, ignore `boundProtoExpr` and emit a plain + // literal-based decoder. const encCtx = new Ctx('enc'); const decCtx = new Ctx('dec'); diff --git a/serializer/plugin/compile/transformer.ts b/serializer/plugin/compile/transformer.ts index 43dc979..c33cfdb 100644 --- a/serializer/plugin/compile/transformer.ts +++ b/serializer/plugin/compile/transformer.ts @@ -7,9 +7,18 @@ * that constructs the codec inline — no runtime `new Function`, no codegen * module needed at runtime. * + * Also detects class declarations with `static [Serializable] = type(...)`: + * - the AOT codec's decoder uses `Object.create(ClassName.prototype)` so + * decoded values are real `instanceof ClassName` instances with prototype + * methods working; + * - the codec auto-registers itself into the runtime registry on module + * load (so `deserialize(bytes)` dispatches by id); + * - top-level `const X = registerClass(ClassName)` calls are rewritten to + * `const X = ClassName[Serializable]` so users can drop the call entirely. + * * Scope (v1): * - Same-file only (no cross-file schema references). - * - Top-level `const X = type(...)` declarations (including `export const`). + * - Top-level `const X = type(...)` and `static [Serializable] = type(...)`. * - Field values may be: * • imported primitive markers (u8 … f64, bool, str, bytes, *Array) * • calls to imported combinators (list, opt, enumOf, flags, tuple) @@ -22,16 +31,9 @@ import { parseSync } from 'oxc-parser'; import MagicString from 'magic-string'; import { compileObject, compileUnion } from '../codegen.ts'; import { s } from '../schema.ts'; -import type { - AnySchema, - ObjectSchema, - UnionSchema, -} from '../descriptors.ts'; +import type { AnySchema, ObjectSchema, UnionSchema } from '../descriptors.ts'; -const PKG_NAMES = new Set([ - '@perf/serializer', - '@perf/serializer/index', -]); +const PKG_NAMES = new Set(['@perf/serializer', '@perf/serializer/index']); interface ImportInfo { bindings: Map; @@ -41,10 +43,6 @@ interface CompiledCodec { schemaName: string; schemaKind: 'object' | 'union'; fieldsDescriptor: string; - encodeBody: string; - decodeBody: string; - closure: Map; - deps: Map; id: number; } @@ -64,8 +62,6 @@ const PRIMITIVES = new Set([ 'f32Array', 'f64Array', 'u8Array', 'u16Array', 'u32Array', 'i32Array', ]); -// oxc AST nodes vary widely per `type`; we treat them as loosely-typed objects -// and check `.type` before reading per-shape fields. type AnyNode = Record; function collectImportsFromSet(program: AnyNode, aliases: Set): ImportInfo { @@ -196,27 +192,58 @@ function collectFields(obj: AnyNode, scope: Scope): Record | interface TypeCallInfo { call: AnyNode; + /** Const name for `const X = type(...)` or class name for `class X { static [Serializable] = type(...) }`. */ declName: string; fn: 'type' | 'oneOf'; + /** If set, the call is a static-field initializer inside this class. */ + boundClass?: string; +} + +function unwrapStmt(stmt: AnyNode): AnyNode { + if (stmt.type === 'ExportNamedDeclaration' && stmt.declaration) return stmt.declaration as AnyNode; + if (stmt.type === 'ExportDefaultDeclaration' && stmt.declaration) return stmt.declaration as AnyNode; + return stmt; } function findTypeCalls(program: AnyNode, imports: ImportInfo): TypeCallInfo[] { const calls: TypeCallInfo[] = []; for (const topStmt of program.body as AnyNode[]) { - const stmt: AnyNode = - topStmt.type === 'ExportNamedDeclaration' && topStmt.declaration - ? (topStmt.declaration as AnyNode) - : topStmt; - if (stmt.type !== 'VariableDeclaration') continue; - for (const decl of stmt.declarations as AnyNode[]) { - const id = decl.id as AnyNode; - const init = decl.init as AnyNode | null; - if (!init || id.type !== 'Identifier' || init.type !== 'CallExpression') continue; - const callee = init.callee as AnyNode; - if (callee.type !== 'Identifier') continue; - const exported = imports.bindings.get(callee.name as string); - if (exported === 'type' || exported === 'oneOf') { - calls.push({ call: init, declName: id.name as string, fn: exported }); + const stmt = unwrapStmt(topStmt); + + // Top-level `const X = type(...)` / `const X = oneOf(...)` + if (stmt.type === 'VariableDeclaration') { + for (const decl of stmt.declarations as AnyNode[]) { + const id = decl.id as AnyNode; + const init = decl.init as AnyNode | null; + if (!init || id.type !== 'Identifier' || init.type !== 'CallExpression') continue; + const callee = init.callee as AnyNode; + if (callee.type !== 'Identifier') continue; + const exported = imports.bindings.get(callee.name as string); + if (exported === 'type' || exported === 'oneOf') { + calls.push({ call: init, declName: id.name as string, fn: exported }); + } + } + continue; + } + + // `class X { static [Serializable] = type(...) }` + if (stmt.type === 'ClassDeclaration') { + const className = (stmt.id as AnyNode | null)?.name as string | undefined; + if (!className) continue; + const body = stmt.body as AnyNode; + for (const member of body.body as AnyNode[]) { + if (member.type !== 'PropertyDefinition' || !member.static || !member.computed) continue; + const key = member.key as AnyNode; + if (key.type !== 'Identifier') continue; + const exported = imports.bindings.get(key.name as string); + if (exported !== 'Serializable') continue; + const value = member.value as AnyNode | null; + if (!value || value.type !== 'CallExpression') continue; + const callee = value.callee as AnyNode; + if (callee.type !== 'Identifier') continue; + const fnName = imports.bindings.get(callee.name as string); + if (fnName !== 'type' && fnName !== 'oneOf') continue; + calls.push({ call: value, declName: className, fn: fnName, boundClass: className }); } } } @@ -298,7 +325,11 @@ function compileCall( const schema = buildSchemaFromTypeCall(info, scope); if (!schema) return null; - const cg = schema.kind === 'object' ? compileObject(schema) : compileUnion(schema); + const protoExpr = info.boundClass ? `${info.boundClass}.prototype` : undefined; + const cg = + schema.kind === 'object' + ? compileObject(schema, { boundProtoExpr: protoExpr }) + : compileUnion(schema); const id = fnv1a16(schema.name); const fname = sanitize(schema.name); if (cg.deps.size > 0) return null; // ref/codec deps not supported yet @@ -326,7 +357,7 @@ function compileCall( ${cg.decodeBody} } const __desc = ${descriptorLit}; - const __codec = { + const __codec = Object.freeze({ ...__desc, id: ${id}, encode(v, into) { @@ -342,10 +373,8 @@ function compileCall( encodeInto(v, w) { encode_${fname}(w, v); }, decodeFrom: decode_${fname}, $infer: undefined, - }; - Object.freeze(__codec); - __serRegisterPrecompiled(__codec, encode_${fname}, decode_${fname}); - return __codec; + }); + return __serRegisterPrecompiled(__codec); })()`; return { @@ -354,10 +383,6 @@ function compileCall( schemaName: schema.name, schemaKind: schema.kind, fieldsDescriptor: descriptorLit, - encodeBody: cg.encodeBody, - decodeBody: cg.decodeBody, - closure: cg.closure, - deps: cg.deps, id, }, }; @@ -384,14 +409,69 @@ function serializeClosureValue(v: unknown): string { function makePrelude(importPath: string): string { return ` -import { Writer as __SerWriter, Reader as __SerReader } from ${JSON.stringify(importPath)}; -const __serRegistry = (globalThis.__serRegistry ??= new Map()); -function __serRegisterPrecompiled(codec, enc, dec) { - __serRegistry.set(codec.id, codec); -} +import { Writer as __SerWriter, Reader as __SerReader, __registerPrecompiled as __serRegisterPrecompiled } from ${JSON.stringify(importPath)}; `; } +/** + * Rewrite `const X = registerClass(Y)` (and statement-form `registerClass(Y)`) + * into a direct reference to the class's pre-attached codec. With the AOT codec + * already living at `Y[Serializable]`, the runtime `registerClass` call is + * redundant — we replace it so users can drop it entirely. + */ +function rewriteRegisterClassCalls( + program: AnyNode, + imports: ImportInfo, + ms: MagicString, +): number { + const serializableLocal = [...imports.bindings.entries()].find( + ([, e]) => e === 'Serializable', + )?.[0]; + // If user hasn't imported Serializable, they can't have written `static [Serializable] = ...`, + // so any registerClass(X) call is using the runtime path. Leave it alone. + if (!serializableLocal) return 0; + + let count = 0; + for (const topStmt of program.body as AnyNode[]) { + const stmt = unwrapStmt(topStmt); + + // `const X = registerClass(Y)` (or `let`/`var`) + if (stmt.type === 'VariableDeclaration') { + for (const decl of stmt.declarations as AnyNode[]) { + const init = decl.init as AnyNode | null; + if (!init || init.type !== 'CallExpression') continue; + if (!isRegisterClassCall(init, imports)) continue; + const arg = (init.arguments as AnyNode[])[0]; + if (!arg || arg.type !== 'Identifier') continue; + ms.overwrite( + init.start as number, + init.end as number, + `${arg.name}[${serializableLocal}]`, + ); + count++; + } + continue; + } + + // Bare `registerClass(Y);` expression-statement + if (stmt.type === 'ExpressionStatement') { + const expr = stmt.expression as AnyNode; + if (expr.type !== 'CallExpression') continue; + if (!isRegisterClassCall(expr, imports)) continue; + // No-op now — codec already self-registers on class init. + ms.overwrite(stmt.start as number, stmt.end as number, '/* registerClass elided */'); + count++; + } + } + return count; +} + +function isRegisterClassCall(call: AnyNode, imports: ImportInfo): boolean { + const callee = call.callee as AnyNode; + if (callee.type !== 'Identifier') return false; + return imports.bindings.get(callee.name as string) === 'registerClass'; +} + export interface TransformOptions { importPath?: string; packageAliases?: string[]; @@ -402,7 +482,11 @@ export interface TransformResult { transformedCount: number; } -export function transform(source: string, filename = 'input.ts', options: TransformOptions = {}): TransformResult { +export function transform( + source: string, + filename = 'input.ts', + options: TransformOptions = {}, +): TransformResult { const importPath = options.importPath ?? '@perf/serializer'; const aliases = new Set(PKG_NAMES); for (const a of options.packageAliases ?? []) aliases.add(a); @@ -420,7 +504,10 @@ export function transform(source: string, filename = 'input.ts', options: Transf let hasTypeImport = false; for (const v of imports.bindings.values()) { - if (v === 'type' || v === 'oneOf') { hasTypeImport = true; break; } + if (v === 'type' || v === 'oneOf') { + hasTypeImport = true; + break; + } } if (!hasTypeImport) return { code: source, transformedCount: 0 }; @@ -452,6 +539,10 @@ export function transform(source: string, filename = 'input.ts', options: Transf } if (transformedCount === 0) return { code: source, transformedCount: 0 }; + + // Now that codecs are inlined, rewrite registerClass(X) → X[Serializable]. + rewriteRegisterClassCalls(program, imports, ms); + ms.prepend(makePrelude(importPath)); return { code: ms.toString(), transformedCount }; } diff --git a/serializer/plugin/index.ts b/serializer/plugin/index.ts index 19f6eaf..df87036 100644 --- a/serializer/plugin/index.ts +++ b/serializer/plugin/index.ts @@ -1,4 +1,4 @@ -// ── Simplified façade (recommended) ──────────────────────────────────────── +// ── Public API ───────────────────────────────────────────────────────────── export { type, oneOf, @@ -10,32 +10,11 @@ export { } from './api.ts'; export type { TypeCodec, InferType, Router } from './api.ts'; -// ── Low-level API (advanced) ─────────────────────────────────────────────── +// ── Low-level (writer/reader for hot paths, framing primitives) ──────────── export { Writer, Reader } from './io.ts'; -export { s, defineSchema } from './schema.ts'; -export type { SchemaBuilder } from './schema.ts'; + +// ── Class contract ───────────────────────────────────────────────────────── export { Serializable } from './symbol.ts'; -export { - register, - registerClass, - serialize, - deserialize, - clearRegistry, -} from './register.ts'; -export type { Codec } from './register.ts'; -export type { - AnySchema, - ObjectSchema, - UnionSchema, - ArraySchema, - OptionalSchema, - EnumSchema, - BitsetSchema, - TupleSchema, - RefSchema, - CodecSchema, - PrimitiveSchema, - TypedArraySchema, - PrimitiveKind, - TypedArrayKind, -} from './descriptors.ts'; + +// ── Test / AOT helpers ───────────────────────────────────────────────────── +export { clearRegistry, __registerPrecompiled } from './register.ts'; diff --git a/serializer/plugin/register.ts b/serializer/plugin/register.ts index b03f3a6..b6a6190 100644 --- a/serializer/plugin/register.ts +++ b/serializer/plugin/register.ts @@ -1,7 +1,6 @@ import { compileObject, compileUnion } from './codegen.ts'; import type { AnySchema, ObjectSchema, UnionSchema } from './descriptors.ts'; -import { Reader, Writer } from './io.ts'; -import { Serializable } from './symbol.ts'; +import type { Reader, Writer } from './io.ts'; export interface Codec { readonly id: number; @@ -14,7 +13,6 @@ type AnyCodec = Codec; const byName = new Map(); const byId = new Map(); -const byCtor = new WeakMap(); function fnv1a16(s: string): number { let h = 0x811c9dc5; @@ -57,6 +55,10 @@ function sanIdent(name: string): string { return name.replace(/[^A-Za-z0-9_]/g, '_'); } +/** + * Compile a schema into a `Codec` and register it in the lookup tables. Used + * internally by `type(...)` / `oneOf(...)`. Idempotent by schema name. + */ export function register(schema: ObjectSchema | UnionSchema): Codec { const existing = byName.get(schema.name); if (existing) return existing as Codec; @@ -149,49 +151,26 @@ return function decode_${fname}(r) { return codec; } -export function registerClass(Ctor: new (...args: never[]) => T): Codec { - const cached = byCtor.get(Ctor); - if (cached) return cached as Codec; - const schema = (Ctor as unknown as Record)[Serializable] as - | ObjectSchema - | UnionSchema - | undefined; - if (!schema) { - throw new Error(`${Ctor.name} has no [Serializable] schema`); - } - const codec = register(schema); - byCtor.set(Ctor, codec); - return codec; -} - -/** - * Encode a value into a framed Uint8Array (2-byte schema ID + body). - * If a Writer is passed, returns a view; otherwise returns a fresh copy. - */ -export function serialize(value: T, codec: Codec, writer?: Writer): Uint8Array { - if (writer) { - writer.u16(codec.id); - codec.encode(writer, value); - return writer.bytes(); - } - const w = new Writer(); - w.u16(codec.id); - codec.encode(w, value); - return w.bytesCopy(); -} - -/** - * Decode a framed Uint8Array by looking up its schema ID. - */ -export function deserialize(bytes: Uint8Array): T { - const r = new Reader(bytes); - const id = r.u16(); - const codec = byId.get(id); - if (!codec) throw new Error(`Unknown schema ID: 0x${id.toString(16)}`); - return codec.decode(r) as T; -} - +/** Reset the global codec registry. Test helper. */ export function clearRegistry(): void { byName.clear(); byId.clear(); } + +/** + * Internal: AOT-generated codecs use this to inject themselves into the + * runtime registry on module load. Not meant for user code. + */ +export function __registerPrecompiled(codec: Codec): Codec { + const existing = byName.get(codec.name); + if (existing) return existing as Codec; + const idExisting = byId.get(codec.id); + if (idExisting && idExisting.name !== codec.name) { + throw new Error( + `Schema ID collision: "${codec.name}" and "${idExisting.name}" both hash to 0x${codec.id.toString(16)}`, + ); + } + byName.set(codec.name, codec as AnyCodec); + byId.set(codec.id, codec as AnyCodec); + return codec; +} diff --git a/serializer/pnpm-lock.yaml b/serializer/pnpm-lock.yaml index 387475e..06d62c8 100644 --- a/serializer/pnpm-lock.yaml +++ b/serializer/pnpm-lock.yaml @@ -189,112 +189,96 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-arm64-gnu@0.132.0': resolution: {integrity: sha512-sQBix5P2cW+IpzTcCwYxnh9yALrKSIkKJThspBvMGcygSMnbzkSvhN7SfuX1hvBk8y1XEChsdkU3ET0V5DmzUw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.126.0': resolution: {integrity: sha512-FQ+MMh7MT0Dr/u8+RWmWKlfoeWPQyHDbhhxJShJlYtROXXPHsRs9EvmQOZZ3sx4Nn7JU8NX+oyw2YzQ7anBJcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxc-parser/binding-linux-arm64-musl@0.132.0': resolution: {integrity: sha512-WozHg3Kc//8Sk756HXXgMbEAvqtG+Lzb9JOojwQzIGDtN78Az2dLttkb71akWYUF/8IgYfDSlfKh4Uot8is5Vw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.126.0': resolution: {integrity: sha512-Wv/T8C98hRQhGTlx2XFyLn5raRMp9U1lOQD+YnXNgAr7wHbJJpZ8mDBU7Rw+M3WytGcGTFcr6kqgfyQeHVtLbQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-ppc64-gnu@0.132.0': resolution: {integrity: sha512-CmX/ulNBOEwWTyVRmcpYKAcAizW6+OjtLJgo7fXoL9OqQvjF4VER8tPomv44vwzfSCy1BHbsB0ZlZYzYJNj4cA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.126.0': resolution: {integrity: sha512-DHx1rT1zauW0ZbLHOiQh5AC9Xs3UkWx2XmfZHs+7nnWYr3sagrufoUQC+/XPwwjMIlCFXiFGM0sFh3TyOCZwqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.132.0': resolution: {integrity: sha512-j9oQS+hM90SdhviNGWbPgT4+Rlq+ac++q/zjgwPD1mVHgxHzATvoRGtDx0sXGmFOQ9J9YkwAhYGb5MAHL6TAsA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.126.0': resolution: {integrity: sha512-umDc2mTShH0U2zcEYf8mIJ163seLJNn54ZUZYeI5jD4qlg9izPwoLrC2aNPKlMJTu6u/ysmQWiEvIiaAG+INkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxc-parser/binding-linux-riscv64-musl@0.132.0': resolution: {integrity: sha512-bLz+Xi+Agnfmd7kWPEsSVwCn2k4EyIalZkNBcQ0OGIv9rqn8VgCPLNd03tM9mKX/5TdlvDXalz0q71BIrOPNqg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.126.0': resolution: {integrity: sha512-PXXeWayclRtO1pxQEeCpiqIglQdhK2mAI2VX5xnsWdImzSB5GpoQ8TNw7vTCKk2k+GZuxl+q1knncidjCyUP9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-s390x-gnu@0.132.0': resolution: {integrity: sha512-U6t2qbJU0ypTfyj9QV3W1Y6mITDTL8ai/OR6NUn85vyHthOvobKWgXzU4tu0EskSzlpuVFz1g0jFGulDIUKHxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.126.0': resolution: {integrity: sha512-wzocjxm34TbB3bFlqG65JiLtvf6ZDg2ZxRkLLbgXwDQUNU+0MPjQN8zy/0jBKNA5fnPLk3XeVdZ7Uin+7+CVkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.132.0': resolution: {integrity: sha512-WcEaSNHFk8yz5YFlQQAlhq6jOFmZBB/RKE7uzhyCIf+pF1Lmv9gUH4221mle2Gd9iHyWT3ySNph8yZgb1xYdWg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.126.0': resolution: {integrity: sha512-e83uftP60jmkPs2+CW6T6A1GYzN2H6IumDAiTntv9WyHR73PI3ImHNBkYqnA3ukeKI3xjcCbhSh9QeJWmufxGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxc-parser/binding-linux-x64-musl@0.132.0': resolution: {integrity: sha512-iQrV4iJzQgRwK3BWRmQl1C3C6g3wYpXN2WLdQdyR+efoUnncdShZAVp9OgcojtlD3MDRbuOMGG3SjxF4fL4nlQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.126.0': resolution: {integrity: sha512-4WiOILHnPrTDY2/L4mE6PZCYwLN1d3ghma6BuTJ452CCgzRMt3uFplCtR+o3r9zdUWJYb370UizpI9CUcWXr1A==} @@ -452,42 +436,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.2': resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.2': resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.2': resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.2': resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.2': resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.2': resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} @@ -819,28 +797,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} diff --git a/serializer/src/main.ts b/serializer/src/main.ts index 079589f..f7b7b81 100644 --- a/serializer/src/main.ts +++ b/serializer/src/main.ts @@ -18,6 +18,7 @@ import { enumOf, flags, Writer, + Serializable, } from '@perf/serializer'; // ─── Tee output to console +
 if we're in a browser ──────────
@@ -206,3 +207,179 @@ const dispatched1 = proto.decode(framedTicker);
 const dispatched2 = proto.decode(framedBook);
 log('  dispatched ticker symbol:', (dispatched1 as Ticker).symbol);
 log('  dispatched book bids[0]:', (dispatched2 as typeof book).bids[0]);
+
+// ─── Example 7: class with [Symbol.serializable] contract ──────────────────
+//
+// Attach the schema to a class via the well-known `Symbol.serializable`. The
+// AOT plugin sees this and:
+//   • generates a class-aware decoder that returns `Object.create(Position.prototype)`
+//     instances, so methods/getters work on decoded values;
+//   • auto-registers the codec into the runtime registry on module load — no
+//     `registerClass(Position)` call needed;
+//   • makes `Position[Serializable]` the codec itself, usable directly.
+
+class Position {
+  side!: 'long' | 'short';
+  qty!: number;
+  entryPrice!: number;
+
+  static [Serializable] = type('Position', {
+    side: enumOf(['long', 'short'] as const),
+    qty: f64,
+    entryPrice: f64,
+  });
+
+  get notional(): number {
+    return this.qty * this.entryPrice;
+  }
+
+  pnl(currentPrice: number): number {
+    return this.side === 'long'
+      ? (currentPrice - this.entryPrice) * this.qty
+      : (this.entryPrice - currentPrice) * this.qty;
+  }
+}
+
+const pos = Object.assign(new Position(), {
+  side: 'long' as const,
+  qty: 2,
+  entryPrice: 67500,
+});
+
+// `Position[Serializable]` IS the codec — no `registerClass(...)` needed.
+const PositionCodec = Position[Serializable]!;
+const posBytes = PositionCodec.encode(pos);
+const back = PositionCodec.decode(posBytes) as Position;
+
+log('\nExample 7: class with Symbol.serializable');
+log(`  encoded: ${posBytes.length}b`);
+log(`  pos.notional:   ${pos.notional}`);
+log(`  back.notional:  ${back.notional}  (method works on decoded value after AOT)`);
+log(`  back instanceof Position: ${back instanceof Position}`);
+log(`  back.pnl(68000): ${back.pnl(68000)}`);
+
+// ─── Example 8: deeply-nested portfolio (5 levels, ~770 objects) ────────────
+//
+// Realistic deep-tree data: Portfolio → Accounts → Holdings → Trades →
+// Executions. Demonstrates that the codec handles arbitrary nesting; under AOT
+// the inner-loop encoder/decoder is fully inlined — no per-level function
+// dispatch.
+
+const Execution = type('Execution', {
+  id: u53,
+  ts: u53,
+  price: f64,
+  qty: f64,
+  venue: enumOf(['NYSE', 'NASDAQ', 'BATS', 'IEX'] as const),
+});
+
+const Trade = type('Trade', {
+  id: u53,
+  symbol: str,
+  side: enumOf(['buy', 'sell'] as const),
+  executions: list(Execution),
+});
+
+const Holding = type('Holding', {
+  symbol: str,
+  qty: f64,
+  avgEntry: f64,
+  trades: list(Trade),
+});
+
+const Account = type('Account', {
+  id: u53,
+  name: str,
+  currency: enumOf(['USD', 'EUR', 'GBP'] as const),
+  balance: f64,
+  holdings: list(Holding),
+});
+
+const Portfolio = type('Portfolio', {
+  ownerId: u53,
+  ts: u53,
+  accounts: list(Account),
+});
+
+type PortfolioT = typeof Portfolio.$infer;
+
+function buildPortfolio(): PortfolioT {
+  const venues = ['NYSE', 'NASDAQ', 'BATS', 'IEX'] as const;
+  const symbols = ['AAPL', 'MSFT', 'GOOG', 'AMZN', 'TSLA'] as const;
+  const currencies = ['USD', 'EUR', 'GBP'] as const;
+
+  return {
+    ownerId: 100001,
+    ts: 1716100000000,
+    accounts: Array.from({ length: 3 }, (_, a) => ({
+      id: 10 + a,
+      name: `Account-${a + 1}`,
+      currency: currencies[a]!,
+      balance: 100_000 * (a + 1),
+      holdings: Array.from({ length: 5 }, (_, p) => ({
+        symbol: symbols[p % symbols.length]!,
+        qty: 100 * (p + 1),
+        avgEntry: 100 + p * 25,
+        trades: Array.from({ length: 10 }, (_, t) => ({
+          id: a * 1000 + p * 100 + t,
+          symbol: symbols[(a + p + t) % symbols.length]!,
+          side: t % 2 === 0 ? ('buy' as const) : ('sell' as const),
+          executions: Array.from({ length: 4 }, (_, e) => ({
+            id: a * 10000 + p * 1000 + t * 10 + e,
+            ts: 1716100000000 + (a * 100 + p * 50 + t * 5 + e) * 1000,
+            price: 100 + ((a + p + t + e) % 100) * 2,
+            qty: 5 + ((p + t + e) % 50),
+            venue: venues[e % 4]!,
+          })),
+        })),
+      })),
+    })),
+  };
+}
+
+const portfolio = buildPortfolio();
+
+// Counts at each level.
+const nAccounts = portfolio.accounts.length;
+const nHoldings = portfolio.accounts.reduce((s, a) => s + a.holdings.length, 0);
+const nTrades = portfolio.accounts.reduce(
+  (s, a) => s + a.holdings.reduce((ss, h) => ss + h.trades.length, 0),
+  0,
+);
+const nExecs = portfolio.accounts.reduce(
+  (s, a) =>
+    s +
+    a.holdings.reduce(
+      (ss, h) => ss + h.trades.reduce((sss, t) => sss + t.executions.length, 0),
+      0,
+    ),
+  0,
+);
+
+const tEnc0 = performance.now();
+const portfolioBytes = Portfolio.encode(portfolio);
+const tEnc1 = performance.now();
+const portfolioJSON = JSON.stringify(portfolio);
+const tEnc2 = performance.now();
+const decodedPortfolio = Portfolio.decode(portfolioBytes);
+const tEnc3 = performance.now();
+JSON.parse(portfolioJSON);
+const tEnc4 = performance.now();
+
+const total = 1 + nAccounts + nHoldings + nTrades + nExecs;
+
+log('\nExample 8: deeply-nested portfolio (5 levels)');
+log(
+  `  ${nAccounts} accounts × ${nHoldings / nAccounts} holdings × ${nTrades / nHoldings} trades × ${nExecs / nTrades} executions = ${total} objects total`,
+);
+log(
+  `  encoded:  ${portfolioBytes.length}b in ${(tEnc1 - tEnc0).toFixed(2)}ms   |   JSON: ${portfolioJSON.length}b in ${(tEnc2 - tEnc1).toFixed(2)}ms   (${((portfolioBytes.length / portfolioJSON.length) * 100).toFixed(0)}% of JSON)`,
+);
+log(
+  `  decoded:  ${(tEnc3 - tEnc2).toFixed(2)}ms                              |   JSON.parse: ${(tEnc4 - tEnc3).toFixed(2)}ms`,
+);
+const sampleExec = decodedPortfolio.accounts[0]!.holdings[0]!.trades[0]!.executions[0]!;
+const origExec = portfolio.accounts[0]!.holdings[0]!.trades[0]!.executions[0]!;
+log(
+  `  round-trip preserves leaf data: ${sampleExec.id === origExec.id && sampleExec.venue === origExec.venue}`,
+);
diff --git a/serializer/test/bench/codec.bench.ts b/serializer/test/bench/codec.bench.ts
index 9a201c4..738bc20 100644
--- a/serializer/test/bench/codec.bench.ts
+++ b/serializer/test/bench/codec.bench.ts
@@ -1,14 +1,14 @@
 import { bench, describe } from 'vitest';
-import { Reader, Writer, deserialize, serialize } from '../../plugin/index.ts';
+import { Reader, Writer, router } from '../../plugin/index.ts';
 import {
+  Book,
+  Order,
+  Ticker,
   buildBook,
   buildOrder,
   buildTicker,
-  registerAll,
 } from './payloads.ts';
 
-const codecs = registerAll();
-
 const ticker = buildTicker();
 const order = buildOrder();
 const book = buildBook(1000);
@@ -23,11 +23,11 @@ const tickerJSON = JSON.stringify(ticker);
 const orderJSON = JSON.stringify(order);
 const bookJSON = JSON.stringify(book);
 
-const tickerBin = serialize(ticker, codecs.ticker);
-const orderBin = serialize(order, codecs.order);
-const bookBin = serialize(book, codecs.book);
+const tickerBin = Ticker.encode(ticker);
+const orderBin = Order.encode(order);
+const bookBin = Book.encode(book);
 
-// One-time payload-size print on module load so it appears once in bench output.
+// One-time payload-size print on module load.
 // eslint-disable-next-line no-console
 console.log(
   '\n--- payload sizes ---\n' +
@@ -40,9 +40,9 @@ describe('encode ticker (5 fields)', () => {
   bench('JSON.stringify', () => {
     JSON.stringify(ticker);
   });
-  bench('codec.encode (pooled)', () => {
+  bench('codec.encodeInto (pooled)', () => {
     wTicker.reset();
-    codecs.ticker.encode(wTicker, ticker);
+    Ticker.encodeInto(ticker, wTicker);
   });
 });
 
@@ -50,9 +50,9 @@ describe('encode order (10 fields + bitset)', () => {
   bench('JSON.stringify', () => {
     JSON.stringify(order);
   });
-  bench('codec.encode (pooled)', () => {
+  bench('codec.encodeInto (pooled)', () => {
     wOrder.reset();
-    codecs.order.encode(wOrder, order);
+    Order.encodeInto(order, wOrder);
   });
 });
 
@@ -60,9 +60,9 @@ describe('encode book (1000 levels)', () => {
   bench('JSON.stringify', () => {
     JSON.stringify(book);
   });
-  bench('codec.encode (pooled)', () => {
+  bench('codec.encodeInto (pooled)', () => {
     wBook.reset();
-    codecs.book.encode(wBook, book);
+    Book.encodeInto(book, wBook);
   });
 });
 
@@ -70,10 +70,9 @@ describe('decode ticker', () => {
   bench('JSON.parse', () => {
     JSON.parse(tickerJSON);
   });
-  bench('codec.decode', () => {
+  bench('codec.decodeFrom', () => {
     const r = new Reader(tickerBin);
-    r.pos = 2;
-    codecs.ticker.decode(r);
+    Ticker.decodeFrom(r);
   });
 });
 
@@ -81,10 +80,9 @@ describe('decode order', () => {
   bench('JSON.parse', () => {
     JSON.parse(orderJSON);
   });
-  bench('codec.decode', () => {
+  bench('codec.decodeFrom', () => {
     const r = new Reader(orderBin);
-    r.pos = 2;
-    codecs.order.decode(r);
+    Order.decodeFrom(r);
   });
 });
 
@@ -92,10 +90,9 @@ describe('decode book (1000 levels)', () => {
   bench('JSON.parse', () => {
     JSON.parse(bookJSON);
   });
-  bench('codec.decode', () => {
+  bench('codec.decodeFrom', () => {
     const r = new Reader(bookBin);
-    r.pos = 2;
-    codecs.book.decode(r);
+    Book.decodeFrom(r);
   });
 });
 
@@ -105,17 +102,18 @@ describe('roundtrip ticker', () => {
   });
   bench('codec (pooled)', () => {
     wTicker.reset();
-    codecs.ticker.encode(wTicker, ticker);
+    Ticker.encodeInto(ticker, wTicker);
     const r = new Reader(wTicker.bytes());
-    codecs.ticker.decode(r);
+    Ticker.decodeFrom(r);
   });
 });
 
-describe('serialize+deserialize ticker (with frame)', () => {
+describe('framed ticker via router', () => {
+  const proto = router(Ticker, Order, Book);
   bench('JSON', () => {
     JSON.parse(JSON.stringify(ticker));
   });
-  bench('serialize/deserialize (framed)', () => {
-    deserialize(serialize(ticker, codecs.ticker));
+  bench('router encode + decode (framed)', () => {
+    proto.decode(proto.encode(ticker, Ticker));
   });
 });
diff --git a/serializer/test/bench/payloads.ts b/serializer/test/bench/payloads.ts
index 7eab1f8..a1a1db1 100644
--- a/serializer/test/bench/payloads.ts
+++ b/serializer/test/bench/payloads.ts
@@ -1,73 +1,50 @@
-import { defineSchema, register, s } from '../../plugin/index.ts';
-import type { Codec } from '../../plugin/index.ts';
+import {
+  type,
+  enumOf,
+  f64,
+  flags,
+  list,
+  str,
+  u53,
+  type TypeCodec,
+} from '../../plugin/index.ts';
 
-export const TickerSchema = defineSchema('BenchTicker', (s) => ({
-  symbol: s.str,
-  last: s.f64,
-  bid: s.f64,
-  ask: s.f64,
-  volume: s.f64,
-}));
+export const Ticker = type('BenchTicker', {
+  symbol: str,
+  last: f64,
+  bid: f64,
+  ask: f64,
+  volume: f64,
+});
 
-export const OrderSchema = defineSchema('BenchOrder', (s) => ({
-  id: s.u53,
-  account: s.u53,
-  symbol: s.str,
-  side: s.enum(['buy', 'sell'] as const),
-  type: s.enum(['limit', 'market', 'stop', 'stop_limit'] as const),
-  price: s.f64,
-  qty: s.f64,
-  filledQty: s.f64,
-  ts: s.u53,
-  flags: s.bitset(['ioc', 'post_only', 'reduce_only'] as const),
-}));
+export const Order = type('BenchOrder', {
+  id: u53,
+  account: u53,
+  symbol: str,
+  side: enumOf(['buy', 'sell'] as const),
+  type: enumOf(['limit', 'market', 'stop', 'stop_limit'] as const),
+  price: f64,
+  qty: f64,
+  filledQty: f64,
+  ts: u53,
+  flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
+});
 
-export const LevelSchema = defineSchema('BenchLevel', (s) => ({
-  p: s.f64,
-  q: s.f64,
-}));
+export const Level = type('BenchLevel', { p: f64, q: f64 });
 
-export const BookSchema = defineSchema('BenchBook', (s) => ({
-  symbol: s.str,
-  ts: s.u53,
-  bids: s.array(LevelSchema),
-  asks: s.array(LevelSchema),
-}));
+export const Book = type('BenchBook', {
+  symbol: str,
+  ts: u53,
+  bids: list(Level),
+  asks: list(Level),
+});
 
-export interface Ticker {
-  symbol: string;
-  last: number;
-  bid: number;
-  ask: number;
-  volume: number;
-}
+export type TickerT = typeof Ticker.$infer;
+export type OrderT = typeof Order.$infer;
+export type LevelT = typeof Level.$infer;
+export type BookT = typeof Book.$infer;
 
-export interface Order {
-  id: number;
-  account: number;
-  symbol: string;
-  side: 'buy' | 'sell';
-  type: 'limit' | 'market' | 'stop' | 'stop_limit';
-  price: number;
-  qty: number;
-  filledQty: number;
-  ts: number;
-  flags: { ioc: boolean; post_only: boolean; reduce_only: boolean };
-}
-
-export interface Level {
-  p: number;
-  q: number;
-}
-
-export interface Book {
-  symbol: string;
-  ts: number;
-  bids: Level[];
-  asks: Level[];
-}
-
-export function buildTicker(): Ticker {
+export function buildTicker(): TickerT {
   return {
     symbol: 'BTC-USD',
     last: 67891.23,
@@ -77,7 +54,7 @@ export function buildTicker(): Ticker {
   };
 }
 
-export function buildOrder(): Order {
+export function buildOrder(): OrderT {
   return {
     id: 9876543210,
     account: 12345678,
@@ -92,9 +69,9 @@ export function buildOrder(): Order {
   };
 }
 
-export function buildBook(depth: number): Book {
-  const bids: Level[] = new Array(depth);
-  const asks: Level[] = new Array(depth);
+export function buildBook(depth: number): BookT {
+  const bids: LevelT[] = new Array(depth);
+  const asks: LevelT[] = new Array(depth);
   for (let i = 0; i < depth; i++) {
     bids[i] = { p: 67890 - i * 0.5, q: 0.1 + (i % 100) * 0.01 };
     asks[i] = { p: 67891 + i * 0.5, q: 0.1 + (i % 100) * 0.01 };
@@ -102,17 +79,9 @@ export function buildBook(depth: number): Book {
   return { symbol: 'BTC-USD', ts: 1716100000123, bids, asks };
 }
 
-export interface Codecs {
-  ticker: Codec;
-  order: Codec;
-  level: Codec;
-  book: Codec;
-}
-
-export function registerAll(): Codecs {
-  const ticker = register(TickerSchema);
-  const order = register(OrderSchema);
-  const level = register(LevelSchema);
-  const book = register(BookSchema);
-  return { ticker, order, level, book };
-}
+export type AllCodecs = {
+  ticker: TypeCodec;
+  order: TypeCodec;
+  level: TypeCodec;
+  book: TypeCodec;
+};
diff --git a/serializer/test/contract.test.ts b/serializer/test/contract.test.ts
index 1fc1534..ba9168c 100644
--- a/serializer/test/contract.test.ts
+++ b/serializer/test/contract.test.ts
@@ -1,79 +1,52 @@
 import { test, expect } from 'vitest';
 import {
   clearRegistry,
-  defineSchema,
-  deserialize,
-  registerClass,
+  enumOf,
+  f64,
   Serializable,
-  serialize,
+  type,
+  u53,
 } from '../plugin/index.ts';
 
-test('class with [Serializable] static schema registers and round-trips', () => {
+test('class with [Serializable] static codec round-trips', () => {
   clearRegistry();
 
   class Order {
-    id!: number;
-    price!: number;
-    qty!: number;
-    side!: 'buy' | 'sell';
-
-    static [Serializable] = defineSchema('OrderClass', (s) => ({
-      id: s.u53,
-      price: s.f64,
-      qty: s.f64,
-      side: s.enum(['buy', 'sell'] as const),
-    }));
+    static [Serializable] = type('OrderClass', {
+      id: u53,
+      price: f64,
+      qty: f64,
+      side: enumOf(['buy', 'sell'] as const),
+    });
   }
 
-  const codec = registerClass(Order);
-
+  const codec = Order[Serializable]!;
   const v = { id: 42, price: 100.5, qty: 1.5, side: 'buy' as const };
-  const bytes = serialize(v, codec);
-  const decoded = deserialize(bytes);
-  expect(decoded).toEqual(v);
-});
-
-test('registerClass caches by constructor', () => {
-  clearRegistry();
-
-  class A {
-    static [Serializable] = defineSchema('AClass', (s) => ({ x: s.u8 }));
-  }
-  const c1 = registerClass(A);
-  const c2 = registerClass(A);
-  expect(c1).toBe(c2);
-});
-
-test('registerClass throws for class missing [Serializable]', () => {
-  clearRegistry();
-
-  class B {}
-
-  expect(() => registerClass(B)).toThrow(/\[Serializable\] schema/);
+  expect(codec.decode(codec.encode(v))).toEqual(v);
 });
 
 test('Symbol.serializable is shared across module boundaries via Symbol.for', () => {
-  const looked = Symbol.for('@perf/serializable');
-  expect(looked).toBe(Serializable);
+  expect(Symbol.for('@perf/serializable')).toBe(Serializable);
 });
 
 test('codec.id is deterministic for the schema name', () => {
   clearRegistry();
-  const A = defineSchema('SameName', (s) => ({ x: s.u8 }));
+
+  class A {
+    static [Serializable] = type('SameName', { x: u53 });
+  }
+  const idA = A[Serializable]!.id;
 
   clearRegistry();
-  const codecA = registerClass(
-    class extends Object {
-      static [Serializable] = A;
-    },
-  );
+  class B {
+    static [Serializable] = type('SameName', { y: f64 });
+  }
+  const idB = B[Serializable]!.id;
 
-  clearRegistry();
-  const codecB = registerClass(
-    class extends Object {
-      static [Serializable] = A;
-    },
-  );
-
-  expect(codecA.id).toBe(codecB.id);
+  expect(idA).toBe(idB);
+});
+
+test('class without [Serializable] has no codec', () => {
+  class Empty {}
+  expect((Empty as unknown as Record)[Serializable]).toBeUndefined();
 });
diff --git a/serializer/test/fuzz.test.ts b/serializer/test/fuzz.test.ts
index 469f43e..373ed44 100644
--- a/serializer/test/fuzz.test.ts
+++ b/serializer/test/fuzz.test.ts
@@ -1,11 +1,16 @@
 import { test, expect } from 'vitest';
 import {
   clearRegistry,
-  defineSchema,
-  deserialize,
-  register,
-  s,
-  serialize,
+  enumOf,
+  f64,
+  flags,
+  list,
+  oneOf,
+  str,
+  type,
+  u32,
+  u53,
+  u8,
 } from '../plugin/index.ts';
 
 function rng(seed: number): () => number {
@@ -34,8 +39,7 @@ function randFloat(): number {
 }
 
 function randInt(maxBits = 32): number {
-  const v = Math.floor(r() * 2 ** maxBits);
-  return v >>> 0;
+  return Math.floor(r() * 2 ** maxBits) >>> 0;
 }
 
 function randString(): string {
@@ -47,14 +51,13 @@ function randString(): string {
 
 test('fuzz: 2000 random ticker round-trips', () => {
   clearRegistry();
-  const Ticker = defineSchema('FuzzTicker', (s) => ({
-    symbol: s.str,
-    last: s.f64,
-    volume: s.f64,
-    count: s.u32,
-    asks: s.array(s.f64),
-  }));
-  const codec = register(Ticker);
+  const Ticker = type('FuzzTicker', {
+    symbol: str,
+    last: f64,
+    volume: f64,
+    count: u32,
+    asks: list(f64),
+  });
 
   for (let i = 0; i < 2000; i++) {
     const v = {
@@ -64,24 +67,22 @@ test('fuzz: 2000 random ticker round-trips', () => {
       count: randInt(32),
       asks: Array.from({ length: Math.floor(r() * 10) }, randFloat),
     };
-    expect(deserialize(serialize(v, codec)), `iteration ${i}`).toEqual(v);
+    expect(Ticker.decode(Ticker.encode(v)), `iteration ${i}`).toEqual(v);
   }
 });
 
 test('fuzz: 1000 random nested orders', () => {
   clearRegistry();
-  const Price = defineSchema('FuzzPrice', (s) => ({ value: s.f64, scale: s.u8 }));
-  register(Price);
-  const Order = defineSchema('FuzzOrder', (s) => ({
-    id: s.u53,
-    symbol: s.str,
+  const Price = type('FuzzPrice', { value: f64, scale: u8 });
+  const Order = type('FuzzOrder', {
+    id: u53,
+    symbol: str,
     price: Price,
-    qty: s.f64,
-    side: s.enum(['buy', 'sell'] as const),
-    tags: s.array(s.str),
-    flags: s.bitset(['ioc', 'post_only', 'reduce_only'] as const),
-  }));
-  const codec = register(Order);
+    qty: f64,
+    side: enumOf(['buy', 'sell'] as const),
+    tags: list(str),
+    flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
+  });
 
   for (let i = 0; i < 1000; i++) {
     const v = {
@@ -97,18 +98,17 @@ test('fuzz: 1000 random nested orders', () => {
         reduce_only: r() < 0.5,
       },
     };
-    expect(deserialize(serialize(v, codec)), `iteration ${i}`).toEqual(v);
+    expect(Order.decode(Order.encode(v)), `iteration ${i}`).toEqual(v);
   }
 });
 
 test('fuzz: 500 random unions', () => {
   clearRegistry();
-  const Event = s.union('FuzzEvent', 'kind', {
-    fill: { price: s.f64, qty: s.f64 },
-    cancel: { reason: s.str },
-    expire: { at: s.u53 },
+  const Event = oneOf('FuzzEvent', 'kind', {
+    fill: { price: f64, qty: f64 },
+    cancel: { reason: str },
+    expire: { at: u53 },
   });
-  const codec = register(Event);
 
   for (let i = 0; i < 500; i++) {
     const which = Math.floor(r() * 3);
@@ -117,6 +117,6 @@ test('fuzz: 500 random unions', () => {
     else if (which === 1) v = { kind: 'cancel', reason: randString() };
     else v = { kind: 'expire', at: Math.floor(r() * 2 ** 40) };
 
-    expect(deserialize(serialize(v, codec)), `iteration ${i}`).toEqual(v);
+    expect(Event.decode(Event.encode(v as never)), `iteration ${i}`).toEqual(v);
   }
 });
diff --git a/serializer/test/schemas.test.ts b/serializer/test/schemas.test.ts
index 0b885e3..17a7092 100644
--- a/serializer/test/schemas.test.ts
+++ b/serializer/test/schemas.test.ts
@@ -1,11 +1,24 @@
 import { test, expect } from 'vitest';
 import {
+  bool,
+  bytes,
   clearRegistry,
-  defineSchema,
-  deserialize,
-  register,
-  s,
-  serialize,
+  enumOf,
+  f64,
+  f64Array,
+  flags,
+  i64,
+  list,
+  oneOf,
+  opt,
+  router,
+  str,
+  tuple,
+  type,
+  u32,
+  u53,
+  u64,
+  u8,
 } from '../plugin/index.ts';
 
 function fresh() {
@@ -14,65 +27,45 @@ function fresh() {
 
 test('flat object with mixed primitives', () => {
   fresh();
-  const Ticker = defineSchema('Ticker', (s) => ({
-    symbol: s.str,
-    last: s.f64,
-    volume: s.f64,
-    count: s.u32,
-  }));
-  const codec = register(Ticker);
+  const Ticker = type('Ticker', {
+    symbol: str,
+    last: f64,
+    volume: f64,
+    count: u32,
+  });
 
   const value = { symbol: 'BTC-USD', last: 45123.45, volume: 1234.5678, count: 99999 };
-  const bytes = serialize(value, codec);
-  const decoded = deserialize(bytes);
-
-  expect(decoded).toEqual(value);
+  expect(Ticker.decode(Ticker.encode(value))).toEqual(value);
 });
 
 test('array of primitives', () => {
   fresh();
-  const Tags = defineSchema('Tags', (s) => ({
-    items: s.array(s.str),
-    counts: s.array(s.u32),
-  }));
-  const codec = register(Tags);
-
+  const Tags = type('Tags', {
+    items: list(str),
+    counts: list(u32),
+  });
   const v = { items: ['a', 'b', 'hello'], counts: [1, 2, 3, 4, 5] };
-  expect(deserialize(serialize(v, codec))).toEqual(v);
+  expect(Tags.decode(Tags.encode(v))).toEqual(v);
 });
 
-test('nested object via inline ObjectSchema', () => {
+test('nested object via inline reference', () => {
   fresh();
-  const Price = defineSchema('Price', (s) => ({ value: s.f64, scale: s.u8 }));
-  const Order = defineSchema('Order', (s) => ({
-    id: s.u53,
-    price: Price,
-    qty: s.f64,
-  }));
-  register(Price);
-  const codec = register(Order);
-
+  const Price = type('Price', { value: f64, scale: u8 });
+  const Order = type('Order', { id: u53, price: Price, qty: f64 });
   const v = { id: 12345, price: { value: 100.5, scale: 2 }, qty: 1.5 };
-  expect(deserialize(serialize(v, codec))).toEqual(v);
+  expect(Order.decode(Order.encode(v))).toEqual(v);
 });
 
 test('optional fields', () => {
   fresh();
-  const Maybe = defineSchema('Maybe', (s) => ({
-    a: s.optional(s.str),
-    b: s.optional(s.f64),
-  }));
-  const codec = register(Maybe);
+  const Maybe = type('Maybe', {
+    a: opt(str),
+    b: opt(f64),
+  });
 
-  expect(deserialize(serialize({ a: 'hi', b: 3.14 }, codec))).toEqual({
-    a: 'hi',
-    b: 3.14,
-  });
-  expect(deserialize(serialize({ a: undefined, b: 1 }, codec))).toEqual({
-    a: undefined,
-    b: 1,
-  });
-  expect(deserialize(serialize({ a: undefined, b: undefined }, codec))).toEqual({
+  expect(Maybe.decode(Maybe.encode({ a: 'hi', b: 3.14 }))).toEqual({ a: 'hi', b: 3.14 });
+  expect(Maybe.decode(Maybe.encode({ a: undefined, b: 1 }))).toEqual({ a: undefined, b: 1 });
+  expect(Maybe.decode(Maybe.encode({ a: undefined, b: undefined }))).toEqual({
     a: undefined,
     b: undefined,
   });
@@ -80,102 +73,90 @@ test('optional fields', () => {
 
 test('enum field', () => {
   fresh();
-  const Side = defineSchema('SidedOrder', (s) => ({
-    side: s.enum(['buy', 'sell'] as const),
-    qty: s.f64,
-  }));
-  const codec = register(Side);
-
+  const Sided = type('SidedOrder', {
+    side: enumOf(['buy', 'sell'] as const),
+    qty: f64,
+  });
   for (const side of ['buy', 'sell'] as const) {
     const v = { side, qty: 1 };
-    expect(deserialize(serialize(v, codec))).toEqual(v);
+    expect(Sided.decode(Sided.encode(v))).toEqual(v);
   }
 });
 
 test('bitset field (≤8 flags)', () => {
   fresh();
-  const Flags = defineSchema('Flags', (s) => ({
-    flags: s.bitset(['ioc', 'post_only', 'reduce_only'] as const),
-  }));
-  const codec = register(Flags);
-
+  const Flags = type('Flags', {
+    flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
+  });
   const v = { flags: { ioc: true, post_only: false, reduce_only: true } };
-  expect(deserialize(serialize(v, codec))).toEqual(v);
+  expect(Flags.decode(Flags.encode(v))).toEqual(v);
 });
 
 test('bitset field (>32 flags uses bigint)', () => {
   fresh();
   const flagNames = Array.from({ length: 40 }, (_, i) => `f${i}`) as readonly string[];
-  const Flags = defineSchema('FlagsBig', (s) => ({
-    flags: s.bitset(flagNames as readonly [string, ...string[]]),
-  }));
-  const codec = register(Flags);
+  const FlagsBig = type('FlagsBig', {
+    flags: flags(flagNames as readonly [string, ...string[]]),
+  });
 
-  const flags: Record = {};
-  for (let i = 0; i < 40; i++) flags[`f${i}`] = i % 3 === 0;
-  const v = { flags };
-  expect(deserialize(serialize(v, codec))).toEqual(v);
+  const flagsValue: Record = {};
+  for (let i = 0; i < 40; i++) flagsValue[`f${i}`] = i % 3 === 0;
+  const v = { flags: flagsValue };
+  expect(FlagsBig.decode(FlagsBig.encode(v))).toEqual(v);
 });
 
 test('tuple field', () => {
   fresh();
-  const Point = defineSchema('Point3D', (s) => ({
-    name: s.str,
-    coord: s.tuple(s.f64, s.f64, s.f64),
-  }));
-  const codec = register(Point);
-
-  const v = { name: 'p', coord: [1.5, 2.5, 3.5] };
-  expect(deserialize(serialize(v, codec))).toEqual(v);
+  const Point = type('Point3D', {
+    name: str,
+    coord: tuple(f64, f64, f64),
+  });
+  const v = { name: 'p', coord: [1.5, 2.5, 3.5] as [number, number, number] };
+  expect(Point.decode(Point.encode(v))).toEqual(v);
 });
 
 test('array of nested objects', () => {
   fresh();
-  const Level = defineSchema('Level', (s) => ({ price: s.f64, qty: s.f64 }));
-  register(Level);
-  const Book = defineSchema('Book', (s) => ({
-    bids: s.array(Level),
-    asks: s.array(Level),
-  }));
-  const codec = register(Book);
-
+  const Level = type('Level', { price: f64, qty: f64 });
+  const Book = type('Book', {
+    bids: list(Level),
+    asks: list(Level),
+  });
   const v = {
     bids: [{ price: 100, qty: 1 }, { price: 99, qty: 2 }],
     asks: [{ price: 101, qty: 0.5 }, { price: 102, qty: 1.5 }, { price: 103, qty: 0.1 }],
   };
-  expect(deserialize(serialize(v, codec))).toEqual(v);
+  expect(Book.decode(Book.encode(v))).toEqual(v);
 });
 
 test('union with discriminator', () => {
   fresh();
-  const Event = s.union('Event', 'kind', {
-    fill: { price: s.f64, qty: s.f64 },
-    cancel: { reason: s.str },
-    expire: { at: s.u53 },
+  const Event = oneOf('Event', 'kind', {
+    fill: { price: f64, qty: f64 },
+    cancel: { reason: str },
+    expire: { at: u53 },
   });
-  const codec = register(Event);
 
   const samples = [
-    { kind: 'fill' as const, price: 100, qty: 0.5 },
-    { kind: 'cancel' as const, reason: 'user' },
-    { kind: 'expire' as const, at: 1700000000 },
+    { kind: 'fill', price: 100, qty: 0.5 },
+    { kind: 'cancel', reason: 'user' },
+    { kind: 'expire', at: 1700000000 },
   ];
   for (const v of samples) {
-    expect(deserialize(serialize(v, codec))).toEqual(v);
+    expect(Event.decode(Event.encode(v as never))).toEqual(v);
   }
 });
 
 test('typed array (f64Array) round-trip', () => {
   fresh();
-  const Signal = defineSchema('Signal', (s) => ({
-    name: s.str,
-    samples: s.f64Array,
-  }));
-  const codec = register(Signal);
+  const Signal = type('Signal', {
+    name: str,
+    samples: f64Array,
+  });
 
   const samples = new Float64Array([1.1, 2.2, 3.3, 4.4, 5.5]);
   const v = { name: 'sig', samples };
-  const decoded = deserialize(serialize(v, codec));
+  const decoded = Signal.decode(Signal.encode(v));
   expect(decoded.name).toBe('sig');
   expect(decoded.samples).toBeInstanceOf(Float64Array);
   expect(decoded.samples.length).toBe(5);
@@ -184,54 +165,50 @@ test('typed array (f64Array) round-trip', () => {
 
 test('bigint u64/i64 round-trip', () => {
   fresh();
-  const Big = defineSchema('Big', (s) => ({
-    u: s.u64,
-    i: s.i64,
-  }));
-  const codec = register(Big);
+  const Big = type('Big', { u: u64, i: i64 });
   const v = { u: 1n << 50n, i: -(1n << 50n) };
-  expect(deserialize(serialize(v, codec))).toEqual(v);
+  expect(Big.decode(Big.encode(v))).toEqual(v);
 });
 
 test('bytes field', () => {
   fresh();
-  const Blob = defineSchema('Blob', (s) => ({
-    data: s.bytes,
-  }));
-  const codec = register(Blob);
+  const Blob = type('Blob', { data: bytes });
   const data = new Uint8Array([0, 1, 2, 3, 254, 255]);
-  const decoded = deserialize<{ data: Uint8Array }>(serialize({ data }, codec));
+  const decoded = Blob.decode(Blob.encode({ data }));
   expect(Array.from(decoded.data)).toEqual(Array.from(data));
 });
 
-test('serialize includes 2-byte schema ID frame', () => {
+test('bool field round-trip', () => {
   fresh();
-  const Sch = defineSchema('Sch', (s) => ({ x: s.u8 }));
-  const codec = register(Sch);
-  const bytes = serialize({ x: 7 }, codec);
+  const T = type('Bools', { a: bool, b: bool });
+  expect(T.decode(T.encode({ a: true, b: false }))).toEqual({ a: true, b: false });
+});
+
+test('router prepends 2-byte schema ID frame', () => {
+  fresh();
+  const Sch = type('Sch', { x: u8 });
+  const proto = router(Sch);
+  const bytes = proto.encode({ x: 7 }, Sch);
   expect(bytes.length).toBeGreaterThanOrEqual(3);
   const id = bytes[0]! | (bytes[1]! << 8);
-  expect(id).toBe(codec.id);
+  expect(id).toBe(Sch.id);
 });
 
 test('large nested order-book payload', () => {
   fresh();
-  const Level = defineSchema('LvlBig', (s) => ({ p: s.f64, q: s.f64 }));
-  register(Level);
-  const Snap = defineSchema('Snap', (s) => ({
-    symbol: s.str,
-    ts: s.u53,
-    bids: s.array(Level),
-    asks: s.array(Level),
-  }));
-  const codec = register(Snap);
+  const Level = type('LvlBig', { p: f64, q: f64 });
+  const Snap = type('Snap', {
+    symbol: str,
+    ts: u53,
+    bids: list(Level),
+    asks: list(Level),
+  });
 
   const bids = Array.from({ length: 1000 }, (_, i) => ({ p: 100 - i * 0.01, q: 1 + i * 0.001 }));
   const asks = Array.from({ length: 1000 }, (_, i) => ({ p: 100 + i * 0.01, q: 1 + i * 0.001 }));
   const v = { symbol: 'BTC-USD', ts: 1700000000123, bids, asks };
 
-  const bytes = serialize(v, codec);
-  const decoded = deserialize(bytes);
+  const decoded = Snap.decode(Snap.encode(v));
   expect(decoded.symbol).toBe(v.symbol);
   expect(decoded.ts).toBe(v.ts);
   expect(decoded.bids.length).toBe(1000);
@@ -240,8 +217,10 @@ test('large nested order-book payload', () => {
   expect(decoded.asks[999]).toEqual(v.asks[999]);
 });
 
-test('deserialize unknown schema ID throws', () => {
+test('router throws for unknown schema ID', () => {
   fresh();
-  const bytes = new Uint8Array([0xff, 0xff, 0]);
-  expect(() => deserialize(bytes)).toThrow(/Unknown schema ID/);
+  const Sch = type('Sch2', { x: u8 });
+  const proto = router(Sch);
+  const bogus = new Uint8Array([0xff, 0xff, 0]);
+  expect(() => proto.decode(bogus)).toThrow(/unknown schema ID/i);
 });
diff --git a/serializer/test/shapes.test.ts b/serializer/test/shapes.test.ts
index 706e0a3..59e2c8e 100644
--- a/serializer/test/shapes.test.ts
+++ b/serializer/test/shapes.test.ts
@@ -1,11 +1,16 @@
 import { test, expect } from 'vitest';
 import {
+  bool,
   clearRegistry,
-  defineSchema,
-  deserialize,
-  register,
-  s,
-  serialize,
+  enumOf,
+  f64,
+  list,
+  oneOf,
+  str,
+  type,
+  u32,
+  u53,
+  u8,
 } from '../plugin/index.ts';
 
 /**
@@ -15,51 +20,36 @@ import {
  */
 test('decoded objects share key order matching schema field order', () => {
   clearRegistry();
-  const Order = defineSchema('ShapeOrder', (s) => ({
-    id: s.u53,
-    price: s.f64,
-    qty: s.f64,
-    side: s.enum(['buy', 'sell'] as const),
-    tags: s.array(s.str),
-  }));
-  const codec = register(Order);
+  const Order = type('ShapeOrder', {
+    id: u53,
+    price: f64,
+    qty: f64,
+    side: enumOf(['buy', 'sell'] as const),
+    tags: list(str),
+  });
 
   const expectedOrder = ['id', 'price', 'qty', 'side', 'tags'];
 
-  const decoded1 = deserialize>(
-    serialize({ id: 1, price: 100, qty: 0.5, side: 'buy', tags: ['a'] }, codec),
-  );
-  const decoded2 = deserialize>(
-    serialize({ id: 999, price: 1e10, qty: 0, side: 'sell', tags: [] }, codec),
-  );
-  const decoded3 = deserialize>(
-    serialize({ id: 2 ** 40, price: -1, qty: 1234, side: 'buy', tags: ['x', 'y', 'z'] }, codec),
+  const d1 = Order.decode(Order.encode({ id: 1, price: 100, qty: 0.5, side: 'buy', tags: ['a'] }));
+  const d2 = Order.decode(Order.encode({ id: 999, price: 1e10, qty: 0, side: 'sell', tags: [] }));
+  const d3 = Order.decode(
+    Order.encode({ id: 2 ** 40, price: -1, qty: 1234, side: 'buy', tags: ['x', 'y', 'z'] }),
   );
 
-  expect(Object.keys(decoded1)).toEqual(expectedOrder);
-  expect(Object.keys(decoded2)).toEqual(expectedOrder);
-  expect(Object.keys(decoded3)).toEqual(expectedOrder);
+  expect(Object.keys(d1)).toEqual(expectedOrder);
+  expect(Object.keys(d2)).toEqual(expectedOrder);
+  expect(Object.keys(d3)).toEqual(expectedOrder);
 });
 
 test('decoded value types are consistent across instances', () => {
   clearRegistry();
-  const T = defineSchema('Types', (s) => ({
-    a: s.u32,
-    b: s.f64,
-    c: s.str,
-    d: s.bool,
-  }));
-  const codec = register(T);
+  const T = type('Types', { a: u32, b: f64, c: str, d: bool });
 
   const types = (o: Record) =>
     Object.entries(o).map(([k, v]) => [k, typeof v]);
 
-  const a = deserialize>(
-    serialize({ a: 1, b: 1.5, c: 'a', d: true }, codec),
-  );
-  const b = deserialize>(
-    serialize({ a: 0, b: 0, c: '', d: false }, codec),
-  );
+  const a = T.decode(T.encode({ a: 1, b: 1.5, c: 'a', d: true }));
+  const b = T.decode(T.encode({ a: 0, b: 0, c: '', d: false }));
   expect(types(a)).toEqual(types(b));
   expect(types(a)).toEqual([
     ['a', 'number'],
@@ -71,35 +61,28 @@ test('decoded value types are consistent across instances', () => {
 
 test('nested object key order is stable', () => {
   clearRegistry();
-  const Price = defineSchema('SPrice', (s) => ({ value: s.f64, scale: s.u8 }));
-  register(Price);
-  const Order = defineSchema('SOrder', (s) => ({
-    id: s.u53,
-    price: Price,
-    qty: s.f64,
-  }));
-  const codec = register(Order);
+  const Price = type('SPrice', { value: f64, scale: u8 });
+  const Order = type('SOrder', { id: u53, price: Price, qty: f64 });
 
   const v = { id: 1, price: { value: 100, scale: 2 }, qty: 1 };
-  const d1 = deserialize>(serialize(v, codec));
-  const d2 = deserialize>(serialize({ ...v, id: 99 }, codec));
+  const d1 = Order.decode(Order.encode(v));
+  const d2 = Order.decode(Order.encode({ ...v, id: 99 }));
 
   expect(Object.keys(d1)).toEqual(['id', 'price', 'qty']);
   expect(Object.keys(d2)).toEqual(['id', 'price', 'qty']);
-  expect(Object.keys(d1.price as Record)).toEqual(['value', 'scale']);
-  expect(Object.keys(d2.price as Record)).toEqual(['value', 'scale']);
+  expect(Object.keys(d1.price)).toEqual(['value', 'scale']);
+  expect(Object.keys(d2.price)).toEqual(['value', 'scale']);
 });
 
 test('union decoded objects place discriminator first', () => {
   clearRegistry();
-  const Event = s.union('SEvent', 'kind', {
-    a: { x: s.u32 },
-    b: { y: s.f64 },
+  const Event = oneOf('SEvent', 'kind', {
+    a: { x: u32 },
+    b: { y: f64 },
   });
-  const codec = register(Event);
 
-  const ea = deserialize>(serialize({ kind: 'a', x: 1 }, codec));
-  const eb = deserialize>(serialize({ kind: 'b', y: 2.5 }, codec));
+  const ea = Event.decode(Event.encode({ kind: 'a', x: 1 } as never)) as Record;
+  const eb = Event.decode(Event.encode({ kind: 'b', y: 2.5 } as never)) as Record;
   expect(Object.keys(ea)[0]).toBe('kind');
   expect(Object.keys(eb)[0]).toBe('kind');
 });
diff --git a/serializer/test/transformer.test.ts b/serializer/test/transformer.test.ts
index 77020e1..f262c48 100644
--- a/serializer/test/transformer.test.ts
+++ b/serializer/test/transformer.test.ts
@@ -161,6 +161,67 @@ export const T = type('TxSmoke', { x: u53, y: f64 });
   expect(result.code).not.toContain("type('TxSmoke'");
 });
 
+test('transformer: class with [Serializable] — decoder returns class instances', async () => {
+  const src = `
+import { type, Serializable, registerClass, f64, enumOf } from '../plugin/index.ts';
+
+export class TxPos {
+  side;
+  qty;
+  entryPrice;
+
+  static [Serializable] = type('TxPos', {
+    side: enumOf(['long', 'short']),
+    qty: f64,
+    entryPrice: f64,
+  });
+
+  get notional() { return this.qty * this.entryPrice; }
+}
+
+export const TxPosCodec = registerClass(TxPos);
+`;
+  const mod = await transformAndImport(src);
+  const TxPos = mod.TxPos as new () => {
+    side: 'long' | 'short';
+    qty: number;
+    entryPrice: number;
+    notional: number;
+  };
+  const codec = mod.TxPosCodec as {
+    encode: (v: unknown) => Uint8Array;
+    decode: (b: Uint8Array) => { side: 'long' | 'short'; qty: number; entryPrice: number };
+  };
+
+  const v = { side: 'long' as const, qty: 2, entryPrice: 100 };
+  const bytes = codec.encode(v);
+  const back = codec.decode(bytes);
+
+  // Decoded value must be an actual TxPos instance — methods/getters work.
+  expect(back instanceof TxPos).toBe(true);
+  expect((back as InstanceType).notional).toBe(200);
+  expect(back.side).toBe('long');
+  expect(back.qty).toBe(2);
+});
+
+test('transformer: registerClass(X) is rewritten to X[Serializable]', () => {
+  const src = `
+import { type, Serializable, registerClass, f64 } from '../plugin/index.ts';
+
+export class TxBox {
+  static [Serializable] = type('TxBox', { v: f64 });
+}
+
+export const TxBoxCodec = registerClass(TxBox);
+`;
+  const result = transform(src, 'test.ts', {
+    importPath: '../plugin/index.ts',
+    packageAliases: ['../plugin/index.ts'],
+  });
+  expect(result.code).toContain('TxBox[Serializable]');
+  expect(result.code).not.toContain('registerClass(TxBox)');
+});
+
 afterAll(() => {
   for (let i = 1; i <= counter; i++) {
     const file = join(GEN_DIR, `__gen_${i}.ts`);