From 8659a15d1b664c9d54df9ebec9b16bd8b9d3a1e6 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Fri, 11 Sep 2026 19:30:56 +0200 Subject: [PATCH 01/33] Add experimental Schema JIT and AOT compilers --- .changeset/add-schema-compilers.md | 9 + packages/effect/SCHEMA.md | 83 ++ packages/effect/package.json | 5 +- packages/effect/runtimeperf/config.json | 701 +++++++++++++++ .../suites/compiler-rebuild/README.md | 47 + .../suites/compiler-rebuild/costs.mts | 91 ++ .../suites/compiler-rebuild/fixtures/aot.ts | 36 + .../suites/compiler-rebuild/fixtures/cases.ts | 119 +++ .../compiler-rebuild/fixtures/generate.mts | 5 + .../compiler-rebuild/fixtures/interpreted.ts | 23 + .../suites/compiler-rebuild/fixtures/jit.ts | 25 + packages/effect/src/SchemaAST.ts | 69 +- packages/effect/src/SchemaParser.ts | 243 ++---- .../effect/src/internal/schema/codegen.ts | 684 +++++++++++++++ .../src/internal/schema/compilerRegistry.ts | 150 ++++ .../effect/src/internal/schema/interpreter.ts | 217 +++++ .../src/unstable/schema/SchemaAOTCompiler.ts | 138 +++ .../src/unstable/schema/SchemaCompiler.ts | 171 ++++ .../unstable/schema/SchemaCompiler/runtime.ts | 119 +++ .../src/unstable/schema/SchemaJITCompiler.ts | 87 ++ .../schema/SchemaJITCompiler/enable.ts | 12 + packages/effect/src/unstable/schema/index.ts | 15 + .../test/schema/SchemaAOTCompiler.test.ts | 65 ++ .../test/schema/SchemaCompilerApi.test.ts | 314 +++++++ .../test/schema/SchemaCompilerArray.test.ts | 117 +++ .../schema/SchemaCompilerConcurrency.test.ts | 38 + .../schema/SchemaCompilerConstruction.test.ts | 294 +++++++ .../schema/SchemaCompilerRegression.test.ts | 456 ++++++++++ .../test/schema/SchemaCompilerStartup.test.ts | 19 + .../test/schema/SchemaJITCompiler.test.ts | 803 ++++++++++++++++++ .../schema/SchemaJITCompilerFallback.test.ts | 178 ++++ .../test/schema/fixtures/aot-import-guard.ts | 14 + .../effect/test/schema/fixtures/aot-runner.ts | 173 ++++ packages/effect/test/schema/fixtures/aot.ts | 201 +++++ .../test/schema/fixtures/construction.ts | 70 ++ .../typetest/schema/SchemaAOTCompiler.tst.ts | 14 + .../typetest/schema/SchemaCompiler.tst.ts | 23 + 37 files changed, 5632 insertions(+), 196 deletions(-) create mode 100644 .changeset/add-schema-compilers.md create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/README.md create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/aot.ts create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/generate.mts create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/interpreted.ts create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/jit.ts create mode 100644 packages/effect/src/internal/schema/codegen.ts create mode 100644 packages/effect/src/internal/schema/compilerRegistry.ts create mode 100644 packages/effect/src/internal/schema/interpreter.ts create mode 100644 packages/effect/src/unstable/schema/SchemaAOTCompiler.ts create mode 100644 packages/effect/src/unstable/schema/SchemaCompiler.ts create mode 100644 packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts create mode 100644 packages/effect/src/unstable/schema/SchemaJITCompiler.ts create mode 100644 packages/effect/src/unstable/schema/SchemaJITCompiler/enable.ts create mode 100644 packages/effect/test/schema/SchemaAOTCompiler.test.ts create mode 100644 packages/effect/test/schema/SchemaCompilerApi.test.ts create mode 100644 packages/effect/test/schema/SchemaCompilerArray.test.ts create mode 100644 packages/effect/test/schema/SchemaCompilerConcurrency.test.ts create mode 100644 packages/effect/test/schema/SchemaCompilerConstruction.test.ts create mode 100644 packages/effect/test/schema/SchemaCompilerRegression.test.ts create mode 100644 packages/effect/test/schema/SchemaCompilerStartup.test.ts create mode 100644 packages/effect/test/schema/SchemaJITCompiler.test.ts create mode 100644 packages/effect/test/schema/SchemaJITCompilerFallback.test.ts create mode 100644 packages/effect/test/schema/fixtures/aot-import-guard.ts create mode 100644 packages/effect/test/schema/fixtures/aot-runner.ts create mode 100644 packages/effect/test/schema/fixtures/aot.ts create mode 100644 packages/effect/test/schema/fixtures/construction.ts create mode 100644 packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts create mode 100644 packages/effect/typetest/schema/SchemaCompiler.tst.ts diff --git a/.changeset/add-schema-compilers.md b/.changeset/add-schema-compilers.md new file mode 100644 index 00000000000..c8a09a4d755 --- /dev/null +++ b/.changeset/add-schema-compilers.md @@ -0,0 +1,9 @@ +--- +"effect": patch +--- + +Add experimental JIT and AOT schema compilers that work through the existing +`SchemaParser` APIs and share a decoder registry. Enable JIT globally with +`effect/unstable/schema/SchemaJITCompiler/enable`, selectively with +`SchemaJITCompiler.enable(ast)`, or install generated AOT decoders without +requiring dynamic function construction. diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 2a25c9b9f48..6546665b1cd 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -70,6 +70,89 @@ means that the library does not provide that benchmark. | Encode unknown input | **0.3472** | — | — | | Decode unknown input | **0.3637** | — | — | +## Experimental schema compilers + +Enable JIT compilation at application startup with a side-effect import: + +```ts +import "effect/unstable/schema/SchemaJITCompiler/enable" +``` + +Alternatively, `SchemaJITCompiler.enable(schema.ast)` enables one AST and the +dependencies reached while parsing it. Importing `SchemaJITCompiler` or the +`unstable/schema` barrel alone does not enable compilation. Operations are +prepared on first use. If dynamic function construction is blocked or compilation +fails, the interpreter remains available. Exceptions from executing a parser are +not treated as compilation failures and do not trigger a retry. + +JIT and AOT use the same source generator. To generate an AOT module at build +time, call `SchemaAOTCompiler.compile(asts)` with an ordered array of ASTs and +save the returned JavaScript. The module exports `install(asts)`. Call it with +the corresponding runtime ASTs before using normal `SchemaParser` functions. +Use a one-element array for a single schema. Generated modules do not import +the generator and work where `new Function` is forbidden. + +Regenerate AOT modules when schema definitions or the Effect version change. +Installation trusts the supplied root order, definitions and shared AST +identities. Callbacks and symbols are read from those ASTs, not serialized. +Include `SchemaAST.toType(schema.ast)` for guards and construction, and +`SchemaAST.flip(schema.ast)` for encoding, when those are distinct ASTs. + +### One registry for all implementations + +A single `WeakMap` associates each exact AST with its decoder entry. The cache +stores functions, never parsing results. The interpreter, JIT, AOT and +`SchemaCompiler.set(ast, decoder)` all use it. + +| Operation | Result | Purpose | +| -------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `decodeEffect` | `Effect` with output or detailed issues | Required complete decoding, including asynchronous work and transformations. | +| `validate` | Output or `SchemaCompiler.invalid` | Optional synchronous fast path without detailed diagnostics. | +| `is` | Boolean | Optional validation without constructing output. | +| `makeEffect` | `Effect` with a constructed value or issues | Optional specialized construction. The registry caches the interpreted constructor when absent. | + +Decoding tries `validate` when available. Success provides the output directly; +failure calls `decodeEffect` for diagnostics. The diagnostic traversal uses child +decoders directly, without restarting their validation fast paths. A boolean +guard prefers `is`, otherwise it uses ordinary decoding (including `validate` +when available). An `invalid` result needs that diagnostic fallback because the +marker is also a possible input value. Composite checks +can require stripped, reconstructed values, so `is` is omitted when it cannot +avoid constructing those values safely. + +Each operation initializes independently. Construction calls `makeEffect` +directly, without validation replay, so defaults and Class constructors execute +once. Field defaults belong to the parent occurrence, not to construction of the +root. Runtime parse options, including product concurrency, retain the +interpreter's semantics. + +Installing a decoder replaces the entry for that AST. Existing consumers that +already captured an entry retain it. Late installation is allowed, but startup +installation is needed to optimize every consumer. Custom decoders supplied to +`set` are trusted to implement the AST's semantics. + +### What is specialized + +Encoding-free graphs of supported primitives, Objects, Arrays, tuples, Unions +and template literals can use generated validators. Struct and homogeneous Array +decoding and construction also have generated loops. These loops share the +interpreter's diagnostic and asynchronous continuation helpers. Other detailed +traversals and constructors use the existing interpreter with registry-resolved +children; there is no separate diagnostic interpreter in the compiler. + +Transformations and middleware never participate in validation replay. Their +orchestration uses the same implementation as interpreted parsing, and pure +child checkpoints can still use generated validators. Suspend is resolved lazily +by JIT. AOT does not evaluate Suspend thunks at build time, so dynamically reached +schemas fall back to the interpreter unless installed separately. Declaration +callbacks remain runtime code; their type parameters can be compiled. + +Checks and property getters in replayable validation must be deterministic and +free of side effects. Proxy inputs and modifications to built-in object behavior +are not supported by the optimization contract. Large or unsupported graphs +retain interpreted paths. AOT removes dynamic source generation, not all parser +initialization or the need for runtime schema objects. + # Defining Elementary Schemas Schema provides built-in schemas for all common TypeScript types. These schemas represent a single value — like a string or a number — and they are the building blocks you combine into more complex shapes. diff --git a/packages/effect/package.json b/packages/effect/package.json index 515ab66b438..656bc52dcf4 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -25,7 +25,10 @@ "concurrency", "observability" ], - "sideEffects": [], + "sideEffects": [ + "./src/unstable/schema/SchemaJITCompiler/enable.ts", + "./dist/unstable/schema/SchemaJITCompiler/enable.js" + ], "exports": { "./package.json": "./package.json", ".": "./src/index.ts", diff --git a/packages/effect/runtimeperf/config.json b/packages/effect/runtimeperf/config.json index 4878a5a82e7..8b3ecdaead4 100644 --- a/packages/effect/runtimeperf/config.json +++ b/packages/effect/runtimeperf/config.json @@ -1703,6 +1703,707 @@ ] } ] + }, + { + "name": "compiler-rebuild", + "fixtures": [ + { + "file": "suites/compiler-rebuild/fixtures/interpreted.ts", + "defaults": { + "tier": 1, + "family": "interpreted", + "implementation": "effect", + "astTags": [ + "Objects", + "String", + "Number", + "Boolean" + ], + "operation": "decode", + "path": "valid", + "size": 3 + }, + "cases": [ + { + "name": "interpreted-parseValid", + "export": "parseValid", + "scenario": "compiler-rebuild-parseValid" + }, + { + "name": "interpreted-parseExtra", + "export": "parseExtra", + "scenario": "compiler-rebuild-parseExtra", + "size": 4 + }, + { + "name": "interpreted-parseInvalid", + "export": "parseInvalid", + "scenario": "compiler-rebuild-parseInvalid", + "path": "invalid" + }, + { + "name": "interpreted-isValid", + "export": "isValid", + "scenario": "compiler-rebuild-isValid", + "operation": "is" + }, + { + "name": "interpreted-isInvalid", + "export": "isInvalid", + "scenario": "compiler-rebuild-isInvalid", + "operation": "is", + "path": "invalid" + }, + { + "name": "interpreted-encode", + "export": "encode", + "scenario": "compiler-rebuild-encode", + "operation": "encode" + }, + { + "name": "interpreted-strict", + "export": "strict", + "scenario": "compiler-rebuild-strict" + }, + { + "name": "interpreted-nested", + "export": "nested", + "scenario": "compiler-rebuild-nested", + "astTags": [ + "Objects", + "Arrays", + "String", + "Number", + "Boolean" + ], + "size": 2 + }, + { + "name": "interpreted-array", + "export": "array", + "scenario": "compiler-rebuild-array", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "interpreted-arrayInvalid", + "export": "arrayInvalid", + "scenario": "compiler-rebuild-arrayInvalid", + "path": "invalid", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 33 + }, + { + "name": "interpreted-tuple", + "export": "tuple", + "scenario": "compiler-rebuild-tuple", + "astTags": [ + "Arrays", + "String", + "Number", + "Boolean" + ], + "size": 34 + }, + { + "name": "interpreted-record", + "export": "record", + "scenario": "compiler-rebuild-record", + "size": 32 + }, + { + "name": "interpreted-union", + "export": "union", + "scenario": "compiler-rebuild-union", + "astTags": [ + "Union", + "Objects", + "Literal", + "Number" + ], + "size": 8 + }, + { + "name": "interpreted-oneOf", + "export": "oneOf", + "scenario": "compiler-rebuild-oneOf", + "astTags": [ + "Union", + "Objects", + "String", + "Number" + ], + "size": 2 + }, + { + "name": "interpreted-transform", + "export": "transform", + "scenario": "compiler-rebuild-transform", + "astTags": [ + "Objects", + "String", + "Number" + ], + "size": 32 + }, + { + "name": "interpreted-transformInvalid", + "export": "transformInvalid", + "scenario": "compiler-rebuild-transformInvalid", + "path": "invalid", + "astTags": [ + "String", + "Number" + ], + "size": 1 + }, + { + "name": "interpreted-middleware", + "export": "middleware", + "scenario": "compiler-rebuild-middleware" + }, + { + "name": "interpreted-recursive", + "export": "recursive", + "scenario": "compiler-rebuild-recursive", + "astTags": [ + "Objects", + "Arrays", + "Suspend", + "Number" + ], + "size": 31 + }, + { + "name": "interpreted-declaration", + "export": "declaration", + "scenario": "compiler-rebuild-declaration", + "astTags": [ + "Declaration", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "interpreted-makeStruct", + "export": "makeStruct", + "scenario": "compiler-rebuild-makeStruct", + "operation": "make", + "astTags": [ + "Objects", + "Number" + ], + "size": 32 + }, + { + "name": "interpreted-makeArray", + "export": "makeArray", + "scenario": "compiler-rebuild-makeArray", + "operation": "make", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "interpreted-makeUnion", + "export": "makeUnion", + "scenario": "compiler-rebuild-makeUnion", + "operation": "make", + "astTags": [ + "Union", + "Objects", + "Literal", + "Number" + ], + "size": 8 + } + ] + }, + { + "file": "suites/compiler-rebuild/fixtures/jit.ts", + "defaults": { + "tier": 1, + "family": "jit", + "implementation": "effect", + "astTags": [ + "Objects", + "String", + "Number", + "Boolean" + ], + "operation": "decode", + "path": "valid", + "size": 3 + }, + "cases": [ + { + "name": "jit-parseValid", + "export": "parseValid", + "scenario": "compiler-rebuild-parseValid" + }, + { + "name": "jit-parseExtra", + "export": "parseExtra", + "scenario": "compiler-rebuild-parseExtra", + "size": 4 + }, + { + "name": "jit-parseInvalid", + "export": "parseInvalid", + "scenario": "compiler-rebuild-parseInvalid", + "path": "invalid" + }, + { + "name": "jit-isValid", + "export": "isValid", + "scenario": "compiler-rebuild-isValid", + "operation": "is" + }, + { + "name": "jit-isInvalid", + "export": "isInvalid", + "scenario": "compiler-rebuild-isInvalid", + "operation": "is", + "path": "invalid" + }, + { + "name": "jit-encode", + "export": "encode", + "scenario": "compiler-rebuild-encode", + "operation": "encode" + }, + { + "name": "jit-strict", + "export": "strict", + "scenario": "compiler-rebuild-strict" + }, + { + "name": "jit-nested", + "export": "nested", + "scenario": "compiler-rebuild-nested", + "astTags": [ + "Objects", + "Arrays", + "String", + "Number", + "Boolean" + ], + "size": 2 + }, + { + "name": "jit-array", + "export": "array", + "scenario": "compiler-rebuild-array", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "jit-arrayInvalid", + "export": "arrayInvalid", + "scenario": "compiler-rebuild-arrayInvalid", + "path": "invalid", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 33 + }, + { + "name": "jit-tuple", + "export": "tuple", + "scenario": "compiler-rebuild-tuple", + "astTags": [ + "Arrays", + "String", + "Number", + "Boolean" + ], + "size": 34 + }, + { + "name": "jit-record", + "export": "record", + "scenario": "compiler-rebuild-record", + "size": 32 + }, + { + "name": "jit-union", + "export": "union", + "scenario": "compiler-rebuild-union", + "astTags": [ + "Union", + "Objects", + "Literal", + "Number" + ], + "size": 8 + }, + { + "name": "jit-oneOf", + "export": "oneOf", + "scenario": "compiler-rebuild-oneOf", + "astTags": [ + "Union", + "Objects", + "String", + "Number" + ], + "size": 2 + }, + { + "name": "jit-transform", + "export": "transform", + "scenario": "compiler-rebuild-transform", + "astTags": [ + "Objects", + "String", + "Number" + ], + "size": 32 + }, + { + "name": "jit-transformInvalid", + "export": "transformInvalid", + "scenario": "compiler-rebuild-transformInvalid", + "path": "invalid", + "astTags": [ + "String", + "Number" + ], + "size": 1 + }, + { + "name": "jit-middleware", + "export": "middleware", + "scenario": "compiler-rebuild-middleware" + }, + { + "name": "jit-recursive", + "export": "recursive", + "scenario": "compiler-rebuild-recursive", + "astTags": [ + "Objects", + "Arrays", + "Suspend", + "Number" + ], + "size": 31 + }, + { + "name": "jit-declaration", + "export": "declaration", + "scenario": "compiler-rebuild-declaration", + "astTags": [ + "Declaration", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "jit-makeStruct", + "export": "makeStruct", + "scenario": "compiler-rebuild-makeStruct", + "operation": "make", + "astTags": [ + "Objects", + "Number" + ], + "size": 32 + }, + { + "name": "jit-makeArray", + "export": "makeArray", + "scenario": "compiler-rebuild-makeArray", + "operation": "make", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "jit-makeUnion", + "export": "makeUnion", + "scenario": "compiler-rebuild-makeUnion", + "operation": "make", + "astTags": [ + "Union", + "Objects", + "Literal", + "Number" + ], + "size": 8 + } + ] + }, + { + "file": "suites/compiler-rebuild/fixtures/aot.ts", + "defaults": { + "tier": 1, + "family": "aot", + "implementation": "effect", + "astTags": [ + "Objects", + "String", + "Number", + "Boolean" + ], + "operation": "decode", + "path": "valid", + "size": 3 + }, + "cases": [ + { + "name": "aot-parseValid", + "export": "parseValid", + "scenario": "compiler-rebuild-parseValid" + }, + { + "name": "aot-parseExtra", + "export": "parseExtra", + "scenario": "compiler-rebuild-parseExtra", + "size": 4 + }, + { + "name": "aot-parseInvalid", + "export": "parseInvalid", + "scenario": "compiler-rebuild-parseInvalid", + "path": "invalid" + }, + { + "name": "aot-isValid", + "export": "isValid", + "scenario": "compiler-rebuild-isValid", + "operation": "is" + }, + { + "name": "aot-isInvalid", + "export": "isInvalid", + "scenario": "compiler-rebuild-isInvalid", + "operation": "is", + "path": "invalid" + }, + { + "name": "aot-encode", + "export": "encode", + "scenario": "compiler-rebuild-encode", + "operation": "encode" + }, + { + "name": "aot-strict", + "export": "strict", + "scenario": "compiler-rebuild-strict" + }, + { + "name": "aot-nested", + "export": "nested", + "scenario": "compiler-rebuild-nested", + "astTags": [ + "Objects", + "Arrays", + "String", + "Number", + "Boolean" + ], + "size": 2 + }, + { + "name": "aot-array", + "export": "array", + "scenario": "compiler-rebuild-array", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "aot-arrayInvalid", + "export": "arrayInvalid", + "scenario": "compiler-rebuild-arrayInvalid", + "path": "invalid", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 33 + }, + { + "name": "aot-tuple", + "export": "tuple", + "scenario": "compiler-rebuild-tuple", + "astTags": [ + "Arrays", + "String", + "Number", + "Boolean" + ], + "size": 34 + }, + { + "name": "aot-record", + "export": "record", + "scenario": "compiler-rebuild-record", + "size": 32 + }, + { + "name": "aot-union", + "export": "union", + "scenario": "compiler-rebuild-union", + "astTags": [ + "Union", + "Objects", + "Literal", + "Number" + ], + "size": 8 + }, + { + "name": "aot-oneOf", + "export": "oneOf", + "scenario": "compiler-rebuild-oneOf", + "astTags": [ + "Union", + "Objects", + "String", + "Number" + ], + "size": 2 + }, + { + "name": "aot-transform", + "export": "transform", + "scenario": "compiler-rebuild-transform", + "astTags": [ + "Objects", + "String", + "Number" + ], + "size": 32 + }, + { + "name": "aot-transformInvalid", + "export": "transformInvalid", + "scenario": "compiler-rebuild-transformInvalid", + "path": "invalid", + "astTags": [ + "String", + "Number" + ], + "size": 1 + }, + { + "name": "aot-middleware", + "export": "middleware", + "scenario": "compiler-rebuild-middleware" + }, + { + "name": "aot-recursive", + "export": "recursive", + "scenario": "compiler-rebuild-recursive", + "astTags": [ + "Objects", + "Arrays", + "Suspend", + "Number" + ], + "size": 31 + }, + { + "name": "aot-declaration", + "export": "declaration", + "scenario": "compiler-rebuild-declaration", + "astTags": [ + "Declaration", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "aot-makeStruct", + "export": "makeStruct", + "scenario": "compiler-rebuild-makeStruct", + "operation": "make", + "astTags": [ + "Objects", + "Number" + ], + "size": 32 + }, + { + "name": "aot-makeArray", + "export": "makeArray", + "scenario": "compiler-rebuild-makeArray", + "operation": "make", + "astTags": [ + "Arrays", + "Objects", + "String", + "Number", + "Boolean" + ], + "size": 32 + }, + { + "name": "aot-makeUnion", + "export": "makeUnion", + "scenario": "compiler-rebuild-makeUnion", + "operation": "make", + "astTags": [ + "Union", + "Objects", + "Literal", + "Number" + ], + "size": 8 + } + ] + } + ] } ] } diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/README.md b/packages/effect/runtimeperf/suites/compiler-rebuild/README.md new file mode 100644 index 00000000000..02e89f326e2 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/README.md @@ -0,0 +1,47 @@ +# Compiler rebuild comparison + +The `compiler-rebuild` suite measures 22 public SchemaParser operations with the +interpreter, selective JIT and generated AOT modules. The fixture families name +the execution mode. Cases cover simple and nested Structs, Arrays, tuples, +Records, anyOf/oneOf Unions, transformations, middleware, recursive schemas, +Declarations, construction, and successful and failing validation. + +```sh +pnpm runtimeperf-compare compiler-rebuild --base schema-compiler +pnpm runtimeperf-compare compiler-rebuild --base main --family interpreted +``` + +The first command compares the current working tree with the archived compiler +branch. The second isolates changes to interpreted parsing against main. Schema +and parser construction are outside the steady-state measurements. AOT fixtures +generate their version-specific module in a separate process before loading it. + +The standard paired harness records five alternating rounds, validates every +fixture before and after measurement, uses a common batch within each pair, and +reports bootstrap confidence intervals. Cross-mode rankings are descriptive; +the paired base/head comparison is the regression evidence. + +## Retained heap and first use + +The standalone cost probe accepts a checkout root, mode, operation, shape and +schema count. Run several fresh processes per combination and alternate the +checkout order. Supported modes are `interpreted`, `jit` and `aot`; operations +are `decode`, `invalid`, `is` and `make`; shapes are `struct`, `array`, `transform` +and `default`. + +```sh +node --expose-gc packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts heap "$PWD" jit decode struct 500 +node --expose-gc packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts cold "$PWD" jit decode struct 500 +``` + +Heap measurements exclude schema construction and module imports, and retain +both schema objects and public parser functions. They include operation setup +and first use. Forced GC removes transient parse output and issues. The AOT +generator runs in a separate process, so its retained heap is not attributed to +the runtime application. + +First-use measurements include constructing a fresh schema, installing its +compiler when requested, creating a public parser and calling it. They exclude +module imports and AOT source generation. Repeated shapes can benefit from V8's +source cache, so this is not process startup latency. Treat these timing probes +as exploratory, separately from the paired throughput results. diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts new file mode 100644 index 00000000000..7aaaf8b29f6 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +const [command, root, mode, operation, shape = "struct", countString = "500"] = process.argv.slice(2) +const count = Number(countString) +const load = (path: string) => import(pathToFileURL(join(root, "packages/effect/src", path + ".ts")).href) +const Schema = await load("Schema") +const Parser = await load("SchemaParser") +const AST = await load("SchemaAST") +const Effect = await load("Effect") +const create = () => { + const struct = Schema.Struct({ name: Schema.String, age: Schema.Number, active: Schema.Boolean }) + return shape === "array" ? Schema.Array(struct) + : shape === "transform" ? Schema.Struct({ value: Schema.NumberFromString }) + : shape === "default" ? Schema.Struct({ value: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1))) }) + : struct +} +const valid = shape === "array" ? Array.from({ length: 32 }, () => ({ name: "Ada", age: 37, active: true })) + : shape === "transform" ? { value: "1" } + : shape === "default" ? {} + : { name: "Ada", age: 37, active: true } +const input = operation === "invalid" ? { name: "Ada", age: "bad", active: true } : valid + +if (command === "generate") { + const AOT = await load("unstable/schema/SchemaAOTCompiler") + const schema = create() + writeFileSync(process.argv[8], AOT.compile([schema.ast, AST.toType(schema.ast)])) +} else { + for (let i = 0; i < 5; i++) globalThis.gc?.() + const beforeImport = process.memoryUsage().heapUsed + let enable: ((ast: unknown) => void) | undefined + let install: ((asts: Array) => void) | undefined + let directory: string | undefined + if (mode === "jit") { + enable = (await load("unstable/schema/SchemaJITCompiler")).enable + } else if (mode === "aot") { + directory = mkdtempSync(join(root, "packages/effect/.compiler-memory-")) + const generated = join(directory, "generated.mjs") + execFileSync(process.execPath, [fileURLToPath(import.meta.url), "generate", root, mode, operation, shape, countString, generated]) + install = (await import(pathToFileURL(generated).href)).install + } + for (let i = 0; i < 5; i++) globalThis.gc?.() + const moduleBytes = process.memoryUsage().heapUsed - beforeImport + const prepare = (schema: any) => { + const ast = operation === "make" || operation === "is" ? AST.toType(schema.ast) : schema.ast + if (enable) enable(ast) + if (install) install([schema.ast, AST.toType(schema.ast)]) + return operation === "make" ? Parser.make(schema) + : operation === "is" ? Parser.is(schema) + : Parser.decodeUnknownSync(schema) + } + const run = (parse: (input: unknown) => unknown) => { + try { + const value = parse(input) + assert.notEqual(operation, "invalid") + return value + } catch (error) { + if (operation !== "invalid") throw error + assert.equal((error as Error).message, "Schema validation failed") + } + } + try { + for (let i = 0; i < 20; i++) run(prepare(create())) + if (command === "heap") { + assert.equal(typeof globalThis.gc, "function") + const schemas = Array.from({ length: count }, create) + for (let i = 0; i < 5; i++) globalThis.gc!() + const before = process.memoryUsage().heapUsed + const parsers = schemas.map((schema) => { const parse = prepare(schema); run(parse); return parse }) + for (let i = 0; i < 5; i++) globalThis.gc!() + const after = process.memoryUsage().heapUsed + // Keep schemas and adapters alive across the measurement. + assert.equal(parsers.length, count) + assert.equal(schemas.length, count) + process.stdout.write(JSON.stringify({ mode, operation, shape, count, moduleBytes, bytesPerSchema: (after - before) / count })) + } else if (command === "cold") { + const samples = [] + for (let round = 0; round < 7; round++) { + const start = process.hrtime.bigint() + for (let i = 0; i < count; i++) run(prepare(create())) + samples.push(Number(process.hrtime.bigint() - start) / count) + } + process.stdout.write(JSON.stringify({ mode, operation, shape, count, nsPerSchema: samples })) + } + } finally { + if (directory) rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/aot.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/aot.ts new file mode 100644 index 00000000000..2129564b51b --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/aot.ts @@ -0,0 +1,36 @@ +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { fixture, roots } from "./cases.ts" +const directory = mkdtempSync(fileURLToPath(new URL("./.aot-", import.meta.url))) +try { + const file = join(directory, "generated.mjs") + execFileSync(process.execPath, [fileURLToPath(new URL("./generate.mts", import.meta.url)), file]) + const generated = await import(pathToFileURL(file).href) + generated.install(roots) +} finally { + rmSync(directory, { recursive: true, force: true }) +} +export const parseValid = () => fixture("parseValid") +export const parseExtra = () => fixture("parseExtra") +export const parseInvalid = () => fixture("parseInvalid") +export const isValid = () => fixture("isValid") +export const isInvalid = () => fixture("isInvalid") +export const encode = () => fixture("encode") +export const strict = () => fixture("strict") +export const nested = () => fixture("nested") +export const array = () => fixture("array") +export const arrayInvalid = () => fixture("arrayInvalid") +export const tuple = () => fixture("tuple") +export const record = () => fixture("record") +export const union = () => fixture("union") +export const oneOf = () => fixture("oneOf") +export const transform = () => fixture("transform") +export const transformInvalid = () => fixture("transformInvalid") +export const middleware = () => fixture("middleware") +export const recursive = () => fixture("recursive") +export const declaration = () => fixture("declaration") +export const makeStruct = () => fixture("makeStruct") +export const makeArray = () => fixture("makeArray") +export const makeUnion = () => fixture("makeUnion") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts new file mode 100644 index 00000000000..73f98399054 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts @@ -0,0 +1,119 @@ +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import * as SchemaAST from "effect/SchemaAST" +import * as SchemaParser from "effect/SchemaParser" +import * as SchemaTransformation from "effect/SchemaTransformation" +import assert from "node:assert/strict" + +export const person = () => Schema.Struct({ name: Schema.String, age: Schema.Number, active: Schema.Boolean }) +export const input = { name: "Ada", age: 37, active: true } +const small = person() +const nested = Schema.Struct({ user: person(), tags: Schema.Array(Schema.String) }) +const array = Schema.Array(person()) +const arrayInput = Array.from({ length: 32 }, () => ({ ...input })) +const tuple = Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number, Schema.Boolean]) +const tupleInput = ["head", ...Array.from({ length: 32 }, (_, i) => i), true] +const record = Schema.Record(Schema.String, person()) +const recordInput = Object.fromEntries(arrayInput.map((value, i) => [String(i), value])) +const union = Schema.Union( + Array.from({ length: 8 }, (_, i) => Schema.Struct({ tag: Schema.Literal(i), value: Schema.Number })) +) +const oneOf = Schema.Union([Schema.Struct({ a: Schema.String }), Schema.Struct({ b: Schema.Number })], { + mode: "oneOf" +}) +const fields = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, Schema.NumberFromString])) +const transformed = Schema.Struct(fields) +const transformedInput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, String(i)])) +const transformedOutput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, i])) +const checkedTransform = Schema.String.pipe( + Schema.decodeTo( + Schema.Number.check(Schema.isGreaterThan(0)), + SchemaTransformation.transform({ decode: Number, encode: String }) + ) +) +const middleware = small.pipe(Schema.middlewareDecoding((effect) => effect)) +const declaration = Schema.ReadonlySet(person()) +const declarationInput = new Set(arrayInput) +interface Node { + readonly value: number + readonly children: ReadonlyArray +} +const recursive: Schema.Codec = Schema.Struct({ + value: Schema.Number, + children: Schema.Array(Schema.suspend(() => recursive)) +}) +const node = (depth: number): Node => ({ + value: depth, + children: depth === 0 ? [] : [node(depth - 1), node(depth - 1)] +}) +const recursiveInput = node(4) +const defaults = Schema.Struct( + Object.fromEntries( + Array.from( + { length: 32 }, + (_, i) => [`v${i}`, Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(i)))] + ) + ) +) + +type Case = { + schema: Schema.Constraint + input: unknown + expected: unknown + operation?: "is" | "make" | "encode" + invalid?: boolean + options?: SchemaAST.ParseOptions +} + +export const cases: Record = { + parseValid: { schema: small, input, expected: input }, + parseExtra: { schema: small, input: { ...input, extra: 1 }, expected: input }, + parseInvalid: { schema: small, input: { ...input, age: "bad" }, expected: true, invalid: true }, + isValid: { schema: small, input, expected: true, operation: "is" }, + isInvalid: { schema: small, input: { ...input, age: "bad" }, expected: false, operation: "is" }, + encode: { schema: small, input, expected: input, operation: "encode" }, + strict: { schema: small, input, expected: input, options: { onExcessProperty: "error" } }, + nested: { schema: nested, input: { user: input, tags: ["a", "b"] }, expected: { user: input, tags: ["a", "b"] } }, + array: { schema: array, input: arrayInput, expected: arrayInput }, + arrayInvalid: { schema: array, input: [...arrayInput, { ...input, age: "bad" }], expected: true, invalid: true }, + tuple: { schema: tuple, input: tupleInput, expected: tupleInput }, + record: { schema: record, input: recordInput, expected: recordInput }, + union: { schema: union, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 } }, + oneOf: { schema: oneOf, input: { b: 1 }, expected: { b: 1 } }, + transform: { schema: transformed, input: transformedInput, expected: transformedOutput }, + transformInvalid: { schema: checkedTransform, input: "-1", expected: true, invalid: true }, + middleware: { schema: middleware, input, expected: input }, + recursive: { schema: recursive, input: recursiveInput, expected: recursiveInput }, + declaration: { schema: declaration, input: declarationInput, expected: declarationInput }, + makeStruct: { schema: defaults, input: {}, expected: transformedOutput, operation: "make" }, + makeArray: { schema: array, input: arrayInput, expected: arrayInput, operation: "make" }, + makeUnion: { schema: union, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 }, operation: "make" } +} + +export const roots = Object.values(cases).flatMap(( + { schema } +) => [schema.ast, SchemaAST.toType(schema.ast), SchemaAST.flip(schema.ast)]) + +export const fixture = (name: string) => { + const { schema, input, expected, operation, invalid, options } = cases[name] + const parse = operation === "is" ? + SchemaParser.is(schema) + : operation === "make" ? + SchemaParser.make(schema) + : operation === "encode" ? + SchemaParser.encodeUnknownSync(schema as Schema.ConstraintEncoder) + : SchemaParser.decodeUnknownSync(schema as Schema.ConstraintDecoder, options) + return { + run: invalid ? + () => { + try { + parse(input) + return false + } catch { + return true + } + } : + () => parse(input), + validate: (result: unknown) => assert.deepEqual(result, expected) + } +} diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/generate.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/generate.mts new file mode 100644 index 00000000000..a45d893f671 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/generate.mts @@ -0,0 +1,5 @@ +import { writeFileSync } from "node:fs" +import { compile } from "effect/unstable/schema/SchemaAOTCompiler" +import { roots } from "./cases.ts" + +writeFileSync(process.argv[2], compile(roots)) diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/interpreted.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/interpreted.ts new file mode 100644 index 00000000000..7fafae2fb89 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/interpreted.ts @@ -0,0 +1,23 @@ +import { fixture } from "./cases.ts" +export const parseValid = () => fixture("parseValid") +export const parseExtra = () => fixture("parseExtra") +export const parseInvalid = () => fixture("parseInvalid") +export const isValid = () => fixture("isValid") +export const isInvalid = () => fixture("isInvalid") +export const encode = () => fixture("encode") +export const strict = () => fixture("strict") +export const nested = () => fixture("nested") +export const array = () => fixture("array") +export const arrayInvalid = () => fixture("arrayInvalid") +export const tuple = () => fixture("tuple") +export const record = () => fixture("record") +export const union = () => fixture("union") +export const oneOf = () => fixture("oneOf") +export const transform = () => fixture("transform") +export const transformInvalid = () => fixture("transformInvalid") +export const middleware = () => fixture("middleware") +export const recursive = () => fixture("recursive") +export const declaration = () => fixture("declaration") +export const makeStruct = () => fixture("makeStruct") +export const makeArray = () => fixture("makeArray") +export const makeUnion = () => fixture("makeUnion") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/jit.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/jit.ts new file mode 100644 index 00000000000..0cea982625f --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/jit.ts @@ -0,0 +1,25 @@ +import { enable } from "effect/unstable/schema/SchemaJITCompiler" +import { fixture, roots } from "./cases.ts" +for (const ast of roots) enable(ast) +export const parseValid = () => fixture("parseValid") +export const parseExtra = () => fixture("parseExtra") +export const parseInvalid = () => fixture("parseInvalid") +export const isValid = () => fixture("isValid") +export const isInvalid = () => fixture("isInvalid") +export const encode = () => fixture("encode") +export const strict = () => fixture("strict") +export const nested = () => fixture("nested") +export const array = () => fixture("array") +export const arrayInvalid = () => fixture("arrayInvalid") +export const tuple = () => fixture("tuple") +export const record = () => fixture("record") +export const union = () => fixture("union") +export const oneOf = () => fixture("oneOf") +export const transform = () => fixture("transform") +export const transformInvalid = () => fixture("transformInvalid") +export const middleware = () => fixture("middleware") +export const recursive = () => fixture("recursive") +export const declaration = () => fixture("declaration") +export const makeStruct = () => fixture("makeStruct") +export const makeArray = () => fixture("makeArray") +export const makeUnion = () => fixture("makeUnion") diff --git a/packages/effect/src/SchemaAST.ts b/packages/effect/src/SchemaAST.ts index 0b6a455c166..6c3614301e1 100644 --- a/packages/effect/src/SchemaAST.ts +++ b/packages/effect/src/SchemaAST.ts @@ -2222,7 +2222,11 @@ export interface Arrays extends ASTNode { readonly encodingChecks: Checks | undefined /** @internal */ - getParser(compile: SchemaParser.Compiler, compileConstructorDefault?: SchemaParser.Compiler): SchemaParser.Parser + getParser( + compile: SchemaParser.Compiler, + compileConstructorDefault?: SchemaParser.Compiler, + generate?: (context: ArrayParserContext) => typeof parseArray + ): SchemaParser.Parser /** @internal */ recur(recur: (ast: AST) => AST): Arrays @@ -2294,7 +2298,8 @@ export const Arrays: new( /** @internal */ getParser( compile: SchemaParser.Compiler, - compileConstructorDefault: SchemaParser.Compiler = compile + compileConstructorDefault: SchemaParser.Compiler = compile, + generate?: (context: ArrayParserContext) => typeof parseArray ): SchemaParser.Parser { // oxlint-disable-next-line @typescript-eslint/no-this-alias const ast = this @@ -2316,6 +2321,20 @@ export const Arrays: new( return rest![0] } + const run = generate !== undefined && elementLen === 0 && ast.rest.length === 1 + ? generate({ + getElement: () => rest![0].parser, + step: parseArrayOptions.step, + resume: (state, item, index, pending, end) => + Effect.flatMap( + Effect.exit(pending), + (exit) => + parseArrayOptions.step(state, item, exit, index) ?? + parseArray(state, state.input, index + 1, end) ?? Effect.void + ) + }) + : parseArray + return Effect.fnUntracedEager(function*(input, options) { if (input === InternalParser.missing) { return InternalParser.missing @@ -2344,7 +2363,7 @@ export const Arrays: new( const end = ast.rest.length === 0 ? elementLen : Math.max(len, elementLen + tailLen) const concurrency = options.concurrency === undefined ? 1 : resolveConcurrency(options.concurrency) const eff = concurrency === 1 - ? parseArray(state, input, 0, end) + ? run(state, input, 0, end) : parseArrayConcurrent(state, input, { concurrency, end }) if (eff) yield* eff @@ -2403,9 +2422,22 @@ export const Arrays: new( return "array" } } +/** @internal */ +export interface ArrayParserContext { + readonly getElement: () => SchemaParser.Parser + readonly step: typeof parseArrayOptions.step + readonly resume: ( + state: ArrayParserState, + item: unknown, + index: number, + pending: Effect.Effect, + end: number + ) => Effect.Effect +} + type ArrayParserState = { readonly ast: AST - readonly input: unknown + readonly input: ReadonlyArray readonly len: number readonly getParser: ( tailThreshold: number, @@ -2707,7 +2739,11 @@ export interface Objects extends ASTNode { readonly encodingChecks: Checks | undefined /** @internal */ - getParser(compile: SchemaParser.Compiler, compileConstructorDefault?: SchemaParser.Compiler): SchemaParser.Parser + getParser( + compile: SchemaParser.Compiler, + compileConstructorDefault?: SchemaParser.Compiler, + generate?: (context: ObjectParserContext) => SchemaParser.Parser + ): SchemaParser.Parser /** @internal */ flip(recur: (ast: AST) => AST): AST @@ -2771,7 +2807,8 @@ export const Objects: new( /** @internal */ getParser( compile: SchemaParser.Compiler, - compileConstructorDefault: SchemaParser.Compiler = compile + compileConstructorDefault: SchemaParser.Compiler = compile, + generate?: (context: ObjectParserContext) => SchemaParser.Parser ): SchemaParser.Parser { // oxlint-disable-next-line @typescript-eslint/no-this-alias const ast = this @@ -3010,6 +3047,10 @@ export const Objects: new( }) } + if (generate !== undefined) { + return generate({ ast, getProperties: compileMembers, fallback, resume, step: stepProperty }) + } + // Fast path: a struct without index signatures, under the default parse // options, needs none of the generator the fallback runs per value. return (input, options) => { @@ -3099,6 +3140,19 @@ export const Objects: new( } } +/** @internal */ +export interface ObjectParserContext { + readonly ast: Objects + readonly getProperties: () => ReadonlyArray + readonly fallback: SchemaParser.Parser + readonly resume: ( + state: ObjectParserState, + index: number, + pending: Effect.Effect + ) => Effect.Effect + readonly step: typeof stepProperty +} + type ObjectParserState = { readonly ast: Objects readonly input: Record @@ -4738,7 +4792,8 @@ function segmentTemplateLiteralParts( return go(0, 0) ? out : undefined } -const parameterFromPropertyKey = applyToSelfOrLastLinkEncodingIdempotent((ast) => { +/** @internal */ +export const parameterFromPropertyKey = applyToSelfOrLastLinkEncodingIdempotent((ast) => { switch (ast._tag) { default: return ast diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index 47b068c7c9b..6ab51743a3f 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -13,9 +13,9 @@ import * as Cause from "./Cause.ts" import * as Effect from "./Effect.ts" import * as Exit from "./Exit.ts" -import { memoize } from "./Function.ts" import { effectIsExit } from "./internal/effect.ts" import * as InternalSchemaCause from "./internal/schema/cause.ts" +import * as CompilerRegistry from "./internal/schema/compilerRegistry.ts" import * as InternalParser from "./internal/schema/parser.ts" import * as Option from "./Option.ts" import * as Result from "./Result.ts" @@ -152,9 +152,31 @@ export function is(schema: S): (input: I) => inp /** @internal */ export function _is(ast: SchemaAST.AST) { - const parser = asExit(run(SchemaAST.toType(ast))) + const typeAST = SchemaAST.toType(ast) + const options = SchemaAST.defaultParseOptions + let parser: ReturnType> | undefined + let initialized = false + let guard: CompilerRegistry.Entry["is"] return (input: I): input is I & T => { - const exit = parser(input, SchemaAST.defaultParseOptions) + if (!initialized) { + const entry = CompilerRegistry.resolve(typeAST) + guard = entry.is + // A value-producing validator can return the invalid symbol as valid data. + // Without a boolean validator, use the ordinary diagnostic fallback too. + initialized = true + } + if (guard !== undefined) { + try { + return guard(input, options) + } catch (error) { + InternalSchemaCause.getSchemaIssueOrThrow( + Cause.die(error), + "Type guard adapter can only return false for schema issues" + ) + return false + } + } + const exit = (parser ??= asExit(run(typeAST)))(input, options) if (Exit.isSuccess(exit)) { return true } @@ -526,7 +548,7 @@ export function decodeUnknownSync>( schema: S, options?: SchemaAST.ParseOptions ): (input: unknown, options?: SchemaAST.ParseOptions) => S["Type"] { - return asSync(decodeUnknownEffect(schema, options)) + return makeSync(schema.ast, options) } /** @@ -871,7 +893,7 @@ export function encodeUnknownSync>( schema: S, options?: SchemaAST.ParseOptions ): (input: unknown, options?: SchemaAST.ParseOptions) => S["Encoded"] { - return asSync(encodeUnknownEffect(schema, options)) + return makeSync(SchemaAST.flip(schema.ast), options) } /** @@ -998,6 +1020,32 @@ function asResult( } } +function makeSync( + ast: SchemaAST.AST, + options?: SchemaAST.ParseOptions +): (input: unknown, options?: SchemaAST.ParseOptions) => T { + let entry: CompilerRegistry.Entry | undefined + let detailed: ((input: unknown, options?: SchemaAST.ParseOptions) => T) | undefined + return (input, overrideOptions) => { + entry ??= CompilerRegistry.resolve(ast) + const parseOptions = options === undefined + ? overrideOptions ?? SchemaAST.defaultParseOptions + : mergeParseOptions(options, overrideOptions) + const validate = entry.validate + if (validate !== undefined && input !== InternalParser.missing) { + let output: unknown + try { + output = validate(input, parseOptions) + } catch (error) { + InternalSchemaCause.getSchemaIssueOrThrow(Cause.die(error), "Sync adapter can only throw schema issues") + throw error + } + if (output !== CompilerRegistry.invalid) return output as T + } + return (detailed ??= asSync(runWithCompiler(() => entry!.decodeEffect, ast)))(input, parseOptions) + } +} + function asSync( parser: (input: E, options?: SchemaAST.ParseOptions) => Effect.Effect ): (input: E, options?: SchemaAST.ParseOptions) => T { @@ -1025,186 +1073,5 @@ export interface Compiler { (ast: SchemaAST.AST): Parser } -const normalCompiler: Compiler = memoize((ast) => makeParser(ast, normalCompiler)) -const constructorCompiler: Compiler = memoize((ast) => makeParser(ast, constructorCompiler, compileConstructorDefault)) -const compileDefaulted = memoize((ast: SchemaAST.AST) => - makeParser(ast, constructorCompiler, compileConstructorDefault, ast.context?.constructorDefault) -) - -function compileConstructorDefault(ast: SchemaAST.AST): Parser { - return ast.context?.constructorDefault ? compileDefaulted(ast) : constructorCompiler(ast) -} - -function applyTransformation( - result: Effect.Effect, - current: unknown, - transformation: SchemaAST.Link["transformation"], - options: SchemaAST.ParseOptions -): Effect.Effect { - let transformed: Effect.Effect, SchemaIssue.Issue, unknown> - if (effectIsExit(result) && result._tag === "Success") { - const optional = InternalParser.toOption( - result === InternalParser.sameExit - ? current - : (result as InternalParser.Success)[InternalParser.args] - ) - transformed = transformation._tag === "Transformation" - ? transformation.decode.run(optional, options) - : transformation.decode(InternalParser.succeed(optional), options) - } else if (transformation._tag === "Transformation") { - transformed = Effect.flatMapEager( - result, - (value) => transformation.decode.run(InternalParser.toOption(value), options) - ) - } else { - transformed = transformation.decode( - Effect.mapEager(result, InternalParser.toOption), - options - ) - } - return effectIsExit(transformed) && transformed._tag === "Success" - ? InternalParser.fromOptionExit( - (transformed as InternalParser.Success, SchemaIssue.Issue>)[InternalParser.args] - ) - : Effect.flatMapEager(transformed, InternalParser.fromOptionExit) -} - -function makeConstructorParser(descriptor: SchemaAST.ConstructorDescriptor, compile: Compiler): Parser { - let sourceParser: Parser - return (input, options) => { - if (input === InternalParser.missing) return InternalParser.missingExit - if (descriptor.isConstructed(input)) return InternalParser.sameExit - const result = (sourceParser ??= compile(descriptor.link.to))(input, options) - return applyTransformation(result, input, descriptor.link.transformation, options) - } -} - -function makeParser( - ast: SchemaAST.AST, - compile: Compiler, - compileConstructorDefault?: Compiler, - constructorDefault?: SchemaAST.Link -): Parser { - const descriptor = compileConstructorDefault ? SchemaAST.getConstructorDescriptor(ast) : undefined - const parser = descriptor - ? makeConstructorParser(descriptor, compile) - : ast.getParser(compile, compileConstructorDefault) - const checks = ast.checks - const links = constructorDefault - ? ast.encoding ? [...ast.encoding, constructorDefault] : [constructorDefault] - : ast.encoding - const encodingChecks = (ast as any).encodingChecks - if (!links && !checks && !encodingChecks) { - return parser - } - let encodingParsers: ReadonlyArray | undefined - const parseLocal = ( - input: unknown, - options: SchemaAST.ParseOptions - ) => { - let result = parser(input, options) - if (encodingChecks && !options.disableChecks) { - if (effectIsExit(result)) { - if (result._tag === "Success") { - const output = result === InternalParser.sameExit - ? input - : (result as InternalParser.Success)[InternalParser.args] - if (input !== InternalParser.missing && output !== InternalParser.missing) { - const issues = SchemaAST.collectIssues(encodingChecks, input, undefined, ast, options) - if (issues) { - result = Effect.fail(new SchemaIssue.Composite(ast, issues, input, options)) - } - } - } - } else { - result = Effect.flatMap(result, (value) => { - if (input !== InternalParser.missing && value !== InternalParser.missing) { - const issues = SchemaAST.collectIssues(encodingChecks, input, undefined, ast, options) - if (issues) { - return Effect.fail(new SchemaIssue.Composite(ast, issues, input, options)) - } - } - return Effect.succeed(value) - }) - } - } - - if (checks && !options.disableChecks) { - if (effectIsExit(result)) { - if (result._tag === "Success") { - const value = result === InternalParser.sameExit - ? input - : (result as InternalParser.Success)[InternalParser.args] - if (value === InternalParser.missing) return result - const issues = SchemaAST.collectIssues(checks, value, undefined, ast, options) - if (issues) { - result = Effect.fail(new SchemaIssue.Composite(ast, issues, value, options)) - } - } - } else { - result = Effect.flatMap(result, (value) => { - if (value !== InternalParser.missing) { - const issues = SchemaAST.collectIssues(checks, value, undefined, ast, options) - if (issues) { - return Effect.fail(new SchemaIssue.Composite(ast, issues, value, options)) - } - } - return Effect.succeed(value) - }) - } - } - - return result - } - if (!links) { - return parseLocal - } - return ( - input: unknown, - options: SchemaAST.ParseOptions - ) => { - const parsers = encodingParsers ??= links.map((link) => compile(link.to)) - let current = input - let result = parsers[parsers.length - 1](input, options) - for (let i = links.length - 1; i >= 0; i--) { - result = applyTransformation(result, current, links[i].transformation, options) - if (i !== 0) { - const next = parsers[i - 1] - if ((result as Exit.Exit)._tag === "Success") { - current = (result as InternalParser.Success)[InternalParser.args] - result = next(current, options) - } else { - result = Effect.flatMapEager(result, (value) => { - const nextResult = next(value, options) - return nextResult === InternalParser.sameExit ? InternalParser.succeed(value) : nextResult - }) - } - } - } - if ((result as Exit.Exit)._tag === "Success") { - const value = (result as InternalParser.Success)[InternalParser.args] - const local = parseLocal(value, options) - return local === InternalParser.sameExit ? result : local - } - result = Effect.catchCause( - result, - (cause) => - Effect.failCauseSync(() => - Cause.map( - cause, - (issue) => - new SchemaIssue.Encoding( - ast, - issue, - input, - options - ) - ) - ) - ) - return Effect.flatMapEager(result, (value) => { - const local = parseLocal(value, options) - return local === InternalParser.sameExit ? InternalParser.succeed(value) : local - }) - } -} +const normalCompiler: Compiler = (ast) => CompilerRegistry.resolve(ast).parser +const constructorCompiler: Compiler = (ast) => CompilerRegistry.resolve(ast).makeEffect diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts new file mode 100644 index 00000000000..798ae307227 --- /dev/null +++ b/packages/effect/src/internal/schema/codegen.ts @@ -0,0 +1,684 @@ +import * as SchemaAST from "../../SchemaAST.ts" +import type { CompiledDecoder } from "../../unstable/schema/SchemaCompiler.ts" +import type { runtime } from "../../unstable/schema/SchemaCompiler/runtime.ts" + +const getEncodingChecks = (ast: SchemaAST.AST): SchemaAST.Checks | undefined => + "encodingChecks" in ast ? ast.encodingChecks : undefined +const getExpectedKeys = (ast: SchemaAST.Objects): ReadonlyArray => + ast.propertySignatures.map((p) => typeof p.name === "number" ? String(p.name) : p.name) + +const isOptional = (ast: SchemaAST.AST): boolean => ast.context?.isOptional ?? false + +const maxGeneratedDepth = 256 +/** @internal */ +export const maxGeneratedNodes = 2048 + +type Emission = "unsupported" | "validate" | "is" +type Operation = "validate" | "is" + +const failureExpression = (operation: Operation): string => operation === "validate" ? "I" : "false" + +/** @internal */ +const getEmission = ( + ast: SchemaAST.AST, + depth = 0, + local = false, + budget = { remaining: maxGeneratedNodes } +): Emission => { + // Count occurrences, not distinct ASTs: shared subgraphs are expanded by the emitter. + if (--budget.remaining < 0 || depth > maxGeneratedDepth || !local && ast.encoding !== undefined) return "unsupported" + switch (ast._tag) { + case "Null": + case "Undefined": + case "Void": + case "Never": + case "Any": + case "Unknown": + case "ObjectKeyword": + case "Enum": + case "UniqueSymbol": + case "Literal": + case "String": + case "Number": + case "Boolean": + case "Symbol": + case "BigInt": + return "is" + case "TemplateLiteral": { + for (const part of ast.parts) { + if (getEmission(part, depth + 1, false, budget) === "unsupported") return "unsupported" + } + return "is" + } + case "Arrays": { + let isOutputFree = ast.checks === undefined + for (const element of ast.elements) { + const emission = getEmission(element, depth + 1, false, budget) + if (emission === "unsupported") return "unsupported" + if (emission === "validate") isOutputFree = false + } + for (const element of ast.rest) { + const emission = getEmission(element, depth + 1, false, budget) + if (emission === "unsupported") return "unsupported" + if (emission === "validate") isOutputFree = false + } + return isOutputFree ? "is" : "validate" + } + case "Objects": { + let isOutputFree = ast.checks === undefined + for (const property of ast.propertySignatures) { + const emission = getEmission(property.type, depth + 1, false, budget) + if (emission === "unsupported") return "unsupported" + if (emission === "validate") isOutputFree = false + } + for (const signature of ast.indexSignatures) { + const key = getEmission(SchemaAST.parameterFromPropertyKey(signature.parameter), depth + 1, false, budget) + const value = getEmission(signature.type, depth + 1, false, budget) + if (key === "unsupported" || value === "unsupported") return "unsupported" + if (key === "validate" || value === "validate") isOutputFree = false + } + return isOutputFree ? "is" : "validate" + } + case "Union": { + let isOutputFree = ast.checks === undefined + for (const type of ast.types) { + const emission = getEmission(type, depth + 1, false, budget) + if (emission === "unsupported") return "unsupported" + if (emission === "validate") isOutputFree = false + } + return isOutputFree ? "is" : "validate" + } + case "Declaration": + case "Suspend": + return "unsupported" + } +} + +const canEmit = (ast: SchemaAST.AST, depth = 0): boolean => getEmission(ast, depth) !== "unsupported" + +type Emitter = { + readonly statements: Array + readonly helpers: Array + readonly initializers: Array + readonly decoderHelpers: Map + readonly unionHelpers: Map + readonly bindings: Array + readonly constantIndexes: Map + next: number +} + +const variable = (emitter: Emitter): string => `v${emitter.next++}` + +const propertyKey = (emitter: Emitter, key: PropertyKey, reference: string): string => + typeof key === "string" ? JSON.stringify(key) : constant(emitter, key, reference) + +const propertyPresence = (input: string, key: string, name: PropertyKey): string => + name === "__proto__" ? `Object.hasOwn(${input},${key})` : `${key} in ${input}` + +const assignProperty = (output: string, key: string, value: string, name: PropertyKey): string => + name === "__proto__" + ? `Object.defineProperty(${output},${key},{value:${value},writable:true,enumerable:true,configurable:true})` + : `${output}[${key}]=${value}` + +const constant = (emitter: Emitter, value: unknown, reference: string): string => { + const cached = emitter.constantIndexes.get(value) + if (cached !== undefined) return `C[${cached}]` + const index = emitter.bindings.length + emitter.bindings.push({ value, reference }) + emitter.constantIndexes.set(value, index) + return `C[${index}]` +} + +const needsPresenceCheck = (ast: SchemaAST.AST): boolean => { + if (!canEmit(ast)) return true + switch (ast._tag) { + case "Undefined": + case "Void": + case "Any": + case "Unknown": + return true + case "Union": + return ast.types.some(needsPresenceCheck) + default: + return false + } +} + +const propertyNeedsPresenceCheck = (name: PropertyKey, ast: SchemaAST.AST): boolean => + name === "__proto__" || needsPresenceCheck(ast) + +/** @internal */ +export const shouldCompileParser = (ast: SchemaAST.AST, local = false): boolean => { + if (!local && ast.encoding !== undefined) return true + if (SchemaAST.getConstructorDescriptor(ast) !== undefined) return true + if (ast.checks !== undefined || getEncodingChecks(ast) !== undefined) return true + switch (ast._tag) { + case "TemplateLiteral": + case "Arrays": + case "Objects": + case "Union": + return true + default: + return false + } +} + +const lookupMemberValues = (ast: SchemaAST.AST): ReadonlyArray | undefined => { + if (ast.checks !== undefined || getEncodingChecks(ast) !== undefined || ast.encoding !== undefined) return undefined + switch (ast._tag) { + case "Null": + return [null] + case "Undefined": + return [undefined] + case "Literal": + return [ast.literal] + case "UniqueSymbol": + return [ast.symbol] + case "Enum": + return [...new Set(ast.enums.map((entry) => entry[1]))] + default: + return undefined + } +} + +const lookupMemberReferences = (ast: SchemaAST.AST, path: string): ReadonlyArray => { + switch (ast._tag) { + case "Null": + return ["null"] + case "Undefined": + return ["void 0"] + case "Literal": + return [`${path}.literal`] + case "UniqueSymbol": + return [`${path}.symbol`] + case "Enum": { + const references = new Map() + ast.enums.forEach((entry, index) => { + if (!references.has(entry[1])) references.set(entry[1], `${path}.enums[${index}][1]`) + }) + return [...references.values()] + } + default: + throw new Error(`Unsupported lookup member: ${ast._tag}`) + } +} + +function emit( + ast: SchemaAST.AST, + input: string, + statements: Array, + emitter: Emitter, + operation: Operation, + path: string +): string { + const output = emitBase(ast, input, statements, emitter, operation, path) + const invalid = failureExpression(operation) + const encodingChecks = getEncodingChecks(ast) + const astConstant = ast.checks !== undefined || encodingChecks !== undefined + ? constant(emitter, ast, path) + : undefined + if (encodingChecks !== undefined) { + statements.push(`if(K(${astConstant},${input},1,o))return ${invalid}`) + } + if (ast.checks === undefined) return operation === "validate" ? output : "true" + const checked = variable(emitter) + statements.push( + `const ${checked}=${output}`, + `if(K(${astConstant},${checked},0,o))return ${invalid}` + ) + return operation === "validate" ? checked : "true" +} + +const emitDecoderHelper = (ast: SchemaAST.AST, emitter: Emitter, operation: Operation, path: string): string => { + const cached = emitter.decoderHelpers.get(ast) + if (cached !== undefined) return cached + const name = `d${emitter.next++}` + emitter.decoderHelpers.set(ast, name) + const statements: Array = [] + const output = emit(ast, "i", statements, emitter, operation, path) + emitter.helpers.push( + `function ${name}(i,o){${statements.join(";")};return ${output}}` + ) + return name +} + +const emitUnionHelper = (ast: SchemaAST.Union, emitter: Emitter, operation: Operation, path: string): string => { + const cached = emitter.unionHelpers.get(ast) + if (cached !== undefined) return cached + const name = `u${emitter.next++}` + emitter.unionHelpers.set(ast, name) + const entries = ast.types.map((type, index) => + `[${constant(emitter, type, `${path}.types[${index}]`)},${ + emitDecoderHelper(type, emitter, operation, `${path}.types[${index}]`) + }]` + ) + emitter.initializers.push(`const ${name}=new Map([${entries.join(",")}])`) + return name +} + +const emitIndexes = ( + ast: SchemaAST.Objects, + input: string, + output: string | undefined, + statements: Array, + emitter: Emitter, + operation: Operation, + path: string +): void => { + const fixedKeys = output === undefined || ast.propertySignatures.length === 0 + ? undefined + : constant( + emitter, + new Set(getExpectedKeys(ast)), + `new Set(${runtimeReference("getExpectedKeys")}(${path}))` + ) + for (let signatureIndex = 0; signatureIndex < ast.indexSignatures.length; signatureIndex++) { + const signature = ast.indexSignatures[signatureIndex] + const signaturePath = `${path}.indexSignatures[${signatureIndex}]` + const keys = variable(emitter) + const index = variable(emitter) + const key = variable(emitter) + const parameter = signature.parameter + statements.push( + `const ${keys}=${ + parameter._tag === "String" && parameter.checks === undefined + ? `Object.keys(${input})` + : `G(${input},${constant(emitter, parameter, `${signaturePath}.parameter`)},o)` + }` + ) + const loop: Array = [`const ${key}=${keys}[${index}]`] + const decodedKey = parameter._tag === "String" && parameter.checks === undefined && parameter.encoding === undefined + ? key + : emit( + SchemaAST.parameterFromPropertyKey(parameter), + key, + loop, + emitter, + operation, + `${runtimeReference("parameterFromPropertyKey")}(${signaturePath}.parameter)` + ) + const value = variable(emitter) + loop.push(`const ${value}=${input}[${key}]`) + const decoded = emit(signature.type, value, loop, emitter, operation, `${signaturePath}.type`) + if (output !== undefined) { + const assign = + `if(${decodedKey}==="__proto__")Object.defineProperty(${output},${decodedKey},{value:${decoded},writable:true,enumerable:true,configurable:true});else ${output}[${decodedKey}]=${decoded}` + loop.push( + fixedKeys === undefined + ? assign + : `if(!${fixedKeys}.has(${key})&&!${fixedKeys}.has(${decodedKey})){${assign}}` + ) + } + statements.push(`for(let ${index}=0;${index}<${keys}.length;${index}++){${loop.join(";")}}`) + } +} + +const emitBase = ( + ast: SchemaAST.AST, + input: string, + statements: Array, + emitter: Emitter, + operation: Operation, + path: string +): string => { + const needsValue = operation === "validate" + const invalid = failureExpression(operation) + switch (ast._tag) { + case "Null": + statements.push(`if(${input}!==null)return ${invalid}`) + return input + case "Undefined": + statements.push(`if(${input}!==void 0)return ${invalid}`) + return input + case "Void": + return "void 0" + case "Never": + statements.push(`return ${invalid}`) + return input + case "Any": + case "Unknown": + return input + case "ObjectKeyword": + statements.push( + `if((${input}===null||typeof ${input}!=="object")&&typeof ${input}!=="function")return ${invalid}` + ) + return input + case "Enum": { + const values = constant( + emitter, + new Set(ast.enums.map((entry) => entry[1])), + `new Set(${path}.enums.map(entry=>entry[1]))` + ) + statements.push(`if(!${values}.has(${input}))return ${invalid}`) + return input + } + case "UniqueSymbol": { + const value = constant(emitter, ast.symbol, `${path}.symbol`) + statements.push(`if(${input}!==${value})return ${invalid}`) + return input + } + case "Literal": { + const value = constant(emitter, ast.literal, `${path}.literal`) + statements.push(`if(${input}!==${value})return ${invalid}`) + return input + } + case "String": + statements.push(`if(typeof ${input}!=="string")return ${invalid}`) + return input + case "Number": + statements.push(`if(typeof ${input}!=="number")return ${invalid}`) + return input + case "Boolean": + statements.push(`if(typeof ${input}!=="boolean")return ${invalid}`) + return input + case "Symbol": + statements.push(`if(typeof ${input}!=="symbol")return ${invalid}`) + return input + case "BigInt": + statements.push(`if(typeof ${input}!=="bigint")return ${invalid}`) + return input + case "TemplateLiteral": { + const template = constant(emitter, ast, path) + statements.push(`if(!T(${template},${input},o))return ${invalid}`) + return input + } + case "Arrays": { + statements.push(`if(!Array.isArray(${input}))return ${invalid}`) + const length = variable(emitter) + statements.push(`const ${length}=${input}.length`) + const elementLength = ast.elements.length + const requiredElementLength = ast.elements.findIndex(isOptional) + const minimumElementLength = requiredElementLength === -1 ? elementLength : requiredElementLength + const tailLength = Math.max(0, ast.rest.length - 1) + if (ast.rest.length === 0) { + statements.push( + minimumElementLength === elementLength + ? `if(${length}!==${elementLength})return ${invalid}` + : `if(${length}<${minimumElementLength}||${length}>${elementLength})return ${invalid}` + ) + if (minimumElementLength === elementLength) { + const elements = ast.elements.map((element, index) => { + const value = variable(emitter) + statements.push(`const ${value}=${input}[${index}]`) + return emit(element, value, statements, emitter, operation, `${path}.elements[${index}]`) + }) + return needsValue ? `[${elements.join(",")}]` : input + } + const output = needsValue ? variable(emitter) : undefined + if (output !== undefined) statements.push(`const ${output}=new Array(${length})`) + for (let index = 0; index < elementLength; index++) { + const value = variable(emitter) + const elementStatements: Array = [`const ${value}=${input}[${index}]`] + const decoded = emit( + ast.elements[index], + value, + elementStatements, + emitter, + operation, + `${path}.elements[${index}]` + ) + if (output !== undefined) elementStatements.push(`${output}[${index}]=${decoded}`) + statements.push( + index < minimumElementLength + ? elementStatements.join(";") + : `if(${index}<${length}){${elementStatements.join(";")}}` + ) + } + return output ?? input + } + statements.push(`if(${length}<${minimumElementLength + tailLength})return ${invalid}`) + const output = needsValue ? variable(emitter) : undefined + if (output !== undefined) statements.push(`const ${output}=new Array(${length})`) + for (let index = 0; index < elementLength; index++) { + const value = variable(emitter) + const elementStatements: Array = [`const ${value}=${input}[${index}]`] + const decoded = emit( + ast.elements[index], + value, + elementStatements, + emitter, + operation, + `${path}.elements[${index}]` + ) + if (output !== undefined) elementStatements.push(`${output}[${index}]=${decoded}`) + statements.push( + index < minimumElementLength + ? elementStatements.join(";") + : `if(${index}<${length}){${elementStatements.join(";")}}` + ) + } + const index = variable(emitter) + const restStatements: Array = [] + const value = variable(emitter) + restStatements.push(`const ${value}=${input}[${index}]`) + const decoded = emit(ast.rest[0], value, restStatements, emitter, operation, `${path}.rest[0]`) + if (output !== undefined) restStatements.push(`${output}[${index}]=${decoded}`) + statements.push( + `for(let ${index}=${elementLength};${index}<${length}-${tailLength};${index}++){${restStatements.join(";")}}` + ) + for (let index = 0; index < tailLength; index++) { + const inputIndex = `${length}-${tailLength - index}` + const value = variable(emitter) + statements.push(`const ${value}=${input}[${inputIndex}]`) + const decoded = emit(ast.rest[index + 1], value, statements, emitter, operation, `${path}.rest[${index + 1}]`) + if (output !== undefined) statements.push(`${output}[${inputIndex}]=${decoded}`) + } + return output ?? input + } + case "Objects": { + if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 0) { + statements.push(`if(${input}===null||${input}===void 0)return ${invalid}`) + return input + } + statements.push( + `if(typeof ${input}!=="object"||${input}===null||Array.isArray(${input}))return ${invalid}` + ) + statements.push( + `if(o!==D&&o.onExcessProperty==="error"&&E(${constant(emitter, ast, path)},${input},o))return ${invalid}` + ) + const hasOptional = ast.propertySignatures.some((property) => isOptional(property.type)) + if (needsValue && ast.propertySignatures.length > 0 && !hasOptional) { + const output = variable(emitter) + const properties = ast.propertySignatures.map((property, index) => { + const propertyPath = `${path}.propertySignatures[${index}]` + const key = propertyKey(emitter, property.name, `${propertyPath}.name`) + const outputKey = typeof property.name === "string" && property.name !== "__proto__" ? key : `[${key}]` + const value = variable(emitter) + if (propertyNeedsPresenceCheck(property.name, property.type)) { + statements.push(`if(!(${propertyPresence(input, key, property.name)}))return ${invalid}`) + } + statements.push(`const ${value}=${input}[${key}]`) + return `${outputKey}:${emit(property.type, value, statements, emitter, operation, `${propertyPath}.type`)}` + }) + statements.push(`const ${output}={${properties.join(",")}}`) + if (ast.indexSignatures.length > 0) emitIndexes(ast, input, output, statements, emitter, operation, path) + return output + } + const output = needsValue ? variable(emitter) : undefined + if (output !== undefined) statements.push(`const ${output}={}`) + for (let propertyIndex = 0; propertyIndex < ast.propertySignatures.length; propertyIndex++) { + const property = ast.propertySignatures[propertyIndex] + const propertyPath = `${path}.propertySignatures[${propertyIndex}]` + const key = propertyKey(emitter, property.name, `${propertyPath}.name`) + const value = variable(emitter) + const propertyStatements: Array = [`const ${value}=${input}[${key}]`] + const decoded = emit(property.type, value, propertyStatements, emitter, operation, `${propertyPath}.type`) + if (output !== undefined) propertyStatements.push(assignProperty(output, key, decoded, property.name)) + statements.push( + isOptional(property.type) + ? `if(${propertyPresence(input, key, property.name)}){${propertyStatements.join(";")}}` + : `${ + propertyNeedsPresenceCheck(property.name, property.type) + ? `if(!(${propertyPresence(input, key, property.name)}))return ${invalid};` + : "" + }${propertyStatements.join(";")}` + ) + } + if (ast.indexSignatures.length > 0) emitIndexes(ast, input, output, statements, emitter, operation, path) + return output ?? input + } + case "Union": { + const memberValues = ast.types.map(lookupMemberValues) + if (memberValues.every((values) => values !== undefined)) { + const references = ast.types.map((type, index) => lookupMemberReferences(type, `${path}.types[${index}]`)) + if (ast.options?.mode !== "oneOf") { + const values = constant(emitter, new Set(memberValues.flat()), `new Set([${references.flat().join(",")}])`) + statements.push(`if(!${values}.has(${input}))return ${invalid}`) + } else { + const counts = new Map() + const valueReferences = new Map() + for (let memberIndex = 0; memberIndex < memberValues.length; memberIndex++) { + const values = memberValues[memberIndex] + for (let valueIndex = 0; valueIndex < values.length; valueIndex++) { + const value = values[valueIndex] + counts.set(value, (counts.get(value) ?? 0) + 1) + if (!valueReferences.has(value)) valueReferences.set(value, references[memberIndex][valueIndex]) + } + } + const entries = [...counts].map(([value, count]) => `[${valueReferences.get(value)},${count}]`) + const lookup = constant(emitter, counts, `new Map([${entries.join(",")}])`) + statements.push(`if(${lookup}.get(${input})!==1)return ${invalid}`) + } + return input + } + const candidates = variable(emitter) + const output = variable(emitter) + const candidate = variable(emitter) + const index = variable(emitter) + const decoder = variable(emitter) + const types = constant(emitter, ast.types, `${path}.types`) + const decoders = emitUnionHelper(ast, emitter, operation, path) + statements.push( + `const ${candidates}=U(${input},${types})`, + `let ${output}=${invalid},${candidate},${decoder}` + ) + if (ast.options?.mode !== "oneOf") { + statements.push( + `for(let ${index}=0;${index}<${candidates}.length;${index}++){${decoder}=${decoders}.get(${candidates}[${index}]);${candidate}=${decoder}(${input},o);if(${candidate}!==${invalid}){${output}=${candidate};break}}` + ) + statements.push(`if(${output}===${invalid})return ${invalid}`) + } else { + const successes = variable(emitter) + statements.push(`let ${successes}=0`) + statements.push( + `for(let ${index}=0;${index}<${candidates}.length;${index}++){${decoder}=${decoders}.get(${candidates}[${index}]);${candidate}=${decoder}(${input},o);if(${candidate}!==${invalid}){if(++${successes}>1)return ${invalid};${output}=${candidate}}}` + ) + statements.push(`if(${successes}!==1)return ${invalid}`) + } + return output + } + default: + throw new Error(`Unsupported Schema AST: ${ast._tag}`) + } +} + +/** @internal */ +export interface Binding { + readonly value: unknown + readonly reference: string +} + +/** @internal */ +export const runtimeReference = (name: keyof typeof runtime): string => `R.${name}` + +const runtimeBindings = (aliases: Readonly>): string => + `const {${Object.entries(aliases).map(([alias, name]) => `${name}:${alias}`).join(",")}}=R;` + +/** @internal */ +export interface GeneratedOperation { + readonly source: string + readonly bindings: ReadonlyArray +} + +const emitOperation = (ast: SchemaAST.AST, operation: Operation): GeneratedOperation => { + const emitter: Emitter = { + statements: [], + helpers: [], + initializers: [], + decoderHelpers: new Map(), + unionHelpers: new Map(), + bindings: [], + constantIndexes: new Map(), + next: 0 + } + const output = emit(ast, "i", emitter.statements, emitter, operation, "ast") + const bindings = { + K: "failsChecks", + T: "matchesTemplateLiteral", + U: "getCandidates", + G: "getIndexSignatureKeys", + D: "defaultParseOptions", + E: "hasExcessProperties" + } as const + const source = `"use strict";${runtimeBindings(operation === "validate" ? { I: "invalid", ...bindings } : bindings)}${ + emitter.helpers.join(";") + };${emitter.initializers.join(";")};return function(i,o){${emitter.statements.join(";")};return ${output}}` + return { source, bindings: emitter.bindings } +} + +/** @internal */ +export type DecoderOperation = keyof CompiledDecoder + +const renderOperation = (emitted: GeneratedOperation): string => + `(function(C,R){${emitted.source}})([${emitted.bindings.map((binding) => binding.reference).join(",")}],R)` + +/** @internal */ +export function generate(ast: SchemaAST.AST, operation: DecoderOperation): string | undefined { + if (operation === "is" || operation === "validate") { + if (!shouldCompileParser(ast)) return undefined + const emission = getEmission(ast) + if (emission === "unsupported" || operation === "is" && emission !== "is") return undefined + return "return " + renderOperation(emitOperation(ast, operation)) + } + const object = ast._tag === "Objects" && ast.propertySignatures.length > 0 && + ast.indexSignatures.length === 0 && ast.propertySignatures.length <= maxGeneratedNodes + ? emitObject(ast) + : "undefined" + const array = ast._tag === "Arrays" && ast.elements.length === 0 && ast.rest.length === 1 + ? emitArray() + : "undefined" + if (operation === "makeEffect") return `return R.make(ast,resolve,${object},${array})` + const checkpoint = ast.encoding !== undefined && getEmission(ast, 0, true) !== "unsupported" + ? `()=>${renderOperation(emitOperation(ast, "validate"))}` + : "undefined" + return `return R.decode(ast,resolve,${object},${getEmission(ast) !== "unsupported"},${checkpoint},${array})` +} + +const emitArray = (): string => + `function({getElement,step,resume}){return function(s,input,index=0,end=input.length){ + const parser=getElement();let r,t,value; + for(;index { + const statements = [ + "if(i===R.missing)return R.missingExit", + "if(o.errors===\"all\"||o.onExcessProperty!==void 0||(o.concurrency!==void 0&&o.concurrency!==1))return fallback(i,o)", + "if(typeof i!==\"object\"||i===null||Array.isArray(i))return R.invalidType(ast,i,o)", + "const properties=getProperties(),out={}", + "const state={ast,input:i,out,options:o,issues:void 0}", + "let r,t,value" + ] + ast.propertySignatures.forEach((property, index) => { + const key = typeof property.name === "symbol" ? `properties[${index}].name` : JSON.stringify(String(property.name)) + const present = property.name === "__proto__" ? `Object.hasOwn(i,${key})` : `${key} in i` + statements.push( + `const p${index}=properties[${index}],h${index}=${present},v${index}=h${index}?i[${key}]:R.missing`, + `r=p${index}.parser(v${index},o)`, + `if(r===R.sameExit){if(h${index}){${assignProperty("out", key, `v${index}`, property.name)}}}else{` + + `if(!R.effectIsExit(r))return resume(state,${index},r);` + + `if(r._tag==="Success"&&(value=r[R.args])!==R.missing){${assignProperty("out", key, "value", property.name)}}` + + `else{t=step(state,p${index},r);if(t)return t}}` + ) + }) + statements.push("return R.succeed(out)") + return `function({ast,getProperties,fallback,resume,step}){return function(i,o){try{${ + statements.join(";") + }}catch(e){return R.die(e)}}}` +} diff --git a/packages/effect/src/internal/schema/compilerRegistry.ts b/packages/effect/src/internal/schema/compilerRegistry.ts new file mode 100644 index 00000000000..2a88da20ed7 --- /dev/null +++ b/packages/effect/src/internal/schema/compilerRegistry.ts @@ -0,0 +1,150 @@ +import * as Effect from "../../Effect.ts" +import type * as SchemaAST from "../../SchemaAST.ts" +import type { Parser } from "../../SchemaParser.ts" +import type { CompiledDecoder, Is, Validate } from "../../unstable/schema/SchemaCompiler.ts" +import * as Interpreter from "./interpreter.ts" +import * as InternalParser from "./parser.ts" + +/** @internal */ +export const invalid = Symbol() + +/** @internal */ +export type Resolve = (ast: SchemaAST.AST) => Entry + +/** @internal */ +export type Compile = (ast: SchemaAST.AST, resolve: Resolve) => CompiledDecoder | undefined + +const cache = new WeakMap() +let compiler: Compile | undefined + +const decodeChild = (ast: SchemaAST.AST): Parser => lazyParser(resolve, ast, "parser") +const makeChild = (ast: SchemaAST.AST): Parser => lazyParser(resolve, ast, "makeEffect") +const makeDefaultedChild = (ast: SchemaAST.AST): Parser => lazyParser(resolve, ast, "makeDefaulted") + +/** @internal */ +export class Entry { + readonly ast: SchemaAST.AST + readonly source: CompiledDecoder | undefined + readonly resolve: Resolve + + constructor( + ast: SchemaAST.AST, + source: CompiledDecoder | undefined, + resolve: Resolve + ) { + this.ast = ast + this.source = source + this.resolve = resolve + } + + private save(key: K, value: Entry[K]): Entry[K] { + Object.defineProperty(this, key, { value }) + return value + } + + get is(): Is | undefined { + return this.save("is", this.source?.is) + } + + get validate(): Validate | undefined { + return this.save("validate", this.source?.validate) + } + + get decodeEffect(): Parser { + return this.save( + "decodeEffect", + this.source?.decodeEffect ?? Interpreter.compile( + this.ast, + this.resolve === resolve ? decodeChild : (ast) => lazyParser(this.resolve, ast, "parser") + ) + ) + } + + get parser(): Parser { + const validate = this.validate + if (validate === undefined) return this.save("parser", this.decodeEffect) + return this.save("parser", withValidation(validate, () => this.decodeEffect)) + } + + get makeEffect(): Parser { + return this.save( + "makeEffect", + this.source?.makeEffect ?? Interpreter.compile( + this.ast, + this.resolve === resolve ? makeChild : (ast) => lazyParser(this.resolve, ast, "makeEffect"), + this.resolve === resolve ? makeDefaultedChild : (ast) => lazyParser(this.resolve, ast, "makeDefaulted") + ) + ) + } + + get makeDefaulted(): Parser { + const link = this.ast.context?.constructorDefault + return this.save( + "makeDefaulted", + link === undefined ? this.makeEffect : Interpreter.withDefault( + this.ast, + (input, options) => this.makeEffect(input, options), + this.resolve === resolve ? makeChild : (ast) => lazyParser(this.resolve, ast, "makeEffect") + ) + ) + } +} + +/** @internal */ +export function withValidation(validate: Validate, decode: () => Parser): Parser { + let detailed: Parser | undefined + return (input, options) => { + if (input !== InternalParser.missing) { + try { + const value = validate(input, options) + if (value !== invalid) return value === input ? InternalParser.sameExit : InternalParser.succeed(value) + } catch (error) { + return Effect.die(error) + } + } + return (detailed ??= decode())(input, options) + } +} + +/** @internal */ +export function lazyParser( + resolve: Resolve, + ast: SchemaAST.AST, + operation: "parser" | "decodeEffect" | "makeEffect" | "makeDefaulted" +): Parser { + const entry = resolve(ast) + if (entry.source === undefined || Object.hasOwn(entry, operation)) return entry[operation] + let parser: Parser | undefined + return (input, options) => (parser ??= entry[operation])(input, options) +} + +/** @internal */ +export function resolve(ast: SchemaAST.AST): Entry { + const cached = cache.get(ast) + if (cached !== undefined) return cached + return set(ast, compiler?.(ast, resolve), resolve) +} + +/** @internal */ +export function set(ast: SchemaAST.AST, decoder: CompiledDecoder | undefined, resolveChild: Resolve = resolve): Entry { + const entry = new Entry(ast, decoder, resolveChild) + cache.set(ast, entry) + return entry +} + +/** @internal */ +export function install(compile: Compile): void { + compiler = compile +} + +/** @internal */ +export function enable(ast: SchemaAST.AST, compile: Compile): void { + const scoped: Resolve = (child) => { + const cached = cache.get(child) + return cached !== undefined && (cached.source !== undefined || cached.resolve === scoped) + ? cached + : set(child, compile(child, scoped), scoped) + } + const decoder = compile(ast, scoped) + if (decoder !== undefined || cache.get(ast)?.source === undefined) set(ast, decoder, scoped) +} diff --git a/packages/effect/src/internal/schema/interpreter.ts b/packages/effect/src/internal/schema/interpreter.ts new file mode 100644 index 00000000000..d26458e05e8 --- /dev/null +++ b/packages/effect/src/internal/schema/interpreter.ts @@ -0,0 +1,217 @@ +import * as Cause from "../../Cause.ts" +import * as Effect from "../../Effect.ts" +import type * as Exit from "../../Exit.ts" +import type * as Option from "../../Option.ts" +import * as SchemaAST from "../../SchemaAST.ts" +import * as SchemaIssue from "../../SchemaIssue.ts" +import type { Compiler, Parser } from "../../SchemaParser.ts" +import { effectIsExit } from "../effect.ts" +import * as InternalParser from "./parser.ts" + +function applyTransformation( + result: Effect.Effect, + current: unknown, + transformation: SchemaAST.Link["transformation"], + options: SchemaAST.ParseOptions +): Effect.Effect { + let transformed: Effect.Effect, SchemaIssue.Issue, unknown> + if (effectIsExit(result) && result._tag === "Success") { + const optional = InternalParser.toOption( + result === InternalParser.sameExit + ? current + : (result as InternalParser.Success)[InternalParser.args] + ) + transformed = transformation._tag === "Transformation" + ? transformation.decode.run(optional, options) + : transformation.decode(InternalParser.succeed(optional), options) + } else if (transformation._tag === "Transformation") { + transformed = Effect.flatMapEager( + result, + (value) => transformation.decode.run(InternalParser.toOption(value), options) + ) + } else { + transformed = transformation.decode( + Effect.mapEager(result, InternalParser.toOption), + options + ) + } + return effectIsExit(transformed) && transformed._tag === "Success" + ? InternalParser.fromOptionExit( + (transformed as InternalParser.Success, SchemaIssue.Issue>)[InternalParser.args] + ) + : Effect.flatMapEager(transformed, InternalParser.fromOptionExit) +} + +function makeConstructorParser(descriptor: SchemaAST.ConstructorDescriptor, compile: Compiler): Parser { + let sourceParser: Parser + return (input, options) => { + if (input === InternalParser.missing) return InternalParser.missingExit + if (descriptor.isConstructed(input)) return InternalParser.sameExit + const result = (sourceParser ??= compile(descriptor.link.to))(input, options) + return applyTransformation(result, input, descriptor.link.transformation, options) + } +} + +/** @internal */ +export function withDefault(ast: SchemaAST.AST, parser: Parser, resolve: Compiler): Parser { + const link = ast.context!.constructorDefault! + let source: Parser | undefined + return (input, options) => { + const result = applyTransformation( + (source ??= resolve(link.to))(input, options), + input, + link.transformation, + options + ) + if (effectIsExit(result) && result._tag === "Success") { + const local = parser((result as InternalParser.Success)[InternalParser.args], options) + return local === InternalParser.sameExit ? result : local + } + return Effect.flatMapEager( + Effect.catchCause(result, (cause) => + Effect.failCause( + Cause.map(cause, (issue) => new SchemaIssue.Encoding(ast, issue, input, options)) + )), + (value) => { + const local = parser(value, options) + return local === InternalParser.sameExit ? InternalParser.succeed(value) : local + } + ) + } +} + +/** @internal */ +export function compile( + ast: SchemaAST.AST, + compile: Compiler, + compileConstructorDefault?: Compiler, + base?: Parser, + specialize?: (local: Parser) => Parser +): Parser { + if (ast._tag === "Declaration") { + // Declaration callbacks can create public parsers for their type parameters. + // Register those ASTs with the same resolver before invoking the callback. + for (const parameter of ast.typeParameters) compile(parameter) + } + const descriptor = compileConstructorDefault ? SchemaAST.getConstructorDescriptor(ast) : undefined + const parser = descriptor + ? makeConstructorParser(descriptor, compile) + : base ?? ast.getParser(compile, compileConstructorDefault) + const checks = ast.checks + const links = ast.encoding + const encodingChecks = (ast as any).encodingChecks + if (!links && !checks && !encodingChecks) { + return parser + } + let encodingParsers: ReadonlyArray | undefined + const parseChecks = ( + input: unknown, + options: SchemaAST.ParseOptions + ) => { + let result = parser(input, options) + if (encodingChecks && !options.disableChecks) { + if (effectIsExit(result)) { + if (result._tag === "Success") { + const output = result === InternalParser.sameExit + ? input + : (result as InternalParser.Success)[InternalParser.args] + if (input !== InternalParser.missing && output !== InternalParser.missing) { + const issues = SchemaAST.collectIssues(encodingChecks, input, undefined, ast, options) + if (issues) { + result = Effect.fail(new SchemaIssue.Composite(ast, issues, input, options)) + } + } + } + } else { + result = Effect.flatMap(result, (value) => { + if (input !== InternalParser.missing && value !== InternalParser.missing) { + const issues = SchemaAST.collectIssues(encodingChecks, input, undefined, ast, options) + if (issues) { + return Effect.fail(new SchemaIssue.Composite(ast, issues, input, options)) + } + } + return Effect.succeed(value) + }) + } + } + + if (checks && !options.disableChecks) { + if (effectIsExit(result)) { + if (result._tag === "Success") { + const value = result === InternalParser.sameExit + ? input + : (result as InternalParser.Success)[InternalParser.args] + if (value === InternalParser.missing) return result + const issues = SchemaAST.collectIssues(checks, value, undefined, ast, options) + if (issues) { + result = Effect.fail(new SchemaIssue.Composite(ast, issues, value, options)) + } + } + } else { + result = Effect.flatMap(result, (value) => { + if (value !== InternalParser.missing) { + const issues = SchemaAST.collectIssues(checks, value, undefined, ast, options) + if (issues) { + return Effect.fail(new SchemaIssue.Composite(ast, issues, value, options)) + } + } + return Effect.succeed(value) + }) + } + } + + return result + } + const parseLocal = specialize === undefined ? parseChecks : specialize(parseChecks) + if (!links) { + return parseLocal + } + return ( + input: unknown, + options: SchemaAST.ParseOptions + ) => { + const parsers = encodingParsers ??= links.map((link) => compile(link.to)) + let current = input + let result = parsers[parsers.length - 1](input, options) + for (let i = links.length - 1; i >= 0; i--) { + result = applyTransformation(result, current, links[i].transformation, options) + if (i !== 0) { + const next = parsers[i - 1] + if ((result as Exit.Exit)._tag === "Success") { + current = (result as InternalParser.Success)[InternalParser.args] + result = next(current, options) + } else { + result = Effect.flatMapEager(result, (value) => { + const nextResult = next(value, options) + return nextResult === InternalParser.sameExit ? InternalParser.succeed(value) : nextResult + }) + } + } + } + if ((result as Exit.Exit)._tag === "Success") { + const value = (result as InternalParser.Success)[InternalParser.args] + const local = parseLocal(value, options) + return local === InternalParser.sameExit ? result : local + } + result = Effect.catchCause( + result, + (cause) => + Effect.failCauseSync(() => + Cause.map( + cause, + (issue) => + new SchemaIssue.Encoding( + ast, + issue, + input, + options + ) + ) + ) + ) + return Effect.flatMapEager(result, (value) => { + const local = parseLocal(value, options) + return local === InternalParser.sameExit ? InternalParser.succeed(value) : local + }) + } +} diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts new file mode 100644 index 00000000000..cb45469ccd9 --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts @@ -0,0 +1,138 @@ +/** + * Generates static JavaScript modules that install Schema decoders in the shared + * registry. Generated modules use the same runtime support as the JIT compiler, + * without importing source generation or constructing functions dynamically. + * + * @since 4.0.0 + */ +import * as Codegen from "../../internal/schema/codegen.ts" +import * as SchemaAST from "../../SchemaAST.ts" +import type { runtime } from "./SchemaCompiler/runtime.ts" + +const helper = (name: keyof typeof runtime): string => `R.${name}` + +const decoder = (ast: SchemaAST.AST): string | undefined => { + if (!Codegen.shouldCompileParser(ast)) return undefined + const operations: ReadonlyArray = ["is", "validate", "decodeEffect", "makeEffect"] + return "{" + operations.flatMap((key) => { + const source = Codegen.generate(ast, key) + return source === undefined ? [] : [`get ${key}(){${source}}`] + }).join(",") + "}" +} + +/** + * Generates a JavaScript ES module exporting `install(asts): void` for an + * ordered array of ASTs and their statically reachable parsing and construction dependencies. + * + * **When to use** + * + * Use to prepare decoders at build time for environments that disallow dynamic + * function construction. Save the returned source as a JavaScript module, then + * call its `install` export with the corresponding runtime ASTs in the same + * order before using parsers. Use a one-element array for a single schema. + * + * **Details** + * + * Generation does not install decoders or execute checks and transformations. + * Installation uses the same registry as `SchemaCompiler.set`; normal + * `SchemaParser` functions consume those entries. Generated validators and + * Struct and homogeneous Array loops are static functions. They share diagnostic and + * asynchronous continuation helpers with the interpreter. Other detailed + * traversals and transformation orchestration use the interpreter with + * registry-resolved children. Transformations and middleware are not replayed. + * Repeated ASTs and shared dependencies are installed once by identity. Fast + * paths can still inline dependency code into multiple parent decoders. + * An empty array generates a module whose installation does nothing. + * Construction uses independently lazy `makeEffect` operations. Struct and + * homogeneous Array loops are emitted as static functions; tuple, Record, Union, leaf, and Class + * constructors use the existing interpreter. Constructor defaults + * and Class source schemas are read from the supplied ASTs, not serialized or + * executed during generation. Construction never runs a validation-and-replay pass. + * + * **Gotchas** + * + * Regenerate the module whenever the schema definition or Effect version + * changes. Installation trusts that the runtime array has the same length and + * root order, and its ASTs have the same definitions and sharing as at build + * time. Functions and symbols are read from those ASTs, not serialized. + * Suspend thunks are not evaluated during generation; their contents and other + * unsupported nodes use the interpreter. + * Installation replaces generated entries, but parsers that already captured + * older entries keep them. Type-side and flipped ASTs are separate registry + * keys; generate and install them separately when needed. Importing the + * generated module alone does not install anything. + * In particular, include `SchemaAST.toType(schema.ast)` to prepare construction + * when it differs from the encoded root. Unsupported constructors use the + * interpreter while statically installed children remain available. + * + * @category compilation + * @since 4.0.0 + */ +export const compile = (asts: ReadonlyArray): string => { + const seen = new Map() + const bindings: Array = [] + const factories: Array = [] + const installations: Array = [] + + const visit = (node: SchemaAST.AST, reference: string): void => { + if (seen.has(node)) return + const index = seen.size + const name = `a${index}` + seen.set(node, name) + bindings.push(`const ${name}=${reference};`) + + switch (node._tag) { + case "Declaration": + node.typeParameters.forEach((child, index) => visit(child, `${name}.typeParameters[${index}]`)) + break + case "TemplateLiteral": + node.parts.forEach((child, index) => visit(child, `${name}.parts[${index}]`)) + break + case "Arrays": + node.elements.forEach((child, index) => visit(child, `${name}.elements[${index}]`)) + node.rest.forEach((child, index) => visit(child, `${name}.rest[${index}]`)) + break + case "Objects": + node.propertySignatures.forEach((property, index) => + visit(property.type, `${name}.propertySignatures[${index}].type`) + ) + node.indexSignatures.forEach((signature, index) => { + visit( + SchemaAST.parameterFromPropertyKey(signature.parameter), + `${helper("parameterFromPropertyKey")}(${name}.indexSignatures[${index}].parameter)` + ) + visit(signature.type, `${name}.indexSignatures[${index}].type`) + }) + break + case "Union": + node.types.forEach((child, index) => visit(child, `${name}.types[${index}]`)) + break + } + node.encoding?.forEach((link, index) => visit(link.to, `${name}.encoding[${index}].to`)) + if (node.context?.constructorDefault !== undefined) { + visit(node.context.constructorDefault.to, `${name}.context.constructorDefault.to`) + } + const descriptor = SchemaAST.getConstructorDescriptor(node) + if (descriptor !== undefined) { + visit(descriptor.link.to, `${helper("getConstructorDescriptor")}(${name}).link.to`) + } + + const source = decoder(node) + if (source !== undefined) { + factories.push(`function d${index}(ast,R,resolve){return ${source}}`) + installations.push(`${helper("set")}(${name},d${index}(${name},R,R.resolve));`) + } + } + asts.forEach((ast, index) => visit(ast, `asts[${index}]`)) + return [ + "// Generated by SchemaAOTCompiler. Regenerate after schema or Effect changes.", + "import { runtime as R } from \"effect/unstable/schema/SchemaCompiler/runtime\";", + ...factories, + "/** @param {ReadonlyArray} asts */", + "export function install(asts){", + ...bindings, + ...installations, + "}", + "" + ].join("\n") +} diff --git a/packages/effect/src/unstable/schema/SchemaCompiler.ts b/packages/effect/src/unstable/schema/SchemaCompiler.ts new file mode 100644 index 00000000000..afb7363b01d --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaCompiler.ts @@ -0,0 +1,171 @@ +/** + * Provides the shared registry used by Schema decoder implementations. A + * decoder installed with {@link set} is consumed transparently by the normal + * `SchemaParser` APIs, allowing runtime and ahead-of-time compilers to use the + * same cache without introducing a compiled Schema type or a second parser API. + * + * The cache associates each exact AST with an entry containing decoder + * operations, never parsing results. The interpreter uses the same registry + * with lazy `decodeEffect` and constructor fallback; JIT, AOT, and manual + * installations may supply `makeEffect` and the optional validation fast paths. + * + * @since 4.0.0 + */ +import type * as Effect from "../../Effect.ts" +import * as CompilerRegistry from "../../internal/schema/compilerRegistry.ts" +import * as InternalParser from "../../internal/schema/parser.ts" +import type * as SchemaAST from "../../SchemaAST.ts" +import type * as SchemaIssue from "../../SchemaIssue.ts" + +/** + * The result returned by {@link Validate} when validation fails. + * + * @category symbols + * @since 4.0.0 + */ +export const invalid = CompilerRegistry.invalid + +/** + * The sentinel distinguishing an absent input from a present `undefined`. + * Decoders and constructors propagate it as a successful result when no value + * is produced. Parents omit optional fields or report missing required keys; + * public root adapters reject it rather than returning it to callers. + * + * @category symbols + * @since 4.0.0 + */ +export const missing = InternalParser.missing + +/** + * A compiled boolean validator. + * + * **Details** + * + * This optional fast path avoids constructing output. Omit it when validation + * requires reconstructed values, such as a Struct check that must see the + * object after excess properties are removed. Type guards then use ordinary + * decoding, including `validate` and its diagnostic fallback when available. + * It must honor the supplied parse options; public `Schema.is` and + * `SchemaParser.is` use the defaults. + * + * @category models + * @since 4.0.0 + */ +export interface Is { + (input: unknown, options: SchemaAST.ParseOptions): boolean +} + +/** + * A compiled validator that returns the decoded value without constructing + * diagnostic issues. + * + * **Details** + * + * This optional synchronous fast path lets valid inputs return their output + * without the detailed decoding pass. For decoding, the registry follows + * {@link invalid} with `decodeEffect` because the sentinel provides no error details + * and can also occur as a valid input value. Type guards without an `is` operation + * use this same fallback. Omit this operation + * when the fast path is unsupported or replay would be unsafe, including ASTs + * containing transformations or middleware. + * + * It must honor every supported `ParseOptions` value. Return {@link invalid} + * for invalid input, never for an unsupported optimization. The detailed decoder + * must also accept valid data that happens to equal this marker. Do not call + * the detailed decoder and discard its failure: decoding would run `decodeEffect` + * again after `invalid`. User checks may themselves construct issues. + * + * @category models + * @since 4.0.0 + */ +export interface Validate { + (input: unknown, options: SchemaAST.ParseOptions): unknown | typeof invalid +} + +/** + * A compiled decoder that returns detailed Schema issues on failure. + * + * **Details** + * + * This required operation implements complete decoding for its AST, including + * transformations, middleware, and asynchronous work when present. It makes + * every parser API usable without optional fast paths and provides diagnostics + * after `validate` returns `invalid`. The implementation can also be interpreted; + * invoking `decodeEffect` does not imply a switch from compiled to interpreted parsing. + * + * @category models + * @since 4.0.0 + */ +export interface Decode { + (input: unknown, options: SchemaAST.ParseOptions): Effect.Effect +} + +/** + * The operations installed for an AST in the shared Schema parser registry. + * + * **Details** + * + * `decodeEffect` is required for complete decoding and detailed failures. `validate` + * and `is` are optional optimizations, not requirements for an AST to be usable. + * The interpreter supplies only `decodeEffect` in this same format. + * An optional `makeEffect` supplies complete node construction. Otherwise the + * registry prepares and caches the interpreted constructor, never the decoder, + * for that operation. Public makers resolve the schema's exact type-side AST. + * + * The registry wraps these operations in an internal entry. + * Decoding tries `validate` when present, returning its output on success or + * calling `decodeEffect` after `invalid`. Without `validate`, or for the {@link missing} + * sentinel, it calls `decodeEffect` directly. Type guards prefer `is`; otherwise + * they use ordinary decoding with the same validation/diagnostic fallback. + * A boolean `false` from `is` needs no diagnostic replay. + * Synchronous decoding and encoding share an adapter that returns successful + * `validate` output directly, without wrapping it in an intermediate Effect. + * Each operation is resolved lazily on first use, so unused fast paths need + * not be compiled. + * Construction calls `makeEffect` directly, without `is` or `validate`, so defaults + * and Class constructors are not replayed after a failure. Field/element defaults + * belong to the parent occurrence, not to the root node or a Union member. + * Runtime options apply to construction too; Union candidate selection preserves + * the constructor's conservative handling of absent discriminants. + * + * @category models + * @since 4.0.0 + */ +export interface CompiledDecoder { + readonly is?: Is | undefined + readonly validate?: Validate | undefined + readonly decodeEffect: Decode + /** + * Constructs this node without replay, including Class construction and child + * defaults. Omit it to use the lazy interpreted constructor. This operation + * initializes independently from decoding and never applies its own root default. + * Propagate `missing` as a success when no value is produced; the parent handles + * optional omission or missing-key issues. A present `undefined` is not `missing`. + */ + readonly makeEffect?: Decode | undefined +} + +/** + * Installs a compiled decoder for an exact AST in the shared Schema parser + * registry. + * + * **Details** + * + * A later call for the same AST replaces the previous entry. Parser functions + * that have already resolved and retained an earlier entry are not updated. + * This also applies when subsequent calls use different parse options. + * The decoder is trusted to implement the semantics of the supplied AST. + * Installation does not evaluate operation getters. Each operation, including + * an absent optional operation, is resolved once when first needed. Accessors + * retain the supplied decoder as their receiver. The supplied object is not + * mutated. JIT installation uses these same rules. + * Replacement includes construction: omitting `makeEffect` in the replacement + * selects interpreted construction for new consumers, without merging the old + * operation into the new entry. Already captured constructors keep their entry. + * + * @category registry + * @since 4.0.0 + */ +export const set = (ast: SchemaAST.AST, decoder: CompiledDecoder): void => { + CompilerRegistry.set(ast, decoder) +} diff --git a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts new file mode 100644 index 00000000000..61373348fa3 --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts @@ -0,0 +1,119 @@ +/** + * Runtime helpers used by generated schema modules. This module contains no + * source generator or dynamic function construction. Generated modules must + * use the same Effect version as their generator. + * + * @since 4.0.0 + */ +import * as Effect from "../../../Effect.ts" +import { effectIsExit } from "../../../internal/effect.ts" +import { lazyParser, type Resolve, resolve, set, withValidation } from "../../../internal/schema/compilerRegistry.ts" +import * as Interpreter from "../../../internal/schema/interpreter.ts" +import * as InternalParser from "../../../internal/schema/parser.ts" +import * as SchemaAST from "../../../SchemaAST.ts" +import * as SchemaIssue from "../../../SchemaIssue.ts" +import { invalid, type Validate } from "../SchemaCompiler.ts" + +type GenerateObject = (context: SchemaAST.ObjectParserContext) => SchemaIssueParser +type GenerateArray = NonNullable[2]> +type SchemaIssueParser = ReturnType + +const decode = ( + ast: SchemaAST.AST, + resolve: Resolve, + generate?: GenerateObject, + detailed = false, + makeValidate?: () => Validate, + generateArray?: GenerateArray +): SchemaIssueParser => { + const child = (ast: SchemaAST.AST) => lazyParser(resolve, ast, detailed ? "decodeEffect" : "parser") + const localChild = makeValidate === undefined + ? child + : (ast: SchemaAST.AST) => lazyParser(resolve, ast, "decodeEffect") + const base = ast._tag === "Objects" && generate !== undefined ? + ast.getParser(localChild, undefined, generate) + : ast._tag === "Arrays" && generateArray !== undefined + ? ast.getParser(localChild, undefined, generateArray) + : makeValidate !== undefined + ? ast.getParser(localChild) + : undefined + const specialize = makeValidate === undefined ? undefined : (local: SchemaIssueParser): SchemaIssueParser => { + try { + return withValidation(makeValidate(), () => local) + } catch { + // Initialization failure selects the local interpreter, without parsing again. + return local + } + } + return Interpreter.compile(ast, child, undefined, base, specialize) +} + +const make = ( + ast: SchemaAST.AST, + resolve: Resolve, + generate?: GenerateObject, + generateArray?: GenerateArray +): SchemaIssueParser => { + const child = (ast: SchemaAST.AST) => lazyParser(resolve, ast, "makeEffect") + const field = (ast: SchemaAST.AST) => lazyParser(resolve, ast, "makeDefaulted") + const base = generate !== undefined && ast._tag === "Objects" ? + ast.getParser(child, field, generate) + : generateArray !== undefined && ast._tag === "Arrays" + ? ast.getParser(child, field, generateArray) + : undefined + return Interpreter.compile(ast, child, field, base) +} + +const failsChecks = ( + ast: SchemaAST.AST, + value: unknown, + encoded: boolean, + options: SchemaAST.ParseOptions +): boolean => { + const checks = encoded ? "encodingChecks" in ast ? ast.encodingChecks : undefined : ast.checks + return !options.disableChecks && checks !== undefined && + SchemaAST.collectIssues(checks, value, undefined, ast, options) !== undefined +} + +const hasExcessProperties = ( + ast: SchemaAST.Objects, + input: Record, + options: SchemaAST.ParseOptions +): boolean => { + const covered = new Set( + ast.propertySignatures.map((p) => typeof p.name === "number" ? String(p.name) : p.name) + ) + for (const index of ast.indexSignatures) { + for (const key of SchemaAST.getIndexSignatureKeys(input, index.parameter, options)) covered.add(key) + } + return Reflect.ownKeys(input).some((key) => !covered.has(key)) +} + +/** @internal */ +export const runtime = { + decode, + make, + resolve, + set, + invalid, + missing: InternalParser.missing, + missingExit: InternalParser.missingExit, + sameExit: InternalParser.sameExit, + args: InternalParser.args, + succeed: InternalParser.succeed, + effectIsExit, + die: Effect.die, + invalidType: (ast: SchemaAST.AST, input: unknown, options: SchemaAST.ParseOptions) => + Effect.fail(new SchemaIssue.InvalidType(ast, input, options)), + failsChecks, + getExpectedKeys: (ast: SchemaAST.Objects) => + ast.propertySignatures.map((p) => typeof p.name === "number" ? String(p.name) : p.name), + hasExcessProperties, + matchesTemplateLiteral: (ast: SchemaAST.TemplateLiteral, input: unknown, options: SchemaAST.ParseOptions) => + typeof input === "string" && ast.matchPart(input, options) !== undefined, + getCandidates: SchemaAST.getCandidates, + getIndexSignatureKeys: SchemaAST.getIndexSignatureKeys, + parameterFromPropertyKey: SchemaAST.parameterFromPropertyKey, + getConstructorDescriptor: SchemaAST.getConstructorDescriptor, + defaultParseOptions: SchemaAST.defaultParseOptions +} diff --git a/packages/effect/src/unstable/schema/SchemaJITCompiler.ts b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts new file mode 100644 index 00000000000..beb96330a4c --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts @@ -0,0 +1,87 @@ +/** + * Installs runtime-generated schema decoders without changing SchemaParser's + * public interface. Operations are compiled lazily. Import the separate + * `SchemaJITCompiler/enable` module to enable compilation globally. + * + * @since 4.0.0 + */ +import { type DecoderOperation, generate, shouldCompileParser } from "../../internal/schema/codegen.ts" +import * as Registry from "../../internal/schema/compilerRegistry.ts" +import type * as SchemaAST from "../../SchemaAST.ts" +import type { CompiledDecoder } from "./SchemaCompiler.ts" +import { runtime } from "./SchemaCompiler/runtime.ts" + +let checked: FunctionConstructor | undefined +let supported = false + +/** @internal */ +export const compiler: Registry.Compile = (ast, resolve) => { + if (!shouldCompileParser(ast)) return undefined + if (checked !== globalThis.Function) { + checked = globalThis.Function + try { + checked("return true") + supported = true + } catch { + supported = false + } + } + if (!supported) return undefined + let decodeFailed = false + let makeFailed = false + const operation = (key: DecoderOperation) => { + if (!(key === "makeEffect" ? makeFailed : decodeFailed)) { + try { + const source = generate(ast, key) + if (source === undefined) return undefined + return globalThis.Function("ast", "R", "resolve", source)(ast, runtime, resolve) + } catch { + // Only code generation and initialization are inside this catch. + if (key === "makeEffect") makeFailed = true + else decodeFailed = true + } + } + return key === "decodeEffect" + ? runtime.decode(ast, resolve) + : key === "makeEffect" + ? runtime.make(ast, resolve) + : undefined + } + return { + get is() { + return operation("is") + }, + get validate() { + return operation("validate") + }, + get decodeEffect() { + return operation("decodeEffect") + }, + get makeEffect() { + return operation("makeEffect") + } + } satisfies CompiledDecoder +} + +/** + * Enables lazy JIT compilation for an AST and its parsing dependencies. + * + * **Details** + * + * Uses the same registry as `SchemaCompiler.set`. Compilation failures, including + * environments that block `new Function`, retain interpreted parsing. Errors + * thrown while parsing are not retried. No checks, transformations or defaults + * execute during installation. + * + * **Gotchas** + * + * Enable before first use to optimize every consumer. Functions that already + * captured an entry keep it. Enable type-side and flipped ASTs separately when + * they differ from the supplied AST. + * + * @category compilation + * @since 4.0.0 + */ +export function enable(ast: SchemaAST.AST): void { + Registry.enable(ast, compiler) +} diff --git a/packages/effect/src/unstable/schema/SchemaJITCompiler/enable.ts b/packages/effect/src/unstable/schema/SchemaJITCompiler/enable.ts new file mode 100644 index 00000000000..6ab43497fb1 --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaJITCompiler/enable.ts @@ -0,0 +1,12 @@ +/** + * Enables lazy JIT compilation globally through a side-effect import. + * Import before the first use of schemas, including construction. Previously + * captured parsers remain usable but are not replaced. If dynamic function + * construction is blocked or compilation fails, parsing uses the interpreter. + * + * @since 4.0.0 + */ +import { install } from "../../../internal/schema/compilerRegistry.ts" +import { compiler } from "../SchemaJITCompiler.ts" + +install(compiler) diff --git a/packages/effect/src/unstable/schema/index.ts b/packages/effect/src/unstable/schema/index.ts index af8486da163..6639e3f7d33 100644 --- a/packages/effect/src/unstable/schema/index.ts +++ b/packages/effect/src/unstable/schema/index.ts @@ -9,6 +9,21 @@ */ export * as Model from "./Model.ts" +/** + * @since 4.0.0 + */ +export * as SchemaAOTCompiler from "./SchemaAOTCompiler.ts" + +/** + * @since 4.0.0 + */ +export * as SchemaCompiler from "./SchemaCompiler.ts" + +/** + * @since 4.0.0 + */ +export * as SchemaJITCompiler from "./SchemaJITCompiler.ts" + /** * @since 4.0.0 */ diff --git a/packages/effect/test/schema/SchemaAOTCompiler.test.ts b/packages/effect/test/schema/SchemaAOTCompiler.test.ts new file mode 100644 index 00000000000..bcb46da7a1e --- /dev/null +++ b/packages/effect/test/schema/SchemaAOTCompiler.test.ts @@ -0,0 +1,65 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema } from "effect" +import * as CompilerRegistry from "effect/internal/schema/compilerRegistry" +import * as SchemaAOTCompiler from "effect/unstable/schema/SchemaAOTCompiler" +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { roots, schemas, suspendEvaluations } from "./fixtures/aot.ts" + +describe("SchemaAOTCompiler", { concurrent: false }, () => { + it("emits deterministic modules without installing a decoder", () => { + let checks = 0 + const schema = Schema.Struct({ + value: Schema.String.check(Schema.makeFilter(() => { + checks++ + return true + })) + }) + const before = CompilerRegistry.resolve(schema.ast) + const source = SchemaAOTCompiler.compile([schema.ast]) + assert.strictEqual(SchemaAOTCompiler.compile([schema.ast]), source) + assert.strictEqual(CompilerRegistry.resolve(schema.ast), before) + assert.strictEqual(checks, 0) + assert.include(source, "effect/unstable/schema/SchemaCompiler/runtime") + assert.notInclude(source, "new Function") + assert.notInclude(source, "SchemaJITCompiler") + }) + + it("deduplicates repeated roots and dependencies shared across roots", () => { + const child = Schema.Struct({ value: Schema.String }) + const first = Schema.Struct({ child }) + const second = Schema.Array(child) + const source = SchemaAOTCompiler.compile([first.ast, second.ast]) + assert.strictEqual( + SchemaAOTCompiler.compile([first.ast, second.ast, child.ast, first.ast, second.ast]), + source + ) + }) + + it("runs generated decoders without dynamic code generation", () => { + const directory = mkdtempSync(fileURLToPath(new URL("../../.schema-aot-test-", import.meta.url))) + try { + for (const [name, schema] of Object.entries(schemas)) { + writeFileSync(join(directory, `${name}.mjs`), SchemaAOTCompiler.compile([schema.ast])) + } + writeFileSync(join(directory, "all.mjs"), SchemaAOTCompiler.compile(roots)) + writeFileSync(join(directory, "empty.mjs"), SchemaAOTCompiler.compile([])) + assert.strictEqual(suspendEvaluations, 0) + for (const mode of ["single", "multiple"]) { + const output = execFileSync(process.execPath, [ + "--disallow-code-generation-from-strings", + "--import", + fileURLToPath(new URL("./fixtures/aot-import-guard.ts", import.meta.url)), + fileURLToPath(new URL("./fixtures/aot-runner.ts", import.meta.url)), + directory, + mode + ], { encoding: "utf8" }) + assert.include(output, "AOT integration passed") + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }, 20_000) +}) diff --git a/packages/effect/test/schema/SchemaCompilerApi.test.ts b/packages/effect/test/schema/SchemaCompilerApi.test.ts new file mode 100644 index 00000000000..cf10230a99d --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerApi.test.ts @@ -0,0 +1,314 @@ +import { assert, describe, it, vi } from "@effect/vitest" +import { Effect, Schema, SchemaAST, SchemaParser } from "effect" +import { SchemaCompiler, SchemaJITCompiler } from "effect/unstable/schema" +import { deepStrictEqual, strictEqual, throws } from "../utils/assert.ts" + +describe("SchemaCompiler", () => { + it("reuses interpreted candidates inside a selectively compiled Union", () => { + const child = Schema.String.annotate({ title: "interpreted Union candidate" }) + const schema = Schema.Union([child, Schema.Number]) + const initialize = vi.spyOn(child.ast, "getParser") + try { + SchemaJITCompiler.enable(schema.ast) + const make = SchemaParser.make(schema) + strictEqual(make("first"), "first") + strictEqual(make("second"), "second") + strictEqual(initialize.mock.calls.length, 1) + } finally { + initialize.mockRestore() + } + }) + + it("installs a decoder in the shared registry", () => { + const schema = Schema.Struct({ value: Schema.String }) + const early = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(early({ value: "interpreted" }), { value: "interpreted" }) + + let decodes = 0 + SchemaCompiler.set(schema.ast, { + is: () => true, + validate: (_input, options) => + options.reportInput === true + ? { value: "compiled" } + : SchemaCompiler.invalid, + decodeEffect: () => { + decodes++ + return Effect.succeed({ value: "detailed" }) + } + }) + + const late = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(late({ value: 1 }, { reportInput: true }), { value: "compiled" }) + deepStrictEqual(late({ value: 1 }), { value: "detailed" }) + strictEqual(decodes, 1) + + // Parsers that resolved the old entry before set keep using it. + deepStrictEqual(early({ value: "interpreted" }), { value: "interpreted" }) + }) + + it("uses is only for type guards", () => { + const schema = Schema.Struct({ value: Schema.String }) + let validations = 0 + SchemaCompiler.set(schema.ast, { + is: (input, options) => { + strictEqual(options, SchemaAST.defaultParseOptions) + return (input as { readonly value?: unknown }).value === "accepted" + }, + validate: (input) => { + validations++ + return input + }, + decodeEffect: Effect.succeed + }) + + strictEqual(SchemaParser.is(schema)({ value: "accepted" }), true) + strictEqual(SchemaParser.is(schema)({ value: "rejected" }), false) + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ value: "decoded" }), { value: "decoded" }) + strictEqual(validations, 1) + }) + + it("retains one resolved entry across sync option paths", () => { + for (const direction of ["decode", "encode"]) { + for (const firstOptions of [undefined, { reportInput: true }]) { + const schema = Schema.Struct({ value: Schema.String }) + const makeSync = () => + direction === "decode" + ? SchemaParser.decodeUnknownSync(schema) + : SchemaParser.encodeUnknownSync(schema) + const decode = makeSync() + const input = { value: "original" } + deepStrictEqual(decode(input, firstOptions), input) + + SchemaCompiler.set(schema.ast, { + validate: () => ({ value: "replacement" }), + decodeEffect: () => Effect.succeed({ value: "replacement" }) + }) + + deepStrictEqual(decode(input), input) + deepStrictEqual(decode(input, { reportInput: true }), input) + deepStrictEqual(makeSync()(input), { value: "replacement" }) + } + } + }) + + it("does not resolve child operations until the child is parsed", () => { + const child = Schema.String.annotate({ title: "lazy child" }) + let reads = 0 + SchemaCompiler.set(child.ast, { + get validate() { + reads++ + return undefined + }, + get decodeEffect() { + reads++ + return Effect.succeed + } + }) + const schema = Schema.Struct({ first: Schema.Number, child }) + const decode = SchemaParser.decodeUnknownSync(schema) + throws(() => decode({ first: "invalid", child: "unreached" })) + strictEqual(reads, 0) + deepStrictEqual(decode({ first: 1, child: "reached" }), { first: 1, child: "reached" }) + strictEqual(reads, 2) + deepStrictEqual(decode({ first: 2, child: "cached" }), { first: 2, child: "cached" }) + strictEqual(reads, 2) + }) + + it("uses an installed child decoder from an interpreted Array", () => { + const child = Schema.String.annotate({ title: "installed array child" }) + SchemaCompiler.set(child.ast, { + validate: (input) => typeof input === "string" ? `${input}!` : SchemaCompiler.invalid, + decodeEffect: (input) => Effect.succeed(`${input}!`) + }) + + deepStrictEqual( + SchemaParser.decodeUnknownSync(Schema.Array(child))(["a"]), + ["a!"] + ) + }) + + it("exposes the canonical missing value to installed decoders", () => { + const schema = Schema.Struct({ value: Schema.optionalKey(Schema.String) }) + assert(schema.ast._tag === "Objects") + const value = schema.ast.propertySignatures[0].type + let sawMissing = false + SchemaCompiler.set(value, { + validate: (input) => typeof input === "string" ? input : SchemaCompiler.invalid, + decodeEffect: (input) => { + sawMissing = input === SchemaCompiler.missing + return Effect.succeed(input) + } + }) + + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({}), {}) + strictEqual(sawMissing, true) + }) + + it("installs encoders on the flipped AST", () => { + const schema = Schema.FiniteFromString + SchemaCompiler.set(SchemaAST.flip(schema.ast), { + validate: () => "aot", + decodeEffect: () => Effect.succeed("detailed") + }) + + strictEqual(SchemaParser.encodeUnknownSync(schema)(1), "aot") + }) +}) + +describe("SchemaJITCompiler", () => { + it("reuses option-independent generated functions for explicit options", () => { + const schema = Schema.Struct({ nested: Schema.Struct({ value: Schema.String }) }) + const input = { nested: { value: "valid" } } + const Function = globalThis.Function + let constructions = 0 + try { + globalThis.Function = ((...args: ReadonlyArray) => { + constructions++ + return Function(...args) + }) as FunctionConstructor + + SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode(input), input) + strictEqual(SchemaParser.is(schema)(input), true) + const initialized = constructions + assert(initialized > 1) + for (const options of [{}, { reportInput: true }, { errors: "all" }, { disableChecks: true }] as const) { + deepStrictEqual(decode(input, options), input) + strictEqual(SchemaParser.is(schema)(input), true) + strictEqual(constructions, initialized) + } + } finally { + globalThis.Function = Function + } + }) + + it("keeps generated operations lazy", () => { + const schema = Schema.Struct({ value: Schema.String }) + const Function = globalThis.Function + let constructions = 0 + try { + globalThis.Function = ((...args: ReadonlyArray) => { + constructions++ + return Function(...args) + }) as FunctionConstructor + + SchemaJITCompiler.enable(schema.ast) + strictEqual(constructions, 1) + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ value: "valid" }), { value: "valid" }) + assert(constructions > 1) + } finally { + globalThis.Function = Function + } + }) + + it("replaces only the selected AST and leaves resolved parsers intact", () => { + const selected = Schema.Struct({ value: Schema.String }) + const untouched = Schema.Struct({ value: Schema.String }) + const early = SchemaParser.decodeUnknownSync(selected) + deepStrictEqual(early({ value: "valid" }), { value: "valid" }) + + SchemaJITCompiler.enable(selected.ast) + + let earlyReads = 0 + throws(() => + early({ + get value() { + earlyReads++ + return 1 + } + }) + ) + strictEqual(earlyReads, 1) + + let selectedReads = 0 + throws(() => + SchemaParser.decodeUnknownSync(selected)({ + get value() { + selectedReads++ + return 1 + } + }) + ) + strictEqual(selectedReads, 2) + + let untouchedReads = 0 + throws(() => + SchemaParser.decodeUnknownSync(untouched)({ + get value() { + untouchedReads++ + return 1 + } + }) + ) + strictEqual(untouchedReads, 1) + }) + + it("compiles descendants through an unsupported lazy root", () => { + const child = Schema.Struct({ value: Schema.String }) + const schema = Schema.suspend(() => child) + SchemaJITCompiler.enable(schema.ast) + + let reads = 0 + throws(() => + SchemaParser.decodeUnknownSync(schema)({ + get value() { + reads++ + return 1 + } + }) + ) + strictEqual(reads, 2) + }) + + it("prepares declaration type parameters with the selective compiler on first use", () => { + const child = Schema.Struct({ value: Schema.String }) + const schema = Schema.ReadonlySet(child) + SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode(new Set([{ value: "a", extra: true }])), new Set([{ value: "a" }])) + throws(() => decode(new Set([{ value: 1 }]))) + }) + + it("does not initialize unused declaration type parameter operations", () => { + const child = Schema.Struct({ value: Schema.String }) + let reads = 0 + SchemaCompiler.set(child.ast, { + get validate() { + reads++ + return undefined + }, + get decodeEffect() { + reads++ + return Effect.succeed + } + }) + const schema = Schema.ReadonlySet(child) + SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownSync(schema) + strictEqual(reads, 0) + deepStrictEqual(decode(new Set()), new Set()) + strictEqual(reads, 0) + deepStrictEqual(decode(new Set([{ value: "a" }])), new Set([{ value: "a" }])) + strictEqual(reads, 2) + }) + + it("preserves an installed decoder when dynamic code generation is unavailable", () => { + const schema = Schema.Struct({ value: Schema.String }) + SchemaCompiler.set(schema.ast, { + validate: () => ({ value: "installed" }), + decodeEffect: () => Effect.succeed({ value: "installed" }) + }) + const Function = globalThis.Function + try { + globalThis.Function = (() => { + throw new Error("dynamic function generation unavailable") + }) as any + SchemaJITCompiler.enable(schema.ast) + } finally { + globalThis.Function = Function + } + + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ value: 1 }), { value: "installed" }) + }) +}) diff --git a/packages/effect/test/schema/SchemaCompilerArray.test.ts b/packages/effect/test/schema/SchemaCompilerArray.test.ts new file mode 100644 index 00000000000..f70c686cf44 --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerArray.test.ts @@ -0,0 +1,117 @@ +import { assert, describe, it, vi } from "@effect/vitest" +import { Deferred, Effect, Fiber, Schema, SchemaGetter, SchemaParser } from "effect" +import { SchemaCompiler, SchemaJITCompiler } from "effect/unstable/schema" + +describe("compiled homogeneous Array traversal", () => { + it("uses the generated sequential driver for construction", () => { + const schema = Schema.Array(Schema.Struct({ value: Schema.Number })) + const parser = vi.spyOn(schema.ast, "getParser") + try { + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(SchemaParser.make(schema)([{ value: 1 }]), [{ value: 1 }]) + assert.strictEqual(typeof parser.mock.calls[0][2], "function") + } finally { + parser.mockRestore() + } + }) + + it.effect("keeps detailed errors and sparse input behavior", () => + Effect.gen(function*() { + const schema = Schema.Array(Schema.NumberFromString) + const inputs = [["1", "2"], ["bad", "2"], ["1", "bad", "bad"], new Array(2)] + const snapshot = Effect.fnUntraced(function*() { + const parse = SchemaParser.decodeUnknownEffect(schema) + const results = [] + for (const errors of ["first", "all"] as const) { + for (const input of inputs) { + results.push(yield* Effect.result(parse(input, { errors }))) + } + } + return results + }) + const interpreted = yield* snapshot() + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(yield* snapshot(), interpreted) + })) + + it.effect("resumes without replaying the pending element or rereading its accessor", () => + Effect.gen(function*() { + const events: Array = [] + const element = Schema.String.pipe(Schema.decode({ + decode: SchemaGetter.transformEffect((value) => + Effect.gen(function*() { + events.push(value) + yield* Effect.yieldNow + return value.toUpperCase() + }) + ), + encode: SchemaGetter.passthrough() + })) + const schema = Schema.Array(element) + let reads = 0 + const input = ["a", "b"] + Object.defineProperty(input, "0", { + get() { + reads++ + return "a" + } + }) + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(yield* SchemaParser.decodeUnknownEffect(schema)(input), ["A", "B"]) + assert.deepStrictEqual(events, ["a", "b"]) + assert.strictEqual(reads, 1) + })) + + it("passes missing constructor results to the shared element step", () => { + for (const optional of [false, true]) { + const required = Schema.String.annotate({ title: "Array constructor element" }) + const element = optional ? Schema.optionalKey(required) : required + SchemaCompiler.set(element.ast, { + decodeEffect: Effect.succeed, + makeEffect: () => Effect.succeed(SchemaCompiler.missing) + }) + const schema = Schema.Array(element) + SchemaJITCompiler.enable(schema.ast) + const make = SchemaParser.make(schema) + if (optional) { + const output = make(["a"]) + assert.strictEqual(output.length, 1) + assert.strictEqual(0 in output, false) + } else { + assert.throws(() => make(["a"]), /Schema validation failed/) + } + } + }) + + it.effect("keeps bounded concurrency on the interpreter's concurrent traversal", () => + Effect.gen(function*() { + const started = yield* Effect.forEach([0, 1, 2], () => Deferred.make()) + const releases = yield* Effect.forEach([0, 1, 2], () => Deferred.make()) + const calls = [0, 0, 0] + const element = Schema.Number.pipe(Schema.decode({ + decode: SchemaGetter.transformEffect((index) => + Effect.gen(function*() { + calls[index]++ + yield* Deferred.succeed(started[index], undefined) + yield* Deferred.await(releases[index]) + return index + }) + ), + encode: SchemaGetter.passthrough() + })) + const schema = Schema.Array(element) + SchemaJITCompiler.enable(schema.ast) + const fiber = yield* SchemaParser.decodeUnknownEffect(schema)([0, 1, 2], { concurrency: 2 }).pipe( + Effect.forkChild + ) + yield* Deferred.await(started[0]) + yield* Deferred.await(started[1]) + assert.strictEqual(yield* Deferred.isDone(started[2]), false) + yield* Deferred.succeed(releases[0], undefined) + yield* Deferred.await(started[2]) + yield* Deferred.succeed(releases[1], undefined) + yield* Deferred.succeed(releases[2], undefined) + assert.deepStrictEqual(yield* Fiber.join(fiber), [0, 1, 2]) + assert.deepStrictEqual(calls, [1, 1, 1]) + })) +}) diff --git a/packages/effect/test/schema/SchemaCompilerConcurrency.test.ts b/packages/effect/test/schema/SchemaCompilerConcurrency.test.ts new file mode 100644 index 00000000000..a4f584c3734 --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerConcurrency.test.ts @@ -0,0 +1,38 @@ +import { assert, describe, it } from "@effect/vitest" +import { Deferred, Effect, Fiber, Schema, SchemaParser } from "effect" +import { SchemaJITCompiler } from "effect/unstable/schema" + +describe("compiled construction concurrency", () => { + for (const product of ["Struct", "Tuple"] as const) { + it.effect(`${product} preserves bounded concurrency and runs defaults once`, () => + Effect.gen(function*() { + const started = yield* Effect.forEach([0, 1, 2], () => Deferred.make()) + const releases = yield* Effect.forEach([0, 1, 2], () => Deferred.make()) + const calls = [0, 0, 0] + const fields = [0, 1, 2].map((index) => + Schema.Number.pipe(Schema.withConstructorDefault(Effect.gen(function*() { + calls[index]++ + yield* Deferred.succeed(started[index], undefined) + yield* Deferred.await(releases[index]) + return index + }))) + ) + const schema: Schema.Codec = product === "Struct" + ? Schema.Struct({ a: fields[0], b: fields[1], c: fields[2] }) + : Schema.Tuple(fields) + SchemaJITCompiler.enable(schema.ast) + const fiber = yield* SchemaParser.makeEffect(schema)(product === "Struct" ? {} : [], { + parseOptions: { concurrency: 2 } + }).pipe(Effect.forkChild) + yield* Deferred.await(started[0]) + yield* Deferred.await(started[1]) + assert.strictEqual(yield* Deferred.isDone(started[2]), false) + yield* Deferred.succeed(releases[0], undefined) + yield* Deferred.await(started[2]) + yield* Deferred.succeed(releases[1], undefined) + yield* Deferred.succeed(releases[2], undefined) + assert.deepStrictEqual(yield* Fiber.join(fiber), product === "Struct" ? { a: 0, b: 1, c: 2 } : [0, 1, 2]) + assert.deepStrictEqual(calls, [1, 1, 1]) + })) + } +}) diff --git a/packages/effect/test/schema/SchemaCompilerConstruction.test.ts b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts new file mode 100644 index 00000000000..8c2a9df1ac9 --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts @@ -0,0 +1,294 @@ +import { assert, describe, it, vi } from "@effect/vitest" +import { Effect, Schema, SchemaAST, SchemaParser } from "effect" +import * as Codegen from "effect/internal/schema/codegen" +import * as Registry from "effect/internal/schema/compilerRegistry" +import { SchemaCompiler, SchemaJITCompiler } from "effect/unstable/schema" +import { constructionCases, constructionEvents, constructionOptions } from "./fixtures/construction.ts" + +describe("Schema compiler construction", { concurrent: false }, () => { + it.effect("matches interpreted construction, effects and options", () => + Effect.gen(function*() { + const fixtures = Object.entries(constructionCases) + const snapshot = Effect.fnUntraced(function*(fixture: typeof fixtures[number][1]) { + const out = [] + const make = SchemaParser.makeEffect(fixture.schema) + for (const parseOptions of constructionOptions) { + for (const input of fixture.inputs) { + constructionEvents.length = 0 + const result = yield* Effect.result(make(input as never, { parseOptions })) + out.push({ result, events: [...constructionEvents] }) + } + } + return out + }) + // Capture every interpreted result before installing shared child ASTs. + const interpreted = yield* Effect.forEach(fixtures, ([, fixture]) => snapshot(fixture)) + for (const [index, [name, fixture]] of fixtures.entries()) { + SchemaJITCompiler.enable(SchemaAST.toType(fixture.schema.ast)) + assert.deepStrictEqual(yield* snapshot(fixture), interpreted[index], name) + } + })) + + it("keeps selective compilation below an interpreted Suspend", () => { + let forced = 0 + const child = Schema.Struct({ value: Schema.String }) + const schema = Schema.suspend(() => { + forced++ + return child + }) + SchemaJITCompiler.enable(SchemaAST.toType(schema.ast)) + assert.strictEqual(forced, 0) + assert.deepStrictEqual(SchemaParser.make(schema)({ value: "a" }), { value: "a" }) + assert.strictEqual(forced, 1) + }) + + it("does not compile validators when only construction is used", () => { + const schema = Schema.Struct({ a: Schema.String }) + const emit = vi.spyOn(Codegen, "generate") + try { + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) + assert.strictEqual( + emit.mock.calls.filter(([, operation]) => operation === "validate" || operation === "is").length, + 0 + ) + } finally { + emit.mockRestore() + } + }) + + it("does not compile construction when only decoding is used", () => { + const schema = Schema.Struct({ a: Schema.String }) + const emit = vi.spyOn(Codegen, "generate") + try { + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ a: "a" }), { a: "a" }) + assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "makeEffect").length, 0) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) + assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "makeEffect").length, 1) + } finally { + emit.mockRestore() + } + }) + + for (const compiled of [false, true]) { + it(`initializes only reached constructor children, compiled=${compiled}`, () => { + const child = Schema.String.annotate({ title: "lazy constructor child" }) + let reads = 0 + const decoder: SchemaCompiler.CompiledDecoder = Object.freeze({ + decodeEffect: Effect.succeed, + get makeEffect() { + assert.strictEqual(this, decoder) + reads++ + return Effect.succeed + } + }) + SchemaCompiler.set(child.ast, decoder) + const schema = Schema.Struct({ first: Schema.Number, child }) + if (compiled) SchemaJITCompiler.enable(schema.ast) + const make = SchemaParser.make(schema) + assert.throws(() => make({ first: "invalid", child: "unreached" } as never)) + assert.strictEqual(reads, 0) + assert.deepStrictEqual(make({ first: 1, child: "a" }), { first: 1, child: "a" }) + assert.deepStrictEqual(make({ first: 2, child: "b" }), { first: 2, child: "b" }) + assert.strictEqual(reads, 1) + }) + } + + it("prepares selective Declaration parameters for public decoders in the callback", () => { + const child = Schema.Struct({ value: Schema.String }) + const schema = Schema.ReadonlySet(child) + SchemaJITCompiler.enable(SchemaAST.toType(schema.ast)) + assert.deepStrictEqual(SchemaParser.make(schema)(new Set([{ value: "a" }])), new Set([{ value: "a" }])) + assert.strictEqual(Registry.resolve(child.ast).source !== undefined, true) + }) + + it("does not restart a parent if compilation fails after a default", () => { + let defaults = 0 + const child = Schema.Struct({ value: Schema.String }) + const schema = Schema.Struct({ + child: child.pipe(Schema.withConstructorDefault(Effect.sync(() => { + defaults++ + return { value: "default" } + }))) + }) + const childAST = schema.fields.child.ast + const emit = Codegen.generate + const failure = vi.spyOn(Codegen, "generate").mockImplementation((ast, operation) => { + if (ast === childAST && operation === "makeEffect") { + assert.strictEqual(defaults, 1) + throw new Error("child compile failed") + } + return emit(ast, operation) + }) + try { + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(SchemaParser.make(schema)({}), { child: { value: "default" } }) + assert.strictEqual(defaults, 1) + } finally { + failure.mockRestore() + } + }) + + for (const operation of ["make", "decode"] as const) { + it(`keeps the other operation compiled after ${operation} compilation fails`, () => { + const schema = Schema.Struct({ a: Schema.String }) + SchemaJITCompiler.enable(schema.ast) + const generate = Codegen.generate + const failed = vi.spyOn(Codegen, "generate").mockImplementation((ast, key) => { + if (ast === schema.ast && key === (operation === "make" ? "makeEffect" : "validate")) { + throw new Error("compile failed") + } + return generate(ast, key) + }) + try { + const first = operation === "make" ? SchemaParser.make(schema) : SchemaParser.decodeUnknownSync(schema) + assert.deepStrictEqual(first({ a: "a" }), { a: "a" }) + const second = operation === "make" ? SchemaParser.decodeUnknownSync(schema) : SchemaParser.make(schema) + assert.deepStrictEqual(second({ a: "a" }), { a: "a" }) + assert( + failed.mock.calls.some(([ast, key]) => + ast === schema.ast && key === (operation === "make" ? "validate" : "makeEffect") + ) + ) + } finally { + failed.mockRestore() + } + }) + } + it("resolves installed construction lazily and independently from decoding", () => { + const schema = Schema.Struct({ a: Schema.String }) + let reads = 0 + let calls = 0 + SchemaCompiler.set(schema.ast, { + get decodeEffect(): SchemaCompiler.Decode { + throw new Error("unused decoder") + }, + get validate(): SchemaCompiler.Validate { + throw new Error("unused validator") + }, + get is(): SchemaCompiler.Is { + throw new Error("unused guard") + }, + get makeEffect() { + reads++ + return (input: unknown) => { + calls++ + return Effect.succeed(input) + } + } + }) + const make = SchemaParser.make(schema) + assert.strictEqual(reads, 0) + assert.deepStrictEqual(make({ a: "a" }), { a: "a" }) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "b" }), { a: "b" }) + assert.strictEqual(reads, 1) + assert.strictEqual(calls, 2) + }) + + it("caches interpreted construction when an installed bundle omits it", () => { + const schema = Schema.Struct({ a: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1))) }) + SchemaCompiler.set(schema.ast, { + decodeEffect: () => { + throw new Error("not a constructor") + } + }) + const entry = Registry.resolve(schema.ast) + const make = entry.makeEffect + assert.strictEqual(entry.makeEffect, make) + assert.deepStrictEqual(SchemaParser.make(schema)({}), { a: 1 }) + }) + + it("keeps previously captured constructors after whole-entry replacement", () => { + const schema = Schema.Struct({ a: Schema.String }) + const make = SchemaParser.make(schema) + assert.deepStrictEqual(make({ a: "a" }), { a: "a" }) + SchemaCompiler.set(schema.ast, { + decodeEffect: Effect.succeed, + makeEffect: () => Effect.succeed({ a: "installed" }) + }) + assert.deepStrictEqual(make({ a: "a" }), { a: "a" }) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "installed" }) + SchemaCompiler.set(schema.ast, { decodeEffect: Effect.succeed }) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) + }) + + it("uses type-side identity without applying root defaults", () => { + const schema = Schema.FiniteFromString.pipe(Schema.withConstructorDefault(Effect.succeed(1))) + const ast = SchemaAST.toType(schema.ast) + SchemaCompiler.set(ast, { decodeEffect: Effect.succeed, makeEffect: Effect.succeed }) + assert.strictEqual(SchemaParser.make(schema)(2), 2) + const number = Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1))) + SchemaJITCompiler.enable(number.ast) + assert.throws(() => SchemaParser.make(number)(undefined as never)) + assert.deepStrictEqual(SchemaParser.make(Schema.Struct({ number }))({}), { number: 1 }) + }) + + it("preserves missing separately from undefined for installed children", () => { + const child = Schema.optionalKey(Schema.Undefined) + const inputs: Array = [] + SchemaCompiler.set(child.ast, { + decodeEffect: Effect.succeed, + makeEffect: (input) => { + inputs.push(input) + return Effect.succeed(input) + } + }) + const schema = Schema.Struct({ child }) + assert.deepStrictEqual(SchemaParser.make(schema)({}), {}) + assert.deepStrictEqual(SchemaParser.make(schema)({ child: undefined }), { child: undefined }) + assert.deepStrictEqual(inputs, [SchemaCompiler.missing, undefined]) + }) + + it("lets parents and public roots handle missing constructor outputs", () => { + const required = Schema.String.annotate({ title: "Required construction output" }) + const optional = Schema.optionalKey(required) + for (const schema of [required, optional]) { + SchemaCompiler.set(schema.ast, { + decodeEffect: Effect.succeed, + makeEffect: () => Effect.succeed(SchemaCompiler.missing) + }) + } + const schema = Schema.Struct({ required, optional }) + SchemaJITCompiler.enable(schema.ast) + assert.throws(() => SchemaParser.make(schema)({ required: "a" }), /Schema validation failed/) + assert.deepStrictEqual(SchemaParser.make(Schema.Struct({ optional }))({ optional: "a" }), {}) + assert.throws(() => SchemaParser.make(required)("a"), /Schema validation failed/) + }) + + it("runs generated Struct construction with shared diagnostic helpers", () => { + const schema = Schema.Struct({ + a: Schema.String, + b: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1))) + }) + SchemaJITCompiler.enable(schema.ast) + const setup = vi.spyOn(schema.ast, "getParser") + const make = SchemaParser.make(schema) + try { + assert.deepStrictEqual(make({ a: "a" }), { a: "a", b: 1 }) + assert.throws(() => make({ a: 1 } as never), /Schema validation failed/) + assert.strictEqual(setup.mock.calls.length, 1) + assert.strictEqual(typeof setup.mock.calls[0][2], "function") + } finally { + setup.mockRestore() + } + }) + + it.effect("executes async defaults once, including on later failure", () => + Effect.gen(function*() { + let defaults = 0 + const schema = Schema.Struct({ + a: Schema.String.pipe(Schema.withConstructorDefault(Effect.gen(function*() { + yield* Effect.yieldNow + defaults++ + return "default" + }))), + b: Schema.Number + }) + SchemaJITCompiler.enable(schema.ast) + const make = SchemaParser.makeEffect(schema) + assert.deepStrictEqual(yield* make({ b: 1 }), { a: "default", b: 1 }) + assert.strictEqual((yield* Effect.exit(make({ b: "bad" } as never)))._tag, "Failure") + assert.strictEqual(defaults, 2) + })) +}) diff --git a/packages/effect/test/schema/SchemaCompilerRegression.test.ts b/packages/effect/test/schema/SchemaCompilerRegression.test.ts new file mode 100644 index 00000000000..065001c0c0e --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerRegression.test.ts @@ -0,0 +1,456 @@ +import { assert, describe, it } from "@effect/vitest" +import { + Effect, + Exit, + Option, + Result, + Schema, + SchemaAST, + SchemaGetter, + SchemaParser, + SchemaTransformation +} from "effect" +import { SchemaCompiler, SchemaJITCompiler } from "effect/unstable/schema" +import { deepStrictEqual, strictEqual } from "../utils/assert.ts" + +describe("compiler regression contracts", () => { + it("preserves template literal issues after compilation", () => { + const schema = Schema.TemplateLiteral(["count:", Schema.Int.check(Schema.isGreaterThan(0))]) + const inputs = ["count:1", "count:0", "count:1.5", "invalid", null] + const snapshot = () => { + const decode = SchemaParser.decodeUnknownResult(schema) + // Diagnostic ASTs contain freshly constructed transformation functions. + return inputs.map((input) => Result.mapError(decode(input), (issue) => JSON.stringify(issue))) + } + const interpreted = snapshot() + SchemaJITCompiler.enable(schema.ast) + deepStrictEqual(snapshot(), interpreted) + }) + + it.effect("preserves missing and present undefined through eager and suspended transformations", () => + Effect.gen(function*() { + for (const suspended of [false, true]) { + const seen: Array> = [] + const schema = Schema.Struct({ + value: Schema.Unknown.pipe( + Schema.decode({ + decode: new SchemaGetter.Getter((input) => { + seen.push(input) + const output = Option.isNone(input) || input.value === "omit" ? Option.none() : Option.some(undefined) + return suspended ? Effect.sync(() => output) : Effect.succeed(output) + }), + encode: SchemaGetter.passthrough() + }), + Schema.optionalKey + ) + }) + for (const compiled of [false, true]) { + if (compiled) SchemaJITCompiler.enable(schema.ast) + seen.length = 0 + const decode = SchemaParser.decodeUnknownEffect(schema) + deepStrictEqual(yield* decode({}), {}) + deepStrictEqual(yield* decode({ value: "omit" }), {}) + deepStrictEqual(yield* decode({ value: "present" }), { value: undefined }) + deepStrictEqual(seen, [Option.none(), Option.some("omit"), Option.some("present")]) + } + } + })) + + it.effect("continues encoding checkpoints after middleware recovery without replaying transformations", () => + Effect.gen(function*() { + for (const suspended of [false, true]) { + const events: Array = [] + const schema = Schema.String.pipe( + Schema.decodeTo( + Schema.Number.check(Schema.isGreaterThan(0)), + SchemaTransformation.transform({ + decode: (input) => { + events.push("first") + return Number(input) + }, + encode: String + }) + ), + Schema.middlewareDecoding((effect) => + Effect.catchEager(effect, () => { + events.push("recover") + return suspended ? Effect.sync(() => Option.some(1)) : Effect.succeed(Option.some(1)) + }) + ), + Schema.decodeTo( + Schema.String, + SchemaTransformation.transform({ + decode: (input) => { + events.push("last") + return String(input) + }, + encode: Number + }) + ) + ) + for (const compiled of [false, true]) { + if (compiled) SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownEffect(schema) + events.length = 0 + strictEqual(yield* decode("2"), "2") + deepStrictEqual(events, ["first", "last"]) + events.length = 0 + strictEqual(yield* decode("-1"), "1") + deepStrictEqual(events, ["first", "recover", "last"]) + events.length = 0 + strictEqual(yield* decode(false), "1") + deepStrictEqual(events, ["recover", "last"]) + } + } + })) + + it.effect("resolved parsers return Effects containing their actual output", () => + Effect.gen(function*() { + const object = { value: "a" } + const cases: ReadonlyArray, unknown, unknown]> = [ + [Schema.String, "a", "a"], + [Schema.Number, -0, -0], + [Schema.Literal(0), -0, -0], + [Schema.Undefined, undefined, undefined], + [Schema.ObjectKeyword, object, object], + [Schema.Json, object, object], + [Schema.Struct({}), 1, 1], + [Schema.TemplateLiteral(["a"]), "a", "a"], + [Schema.FiniteFromString, "1", 1] + ] + for (const [schema, input, expected] of cases) { + for (const compiled of [false, true]) { + if (compiled) SchemaJITCompiler.enable(schema.ast) + const parser = SchemaParser.decodeUnknownEffect(schema) + const effect = parser(input, SchemaAST.defaultParseOptions) + strictEqual(Effect.isEffect(effect), true) + const output = yield* Effect.map(effect, (value) => value) + strictEqual(Object.is(output, expected), true) + const publicEffect = SchemaParser.decodeUnknownEffect(schema)(input) + strictEqual(Effect.isEffect(publicEffect), true) + strictEqual(Object.is(yield* publicEffect, expected), true) + } + } + })) + + it.effect("retains public success values across subsequent and reentrant parser calls", () => + Effect.gen(function*() { + let reenter: (input: unknown) => string + const schema = Schema.String.check( + Schema.makeFilter((value) => value !== "first" || reenter("nested") === "nested") + ) + for (const compiled of [false, true]) { + if (compiled) SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownEffect(schema) + const decodeSync = SchemaParser.decodeUnknownSync(schema) + reenter = decodeSync + const first = decode("first") + const second = decode("second") + strictEqual( + yield* Effect.map(first, (value) => { + strictEqual(decodeSync("nested"), "nested") + return value + }), + "first" + ) + strictEqual(yield* second, "second") + strictEqual(yield* first, "first") + } + })) + + it.effect("preserves unchanged fields before and after asynchronous transformations", () => + Effect.gen(function*() { + const number = Schema.String.pipe(Schema.decodeTo(Schema.Number, { + decode: new SchemaGetter.Getter((input) => Effect.yieldNow.pipe(Effect.as(Option.map(input, Number)))), + encode: SchemaGetter.transform(String) + })) + const tuple = Schema.Tuple([Schema.String, number, Schema.Undefined]).check( + Schema.makeFilter((value) => value[0] === "before" && value[1] === 42 && value[2] === undefined) + ) + const struct = Schema.Struct({ before: Schema.String, middle: number, after: Schema.Undefined }).check( + Schema.makeFilter((value) => value.before === "before" && value.middle === 42 && value.after === undefined) + ) + for (const compiled of [false, true]) { + if (compiled) { + SchemaJITCompiler.enable(tuple.ast) + SchemaJITCompiler.enable(struct.ast) + } + deepStrictEqual( + yield* SchemaParser.decodeUnknownEffect(tuple)(["before", "42", undefined]), + ["before", 42, undefined] + ) + deepStrictEqual( + yield* SchemaParser.decodeUnknownEffect(struct)({ before: "before", middle: "42", after: undefined }), + { before: "before", middle: 42, after: undefined } + ) + } + })) + + it("calls installed operations with options without inspecting extra function properties", () => { + const schema = Schema.Struct({ value: Schema.String }) + const seen: Array = [] + const is: SchemaCompiler.Is = (_input, options) => { + seen.push(options) + return true + } + const validate: SchemaCompiler.Validate = (input, options) => { + seen.push(options) + return input + } + for (const operation of [is, validate]) { + Object.defineProperty(operation, "default", { + get() { + throw new Error("Not part of the compiled decoder contract") + } + }) + } + SchemaCompiler.set(schema.ast, { is, validate, decodeEffect: Effect.succeed }) + const input = { value: "a" } + strictEqual(SchemaParser.is(schema)(input), true) + strictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) + const options = { reportInput: true } + strictEqual(SchemaParser.decodeUnknownSync(schema, options)(input), input) + deepStrictEqual(seen, [SchemaAST.defaultParseOptions, SchemaAST.defaultParseOptions, options]) + }) + + it("bounds inlining of shared subgraphs", () => { + let schema: Schema.Codec = Schema.Struct({ value: Schema.optionalKey(Schema.String) }) + let valid: unknown = { value: "value" } + let invalid: unknown = { value: 1 } + for (let i = 0; i < 16; i++) { + schema = Schema.Struct({ + left: Schema.optionalKey(schema), + right: Schema.optionalKey(schema) + }) + valid = { left: valid } + invalid = { left: invalid } + } + const cases = [ + { schema, valid, invalid }, + { schema, valid: { left: { right: {} } }, invalid: { left: { right: 1 } } }, + { schema: Schema.Array(schema), valid: [{}], invalid: [1] }, + { schema: Schema.Union([schema, Schema.String]), valid: {}, invalid: 1 } + ] + for (const { schema, valid, invalid } of cases) { + const expected = SchemaParser.decodeUnknownResult(schema)(invalid) + assert(Result.isFailure(expected)) + SchemaJITCompiler.enable(schema.ast) + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)(valid), valid) + strictEqual(SchemaParser.is(schema)(valid), true) + strictEqual(SchemaParser.is(schema)(invalid), false) + deepStrictEqual(SchemaParser.decodeUnknownResult(schema)(invalid), expected) + } + }) + + it("bounds generated composed parsers for wide objects", () => { + const property = Schema.optionalKey(Schema.String) + const schema = Schema.Struct(Object.fromEntries( + Array.from({ length: 4096 }, (_, i) => [`key${i}`, property]) + )) + SchemaJITCompiler.enable(schema.ast) + const input = { key4095: "last" } + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) + strictEqual(SchemaParser.is(schema)(input), true) + strictEqual(SchemaParser.is(schema)({ key4095: 1 }), false) + }) + + it("stops oneOf after its second successful candidate", () => { + const schema = Schema.Union([ + Schema.String.check(Schema.isMinLength(1)), + Schema.String.check(Schema.isMaxLength(10)), + Schema.String.check(Schema.makeFilter(() => { + throw new Error("The third candidate must not be evaluated") + })) + ], { mode: "oneOf" }) + for (const compiled of [false, true]) { + if (compiled) SchemaJITCompiler.enable(schema.ast) + strictEqual(SchemaParser.is(schema)("hello"), false) + for (const options of [undefined, { errors: "all" }] as const) { + const result = SchemaParser.decodeUnknownResult(schema, options)("hello") + assert(Result.isFailure(result)) + strictEqual(result.failure._tag, "OneOf") + } + } + }) + + it.effect("accepts both zero signs and preserves the input across parser adapters", () => + Effect.gen(function*() { + const options: Array = [ + undefined, + { errors: "all" }, + { reportInput: true } + ] + for (const literal of [0, -0]) { + const schemas = [ + Schema.Literal(literal), + Schema.Union([Schema.Literal(literal), Schema.Literal(1)]), + Schema.Literal(literal).check(Schema.makeFilter((n) => Object.is(n, -0))) + ] + for (const schema of schemas) { + const nested = Schema.Struct({ values: Schema.Array(schema) }) + for (const compiled of [false, true]) { + if (compiled) { + SchemaJITCompiler.enable(schema.ast) + SchemaJITCompiler.enable(nested.ast) + } + for (const input of [0, -0]) { + if (schema.ast.checks && !Object.is(input, -0)) { + strictEqual(SchemaParser.is(schema)(input), false) + assert(Result.isFailure(SchemaParser.decodeUnknownResult(schema)(input))) + continue + } + strictEqual(SchemaParser.is(schema)(input), true) + for (const option of options) { + strictEqual(Object.is(SchemaParser.decodeUnknownSync(schema, option)(input), input), true) + strictEqual(Object.is(SchemaParser.encodeUnknownSync(schema, option)(input), input), true) + const result = SchemaParser.decodeUnknownResult(schema, option)(input) + assert(Result.isSuccess(result)) + strictEqual(Object.is(result.success, input), true) + const exit = SchemaParser.decodeUnknownExit(schema, option)(input) + assert(Exit.isSuccess(exit)) + strictEqual(Object.is(exit.value, input), true) + const optional = SchemaParser.decodeUnknownOption(schema, option)(input) + assert(Option.isSome(optional)) + strictEqual(Object.is(optional.value, input), true) + const output = yield* SchemaParser.decodeUnknownEffect(schema, option)(input) + strictEqual(Object.is(output, input), true) + const decoded = SchemaParser.decodeUnknownSync(nested, option)({ values: [input] }) + strictEqual(Object.is(decoded.values[0], input), true) + } + } + } + } + } + })) + + it("retains the original encoding AST in local checks", () => { + const schema = Schema.NumberFromString.check( + Schema.makeFilter((_value, ast) => ast === schema.ast && ast.encoding !== undefined) + ) + strictEqual(SchemaParser.decodeUnknownSync(schema)("1"), 1) + SchemaJITCompiler.enable(schema.ast) + strictEqual(SchemaParser.decodeUnknownSync(schema)("1"), 1) + }) + + it("retains the original encoding AST in structural issues", () => { + const schema = Schema.String.pipe(Schema.decodeTo( + Schema.Number, + SchemaTransformation.transform({ decode: () => "invalid" as any, encode: String }) + )) + for (const compiled of [false, true]) { + if (compiled) SchemaJITCompiler.enable(schema.ast) + const result = SchemaParser.decodeUnknownResult(schema)("input") + assert(Result.isFailure(result)) + assert(result.failure._tag === "InvalidType") + strictEqual(result.failure.ast, schema.ast) + } + }) + + it("installs accessors without evaluating them and reads only the selected operation once", () => { + const schema = Schema.Struct({ value: Schema.String }) + const reads: Array = [] + const decoder = { + get is() { + strictEqual(this, decoder) + reads.push("is") + return (_input: unknown) => true + }, + get validate() { + strictEqual(this, decoder) + reads.push("validate") + return (input: unknown) => input + }, + get decodeEffect() { + strictEqual(this, decoder) + reads.push("decode") + return Effect.succeed + } + } + SchemaCompiler.set(schema.ast, decoder) + deepStrictEqual(reads, []) + strictEqual(SchemaParser.is(schema)({ value: "a" }), true) + strictEqual(SchemaParser.is(schema)({ value: "b" }), true) + deepStrictEqual(reads, ["is"]) + const input = { value: "a" } + strictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) + strictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) + deepStrictEqual(reads, ["is", "validate"]) + }) + + it("memoizes an absent optional operation", () => { + const schema = Schema.Struct({ value: Schema.String }) + let reads = 0 + SchemaCompiler.set(schema.ast, { + get is() { + reads++ + return undefined + }, + validate: (input) => input, + decodeEffect: Effect.succeed + }) + strictEqual(SchemaParser.is(schema)({ value: "a" }), true) + strictEqual(SchemaParser.is(schema)({ value: "b" }), true) + strictEqual(reads, 1) + }) + + it("shares lazy detailed decoding across public adapters without mutating the supplied decoder", () => { + const schema = Schema.Struct({ value: Schema.String }) + const reads: Array = [] + const decoder = Object.freeze({ + get validate() { + strictEqual(this, decoder) + reads.push("validate") + return () => SchemaCompiler.invalid + }, + get decodeEffect() { + strictEqual(this, decoder) + reads.push("decode") + return Effect.succeed + } + }) + SchemaCompiler.set(schema.ast, decoder) + const input = { value: "a" } + strictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) + deepStrictEqual(SchemaParser.decodeUnknownResult(schema)(input), Result.succeed(input)) + strictEqual(SchemaParser.decodeUnknownSync(schema, { reportInput: true })(input), input) + deepStrictEqual(reads, ["validate", "decode"]) + }) + + it("does not restart validation inside the detailed decoder", () => { + const schema = Schema.Struct({ values: Schema.Array(Schema.Struct({ value: Schema.String })) }) + SchemaJITCompiler.enable(schema.ast) + let reads = 0 + const result = SchemaParser.decodeUnknownResult(schema)({ + values: [{ + get value() { + reads++ + return 1 + } + }] + }) + assert(Result.isFailure(result)) + strictEqual(reads, 2) + }) + + it("uses the interpreter after selective JIT generation fails", () => { + const schema = Schema.Struct({ value: Schema.String }) + const original = globalThis.Function + const defect = new SyntaxError("generated source defect") + let attempts = 0 + try { + globalThis.Function = ((...parameters: Array) => { + if (parameters.length === 1 && parameters[0] === "return true") return original(...parameters) + attempts++ + throw defect + }) as FunctionConstructor + SchemaJITCompiler.enable(schema.ast) + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode({ value: "a", extra: true }), { value: "a" }) + assert(Result.isFailure(SchemaParser.decodeUnknownResult(schema)({ value: 1 }))) + deepStrictEqual(decode({ value: "b" }), { value: "b" }) + strictEqual(attempts, 1) + } finally { + globalThis.Function = original + } + }) +}) diff --git a/packages/effect/test/schema/SchemaCompilerStartup.test.ts b/packages/effect/test/schema/SchemaCompilerStartup.test.ts new file mode 100644 index 00000000000..8999f7892a3 --- /dev/null +++ b/packages/effect/test/schema/SchemaCompilerStartup.test.ts @@ -0,0 +1,19 @@ +import { assert, describe, it } from "@effect/vitest" +import { Schema, SchemaParser } from "effect" +import * as Registry from "effect/internal/schema/compilerRegistry" + +describe("Schema compiler startup", () => { + it("allows late global activation without replacing a maker's interpreted entry", async () => { + const schema = Schema.Struct({ a: Schema.String }) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) + const before = Registry.resolve(schema.ast) + assert.strictEqual(before.source, undefined) + const unused = Schema.Struct({ a: Schema.Number }) + const make = SchemaParser.make(unused) + await import("effect/unstable/schema/SchemaJITCompiler/enable") + assert.strictEqual(Registry.resolve(schema.ast), before) + assert.deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ a: "a" }), { a: "a" }) + assert.deepStrictEqual(make({ a: 1 }), { a: 1 }) + assert.strictEqual(Registry.resolve(unused.ast).source !== undefined, true) + }) +}) diff --git a/packages/effect/test/schema/SchemaJITCompiler.test.ts b/packages/effect/test/schema/SchemaJITCompiler.test.ts new file mode 100644 index 00000000000..e6925af8d3c --- /dev/null +++ b/packages/effect/test/schema/SchemaJITCompiler.test.ts @@ -0,0 +1,803 @@ +import { assert, describe, it } from "@effect/vitest" +import { + Cause, + Deferred, + Effect, + Fiber, + Result, + Schema, + SchemaGetter, + SchemaIssue, + SchemaParser, + SchemaTransformation +} from "effect" +import { SchemaCompiler } from "effect/unstable/schema" +// oxlint-disable-next-line no-unassigned-import +import "effect/unstable/schema/SchemaJITCompiler/enable" +import { assertSchemaIssueError, deepStrictEqual, strictEqual, throws } from "../utils/assert.ts" + +const schema = Schema.Struct({ + name: Schema.String, + count: Schema.Number, + active: Schema.Boolean, + nested: Schema.Struct({ value: Schema.String }) +}) + +const decode = SchemaParser.decodeUnknownSync(schema) +const is = SchemaParser.is(schema) + +describe("SchemaJITCompiler", () => { + it.effect("preserves product concurrency", () => + Effect.gen(function*() { + const started = yield* Effect.forEach([0, 1, 2], () => Deferred.make()) + const releases = yield* Effect.forEach([0, 1, 2], () => Deferred.make()) + const item = Schema.String.pipe(Schema.decode({ + decode: SchemaGetter.transformEffect((value) => { + const index = value.charCodeAt(0) - 97 + return Deferred.succeed(started[index], undefined).pipe( + Effect.andThen(Deferred.await(releases[index])), + Effect.as(value) + ) + }), + encode: SchemaGetter.passthrough() + })) + const schema = Schema.Struct({ a: item, b: item, c: item }) + const fiber = yield* SchemaParser.decodeUnknownEffect(schema)( + { a: "a", b: "b", c: "c" }, + { concurrency: 2 } + ).pipe(Effect.forkChild) + + yield* Deferred.await(started[0]) + yield* Deferred.await(started[1]) + strictEqual(yield* Deferred.isDone(started[2]), false) + + yield* Deferred.succeed(releases[0], undefined) + yield* Deferred.await(started[2]) + yield* Deferred.succeed(releases[1], undefined) + yield* Deferred.succeed(releases[2], undefined) + + deepStrictEqual(yield* Fiber.join(fiber), { a: "a", b: "b", c: "c" }) + })) + + it("compiles a decoder lazily after import", () => { + const input = { + name: "a", + count: 1, + active: true, + nested: { value: "b", extra: true }, + extra: true + } + const output = decode(input) + + deepStrictEqual(output, { + name: "a", + count: 1, + active: true, + nested: { value: "b" } + }) + assert.notStrictEqual(output, input) + assert.notStrictEqual(output.nested, input.nested) + }) + + it("compiles type guards", () => { + strictEqual( + is({ + name: "a", + count: 1, + active: true, + nested: { value: "b" } + }), + true + ) + strictEqual( + is({ + name: 1, + count: 1, + active: true, + nested: { value: "b" } + }), + false + ) + + const defect = new Error("boom") + let reads = 0 + throws(() => + is({ + get name(): string { + reads++ + throw defect + }, + count: 1, + active: true, + nested: { value: "b" } + }), (error) => { + assert(error instanceof Error) + strictEqual(error.message, "Type guard adapter can only return false for schema issues") + assert(Cause.hasDies(error.cause as Cause.Cause)) + }) + strictEqual(reads, 1) + }) + + it("does not confuse a valid value with the invalid sentinel", () => { + const schemas = [ + Schema.Union([Schema.Symbol, Schema.String]), + Schema.Union([Schema.UniqueSymbol(SchemaCompiler.invalid), Schema.Literal("valid")]) + ] + for (const schema of schemas) { + strictEqual(SchemaParser.is(schema)(SchemaCompiler.invalid), true) + strictEqual(SchemaParser.decodeUnknownSync(schema)(SchemaCompiler.invalid), SchemaCompiler.invalid) + } + }) + + it("uses default options in compiled type guards", () => { + const structural = Schema.Struct({ value: Schema.String }) + const is = SchemaParser.is(structural) + strictEqual(is({ value: "a" }), true) + strictEqual(is({ value: "a", extra: true }), true) + strictEqual(is({ value: 1 }), false) + + const checked = Schema.Struct({ a: Schema.String, b: Schema.String }).check( + Schema.makeFilter((value, _ast, options) => options.reportInput === true && !Object.hasOwn(value, "extra")) + ) + const input = { b: "b", a: "a", extra: true } + strictEqual(SchemaParser.is(checked)(input), false) + deepStrictEqual(SchemaParser.decodeUnknownSync(checked)(input, { reportInput: true }), { a: "a", b: "b" }) + deepStrictEqual(SchemaParser.decodeUnknownSync(checked)(input, { disableChecks: true }), { a: "a", b: "b" }) + strictEqual(SchemaParser.is(checked)(input), false) + }) + + it("accepts the invalid sentinel inside a checked composite", () => { + const schema = Schema.Struct({ value: Schema.Union([Schema.Symbol, Schema.String]) }).check( + Schema.makeFilter(() => true) + ) + const input = { value: SchemaCompiler.invalid } + strictEqual(SchemaParser.is(schema)(input), true) + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) + }) + + it("keeps runtime options for nested checks, template parts and record keys", () => { + const checked = Schema.Struct({ + nested: Schema.Struct({ value: Schema.String }).check( + Schema.makeFilter((_value, _ast, options) => options.reportInput === true) + ) + }) + const value = { nested: { value: "valid" } } + strictEqual(SchemaParser.is(checked)(value), false) + deepStrictEqual(SchemaParser.decodeUnknownSync(checked)(value, { reportInput: true }), value) + + const template = Schema.Struct({ + value: Schema.TemplateLiteral(["prefix-", Schema.String.check(Schema.isMinLength(2))]) + }) + strictEqual(SchemaParser.is(template)({ value: "prefix-a" }), false) + deepStrictEqual(SchemaParser.decodeUnknownSync(template)({ value: "prefix-a" }, { disableChecks: true }), { + value: "prefix-a" + }) + strictEqual(SchemaParser.is(template)({ value: "prefix-a" }), false) + + const record = Schema.Record(Schema.String.check(Schema.isStartsWith("x")), Schema.Number) + const decode = SchemaParser.decodeUnknownSync(record) + deepStrictEqual(decode({ x: 1, y: 2 }), { x: 1 }) + deepStrictEqual(decode({ x: 1, y: 2 }, { disableChecks: true }), { x: 1, y: 2 }) + }) + + it("runs the diagnostic phase after fast validation fails", () => { + let reads = 0 + const input = { + get name() { + reads++ + return 1 + }, + count: 1, + active: true, + nested: { value: "b" } + } + + throws(() => decode(input), (error) => { + assertSchemaIssueError(error, `Expected string\n at ["name"]`) + }) + strictEqual(reads, 2) + }) + + it("runs one diagnostic pass for a nested failure", () => { + let checks = 0 + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ + nested: Schema.Struct({ + value: Schema.String.check(Schema.makeFilter(() => { + checks++ + return false + })) + }) + })) + + throws(() => decode({ nested: { value: "invalid" } }), (error) => { + assertSchemaIssueError( + error, + `Expected + at ["nested"]["value"]` + ) + }) + strictEqual(checks, 2) + }) + + it("preserves diagnostics with shared parser helpers", () => { + const schema = Schema.Struct({ value: Schema.String }) + const decode = SchemaParser.decodeUnknownSync(schema) + + deepStrictEqual(decode({ value: "a", extra: true }), { value: "a" }) + throws(() => decode({ value: 1 }), (error) => { + assertSchemaIssueError( + error, + `Expected string + at ["value"]` + ) + }) + throws(() => decode({ value: "a", extra: true }, { onExcessProperty: "error" }), (error) => { + assertSchemaIssueError( + error, + `Expected no excess property + at ["extra"]` + ) + }) + }) + + it("executes transformations and middleware once", () => { + let transformations = 0 + const transformed = Schema.String.pipe( + Schema.decodeTo( + Schema.String.check(Schema.isMinLength(2)), + SchemaTransformation.transform({ + decode: (value) => { + transformations++ + return value.trim() + }, + encode: (value) => value + }) + ) + ) + const decodeTransformed = SchemaParser.decodeUnknownSync(transformed) + + strictEqual(decodeTransformed(" valid "), "valid") + strictEqual(transformations, 1) + throws(() => decodeTransformed(" x ")) + strictEqual(transformations, 2) + + let middlewareRuns = 0 + const middleware = Schema.Struct({ value: Schema.String }).pipe( + Schema.middlewareDecoding((effect) => { + middlewareRuns++ + return effect + }) + ) + + deepStrictEqual(SchemaParser.decodeUnknownSync(middleware)({ value: "valid" }), { value: "valid" }) + strictEqual(middlewareRuns, 1) + }) + + it("decodes transformed Struct properties with runtime options", () => { + const schema = Schema.Struct({ + first: Schema.FiniteFromString, + second: Schema.FiniteFromString + }) + + const decode = SchemaParser.decodeUnknownSync(schema) + for ( + const options of [ + undefined, + {}, + { errors: "first" }, + { onExcessProperty: "ignore" }, + { reportInput: true }, + { disableChecks: true } + ] as const + ) { + deepStrictEqual(decode({ first: "1", second: "2" }, options), { first: 1, second: 2 }) + } + }) + + it("applies Struct output and encoding checks after compiled fields without replay", () => { + let transformations = 0 + let checks = 0 + const schema = Schema.Struct({ + value: Schema.String.pipe(Schema.decodeTo( + Schema.Number, + SchemaTransformation.transform({ + decode: (value) => { + transformations++ + return Number(value) + }, + encode: String + }) + )) + }).check(Schema.makeFilter((output) => { + checks++ + deepStrictEqual(Object.keys(output), ["value"]) + return output.value > 0 + })).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((input) => input.value !== "01")), + Schema.flip + ) + let interpreted = 0 + const getParser = schema.ast.getParser.bind(schema.ast) + Object.defineProperty(schema.ast, "getParser", { + value(...args: Parameters) { + interpreted++ + return getParser(...args) + } + }) + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode({ value: "1", extra: true }), { value: 1 }) + throws(() => decode({ value: "-1", extra: true })) + strictEqual(transformations, 2) + strictEqual(checks, 2) + strictEqual(interpreted, 1) + + deepStrictEqual(decode({ value: "-1" }, { disableChecks: true }), { value: -1 }) + strictEqual(checks, 2) + throws(() => decode({ value: "-1" }, { errors: "all" })) + strictEqual(transformations, 4) + strictEqual(checks, 3) + strictEqual(interpreted, 1) + throws(() => decode({ value: "01" })) + strictEqual(transformations, 5) + strictEqual(checks, 3) + }) + + it("replaces the parser used by the other decoding adapters", () => { + let checks = 0 + const schema = Schema.String.check(Schema.makeFilter(() => { + checks++ + return false + })) + + assert(Result.isFailure(SchemaParser.decodeUnknownResult(schema)("value"))) + strictEqual(checks, 2) + }) + + it("replaces the parser used by encoding adapters", () => { + let checks = 0 + const schema = Schema.String.check(Schema.makeFilter(() => { + checks++ + return false + })) + + assert(Result.isFailure(SchemaParser.encodeUnknownResult(schema)("value"))) + strictEqual(checks, 2) + }) + + it("does not replay defects", () => { + const defect = new Error("boom") + let reads = 0 + const input = { + get name(): string { + reads++ + throw defect + }, + count: 1, + active: true, + nested: { value: "b" } + } + + throws(() => decode(input), (error) => { + assert(error instanceof Error) + strictEqual(error.message, "Sync adapter can only throw schema issues") + assert(Cause.hasDies(error.cause as Cause.Cause)) + }) + strictEqual(reads, 1) + }) + + it("honors explicit ParseOptions in the compiled diagnostic phase", () => { + throws(() => + decode({ + name: "a", + count: 1, + active: true, + nested: { value: "b", nestedExtra: true }, + extra: true + }, { onExcessProperty: "error", errors: "all" }), (error) => { + assertSchemaIssueError( + error, + `Expected no excess property + at ["extra"] +Expected no excess property + at ["nested"]["nestedExtra"]` + ) + }) + }) + + it("compiles symbol-keyed Struct properties", () => { + const key = Symbol("key") + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ + text: Schema.String, + [key]: Schema.Number + })) + const output = decode({ text: "value", [key]: 1, extra: true }) + + strictEqual(output.text, "value") + strictEqual(output[key], 1) + deepStrictEqual(Reflect.ownKeys(output), ["text", key]) + }) + + it("uses the interpreter for unsupported schemas", () => { + const date = new Date(0) + const decode = SchemaParser.decodeUnknownSync(Schema.instanceOf(Date)) + strictEqual(decode(date), date) + }) + + it("uses the interpreter when dynamic function generation is unavailable", () => { + const Function = globalThis.Function + try { + globalThis.Function = (() => { + throw new Error("dynamic function generation unavailable") + }) as any + const schema = Schema.Struct({ value: Schema.String }) + const getParser = schema.ast.getParser.bind(schema.ast) + let interpreterConstructions = 0 + Object.defineProperty(schema.ast, "getParser", { + configurable: true, + value(...args: Parameters) { + interpreterConstructions++ + return getParser(...args) + } + }) + const decode = SchemaParser.decodeUnknownSync(schema) + + deepStrictEqual(decode({ value: "a" }), { value: "a" }) + strictEqual(interpreterConstructions, 1) + throws(() => decode({ value: 1 }), (error) => { + assertSchemaIssueError( + error, + `Expected string + at ["value"]` + ) + }) + } finally { + globalThis.Function = Function + } + }) + + it("compiles primitive leaves without confusing undefined with a missing key", () => { + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ + undefined: Schema.Undefined, + unknown: Schema.Unknown, + bigint: Schema.BigInt, + symbol: Schema.Symbol, + literal: Schema.Literal("a") + })) + const symbol = globalThis.Symbol("a") + + deepStrictEqual( + decode({ undefined, unknown: undefined, bigint: 1n, symbol, literal: "a", extra: true }), + { undefined, unknown: undefined, bigint: 1n, symbol, literal: "a" } + ) + throws(() => decode({ unknown: undefined, bigint: 1n, symbol, literal: "a" }), (error) => { + assertSchemaIssueError(error, `Missing key\n at ["undefined"]`) + }) + }) + + it("compiles arrays and tuples with rest and tail elements", () => { + const decodeArray = SchemaParser.decodeUnknownSync(Schema.Array(Schema.String)) + deepStrictEqual(decodeArray(["a", "b"]), ["a", "b"]) + + const decodeTuple = SchemaParser.decodeUnknownSync( + Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number, Schema.Boolean]) + ) + deepStrictEqual(decodeTuple(["a", 1, 2, true]), ["a", 1, 2, true]) + throws(() => decodeTuple(["a", 1, 2]), (error) => { + assertSchemaIssueError(error, `Expected boolean\n at [2]`) + }) + }) + + it("compiles optional tuple elements", () => { + const schema = Schema.Tuple([Schema.optionalKey(Schema.String)]) + const decode = SchemaParser.decodeUnknownSync(schema) + const is = SchemaParser.is(schema) + + deepStrictEqual(decode([]), []) + deepStrictEqual(decode(["a"]), ["a"]) + strictEqual(is([]), true) + strictEqual(is(["a"]), true) + strictEqual(is([1]), false) + }) + + it("preserves the input sign for signed-zero literals", () => { + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ + negative: Schema.Literal(-0), + positive: Schema.Union([Schema.Literal(0), Schema.Literal(1)]), + union: Schema.Union([Schema.Literal(-0), Schema.Literal(1)]) + })) + const output = decode({ negative: 0, positive: -0, union: 0 }) + + strictEqual(Object.is(output.negative, 0), true) + strictEqual(Object.is(output.positive, -0), true) + strictEqual(Object.is(output.union, 0), true) + }) + + it("compiles primitive anyOf and oneOf unions", () => { + const decodeAnyOf = SchemaParser.decodeUnknownSync( + Schema.Union([Schema.Literal("a"), Schema.Literal("b"), Schema.Number]) + ) + strictEqual(decodeAnyOf("b"), "b") + strictEqual(decodeAnyOf(1), 1) + throws(() => decodeAnyOf(true), (error) => { + assertSchemaIssueError(error, "Expected \"a\" | \"b\" | number") + }) + + const decodeOneOf = SchemaParser.decodeUnknownSync( + Schema.Union([Schema.String, Schema.Literal("a")], { mode: "oneOf" }) + ) + strictEqual(decodeOneOf("b"), "b") + throws(() => decodeOneOf("a"), (error) => { + assertSchemaIssueError(error, "Expected exactly one member to match") + }) + }) + + it("compiles structural unions through canonical candidate selection", () => { + const decodeTagged = SchemaParser.decodeUnknownSync(Schema.Union([ + Schema.Struct({ kind: Schema.Literal("a"), value: Schema.String }), + Schema.Struct({ kind: Schema.Literal("b"), value: Schema.Number }) + ])) + deepStrictEqual( + decodeTagged({ kind: "b", value: 1, extra: true }), + { kind: "b", value: 1 } + ) + + const decodeUntagged = SchemaParser.decodeUnknownSync(Schema.Union([ + Schema.Struct({ first: Schema.String }), + Schema.Struct({ second: Schema.String }) + ])) + deepStrictEqual( + decodeUntagged({ first: "a", second: "b" }), + { first: "a" } + ) + + const decodeOneOf = SchemaParser.decodeUnknownSync(Schema.Union([ + Schema.Struct({ first: Schema.String }), + Schema.Struct({ second: Schema.String }) + ], { mode: "oneOf" })) + throws(() => decodeOneOf({ first: "a", second: "b" }), (error) => { + assertSchemaIssueError(error, "Expected exactly one member to match") + }) + }) + + it("compiles optional object properties", () => { + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ + required: Schema.String, + optional: Schema.optionalKey(Schema.Number), + undefined: Schema.optionalKey(Schema.Undefined) + })) + + deepStrictEqual(decode({ required: "a" }), { required: "a" }) + deepStrictEqual( + decode({ required: "a", optional: 1, undefined, extra: true }), + { required: "a", optional: 1, undefined } + ) + throws(() => decode({ required: "a", optional: "invalid" }), (error) => { + assertSchemaIssueError(error, `Expected number\n at ["optional"]`) + }) + }) + + it("compiles string records", () => { + const decode = SchemaParser.decodeUnknownSync( + Schema.Record(Schema.String, Schema.Struct({ count: Schema.Number })) + ) + deepStrictEqual( + decode({ a: { count: 1 }, b: { count: 2 }, extra: { count: 3, ignored: true } }), + { a: { count: 1 }, b: { count: 2 }, extra: { count: 3 } } + ) + throws(() => decode({ a: { count: "invalid" } }), (error) => { + assertSchemaIssueError(error, `Expected number\n at ["a"]["count"]`) + }) + }) + + it("compiles symbol and template-literal records", () => { + const symbol = Symbol("key") + const decodeSymbols = SchemaParser.decodeUnknownSync(Schema.Record(Schema.Symbol, Schema.Number)) + const symbolOutput = decodeSymbols({ text: "ignored", [symbol]: 1 }) + strictEqual(symbolOutput[symbol], 1) + deepStrictEqual(Reflect.ownKeys(symbolOutput), [symbol]) + + const decodeTemplates = SchemaParser.decodeUnknownSync( + Schema.Record(Schema.TemplateLiteral(["data-", Schema.String]), Schema.Number) + ) + deepStrictEqual( + decodeTemplates({ "data-a": 1, ignored: 2, "data-b": 3 }), + { "data-a": 1, "data-b": 3 } + ) + }) + + it("compiles fixed properties with index signatures", () => { + const decode = SchemaParser.decodeUnknownSync( + Schema.StructWithRest( + Schema.Struct({ fixed: Schema.Trim }), + [Schema.Record(Schema.String, Schema.String)] + ) + ) + deepStrictEqual(decode({ fixed: " value ", other: "other" }), { fixed: "value", other: "other" }) + }) + + it("compiles decoded index keys", () => { + const decodeNumbers = SchemaParser.decodeUnknownSync(Schema.Record(Schema.Number, Schema.Number)) + deepStrictEqual(decodeNumbers({ 1: 1, other: "ignored" }), { 1: 1 }) + + const decodeCamelCase = SchemaParser.decodeUnknownSync( + Schema.Record( + Schema.String.pipe(Schema.decodeTo(Schema.String, SchemaTransformation.toUpperCase())), + Schema.Number + ) + ) + deepStrictEqual(decodeCamelCase({ a: 1, b: 2 }), { A: 1, B: 2 }) + }) + + it("compiles encoding checks", () => { + const checked = Schema.Struct({ value: Schema.String }).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((input) => input.value.length > 1)), + Schema.flip + ) + const decode = SchemaParser.decodeUnknownSync(checked) + deepStrictEqual(decode({ value: "valid" }), { value: "valid" }) + throws(() => decode({ value: "" })) + }) + + it("runs checks against decoded output", () => { + const decodeString = SchemaParser.decodeUnknownSync( + Schema.String.check(Schema.isMinLength(2)) + ) + strictEqual(decodeString("ab"), "ab") + throws(() => decodeString("a"), (error) => { + assertSchemaIssueError(error, "Expected a value with a length of at least 2") + }) + + const decodeObject = SchemaParser.decodeUnknownSync( + Schema.Struct({ value: Schema.String }).check(Schema.isMaxProperties(1)) + ) + deepStrictEqual(decodeObject({ value: "a", extra: true }), { value: "a" }) + }) + + it("runs compiled type guard checks against decoded output", () => { + const checked = Schema.Struct({ value: Schema.String }).check(Schema.isMaxProperties(1)) + const isRoot = SchemaParser.is(checked) + const isNested = SchemaParser.is(Schema.Struct({ nested: checked })) + + strictEqual(isRoot({ value: "a", extra: true }), true) + strictEqual(isNested({ nested: { value: "a", extra: true }, extra: true }), true) + strictEqual(isNested({ nested: { value: 1 } }), false) + }) + + it("uses compiled checkpoints around interpreted declarations and transformations", () => { + const date = new Date(0) + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ + date: Schema.instanceOf(Date), + count: Schema.FiniteFromString + })) + + const output = decode({ date, count: "1", extra: true }) + strictEqual(output.date, date) + strictEqual(output.count, 1) + deepStrictEqual(Reflect.ownKeys(output), ["date", "count"]) + }) + + it("uses compiled checkpoints inside root encoding chains", () => { + const decodeNumber = SchemaParser.decodeUnknownSync(Schema.FiniteFromString) + strictEqual(decodeNumber("1"), 1) + throws(() => decodeNumber("invalid"), (error) => { + assertSchemaIssueError(error, "Expected a finite number") + }) + + const decodeJson = SchemaParser.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ value: Schema.Number })) + ) + deepStrictEqual(decodeJson("{\"value\":1,\"extra\":true}"), { value: 1 }) + }) + + it("does not replay transformations when a compiled checkpoint fails", () => { + let sourceChecks = 0 + let firstTransformations = 0 + let secondTransformations = 0 + let checks = 0 + const schema = Schema.Struct({ + value: Schema.String.check(Schema.makeFilter((value) => { + sourceChecks++ + return value !== "blocked" + })).pipe( + Schema.decodeTo( + Schema.Number.check(Schema.makeFilter((value) => { + checks++ + return value > 0 + })), + SchemaTransformation.transform({ + decode: (value) => { + firstTransformations++ + return Number(value) + }, + encode: String + }) + ), + Schema.decodeTo( + Schema.String, + SchemaTransformation.transform({ + decode: (value) => { + secondTransformations++ + return String(value) + }, + encode: Number + }) + ) + ) + }) + + throws(() => SchemaParser.decodeUnknownSync(schema)({ value: "blocked" })) + strictEqual(sourceChecks, 2) + strictEqual(firstTransformations, 0) + strictEqual(secondTransformations, 0) + + sourceChecks = 0 + throws(() => SchemaParser.decodeUnknownSync(schema)({ value: "-1" })) + strictEqual(sourceChecks, 1) + strictEqual(firstTransformations, 1) + strictEqual(secondTransformations, 0) + strictEqual(checks, 2) + }) + + it("preserves mixed causes from interpreted encoding chains", () => { + const cause = Cause.combine( + Cause.fail(new SchemaIssue.InvalidValue({ message: "schema issue" })), + Cause.die(new Error("defect")) + ) + const schema = Schema.String.pipe(Schema.decode({ + decode: new SchemaGetter.Getter(() => Effect.failCause(cause)), + encode: SchemaGetter.passthrough() + })) + + throws(() => SchemaParser.decodeUnknownSync(schema)("value"), (error) => { + assert(error instanceof Error) + strictEqual(error.message, "Sync adapter can only throw schema issues") + const issue = Cause.findError(error.cause as Cause.Cause) + assert(Result.isSuccess(issue)) + strictEqual(issue.success._tag, "Encoding") + assert(Cause.hasDies(error.cause as Cause.Cause)) + }) + }) + + it("keeps Suspend parsers lazy", () => { + interface Category { + readonly value: string + readonly children: ReadonlyArray + } + let evaluations = 0 + const schema: Schema.Codec = Schema.Struct({ + value: Schema.String, + children: Schema.Array(Schema.suspend((): Schema.Codec => { + evaluations++ + return schema + })) + }) + const decode = SchemaParser.decodeUnknownSync(schema) + + deepStrictEqual(decode({ value: "root", children: [] }), { value: "root", children: [] }) + strictEqual(evaluations, 0) + deepStrictEqual( + decode({ value: "root", children: [{ value: "child", children: [] }] }), + { value: "root", children: [{ value: "child", children: [] }] } + ) + strictEqual(evaluations, 1) + }) + + it("does not replay declaration defects", () => { + const defect = new Error("declaration defect") + let runs = 0 + const declaration = Schema.declareConstructor()([], () => () => { + runs++ + return Effect.die(defect) + }) + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ declaration })) + + throws(() => decode({ declaration: "value" }), (error) => { + assert(error instanceof Error) + strictEqual(error.message, "Sync adapter can only throw schema issues") + assert(Cause.hasDies(error.cause as Cause.Cause)) + }) + strictEqual(runs, 1) + }) +}) diff --git a/packages/effect/test/schema/SchemaJITCompilerFallback.test.ts b/packages/effect/test/schema/SchemaJITCompilerFallback.test.ts new file mode 100644 index 00000000000..2ff42e7826c --- /dev/null +++ b/packages/effect/test/schema/SchemaJITCompilerFallback.test.ts @@ -0,0 +1,178 @@ +import { assert, describe, it, vi } from "@effect/vitest" +import { Cause, Schema, SchemaParser, SchemaTransformation } from "effect" +import * as Codegen from "effect/internal/schema/codegen" +// oxlint-disable-next-line no-unassigned-import +import "effect/unstable/schema/SchemaJITCompiler/enable" +import { assertSchemaIssueError, deepStrictEqual, strictEqual, throws } from "../utils/assert.ts" + +describe("Schema JIT compilation fallback", () => { + for (const phase of ["construction", "factory"] as const) { + for (const firstOperation of ["decode", "is"] as const) { + it(`recovers ${phase} failure during ${firstOperation} without retrying`, () => { + const schema = Schema.Struct({ value: Schema.String }) + const getParser = vi.spyOn(schema.ast, "getParser") + const Function = globalThis.Function + let attempts = 0 + try { + globalThis.Function = ((...args: Array) => { + if (args.length === 1 && args[0] === "return true") return Function(...args) + attempts++ + if (phase === "construction") throw new SyntaxError("invalid generated source") + return () => { + throw new Error("generated factory failed") + } + }) as FunctionConstructor + + const decode = SchemaParser.decodeUnknownSync(schema) + const is = SchemaParser.is(schema) + const input = { value: "valid", extra: true } + if (firstOperation === "is") strictEqual(is(input), true) + deepStrictEqual(decode(input), { value: "valid" }) + strictEqual(is(input), true) + strictEqual(is({ value: 1 }), false) + throws(() => decode({ value: 1 }), (error) => { + assertSchemaIssueError(error, `Expected string\n at ["value"]`) + }) + throws(() => decode(input, { onExcessProperty: "error" }), (error) => { + assertSchemaIssueError(error, `Expected no excess property\n at ["extra"]`) + }) + deepStrictEqual(SchemaParser.decodeUnknownSync(schema)(input), { value: "valid" }) + strictEqual(attempts, 1) + strictEqual(getParser.mock.calls.length, 1) + } finally { + globalThis.Function = Function + getParser.mockRestore() + } + }) + } + } + + for (const phase of ["generate"] as const) { + it(`recovers errors in ${phase} without disabling other schemas`, () => { + const failure = vi.spyOn(Codegen, phase).mockImplementationOnce(() => { + throw new Error("compiler failed") + }) + const schema = Schema.Struct({ value: Schema.String }) + const getParser = vi.spyOn(schema.ast, "getParser") + try { + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode({ value: "valid" }), { value: "valid" }) + strictEqual(getParser.mock.calls.length, 1) + const attempts = failure.mock.calls.length + deepStrictEqual(decode({ value: "again" }), { value: "again" }) + strictEqual(failure.mock.calls.length, attempts) + + const other = Schema.Struct({ value: Schema.String }) + const otherParser = vi.spyOn(other.ast, "getParser") + try { + deepStrictEqual(SchemaParser.decodeUnknownSync(other)({ value: "compiled" }), { value: "compiled" }) + strictEqual(otherParser.mock.calls.length, 0) + } finally { + otherParser.mockRestore() + } + } finally { + failure.mockRestore() + getParser.mockRestore() + } + }) + } + + it("recovers composed decoder generation without repeating transformations", () => { + let transformations = 0 + const field = Schema.String.pipe(Schema.decodeTo( + Schema.String, + SchemaTransformation.transform({ + decode: (value) => { + transformations++ + return value.trim() + }, + encode: (value) => value + }) + )) + const schema = Schema.Struct({ value: field }) + const generate = Codegen.generate + const failure = vi.spyOn(Codegen, "generate").mockImplementation((ast, operation) => { + if (ast === schema.ast) throw new Error("composed decoder generation failed") + return generate(ast, operation) + }) + try { + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode({ value: " a " }), { value: "a" }) + deepStrictEqual(decode({ value: " b " }), { value: "b" }) + strictEqual(transformations, 2) + strictEqual(failure.mock.calls.filter(([ast]) => ast === schema.ast).length, 1) + } finally { + failure.mockRestore() + } + }) + + it("recovers decoder generation without replaying its encoding or middleware", () => { + let transformations = 0 + let middlewareRuns = 0 + const schema = Schema.String.pipe( + Schema.decodeTo( + Schema.Struct({ value: Schema.String.check(Schema.isMinLength(2)) }), + SchemaTransformation.transform({ + decode: (value) => { + transformations++ + return { value: value.trim() } + }, + encode: (value) => value.value + }) + ), + Schema.middlewareDecoding((effect) => { + middlewareRuns++ + return effect + }) + ) + const emit = Codegen.generate + let attempts = 0 + let transformationsAtFailure = 0 + const failure = vi.spyOn(Codegen, "generate").mockImplementation((ast, operation) => { + if (ast === schema.ast) { + attempts++ + transformationsAtFailure = transformations + throw new Error("local checkpoint generation failed") + } + return emit(ast, operation) + }) + try { + const decode = SchemaParser.decodeUnknownSync(schema) + deepStrictEqual(decode(" valid "), { value: "valid" }) + strictEqual(transformationsAtFailure, 0) + strictEqual(transformations, 1) + strictEqual(middlewareRuns, 1) + throws(() => decode(" x ")) + strictEqual(transformations, 2) + strictEqual(middlewareRuns, 2) + strictEqual(attempts, 1) + } finally { + failure.mockRestore() + } + }) + + it("propagates errors from executing generated code without interpreting the input", () => { + const schema = Schema.Struct({ value: Schema.String }) + const getParser = vi.spyOn(schema.ast, "getParser") + const Function = globalThis.Function + let executions = 0 + const defect = new Error("generated parser failed") + try { + globalThis.Function = ((...args: Array) => { + if (args.length === 1 && args[0] === "return true") return Function(...args) + return () => () => { + executions++ + throw defect + } + }) as FunctionConstructor + const result = SchemaParser.decodeUnknownExit(schema)({ value: "valid" }) + assert(result._tag === "Failure") + assert(Cause.hasDies(result.cause)) + strictEqual(executions, 1) + strictEqual(getParser.mock.calls.length, 0) + } finally { + globalThis.Function = Function + getParser.mockRestore() + } + }) +}) diff --git a/packages/effect/test/schema/fixtures/aot-import-guard.ts b/packages/effect/test/schema/fixtures/aot-import-guard.ts new file mode 100644 index 00000000000..35365952321 --- /dev/null +++ b/packages/effect/test/schema/fixtures/aot-import-guard.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict" +import { registerHooks } from "node:module" + +registerHooks({ + resolve(specifier, context, nextResolve) { + const resolved = nextResolve(specifier, context) + assert.doesNotMatch( + resolved.url, + /\/(?:internal\/schema\/(?:codegen|jitCompiler)|unstable\/schema\/Schema(?:AOT|JIT)Compiler)(?:\.|\/)/, + "Generated modules must not load source generation or the JIT compiler" + ) + return resolved + } +}) diff --git a/packages/effect/test/schema/fixtures/aot-runner.ts b/packages/effect/test/schema/fixtures/aot-runner.ts new file mode 100644 index 00000000000..9959d398378 --- /dev/null +++ b/packages/effect/test/schema/fixtures/aot-runner.ts @@ -0,0 +1,173 @@ +import { Effect, Result, type SchemaAST, SchemaParser } from "effect" +import type * as CompilerRegistryModule from "effect/internal/schema/compilerRegistry" +import assert from "node:assert/strict" +import { join } from "node:path" +import { pathToFileURL } from "node:url" +import { asyncFixture, events, lazy, proof, roots, schemas, suspendEvaluations, synchronous } from "./aot.ts" +import { + Constructed, + constructionCases, + constructionEvents, + constructionOptions, + constructionSchemas +} from "./construction.ts" + +const CompilerRegistry: typeof CompilerRegistryModule = await import( + new URL("../../../src/internal/schema/compilerRegistry.ts", import.meta.url).href +) + +const options: ReadonlyArray = [ + undefined, + { errors: "all", reportInput: true }, + { onExcessProperty: "error" }, + { disableChecks: true } +] + +const snapshot = () => + Object.fromEntries( + Object.entries(synchronous).map(([name, { inputs, schema }]) => { + const is = SchemaParser.is(schema) + const results = options.map((option) => { + const decode = SchemaParser.decodeUnknownResult(schema, option) + return inputs.map((input) => { + events.length = 0 + const result = decode(input) + const calls = [...events] + return { + // Template diagnostics construct internal ASTs with fresh functions. + result: name === "templateLiteral" || name === "templateLiteralParser" + ? Result.mapError(result, (issue) => JSON.stringify(issue)) + : result, + calls, + is: is(input) + } + }) + }) + return [name, results] + }) + ) + +const snapshotAsync = async () => { + const results = [] + const decode = SchemaParser.decodeUnknownEffect(asyncFixture.schema) + for (const input of asyncFixture.inputs) { + events.length = 0 + const result = await Effect.runPromise(Effect.result(decode(input))) + results.push({ result, calls: [...events] }) + } + return results +} + +assert.throws(() => new Function("return true"), EvalError) +const interpreted = snapshot() +const interpretedAsync = await snapshotAsync() +const snapshotConstruction = async () => { + const out = [] + for (const fixture of Object.values(constructionCases)) { + const make = SchemaParser.makeEffect(fixture.schema) + for (const parseOptions of constructionOptions) { + for (const input of fixture.inputs) { + constructionEvents.length = 0 + const result = await Effect.runPromise(Effect.result(make(input as never, { parseOptions }))) + out.push({ result, events: [...constructionEvents] }) + } + } + } + return out +} +const interpretedConstruction = await snapshotConstruction() +assert.equal(suspendEvaluations, 0) + +const prepareProof = proof.ast.getParser.bind(proof.ast) +Object.defineProperty(proof.ast, "getParser", { + value(...args: Parameters) { + assert.equal(typeof args[2], "function", "AOT must supply the generated property loop") + return prepareProof(...args) + } +}) + +const before = CompilerRegistry.resolve(schemas.struct.ast) +const empty = await import(pathToFileURL(join(process.argv[2], "empty.mjs")).href) +assert.equal(empty.install([]), undefined) +assert.equal(CompilerRegistry.resolve(schemas.struct.ast), before) + +if (process.argv[3] === "multiple") { + const generated = await import(pathToFileURL(join(process.argv[2], "all.mjs")).href) + assert.equal(CompilerRegistry.resolve(schemas.struct.ast), before) + assert.equal(generated.install(roots), undefined) +} else { + for (const [name, schema] of Object.entries(schemas)) { + const before = name === "struct" ? CompilerRegistry.resolve(schema.ast) : undefined + const generated = await import(pathToFileURL(join(process.argv[2], `${name}.mjs`)).href) + if (before !== undefined) assert.equal(CompilerRegistry.resolve(schema.ast), before) + assert.equal(generated.install([schema.ast]), undefined) + } +} +assert.equal(suspendEvaluations, 0) + +for ( + const name of [ + "struct", + "array", + "tuple", + "tagged", + "sentinel", + "sentinelLookup", + "record", + "transformed", + "transformedStruct", + "checkedTransformedStruct", + "encodingCheckedTransformedStruct", + "asynchronous", + "middleware" + ] +) { + assert.equal(CompilerRegistry.resolve(schemas[name].ast).source !== undefined, true, name) +} + +assert.deepEqual(snapshot(), interpreted) +assert.deepEqual(await snapshotAsync(), interpretedAsync) +for (const [name, schema] of Object.entries(constructionSchemas)) { + if (name === "construct-declaration") continue + assert.equal(CompilerRegistry.resolve(schema.ast).source !== undefined, true, name) +} +assert.deepEqual(await snapshotConstruction(), interpretedConstruction) +const instance = Constructed.make({}) +constructionEvents.length = 0 +assert.equal(Constructed.make(instance), instance) +assert.equal(await Effect.runPromise(Constructed.makeEffect(instance)), instance) +assert.deepEqual(constructionEvents, []) + +const transform = SchemaParser.decodeUnknownResult(schemas.transformed) +events.length = 0 +assert.deepEqual(transform("2"), Result.succeed(2)) +assert.deepEqual(events, ["transform"]) +events.length = 0 +assert.ok(Result.isFailure(transform("-1"))) +assert.deepEqual(events, ["transform"]) + +events.length = 0 +assert.deepEqual(SchemaParser.decodeUnknownResult(schemas.middleware)("-1"), Result.succeed(1)) +assert.deepEqual(events, ["transform", "middleware", "recover"]) + +assert.deepEqual(SchemaParser.decodeUnknownSync(proof)({ value: "a", extra: true }), { value: "a" }) +assert.deepEqual(SchemaParser.make(proof)({ value: "constructed" }), { value: "constructed" }) +assert.ok(Result.isFailure(SchemaParser.decodeUnknownResult(proof)({ value: 1 }))) +assert.equal(SchemaParser.is(proof)({ value: "a" }), true) +assert.equal(SchemaParser.is(proof)({ value: 1 }), false) +assert.deepEqual(SchemaParser.decodeUnknownSync(schemas.proofArray)([{ value: "a", extra: true }]), [{ value: "a" }]) +assert.ok(Result.isFailure(SchemaParser.decodeUnknownResult(schemas.proofArray)([{ value: 1 }]))) +let invalidReads = 0 +assert.ok(Result.isFailure( + SchemaParser.decodeUnknownResult(schemas.proofArray)([{ + get value() { + invalidReads++ + return 1 + } + }]) +)) +assert.equal(invalidReads, 2) + +assert.deepEqual(SchemaParser.decodeUnknownSync(lazy)({ value: "lazy" }), { value: "lazy" }) +assert.equal(suspendEvaluations, 1) +process.stdout.write("AOT integration passed\n") diff --git a/packages/effect/test/schema/fixtures/aot.ts b/packages/effect/test/schema/fixtures/aot.ts new file mode 100644 index 00000000000..3841ef9fcc0 --- /dev/null +++ b/packages/effect/test/schema/fixtures/aot.ts @@ -0,0 +1,201 @@ +import { Effect, Option, Schema, SchemaGetter, SchemaTransformation } from "effect" +import { invalid } from "effect/unstable/schema/SchemaCompiler" +import { constructionSchemas } from "./construction.ts" + +export const key = Symbol("key") +export const token = Symbol("token") +export const events: Array = [] + +const transformed = Schema.String.pipe( + Schema.decodeTo( + Schema.Number.check(Schema.isGreaterThan(0)), + SchemaTransformation.transform({ + decode: (input) => { + events.push("transform") + return Number(input) + }, + encode: String + }) + ) +) + +const middleware = transformed.pipe( + Schema.middlewareDecoding((effect) => { + events.push("middleware") + return Effect.catchEager(effect, () => { + events.push("recover") + return Effect.succeed(Option.some(1)) + }) + }) +) + +const asynchronous = Schema.String.pipe( + Schema.decodeTo(Schema.Number.check(Schema.isGreaterThan(0)), { + decode: new SchemaGetter.Getter((input) => { + events.push("async") + return Effect.yieldNow.pipe(Effect.as(Option.map(input, Number))) + }), + encode: SchemaGetter.transform(String) + }) +) + +interface Fixture { + readonly schema: Schema.ConstraintDecoder + readonly inputs: ReadonlyArray +} + +export const synchronous = { + struct: { + schema: Schema.Struct({ + name: Schema.String, + nested: Schema.Struct({ count: Schema.Number.check(Schema.isGreaterThan(0)) }), + optional: Schema.optionalKey(Schema.String) + }).check(Schema.makeFilter((input) => Object.keys(input).length <= 3)), + inputs: [ + { name: "a", nested: { count: 1, ignored: true }, extra: true }, + { name: "a", nested: { count: -1 } }, + { name: 1, nested: { count: "invalid" } }, + { name: "a" } + ] + }, + array: { + schema: Schema.Array(Schema.Struct({ value: Schema.String })), + inputs: [[{ value: "a", extra: true }], [{ value: 1 }, {}], null] + }, + tuple: { + schema: Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number, Schema.Boolean]), + inputs: [["a", 1, 2, true], ["a", true], ["a", "invalid", false], ["a"]] + }, + tagged: { + schema: Schema.Union([ + Schema.Struct({ kind: Schema.Literal("a"), value: Schema.String }), + Schema.Struct({ kind: Schema.Literal("b"), value: Schema.Number }) + ]), + inputs: [{ kind: "b", value: 1 }, { kind: "b", value: "invalid" }, { kind: "c" }] + }, + literals: { + schema: Schema.Literals(["a", "b", 0, 1, 2, 3, 4, 5, 6, 7, 8]), + inputs: ["a", -0, 8, 9, null] + }, + oneOf: { + schema: Schema.Union([Schema.String, Schema.Literal("a")], { mode: "oneOf" }), + inputs: ["b", "a", false] + }, + sentinel: { + schema: Schema.Union([Schema.Symbol, Schema.String]), + inputs: [invalid] + }, + sentinelLookup: { + schema: Schema.Union([Schema.UniqueSymbol(invalid), Schema.Literal("valid")]), + inputs: [invalid] + }, + checkedSentinel: { + schema: Schema.Struct({ value: Schema.Union([Schema.Symbol, Schema.String]) }).check( + Schema.makeFilter(() => true) + ), + inputs: [{ value: invalid }] + }, + symbols: { + schema: Schema.Struct({ [key]: Schema.UniqueSymbol(token) }), + inputs: [{ [key]: token }, { [key]: key }, {}] + }, + enumeration: { + schema: Schema.Enum({ a: "a", b: "b", c: 0, d: 1, e: 2, f: 3, g: 4, h: 5, i: 6 }), + inputs: ["a", -0, 6, "invalid"] + }, + record: { + schema: Schema.Record(Schema.String, Schema.Struct({ count: Schema.Number })), + inputs: [{ a: { count: 1 } }, { a: { count: "invalid" }, b: {} }, {}] + }, + mixedRecord: { + schema: Schema.StructWithRest( + Schema.Struct({ fixed: Schema.Number }), + [ + Schema.Record(Schema.TemplateLiteral(["data-", Schema.String]), Schema.Number), + Schema.Record(Schema.Symbol, Schema.Number) + ] + ), + inputs: [ + { fixed: 1, "data-a": 2, [key]: 3 }, + { fixed: 1, ignored: true }, + { fixed: 1, [key]: "invalid" } + ] + }, + numericRecord: { + schema: Schema.Record(Schema.Union([Schema.Literal(1), Schema.Symbol]), Schema.String), + inputs: [{ 1: "one", [key]: "symbol" }, { 1: "one", extra: true }, { 1: 1 }] + }, + templateLiteral: { + schema: Schema.TemplateLiteral(["count:", Schema.Int.check(Schema.isGreaterThan(0))]), + inputs: ["count:1", "count:0", "count:1.5", "invalid", null] + }, + templateLiteralParser: { + schema: Schema.TemplateLiteralParser(["bit:", Schema.BooleanFromBit]), + inputs: ["bit:1", "bit:0", "bit:true", null] + }, + transformed: { schema: transformed, inputs: ["2", "-1", false] }, + checkedTransformedStruct: { + schema: Schema.Struct({ value: transformed }).check(Schema.makeFilter((output) => { + events.push("struct check") + return output.value < 10 && Object.keys(output).length === 1 + })), + inputs: [{ value: "2", extra: true }, { value: "12" }, { value: "-1" }, {}] + }, + encodingCheckedTransformedStruct: { + schema: Schema.Struct({ value: transformed }).pipe( + Schema.flip, + Schema.check(Schema.makeFilter((input) => { + events.push("encoding check") + return input.value !== "02" + })), + Schema.flip + ), + inputs: [{ value: "2" }, { value: "02" }, { value: "-1" }] + }, + transformedStruct: { + schema: Schema.Struct({ before: Schema.String, value: transformed, after: Schema.Boolean }), + inputs: [ + { before: "a", value: "2", after: true }, + { before: "a", value: "-1", after: true }, + { before: "a", value: "2", after: "invalid" } + ] + }, + middleware: { + schema: middleware, + inputs: ["2", "-1", false] + } +} satisfies Record + +export const asyncFixture = { + schema: Schema.Struct({ before: Schema.String, value: asynchronous, after: Schema.Boolean }).check( + Schema.makeFilter((output) => { + events.push("async struct check") + return output.value < 10 + }) + ), + inputs: [ + { before: "a", value: "2", after: true }, + { before: "a", value: "12", after: true }, + { before: "a", value: "-1", after: true }, + { before: "a", value: "2", after: "invalid" } + ] +} + +export let suspendEvaluations = 0 +export const lazy = Schema.suspend(() => { + suspendEvaluations++ + return Schema.Struct({ value: Schema.String }) +}) + +export const proof = Schema.Struct({ value: Schema.String }) + +export const schemas: Readonly>> = { + ...constructionSchemas, + ...Object.fromEntries(Object.entries(synchronous).map(([name, fixture]) => [name, fixture.schema])), + asynchronous: asyncFixture.schema, + lazy, + proofArray: Schema.Array(proof), + proof +} + +export const roots = [...Object.values(schemas).map((schema) => schema.ast), proof.ast] diff --git a/packages/effect/test/schema/fixtures/construction.ts b/packages/effect/test/schema/fixtures/construction.ts new file mode 100644 index 00000000000..395b30a0e7b --- /dev/null +++ b/packages/effect/test/schema/fixtures/construction.ts @@ -0,0 +1,70 @@ +import { Effect, Schema, SchemaAST } from "effect" + +export const constructionEvents: Array = [] +const value = Schema.Number.pipe(Schema.withConstructorDefault(Effect.sync(() => { + constructionEvents.push("default") + return 1 +}))) +export class Constructed extends Schema.TaggedClass()("Constructed", { + value +}) { + readonly initialized = constructionEvents.push("class") > 0 +} + +export const constructionCases = { + struct: { + schema: Schema.Struct({ value, optional: Schema.optionalKey(Schema.String) }) + .check(Schema.makeFilter((input) => { + constructionEvents.push("check") + return input.value > 0 + })), + inputs: [{}, { value: undefined }, { value: -1 }, { value: "bad", optional: 1 }, { value: 2, extra: true }] + }, + array: { schema: Schema.Array(value), inputs: [[undefined, 2], ["bad", 3], null] }, + tuple: { + schema: Schema.TupleWithRest(Schema.Tuple([value]), [Schema.String, Schema.Boolean]), + inputs: [[], [undefined, "a", true], ["bad", 1, false], [1, true]] + }, + optionalTuple: { + schema: Schema.Tuple([Schema.optionalKey(Schema.Undefined)]), + inputs: [[], [undefined], [1], [undefined, 2]] + }, + record: { schema: Schema.Record(Schema.String, value), inputs: [{ a: undefined }, { a: "bad", b: "bad" }, {}, null] }, + mixedRecord: { + schema: Schema.StructWithRest(Schema.Struct({ value }), [ + Schema.Record(Schema.TemplateLiteral(["x-", Schema.String]), Schema.Number) + ]), + inputs: [{}, { value: 1, "x-a": 2 }, { value: "bad", "x-a": "bad", extra: true }] + }, + union: { + schema: Schema.Union([ + Schema.Struct({ _tag: Schema.tag("A"), value }), + Schema.Struct({ _tag: Schema.tag("B"), text: Schema.String }) + ]), + inputs: [{}, { text: "a" }, { _tag: "A", value: "bad" }, { _tag: "C" }] + }, + oneOf: { schema: Schema.Union([Schema.String, Schema.Literal("a")], { mode: "oneOf" }), inputs: ["a", "b", 1] }, + class: { + schema: Constructed, + inputs: [{}, { value: undefined }, { value: -1 }, { value: "bad" }, { _tag: "wrong" }] + }, + transformed: { + schema: Schema.Struct({ value: Schema.FiniteFromString.pipe(Schema.withConstructorDefault(Effect.succeed(1))) }), + inputs: [{}, { value: "1" }, { value: 2 }] + }, + declaration: { schema: Schema.ReadonlySet(Schema.Number), inputs: [new Set([1]), new Set(["bad"]), null] }, + empty: { schema: Schema.Struct({}), inputs: [{}, 1, [], null] } +} satisfies Record }> + +export const constructionSchemas = Object.fromEntries( + Object.entries(constructionCases).map(( + [name, test] + ) => [`construct-${name}`, Schema.make(SchemaAST.toType(test.schema.ast))]) +) + +export const constructionOptions: ReadonlyArray = [ + undefined, + { errors: "all", reportInput: true }, + { onExcessProperty: "error" }, + { disableChecks: true } +] diff --git a/packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts b/packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts new file mode 100644 index 00000000000..613675606ec --- /dev/null +++ b/packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts @@ -0,0 +1,14 @@ +import { Schema, SchemaAST } from "effect" +import * as SchemaAOTCompiler from "effect/unstable/schema/SchemaAOTCompiler" +import { describe, expect, it } from "tstyche" + +describe("SchemaAOTCompiler", () => { + it("compiles ASTs to module source", () => { + expect(SchemaAOTCompiler.compile([Schema.String.ast])).type.toBe() + expect(SchemaAOTCompiler.compile).type.toBeCallableWith([SchemaAST.string, SchemaAST.number] as const) + expect(SchemaAOTCompiler.compile).type.toBeCallableWith([]) + expect(SchemaAOTCompiler.compile).type.not.toBeCallableWith(SchemaAST.string) + expect(SchemaAOTCompiler.compile).type.not.toBeCallableWith(Schema.String) + expect(SchemaAOTCompiler.compile).type.not.toBeCallableWith([Schema.String]) + }) +}) diff --git a/packages/effect/typetest/schema/SchemaCompiler.tst.ts b/packages/effect/typetest/schema/SchemaCompiler.tst.ts new file mode 100644 index 00000000000..e271b4790ba --- /dev/null +++ b/packages/effect/typetest/schema/SchemaCompiler.tst.ts @@ -0,0 +1,23 @@ +import { Effect, Schema } from "effect" +import { SchemaCompiler, SchemaJITCompiler } from "effect/unstable/schema" +import { describe, expect, it } from "tstyche" + +describe("SchemaCompiler", () => { + it("set", () => { + const decoder = { + is: (input, _options) => typeof input === "string", + validate: (input, _options) => typeof input === "string" ? input : SchemaCompiler.invalid, + decodeEffect: (input, _options) => Effect.succeed(input), + makeEffect: (input, _options) => Effect.succeed(input) + } satisfies SchemaCompiler.CompiledDecoder + + expect(SchemaCompiler.set(Schema.String.ast, decoder)).type.toBe() + expect(SchemaCompiler.set).type.toBeCallableWith(Schema.String.ast, { decodeEffect: Effect.succeed }) + expect(SchemaCompiler.set).type.not.toBeCallableWith(Schema.String.ast, { makeEffect: Effect.succeed }) + }) + + it("enable", () => { + expect(SchemaJITCompiler.enable(Schema.String.ast)).type.toBe() + expect(SchemaJITCompiler.enable).type.not.toBeCallableWith(Schema.String) + }) +}) From 512d53c6cd76c21e39009a679b340e4bd0578959 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Fri, 11 Sep 2026 19:44:39 +0200 Subject: [PATCH 02/33] Fix AOT integration test under Bun --- .../test/schema/fixtures/aot-import-guard.ts | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/effect/test/schema/fixtures/aot-import-guard.ts b/packages/effect/test/schema/fixtures/aot-import-guard.ts index 35365952321..011dfaf5979 100644 --- a/packages/effect/test/schema/fixtures/aot-import-guard.ts +++ b/packages/effect/test/schema/fixtures/aot-import-guard.ts @@ -1,14 +1,22 @@ import assert from "node:assert/strict" -import { registerHooks } from "node:module" +import * as Module from "node:module" -registerHooks({ - resolve(specifier, context, nextResolve) { - const resolved = nextResolve(specifier, context) - assert.doesNotMatch( - resolved.url, - /\/(?:internal\/schema\/(?:codegen|jitCompiler)|unstable\/schema\/Schema(?:AOT|JIT)Compiler)(?:\.|\/)/, - "Generated modules must not load source generation or the JIT compiler" - ) - return resolved - } -}) +if (Module.registerHooks !== undefined) { + Module.registerHooks({ + resolve(specifier, context, nextResolve) { + const resolved = nextResolve(specifier, context) + assert.doesNotMatch( + resolved.url, + /\/(?:internal\/schema\/(?:codegen|jitCompiler)|unstable\/schema\/Schema(?:AOT|JIT)Compiler)(?:\.|\/)/, + "Generated modules must not load source generation or the JIT compiler" + ) + return resolved + } + }) +} else { + Object.defineProperty(globalThis, "Function", { + value: function() { + throw new EvalError("Code generation from strings disallowed") + } + }) +} From c5ce3c1a4cd19a12037cb5f9b8378bd287a3c5f3 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Fri, 11 Sep 2026 22:05:46 +0200 Subject: [PATCH 03/33] Optimize schema compiler integration overhead --- packages/effect/runtimeperf/compare.mts | 31 +++++--- packages/effect/src/SchemaParser.ts | 79 +++++++++++++------ .../src/internal/schema/compilerRegistry.ts | 10 +-- 3 files changed, 83 insertions(+), 37 deletions(-) diff --git a/packages/effect/runtimeperf/compare.mts b/packages/effect/runtimeperf/compare.mts index 7c2cf346ea6..b964fcf074a 100644 --- a/packages/effect/runtimeperf/compare.mts +++ b/packages/effect/runtimeperf/compare.mts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process" -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs" +import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs" import os from "node:os" -import { join } from "node:path" +import { dirname, join } from "node:path" import process from "node:process" import { materializeFixture } from "./materialize.mts" import { analyzePairs } from "./stats.mts" @@ -90,6 +90,22 @@ const createWorktree = (runRoot, name, sha) => { return path } +const applyWorktreeChanges = (path, untracked) => { + const diff = runGit(["diff", "--binary", "HEAD", "--"]) + if (diff !== "") { + const result = run("git", ["apply", "--binary", "-"], { cwd: path, input: `${diff}\n` }) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error(`${result.stdout}${result.stderr}`.trim()) + } + } + for (const file of untracked) { + const target = join(path, file.path) + mkdirSync(dirname(target), { recursive: true }) + cpSync(join(repoRoot, file.path), target, { recursive: true }) + } +} + const worktreeState = () => { const diff = runGit(["diff", "--binary", "HEAD", "--"]) const untrackedOutput = runGit([ @@ -136,16 +152,13 @@ const main = () => { try { const baseRoot = createWorktree(runRoot, "base", baseSha) worktrees.push(baseRoot) - const headRoot = options.head === "worktree" - ? repoRoot - : createWorktree(runRoot, "head", headSha) - if (headRoot !== repoRoot) worktrees.push(headRoot) + const headRoot = createWorktree(runRoot, "head", headSha) + worktrees.push(headRoot) + if (options.head === "worktree") applyWorktreeChanges(headRoot, state.untracked) for (const fixture of selected) { const baseFixturePath = materializeFixture(baseRoot, fixture) - const headFixturePath = headRoot === repoRoot - ? fixture.fixturePath - : materializeFixture(headRoot, fixture) + const headFixturePath = materializeFixture(headRoot, fixture) const baseCalibration = calibrateFixture(fixture, defaults, baseFixturePath) const headCalibration = calibrateFixture(fixture, defaults, headFixturePath) const batchSize = Math.max(baseCalibration.batchSize, headCalibration.batchSize) diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index 6ab51743a3f..8061cbe1bea 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -109,18 +109,7 @@ export function makeOption(schema: S) { * @since 4.0.0 */ export function make(schema: S) { - const parser = makeEffect(schema) - return (input: S["~type.make.in"], options?: Schema.MakeOptions): S["Type"] => { - const exit = Effect.runSyncExit(parser(input, options)) - if (Exit.isSuccess(exit)) { - return exit.value - } - const issue = InternalSchemaCause.getSchemaIssueOrThrow( - exit.cause, - "Constructor adapter can only throw schema issues" - ) - throw new Error("Schema validation failed", { cause: issue }) - } + return makeConstructorSync(SchemaAST.toType(schema.ast)) } /** @@ -153,21 +142,19 @@ export function is(schema: S): (input: I) => inp /** @internal */ export function _is(ast: SchemaAST.AST) { const typeAST = SchemaAST.toType(ast) - const options = SchemaAST.defaultParseOptions - let parser: ReturnType> | undefined - let initialized = false - let guard: CompilerRegistry.Entry["is"] + let entry: CompilerRegistry.Entry | undefined + let parser: Parser | undefined + let guard: CompilerRegistry.Entry["is"] | null return (input: I): input is I & T => { - if (!initialized) { - const entry = CompilerRegistry.resolve(typeAST) - guard = entry.is + if (guard === undefined) { + entry = CompilerRegistry.resolve(typeAST) + guard = entry.is ?? null // A value-producing validator can return the invalid symbol as valid data. // Without a boolean validator, use the ordinary diagnostic fallback too. - initialized = true } - if (guard !== undefined) { + if (guard !== null) { try { - return guard(input, options) + return guard(input, SchemaAST.defaultParseOptions) } catch (error) { InternalSchemaCause.getSchemaIssueOrThrow( Cause.die(error), @@ -176,7 +163,8 @@ export function _is(ast: SchemaAST.AST) { return false } } - const exit = (parser ??= asExit(run(typeAST)))(input, options) + const result = (parser ??= entry!.parser)(input, SchemaAST.defaultParseOptions) + const exit = Effect.runSyncExit(parserResult(result, input)) if (Exit.isSuccess(exit)) { return true } @@ -948,6 +936,22 @@ export function run(ast: SchemaAST.AST) { return runWithCompiler(normalCompiler, ast) } +function parserResult( + result: Effect.Effect, + input: unknown +): Effect.Effect { + if (result === InternalParser.sameExit) { + return Effect.succeed(input) as Effect.Effect + } + if (!effectIsExit(result)) { + return Effect.flatMapEager(result, getValue) + } + return (result as InternalParser.Success)[InternalParser.args] === + InternalParser.missing + ? getValue(InternalParser.missing) + : result as Effect.Effect +} + function runWithCompiler(compiler: Compiler, ast: SchemaAST.AST) { let parser: Parser return (input: unknown, options?: SchemaAST.ParseOptions): Effect.Effect => { @@ -1026,6 +1030,7 @@ function makeSync( ): (input: unknown, options?: SchemaAST.ParseOptions) => T { let entry: CompilerRegistry.Entry | undefined let detailed: ((input: unknown, options?: SchemaAST.ParseOptions) => T) | undefined + let parser: Parser | undefined return (input, overrideOptions) => { entry ??= CompilerRegistry.resolve(ast) const parseOptions = options === undefined @@ -1042,6 +1047,10 @@ function makeSync( } if (output !== CompilerRegistry.invalid) return output as T } + if (entry.source === undefined) { + const result = (parser ??= entry.decodeEffect)(input, parseOptions) + return runSync(parserResult(result, input), "Sync adapter can only throw schema issues") + } return (detailed ??= asSync(runWithCompiler(() => entry!.decodeEffect, ast)))(input, parseOptions) } } @@ -1060,6 +1069,30 @@ function asSync( } } +function runSync(effect: Effect.Effect, message: string): T { + const exit = Effect.runSyncExit(effect) + if (Exit.isSuccess(exit)) { + return exit.value + } + const issue = InternalSchemaCause.getSchemaIssueOrThrow(exit.cause, message) + throw new Error("Schema validation failed", { cause: issue }) +} + +function makeConstructorSync( + ast: SchemaAST.AST +): (input: E, options?: Schema.MakeOptions) => T { + let entry: CompilerRegistry.Entry | undefined + let parser: Parser | undefined + return (input, options) => { + entry ??= CompilerRegistry.resolve(ast) + const parseOptions = options?.disableChecks + ? options.parseOptions ? { ...options.parseOptions, disableChecks: true } : { disableChecks: true } + : options?.parseOptions ?? SchemaAST.defaultParseOptions + const result = (parser ??= entry.makeEffect)(input, parseOptions) + return runSync(parserResult(result, input), "Constructor adapter can only throw schema issues") + } +} + /** @internal */ export interface Parser { ( diff --git a/packages/effect/src/internal/schema/compilerRegistry.ts b/packages/effect/src/internal/schema/compilerRegistry.ts index 2a88da20ed7..fe0f620580f 100644 --- a/packages/effect/src/internal/schema/compilerRegistry.ts +++ b/packages/effect/src/internal/schema/compilerRegistry.ts @@ -43,11 +43,11 @@ export class Entry { } get is(): Is | undefined { - return this.save("is", this.source?.is) + return this.source === undefined ? undefined : this.save("is", this.source.is) } get validate(): Validate | undefined { - return this.save("validate", this.source?.validate) + return this.source === undefined ? undefined : this.save("validate", this.source.validate) } get decodeEffect(): Parser { @@ -62,7 +62,7 @@ export class Entry { get parser(): Parser { const validate = this.validate - if (validate === undefined) return this.save("parser", this.decodeEffect) + if (validate === undefined) return this.decodeEffect return this.save("parser", withValidation(validate, () => this.decodeEffect)) } @@ -79,9 +79,9 @@ export class Entry { get makeDefaulted(): Parser { const link = this.ast.context?.constructorDefault - return this.save( + return link === undefined ? this.makeEffect : this.save( "makeDefaulted", - link === undefined ? this.makeEffect : Interpreter.withDefault( + Interpreter.withDefault( this.ast, (input, options) => this.makeEffect(input, options), this.resolve === resolve ? makeChild : (ast) => lazyParser(this.resolve, ast, "makeEffect") From dcd9096cff2955a73db01ce13309c24129ba72ea Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Fri, 11 Sep 2026 22:40:41 +0200 Subject: [PATCH 04/33] Add Zod compiler benchmarks --- packages/effect/runtimeperf/README.md | 3 + packages/effect/runtimeperf/config.json | 126 ++++++++++++++++++ packages/effect/runtimeperf/run.mts | 10 +- .../suites/compiler-rebuild/README.md | 15 ++- .../compiler-rebuild/fixtures/zod-compiled.ts | 119 +++++++++++++++++ .../effect/runtimeperf/test/registry.test.mts | 14 +- 6 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts diff --git a/packages/effect/runtimeperf/README.md b/packages/effect/runtimeperf/README.md index a1d28cca18c..0670edaf3ea 100644 --- a/packages/effect/runtimeperf/README.md +++ b/packages/effect/runtimeperf/README.md @@ -42,6 +42,7 @@ pnpm runtimeperf object-32-valid pnpm runtimeperf schema/object-32-valid-effect pnpm runtimeperf --family arrays pnpm runtimeperf --implementation zod4 +pnpm runtimeperf --implementation zod4-compiled ``` Override measurement settings: @@ -123,6 +124,8 @@ Zod parsing cases import `zod/v4` and call `safeParse` with `{ jitless: true }`; its Standard Schema and codec cases use their native APIs. Valibot uses the corresponding `is`, `safeParse` and Standard Schema APIs. The focused Effect adapter family measures the overhead of public APIs that wrap parser issues. +The compiler comparison calls `z.compile(schema, { strict: true })` and uses +Zod's `validate` API for boolean checks. ## Measurement model diff --git a/packages/effect/runtimeperf/config.json b/packages/effect/runtimeperf/config.json index 8b3ecdaead4..3f93f380ca4 100644 --- a/packages/effect/runtimeperf/config.json +++ b/packages/effect/runtimeperf/config.json @@ -2402,6 +2402,132 @@ "size": 8 } ] + }, + { + "file": "suites/compiler-rebuild/fixtures/zod-compiled.ts", + "defaults": { + "tier": 1, + "family": "zod-compiled", + "implementation": "zod4-compiled", + "astTags": [], + "operation": "decode", + "path": "valid", + "size": 3 + }, + "cases": [ + { + "name": "zod-compiled-parseValid", + "export": "parseValid", + "scenario": "compiler-rebuild-parseValid" + }, + { + "name": "zod-compiled-parseExtra", + "export": "parseExtra", + "scenario": "compiler-rebuild-parseExtra", + "size": 4 + }, + { + "name": "zod-compiled-parseInvalid", + "export": "parseInvalid", + "scenario": "compiler-rebuild-parseInvalid", + "path": "invalid" + }, + { + "name": "zod-compiled-isValid", + "export": "isValid", + "scenario": "compiler-rebuild-isValid", + "operation": "is" + }, + { + "name": "zod-compiled-isInvalid", + "export": "isInvalid", + "scenario": "compiler-rebuild-isInvalid", + "operation": "is", + "path": "invalid" + }, + { + "name": "zod-compiled-encode", + "export": "encode", + "scenario": "compiler-rebuild-encode", + "operation": "encode" + }, + { + "name": "zod-compiled-strict", + "export": "strict", + "scenario": "compiler-rebuild-strict" + }, + { + "name": "zod-compiled-nested", + "export": "nested", + "scenario": "compiler-rebuild-nested", + "size": 2 + }, + { + "name": "zod-compiled-array", + "export": "array", + "scenario": "compiler-rebuild-array", + "size": 32 + }, + { + "name": "zod-compiled-arrayInvalid", + "export": "arrayInvalid", + "scenario": "compiler-rebuild-arrayInvalid", + "path": "invalid", + "size": 33 + }, + { + "name": "zod-compiled-record", + "export": "record", + "scenario": "compiler-rebuild-record", + "size": 32 + }, + { + "name": "zod-compiled-union", + "export": "union", + "scenario": "compiler-rebuild-union", + "size": 8 + }, + { + "name": "zod-compiled-transform", + "export": "transform", + "scenario": "compiler-rebuild-transform", + "size": 32 + }, + { + "name": "zod-compiled-transformInvalid", + "export": "transformInvalid", + "scenario": "compiler-rebuild-transformInvalid", + "path": "invalid", + "size": 1 + }, + { + "name": "zod-compiled-declaration", + "export": "declaration", + "scenario": "compiler-rebuild-declaration", + "size": 32 + }, + { + "name": "zod-compiled-makeStruct", + "export": "makeStruct", + "scenario": "compiler-rebuild-makeStruct", + "operation": "make", + "size": 32 + }, + { + "name": "zod-compiled-makeArray", + "export": "makeArray", + "scenario": "compiler-rebuild-makeArray", + "operation": "make", + "size": 32 + }, + { + "name": "zod-compiled-makeUnion", + "export": "makeUnion", + "scenario": "compiler-rebuild-makeUnion", + "operation": "make", + "size": 8 + } + ] } ] } diff --git a/packages/effect/runtimeperf/run.mts b/packages/effect/runtimeperf/run.mts index b97665f60bb..e4f1e683d5f 100644 --- a/packages/effect/runtimeperf/run.mts +++ b/packages/effect/runtimeperf/run.mts @@ -31,7 +31,7 @@ Options: --warmup-time --tier <0-3> --family - --implementation + --implementation ` const rotate = (items, offset) => items.map((_, index) => items[(index + offset) % items.length]) @@ -81,7 +81,7 @@ const main = () => { const effect = group.find((result) => result.fixture.implementation === "effect") if (!effect) continue for (const candidate of group) { - if (candidate === effect) continue + if (candidate.fixture.implementation === "effect") continue crossLibrary.push({ scenario, implementation: candidate.fixture.implementation, @@ -119,7 +119,8 @@ const main = () => { crossLibraryDecodeApis: { effect: "SchemaParser.decodeUnknownExit (SchemaIssue)", valibot: "safeParser", - zod4: "safeParse ({ jitless: true })" + zod4: "safeParse ({ jitless: true })", + "zod4-compiled": "z.compile(schema, { strict: true })" }, artifactMode: "repository", git: currentGitState(), @@ -140,11 +141,12 @@ const main = () => { writeJson(path, report) const comparisons = new Map(crossLibrary.map((item) => [`${item.scenario}/${item.implementation}`, item])) printTable( - ["scenario", "implementation", "ns/op", "mad", "vs Effect"], + ["scenario", "family", "implementation", "ns/op", "mad", "vs Effect"], results.map((result) => { const comparison = comparisons.get(`${result.fixture.scenario}/${result.fixture.implementation}`) return [ result.fixture.scenario, + result.fixture.family, result.fixture.implementation, formatNs(result.aggregate.median), formatNs(result.aggregate.mad), diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/README.md b/packages/effect/runtimeperf/suites/compiler-rebuild/README.md index 02e89f326e2..0f616e5e9df 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/README.md +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/README.md @@ -1,14 +1,23 @@ # Compiler rebuild comparison The `compiler-rebuild` suite measures 22 public SchemaParser operations with the -interpreter, selective JIT and generated AOT modules. The fixture families name -the execution mode. Cases cover simple and nested Structs, Arrays, tuples, -Records, anyOf/oneOf Unions, transformations, middleware, recursive schemas, +interpreter, selective JIT and generated AOT modules. Eighteen cases also run +against `z.compile(schema, { strict: true })`. The fixture families name the +execution mode. Cases cover simple and nested Structs, Arrays, tuples, Records, +anyOf/oneOf Unions, transformations, middleware, recursive schemas, Declarations, construction, and successful and failing validation. +The Effect tuple has a trailing element after its rest element, Zod compilation +does not support recursive schemas or `z.xor`, and Zod has no equivalent +decoding middleware. Those four cases intentionally have no Zod fixture. Zod +compilation only accelerates forward parsing, so the encode case measures its +documented runtime fallback. Zod parsing stands in for Effect construction +because Zod has no separate constructor operation. + ```sh pnpm runtimeperf-compare compiler-rebuild --base schema-compiler pnpm runtimeperf-compare compiler-rebuild --base main --family interpreted +pnpm runtimeperf compiler-rebuild ``` The first command compares the current working tree with the archived compiler diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts new file mode 100644 index 00000000000..395d03a902e --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict" +import * as z from "zod/v4" + +const person = () => z.object({ name: z.string(), age: z.number(), active: z.boolean() }) +const input = { name: "Ada", age: 37, active: true } +const small = person() +const nestedSchema = z.object({ user: person(), tags: z.array(z.string()) }) +const arraySchema = z.array(person()) +const arrayInput = Array.from({ length: 32 }, () => ({ ...input })) +const recordSchema = z.record(z.string(), person()) +const recordInput = Object.fromEntries(arrayInput.map((value, i) => [String(i), value])) +const unionSchema = z.union( + Array.from({ length: 8 }, (_, i) => z.object({ tag: z.literal(i), value: z.number() })) as [ + z.ZodObject, + z.ZodObject, + ...Array + ] +) +const fields = Object.fromEntries( + Array.from({ length: 32 }, (_, i) => [`v${i}`, z.string().transform(Number)]) +) +const transformedSchema = z.object(fields) +const transformedInput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, String(i)])) +const transformedOutput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, i])) +const checkedTransform = z.string().transform(Number).pipe(z.number().positive()) +const declarationSchema = z.set(person()) +const declarationInput = new Set(arrayInput) +const defaultsSchema = z.object( + Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, z.number().default(i)])) +) + +type Case = { + readonly schema: z.ZodType + readonly input: unknown + readonly expected: unknown + readonly operation?: "is" | "make" | "encode" + readonly invalid?: boolean +} + +const cases: Record = { + parseValid: { schema: small, input, expected: input }, + parseExtra: { schema: small, input: { ...input, extra: 1 }, expected: input }, + parseInvalid: { schema: small, input: { ...input, age: "bad" }, expected: true, invalid: true }, + isValid: { schema: small, input, expected: true, operation: "is" }, + isInvalid: { schema: small, input: { ...input, age: "bad" }, expected: false, operation: "is" }, + encode: { schema: small, input, expected: input, operation: "encode" }, + strict: { + schema: z.strictObject({ name: z.string(), age: z.number(), active: z.boolean() }), + input, + expected: input + }, + nested: { + schema: nestedSchema, + input: { user: input, tags: ["a", "b"] }, + expected: { user: input, tags: ["a", "b"] } + }, + array: { schema: arraySchema, input: arrayInput, expected: arrayInput }, + arrayInvalid: { + schema: arraySchema, + input: [...arrayInput, { ...input, age: "bad" }], + expected: true, + invalid: true + }, + record: { schema: recordSchema, input: recordInput, expected: recordInput }, + union: { schema: unionSchema, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 } }, + transform: { schema: transformedSchema, input: transformedInput, expected: transformedOutput }, + transformInvalid: { schema: checkedTransform, input: "-1", expected: true, invalid: true }, + declaration: { schema: declarationSchema, input: declarationInput, expected: declarationInput }, + makeStruct: { schema: defaultsSchema, input: {}, expected: transformedOutput, operation: "make" }, + makeArray: { schema: arraySchema, input: arrayInput, expected: arrayInput, operation: "make" }, + makeUnion: { + schema: unionSchema, + input: { tag: 7, value: 1 }, + expected: { tag: 7, value: 1 }, + operation: "make" + } +} + +const fixture = (name: string) => { + const { schema: source, input, expected, operation, invalid } = cases[name] + const schema = z.compile(source, { strict: true }) + const parse = operation === "is" ? + (input: unknown) => z.validate(schema, input) + : operation === "encode" ? + (input: unknown) => z.encode(schema, input) + : (input: unknown) => schema.parse(input) + return { + run: invalid ? + () => { + try { + parse(input) + return false + } catch { + return true + } + } : + () => parse(input), + validate: (result: unknown) => assert.deepEqual(result, expected) + } +} + +export const parseValid = () => fixture("parseValid") +export const parseExtra = () => fixture("parseExtra") +export const parseInvalid = () => fixture("parseInvalid") +export const isValid = () => fixture("isValid") +export const isInvalid = () => fixture("isInvalid") +export const encode = () => fixture("encode") +export const strict = () => fixture("strict") +export const nested = () => fixture("nested") +export const array = () => fixture("array") +export const arrayInvalid = () => fixture("arrayInvalid") +export const record = () => fixture("record") +export const union = () => fixture("union") +export const transform = () => fixture("transform") +export const transformInvalid = () => fixture("transformInvalid") +export const declaration = () => fixture("declaration") +export const makeStruct = () => fixture("makeStruct") +export const makeArray = () => fixture("makeArray") +export const makeUnion = () => fixture("makeUnion") diff --git a/packages/effect/runtimeperf/test/registry.test.mts b/packages/effect/runtimeperf/test/registry.test.mts index 7cd39bee97e..408a8d89c46 100644 --- a/packages/effect/runtimeperf/test/registry.test.mts +++ b/packages/effect/runtimeperf/test/registry.test.mts @@ -9,7 +9,7 @@ describe("runtimeperf registry", () => { const { fixtures } = loadRegistry() assert.equal(new Set(fixtures.map((fixture) => fixture.target)).size, fixtures.length) for (const fixture of fixtures) { - assert.ok(["effect", "fast-check-v4", "valibot", "zod4"].includes(fixture.implementation)) + assert.ok(["effect", "fast-check-v4", "valibot", "zod4", "zod4-compiled"].includes(fixture.implementation)) } }) @@ -115,6 +115,18 @@ describe("runtimeperf registry", () => { } }) + it("uses strict Zod compilation for the compiler comparison fixtures", async () => { + const { fixtures } = loadRegistry() + const compiled = fixtures.filter((fixture) => fixture.implementation === "zod4-compiled") + assert.equal(compiled.length, 18) + assert.equal(compiled.every((fixture) => fixture.suite === "compiler-rebuild"), true) + const paths = new Set(compiled.map((fixture) => fixture.fixturePath)) + assert.equal(paths.size, 1) + const source = await readFile([...paths][0], "utf8") + assert.match(source, /from "zod\/v4"/) + assert.match(source, /z\.compile\(source, \{ strict: true \}\)/) + }) + it("loads, runs and validates every fixture export", async () => { const { fixtures } = loadRegistry() const modules = new Map() From bcd2106d4993615715a6a78ef30741310be31ae6 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Sat, 12 Sep 2026 08:03:52 +0200 Subject: [PATCH 05/33] Reduce Schema compiler overhead --- packages/effect/runtimeperf/README.md | 3 +- packages/effect/runtimeperf/config.json | 76 +++++++++++++++ .../suites/compiler-rebuild/README.md | 12 ++- .../compiler-rebuild/fixtures/valibot.ts | 76 +++++++++++++++ packages/effect/src/SchemaParser.ts | 20 +++- .../src/internal/schema/compilerRegistry.ts | 95 ++++++++++++++----- 6 files changed, 252 insertions(+), 30 deletions(-) create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/valibot.ts diff --git a/packages/effect/runtimeperf/README.md b/packages/effect/runtimeperf/README.md index 0670edaf3ea..e59c2bab34f 100644 --- a/packages/effect/runtimeperf/README.md +++ b/packages/effect/runtimeperf/README.md @@ -125,7 +125,8 @@ its Standard Schema and codec cases use their native APIs. Valibot uses the corresponding `is`, `safeParse` and Standard Schema APIs. The focused Effect adapter family measures the overhead of public APIs that wrap parser issues. The compiler comparison calls `z.compile(schema, { strict: true })` and uses -Zod's `validate` API for boolean checks. +Zod's `validate` API for boolean checks. Ten representative scenarios also run +against equivalent Valibot schemas using `parse` and `is`. ## Measurement model diff --git a/packages/effect/runtimeperf/config.json b/packages/effect/runtimeperf/config.json index 3f93f380ca4..0c0da1657b7 100644 --- a/packages/effect/runtimeperf/config.json +++ b/packages/effect/runtimeperf/config.json @@ -2403,6 +2403,82 @@ } ] }, + { + "file": "suites/compiler-rebuild/fixtures/valibot.ts", + "defaults": { + "tier": 1, + "family": "valibot", + "implementation": "valibot", + "astTags": [], + "operation": "decode", + "path": "valid", + "size": 3 + }, + "cases": [ + { + "name": "valibot-parseValid", + "export": "parseValid", + "scenario": "compiler-rebuild-parseValid" + }, + { + "name": "valibot-parseInvalid", + "export": "parseInvalid", + "scenario": "compiler-rebuild-parseInvalid", + "path": "invalid" + }, + { + "name": "valibot-isValid", + "export": "isValid", + "scenario": "compiler-rebuild-isValid", + "operation": "is" + }, + { + "name": "valibot-isInvalid", + "export": "isInvalid", + "scenario": "compiler-rebuild-isInvalid", + "operation": "is", + "path": "invalid" + }, + { + "name": "valibot-array", + "export": "array", + "scenario": "compiler-rebuild-array", + "size": 32 + }, + { + "name": "valibot-record", + "export": "record", + "scenario": "compiler-rebuild-record", + "size": 32 + }, + { + "name": "valibot-union", + "export": "union", + "scenario": "compiler-rebuild-union", + "size": 8 + }, + { + "name": "valibot-transform", + "export": "transform", + "scenario": "compiler-rebuild-transform", + "size": 32 + }, + { + "name": "valibot-makeStruct", + "export": "makeStruct", + "scenario": "compiler-rebuild-makeStruct", + "operation": "make", + "size": 32 + }, + { + "name": "valibot-makeArray", + "export": "makeArray", + "scenario": "compiler-rebuild-makeArray", + "operation": "make", + "size": 32 + } + ] + }, { "file": "suites/compiler-rebuild/fixtures/zod-compiled.ts", "defaults": { diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/README.md b/packages/effect/runtimeperf/suites/compiler-rebuild/README.md index 0f616e5e9df..2157f6bbf26 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/README.md +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/README.md @@ -2,17 +2,19 @@ The `compiler-rebuild` suite measures 22 public SchemaParser operations with the interpreter, selective JIT and generated AOT modules. Eighteen cases also run -against `z.compile(schema, { strict: true })`. The fixture families name the -execution mode. Cases cover simple and nested Structs, Arrays, tuples, Records, -anyOf/oneOf Unions, transformations, middleware, recursive schemas, -Declarations, construction, and successful and failing validation. +against `z.compile(schema, { strict: true })`; ten representative cases also +run against Valibot. The fixture families name the execution mode. Cases +cover simple and nested Structs, Arrays, tuples, Records, anyOf/oneOf Unions, +transformations, middleware, recursive schemas, Declarations, construction, +and successful and failing validation. The Effect tuple has a trailing element after its rest element, Zod compilation does not support recursive schemas or `z.xor`, and Zod has no equivalent decoding middleware. Those four cases intentionally have no Zod fixture. Zod compilation only accelerates forward parsing, so the encode case measures its documented runtime fallback. Zod parsing stands in for Effect construction -because Zod has no separate constructor operation. +because Zod has no separate constructor operation. Valibot parsing with defaults +is used for the same comparison. ```sh pnpm runtimeperf-compare compiler-rebuild --base schema-compiler diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/valibot.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/valibot.ts new file mode 100644 index 00000000000..6d856a3a6ea --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/valibot.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import * as v from "valibot" + +const person = () => v.object({ name: v.string(), age: v.number(), active: v.boolean() }) +const input = { name: "Ada", age: 37, active: true } +const small = person() +const arraySchema = v.array(person()) +const arrayInput = Array.from({ length: 32 }, () => ({ ...input })) +const recordSchema = v.record(v.string(), person()) +const recordInput = Object.fromEntries(arrayInput.map((value, i) => [String(i), value])) +const unionSchema = v.variant( + "tag", + Array.from({ length: 8 }, (_, i) => v.object({ tag: v.literal(i), value: v.number() })) +) +const transformedSchema = v.object( + Object.fromEntries( + Array.from({ length: 32 }, (_, i) => [`v${i}`, v.pipe(v.string(), v.transform(Number))]) + ) +) +const transformedInput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, String(i)])) +const transformedOutput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, i])) +const defaultsSchema = v.object( + Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, v.optional(v.number(), i)])) +) + +type Case = { + readonly schema: v.BaseSchema> + readonly input: unknown + readonly expected: unknown + readonly operation?: "is" | "make" + readonly invalid?: boolean +} + +const cases: Record = { + parseValid: { schema: small, input, expected: input }, + parseInvalid: { schema: small, input: { ...input, age: "bad" }, expected: true, invalid: true }, + isValid: { schema: small, input, expected: true, operation: "is" }, + isInvalid: { schema: small, input: { ...input, age: "bad" }, expected: false, operation: "is" }, + array: { schema: arraySchema, input: arrayInput, expected: arrayInput }, + record: { schema: recordSchema, input: recordInput, expected: recordInput }, + union: { schema: unionSchema, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 } }, + transform: { schema: transformedSchema, input: transformedInput, expected: transformedOutput }, + makeStruct: { schema: defaultsSchema, input: {}, expected: transformedOutput, operation: "make" }, + makeArray: { schema: arraySchema, input: arrayInput, expected: arrayInput, operation: "make" } +} + +const fixture = (name: string) => { + const { schema, input, expected, operation, invalid } = cases[name] + const parse = operation === "is" + ? (input: unknown) => v.is(schema, input) + : (input: unknown) => v.parse(schema, input) + return { + run: invalid + ? () => { + try { + parse(input) + return false + } catch { + return true + } + } + : () => parse(input), + validate: (result: unknown) => assert.deepEqual(result, expected) + } +} + +export const parseValid = () => fixture("parseValid") +export const parseInvalid = () => fixture("parseInvalid") +export const isValid = () => fixture("isValid") +export const isInvalid = () => fixture("isInvalid") +export const array = () => fixture("array") +export const record = () => fixture("record") +export const union = () => fixture("union") +export const transform = () => fixture("transform") +export const makeStruct = () => fixture("makeStruct") +export const makeArray = () => fixture("makeArray") diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index 8061cbe1bea..a6cb770caec 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -142,6 +142,18 @@ export function is(schema: S): (input: I) => inp /** @internal */ export function _is(ast: SchemaAST.AST) { const typeAST = SchemaAST.toType(ast) + if (!CompilerRegistry.compilerAdaptersEnabled) { + const parser = asExit(run(typeAST)) + return (input: I): input is I & T => { + const exit = parser(input, SchemaAST.defaultParseOptions) + if (Exit.isSuccess(exit)) return true + InternalSchemaCause.getSchemaIssueOrThrow( + exit.cause, + "Type guard adapter can only return false for schema issues" + ) + return false + } + } let entry: CompilerRegistry.Entry | undefined let parser: Parser | undefined let guard: CompilerRegistry.Entry["is"] | null @@ -536,7 +548,9 @@ export function decodeUnknownSync>( schema: S, options?: SchemaAST.ParseOptions ): (input: unknown, options?: SchemaAST.ParseOptions) => S["Type"] { - return makeSync(schema.ast, options) + return CompilerRegistry.compilerAdaptersEnabled + ? makeSync(schema.ast, options) + : asSync(decodeUnknownEffect(schema, options)) } /** @@ -881,7 +895,9 @@ export function encodeUnknownSync>( schema: S, options?: SchemaAST.ParseOptions ): (input: unknown, options?: SchemaAST.ParseOptions) => S["Encoded"] { - return makeSync(SchemaAST.flip(schema.ast), options) + return CompilerRegistry.compilerAdaptersEnabled + ? makeSync(SchemaAST.flip(schema.ast), options) + : asSync(encodeUnknownEffect(schema, options)) } /** diff --git a/packages/effect/src/internal/schema/compilerRegistry.ts b/packages/effect/src/internal/schema/compilerRegistry.ts index fe0f620580f..7d88bfa22f6 100644 --- a/packages/effect/src/internal/schema/compilerRegistry.ts +++ b/packages/effect/src/internal/schema/compilerRegistry.ts @@ -15,45 +15,53 @@ export type Resolve = (ast: SchemaAST.AST) => Entry export type Compile = (ast: SchemaAST.AST, resolve: Resolve) => CompiledDecoder | undefined const cache = new WeakMap() -let compiler: Compile | undefined +let compiler: ((ast: SchemaAST.AST, resolve: Resolve) => Entry | undefined) | undefined + +/** @internal */ +export let compilerAdaptersEnabled = false + +function activateCompilerAdapters(): void { + compilerAdaptersEnabled = true +} const decodeChild = (ast: SchemaAST.AST): Parser => lazyParser(resolve, ast, "parser") const makeChild = (ast: SchemaAST.AST): Parser => lazyParser(resolve, ast, "makeEffect") const makeDefaultedChild = (ast: SchemaAST.AST): Parser => lazyParser(resolve, ast, "makeDefaulted") /** @internal */ -export class Entry { +export interface Entry { + readonly ast: SchemaAST.AST + readonly source?: CompiledDecoder | undefined + readonly resolve: Resolve + readonly is?: Is | undefined + readonly validate?: Validate | undefined + readonly decodeEffect: Parser + readonly parser: Parser + readonly makeEffect: Parser + readonly makeDefaulted: Parser +} + +class InterpretedEntry implements Entry { readonly ast: SchemaAST.AST - readonly source: CompiledDecoder | undefined readonly resolve: Resolve constructor( ast: SchemaAST.AST, - source: CompiledDecoder | undefined, resolve: Resolve ) { this.ast = ast - this.source = source this.resolve = resolve } - private save(key: K, value: Entry[K]): Entry[K] { + protected save(key: K, value: Entry[K]): Entry[K] { Object.defineProperty(this, key, { value }) return value } - get is(): Is | undefined { - return this.source === undefined ? undefined : this.save("is", this.source.is) - } - - get validate(): Validate | undefined { - return this.source === undefined ? undefined : this.save("validate", this.source.validate) - } - get decodeEffect(): Parser { return this.save( "decodeEffect", - this.source?.decodeEffect ?? Interpreter.compile( + Interpreter.compile( this.ast, this.resolve === resolve ? decodeChild : (ast) => lazyParser(this.resolve, ast, "parser") ) @@ -61,15 +69,13 @@ export class Entry { } get parser(): Parser { - const validate = this.validate - if (validate === undefined) return this.decodeEffect - return this.save("parser", withValidation(validate, () => this.decodeEffect)) + return this.decodeEffect } get makeEffect(): Parser { return this.save( "makeEffect", - this.source?.makeEffect ?? Interpreter.compile( + Interpreter.compile( this.ast, this.resolve === resolve ? makeChild : (ast) => lazyParser(this.resolve, ast, "makeEffect"), this.resolve === resolve ? makeDefaultedChild : (ast) => lazyParser(this.resolve, ast, "makeDefaulted") @@ -90,6 +96,41 @@ export class Entry { } } +class InstalledEntry extends InterpretedEntry { + readonly source: CompiledDecoder + + constructor(ast: SchemaAST.AST, source: CompiledDecoder, resolve: Resolve) { + super(ast, resolve) + this.source = source + } + + get is(): Is | undefined { + return this.save("is", this.source.is) + } + + get validate(): Validate | undefined { + return this.save("validate", this.source.validate) + } + + override get decodeEffect(): Parser { + return this.save("decodeEffect", this.source.decodeEffect) + } + + override get parser(): Parser { + const validate = this.validate + return validate === undefined + ? this.decodeEffect + : this.save("parser", withValidation(validate, () => this.decodeEffect)) + } + + override get makeEffect(): Parser { + const makeEffect = this.source.makeEffect + return makeEffect === undefined + ? super.makeEffect + : this.save("makeEffect", makeEffect) + } +} + /** @internal */ export function withValidation(validate: Validate, decode: () => Parser): Parser { let detailed: Parser | undefined @@ -122,23 +163,33 @@ export function lazyParser( export function resolve(ast: SchemaAST.AST): Entry { const cached = cache.get(ast) if (cached !== undefined) return cached - return set(ast, compiler?.(ast, resolve), resolve) + const entry = compiler?.(ast, resolve) ?? new InterpretedEntry(ast, resolve) + cache.set(ast, entry) + return entry } /** @internal */ export function set(ast: SchemaAST.AST, decoder: CompiledDecoder | undefined, resolveChild: Resolve = resolve): Entry { - const entry = new Entry(ast, decoder, resolveChild) + if (decoder !== undefined) activateCompilerAdapters() + const entry = decoder === undefined + ? new InterpretedEntry(ast, resolveChild) + : new InstalledEntry(ast, decoder, resolveChild) cache.set(ast, entry) return entry } /** @internal */ export function install(compile: Compile): void { - compiler = compile + activateCompilerAdapters() + compiler = (ast, resolve) => { + const decoder = compile(ast, resolve) + return decoder === undefined ? undefined : new InstalledEntry(ast, decoder, resolve) + } } /** @internal */ export function enable(ast: SchemaAST.AST, compile: Compile): void { + activateCompilerAdapters() const scoped: Resolve = (child) => { const cached = cache.get(child) return cached !== undefined && (cached.source !== undefined || cached.resolve === scoped) From 21a219fa4b5ddf4bf42e0885a9e9926280039cfa Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Sat, 12 Sep 2026 21:39:07 +0200 Subject: [PATCH 06/33] Add high-level Schema AOT build API --- .changeset/add-schema-compilers.md | 5 +- packages/effect/SCHEMA.md | 32 ++- .../schema/SchemaAOTCompiler/Build.ts | 272 ++++++++++++++++++ .../schema/SchemaAOTCompilerBuild.test.ts | 145 ++++++++++ .../effect/test/schema/fixtures/aot-build.ts | 10 + .../schema/SchemaAOTCompilerBuild.tst.ts | 34 +++ 6 files changed, 494 insertions(+), 4 deletions(-) create mode 100644 packages/effect/src/unstable/schema/SchemaAOTCompiler/Build.ts create mode 100644 packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts create mode 100644 packages/effect/test/schema/fixtures/aot-build.ts create mode 100644 packages/effect/typetest/schema/SchemaAOTCompilerBuild.tst.ts diff --git a/.changeset/add-schema-compilers.md b/.changeset/add-schema-compilers.md index c8a09a4d755..aaedf88cfb6 100644 --- a/.changeset/add-schema-compilers.md +++ b/.changeset/add-schema-compilers.md @@ -6,4 +6,7 @@ Add experimental JIT and AOT schema compilers that work through the existing `SchemaParser` APIs and share a decoder registry. Enable JIT globally with `effect/unstable/schema/SchemaJITCompiler/enable`, selectively with `SchemaJITCompiler.enable(ast)`, or install generated AOT decoders without -requiring dynamic function construction. +requiring dynamic function construction. The new +`effect/unstable/schema/SchemaAOTCompiler/Build` entrypoint discovers direct +Schema exports from explicit module loaders and writes a self-installing AOT +module through Effect's `FileSystem` and `Path` services. diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 6546665b1cd..2105c7eec56 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -92,12 +92,38 @@ the corresponding runtime ASTs before using normal `SchemaParser` functions. Use a one-element array for a single schema. Generated modules do not import the generator and work where `new Function` is forbidden. -Regenerate AOT modules when schema definitions or the Effect version change. -Installation trusts the supplied root order, definitions and shared AST -identities. Callbacks and symbols are read from those ASTs, not serialized. +The low-level installation trusts the supplied root order and AST definitions. Include `SchemaAST.toType(schema.ast)` for guards and construction, and `SchemaAST.flip(schema.ast)` for encoding, when those are distinct ASTs. +`effect/unstable/schema/SchemaAOTCompiler/Build` provides the higher-level +workflow. Its `build` function loads direct Schema exports, writes a +self-installing module through `FileSystem`, and prepares decoding by default: + +```ts +import * as SchemaAOTCompilerBuild from "effect/unstable/schema/SchemaAOTCompiler/Build" + +SchemaAOTCompilerBuild.build({ + modules: { + "./schemas/User.js": () => import("./schemas/User.js"), + "./schemas/Order.js": () => import("./schemas/Order.js") + }, + baseUrl: import.meta.url, + outFile: "./generated/schema-aot.js" +}) +``` + +Import the generated file at application startup. Module keys identify imports +relative to `baseUrl`; each loader must return that same module during the +build. The lazy record produced by `import.meta.glob` can be passed directly. +Request `encode`, `is`, or `make` explicitly when those directions also need +AOT roots. Loading executes the selected application modules during the build. +Run the returned Effect with the platform's `FileSystem` and `Path` services, +and ensure the bundler retains the generated side-effect import. + +Regenerate AOT modules when schema definitions or the Effect version change. +Callbacks and symbols are read from runtime ASTs, not serialized. + ### One registry for all implementations A single `WeakMap` associates each exact AST with its decoder entry. The cache diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler/Build.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler/Build.ts new file mode 100644 index 00000000000..438956edf2b --- /dev/null +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler/Build.ts @@ -0,0 +1,272 @@ +/** + * Builds self-installing modules from ahead-of-time compiled Schema decoders. + * + * @since 4.0.0 + */ +import * as Data from "../../../Data.ts" +import * as Effect from "../../../Effect.ts" +import * as FileSystem from "../../../FileSystem.ts" +import * as Path from "../../../Path.ts" +import type * as PlatformError from "../../../PlatformError.ts" +import * as Schema from "../../../Schema.ts" +import * as SchemaAST from "../../../SchemaAST.ts" +import * as SchemaAOTCompiler from "../SchemaAOTCompiler.ts" + +/** + * An operation whose root AST should be prepared by {@link build}. + * + * @category models + * @since 4.0.0 + */ +export type Operation = "decode" | "encode" | "is" | "make" + +/** + * Loads the exports of a schema module during a build. + * + * @category models + * @since 4.0.0 + */ +export interface ModuleLoader { + (): PromiseLike +} + +/** + * Options for {@link build}. + * + * @category models + * @since 4.0.0 + */ +export interface BuildOptions { + /** + * Maps import specifiers relative to `baseUrl` to loaders for those same + * modules. Only directly exported Schema values are compiled. + */ + readonly modules: Readonly> + /** The URL against which relative module specifiers are resolved. */ + readonly baseUrl: string | URL + /** The file-system path of the generated module. */ + readonly outFile: string + /** The parser operations to prepare. Defaults to decoding. */ + readonly operations?: ReadonlyArray | undefined +} + +/** + * A summary of a completed {@link build}. + * + * @category models + * @since 4.0.0 + */ +export interface BuildResult { + /** The absolute path written by the build. */ + readonly outFile: string + /** The number of loaded modules containing at least one Schema export. */ + readonly modules: number + /** The number of directly exported Schema values found in those modules. */ + readonly schemas: number +} + +/** + * An error raised while loading modules or generating an AOT module. + * + * @category errors + * @since 4.0.0 + */ +export class BuildError extends Data.TaggedError("BuildError")<{ + readonly _tag: "BuildError" + readonly cause: unknown + readonly kind: "Generate" | "InvalidModule" | "LoadModule" | "ResolveModule" + readonly message: string + readonly module?: string | undefined +}> {} + +interface ExportedSchema { + readonly alias: string + readonly exportName: string + readonly schema: Schema.Top +} + +const operationOrder: ReadonlyArray = ["decode", "encode", "is", "make"] + +const importPath = ( + path: Path.Path, + outFile: string, + baseUrl: string | URL, + specifier: string +): Effect.Effect => + Effect.gen(function*() { + const url = yield* Effect.try({ + try: () => new URL(specifier, baseUrl), + catch: (cause) => + new BuildError({ + cause, + kind: "ResolveModule", + message: `Could not resolve schema module ${JSON.stringify(specifier)}`, + module: specifier + }) + }) + const sourcePath = yield* path.fromFileUrl(url).pipe( + Effect.mapError((cause) => + new BuildError({ + cause, + kind: "ResolveModule", + message: `Schema module ${JSON.stringify(specifier)} does not resolve to a file`, + module: specifier + }) + ) + ) + const relative = path.relative(path.dirname(outFile), sourcePath) + if (path.isAbsolute(relative)) { + return yield* new BuildError({ + cause: undefined, + kind: "ResolveModule", + message: `Schema module ${JSON.stringify(specifier)} cannot be imported relative to the output file`, + module: specifier + }) + } + const normalized = path.sep === "/" ? relative : relative.split(path.sep).join("/") + return normalized.startsWith(".") ? normalized : `./${normalized}` + }) + +const root = ( + exported: ExportedSchema, + operation: Operation +): readonly [ast: SchemaAST.AST, expression: string, derived: boolean] => { + const expression = `${exported.alias}[${JSON.stringify(exported.exportName)}].ast` + switch (operation) { + case "decode": + return [exported.schema.ast, expression, false] + case "encode": { + const ast = SchemaAST.flip(exported.schema.ast) + return ast === exported.schema.ast ? [ast, expression, false] : [ast, `A.flip(${expression})`, true] + } + case "is": + case "make": { + const ast = SchemaAST.toType(exported.schema.ast) + return ast === exported.schema.ast ? [ast, expression, false] : [ast, `A.toType(${expression})`, true] + } + } +} + +/** + * Writes a self-installing AOT module for Schema values exported by a set of + * modules. + * + * **When to use** + * + * Use when a build script should let an application install generated decoders + * by importing one generated module at startup. + * + * **Details** + * + * Module keys are import specifiers relative to `baseUrl`. Their loaders run + * sequentially during the build. The builder sorts module specifiers and export + * names, compiles direct Schema exports, and writes deterministic source using + * `FileSystem`. The generated module imports those exports and installs their + * decoders in the shared Schema parser registry as a module side effect. A lazy + * record returned by `import.meta.glob` can be passed as `modules` directly. + * + * Decoding is prepared by default. `is` and `make` prepare the type-side AST; + * `encode` prepares the flipped AST. Nested dependencies are discovered by the + * AOT compiler and do not need separate exports. + * + * **Gotchas** + * + * Loaders execute application modules during the build. Each loader must return + * the same module named by its key. The generated file must be rebuilt after a + * schema definition or Effect version changes. Unsupported operations retain + * the interpreter fallback. Build configurations that mark modules as + * side-effect free must retain the generated import. + * + * @category compilation + * @since 4.0.0 + */ +export const build: ( + options: BuildOptions +) => Effect.Effect< + BuildResult, + BuildError | PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path +> = Effect.fnUntraced(function*(options) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const outFile = path.resolve(options.outFile) + const requested = new Set(options.operations ?? ["decode"]) + const operations = operationOrder.filter((operation) => requested.has(operation)) + const imports: Array = [] + const exportedSchemas: Array = [] + + for (const specifier of Object.keys(options.modules).sort()) { + const loader = options.modules[specifier]! + const loaded = yield* Effect.tryPromise({ + try: () => loader(), + catch: (cause) => + new BuildError({ + cause, + kind: "LoadModule", + message: `Could not load schema module ${JSON.stringify(specifier)}`, + module: specifier + }) + }) + if (typeof loaded !== "object" || loaded === null) { + return yield* new BuildError({ + cause: loaded, + kind: "InvalidModule", + message: `Schema module ${JSON.stringify(specifier)} did not load a module namespace`, + module: specifier + }) + } + const namespace = loaded as Readonly> + const exports = Object.keys(namespace).filter((name) => Schema.isSchema(namespace[name])).sort() + if (exports.length === 0) continue + const alias = `m${imports.length}` + const specifierFromOutput = yield* importPath(path, outFile, options.baseUrl, specifier) + imports.push(`import * as ${alias} from ${JSON.stringify(specifierFromOutput)};`) + for (const exportName of exports) { + exportedSchemas.push({ + alias, + exportName, + schema: namespace[exportName] as Schema.Top + }) + } + } + + const source = yield* Effect.try({ + try: () => { + const asts: Array = [] + const expressions: Array = [] + const seen = new Set() + let needsSchemaASTImport = false + for (const exported of exportedSchemas) { + for (const operation of operations) { + const [ast, expression, isDerived] = root(exported, operation) + if (seen.has(ast)) continue + seen.add(ast) + asts.push(ast) + expressions.push(expression) + needsSchemaASTImport ||= isDerived + } + } + return [ + ...imports, + ...(needsSchemaASTImport ? ["import * as A from \"effect/SchemaAST\";"] : []), + SchemaAOTCompiler.compile(asts), + `install([${expressions.join(",")}]);`, + "" + ].join("\n") + }, + catch: (cause) => + new BuildError({ + cause, + kind: "Generate", + message: "Could not generate the Schema AOT module" + }) + }) + + yield* fs.makeDirectory(path.dirname(outFile), { recursive: true }) + yield* fs.writeFileString(outFile, source) + return { + outFile, + modules: imports.length, + schemas: exportedSchemas.length + } +}) diff --git a/packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts b/packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts new file mode 100644 index 00000000000..620f620767f --- /dev/null +++ b/packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts @@ -0,0 +1,145 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, FileSystem, Path, SchemaParser } from "effect" +import * as CompilerRegistry from "effect/internal/schema/compilerRegistry" +import * as SchemaAOTCompilerBuild from "effect/unstable/schema/SchemaAOTCompiler/Build" +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import * as Schemas from "./fixtures/aot-build.ts" + +describe("SchemaAOTCompilerBuild", { concurrent: false }, () => { + it.effect("writes a deterministic self-installing module", () => + Effect.gen(function*() { + const directory = yield* Effect.acquireRelease( + Effect.sync(() => mkdtempSync(fileURLToPath(new URL("../../.schema-aot-build-test-", import.meta.url)))), + (directory) => Effect.sync(() => rmSync(directory, { recursive: true, force: true })) + ) + const outFile = join(directory, "generated.mjs") + const fileSystem = FileSystem.layerNoop({ + makeDirectory: (path, options) => + Effect.sync(() => { + mkdirSync(path, options) + }), + writeFileString: (path, source) => Effect.sync(() => writeFileSync(path, source)) + }) + const options = { + modules: { + "./fixtures/aot-build.ts": () => Promise.resolve(Schemas) + }, + baseUrl: import.meta.url, + outFile, + operations: ["make", "decode", "is", "encode", "decode"] + } as const + + const first = yield* SchemaAOTCompilerBuild.build(options).pipe( + Effect.provide(fileSystem), + Effect.provide(Path.layer) + ) + const source = yield* Effect.sync(() => readFileSync(outFile, "utf8")) + const second = yield* SchemaAOTCompilerBuild.build(options).pipe( + Effect.provide(fileSystem), + Effect.provide(Path.layer) + ) + const secondSource = yield* Effect.sync(() => readFileSync(outFile, "utf8")) + + assert.deepStrictEqual(first, { + outFile, + modules: 1, + schemas: 2 + }) + assert.deepStrictEqual(second, first) + assert.strictEqual(secondSource, source) + assert.include(source, "effect/SchemaAST") + assert.include(source, "A.flip(m0[\"Port\"].ast)") + assert.include(source, "A.toType(m0[\"Port\"].ast)") + assert.notInclude(source, "ignored") + + const before = CompilerRegistry.resolve(Schemas.User.ast) + yield* Effect.promise(() => import(`${pathToFileURL(outFile).href}?test=${Date.now()}`)) + assert.notStrictEqual(CompilerRegistry.resolve(Schemas.User.ast), before) + assert.deepStrictEqual(SchemaParser.decodeUnknownSync(Schemas.User)({ name: "Ada", port: "8080" }), { + name: "Ada", + port: 8080 + }) + assert.strictEqual(SchemaParser.is(Schemas.User)({ name: "Ada", port: 8080 }), true) + assert.deepStrictEqual(SchemaParser.make(Schemas.User)({ name: "Ada", port: 8080 }), { + name: "Ada", + port: 8080 + }) + assert.deepStrictEqual(SchemaParser.encodeUnknownSync(Schemas.User)({ name: "Ada", port: 8080 }), { + name: "Ada", + port: "8080" + }) + })) + + it.effect("uses decoding as the default operation and ignores non-Schema exports", () => + Effect.gen(function*() { + let written = "" + const result = yield* SchemaAOTCompilerBuild.build({ + modules: { + "./fixtures/aot-build.ts": () => Promise.resolve(Schemas), + "./fixtures/ignored.ts": () => Promise.resolve({ value: 1 }) + }, + baseUrl: import.meta.url, + outFile: "/generated/schema-aot.mjs" + }).pipe( + Effect.provide(FileSystem.layerNoop({ + makeDirectory: () => Effect.void, + writeFileString: (_path, source) => + Effect.sync(() => { + written = source + }) + })), + Effect.provide(Path.layer) + ) + + assert.deepStrictEqual(result, { + outFile: "/generated/schema-aot.mjs", + modules: 1, + schemas: 2 + }) + assert.notInclude(written, "import * as A from \"effect/SchemaAST\"") + assert.notInclude(written, "ignored.ts") + assert.include(written, "install([m0[\"Port\"].ast,m0[\"User\"].ast]);") + })) + + it.effect("reports module loading failures", () => + Effect.gen(function*() { + const error = yield* SchemaAOTCompilerBuild.build({ + modules: { + "./fixtures/failing.ts": () => Promise.reject("boom") + }, + baseUrl: import.meta.url, + outFile: "/generated/schema-aot.mjs" + }).pipe( + Effect.provide(FileSystem.layerNoop({})), + Effect.provide(Path.layer), + Effect.flip + ) + + assert.instanceOf(error, SchemaAOTCompilerBuild.BuildError) + assert.strictEqual(error.kind, "LoadModule") + assert.strictEqual(error.module, "./fixtures/failing.ts") + assert.strictEqual(error.cause, "boom") + })) + + it.effect("reports invalid loaded modules", () => + Effect.gen(function*() { + const error = yield* SchemaAOTCompilerBuild.build({ + modules: { + "./fixtures/invalid.ts": () => Promise.resolve(1) + }, + baseUrl: import.meta.url, + outFile: "/generated/schema-aot.mjs" + }).pipe( + Effect.provide(FileSystem.layerNoop({})), + Effect.provide(Path.layer), + Effect.flip + ) + + assert.instanceOf(error, SchemaAOTCompilerBuild.BuildError) + assert.strictEqual(error.kind, "InvalidModule") + assert.strictEqual(error.module, "./fixtures/invalid.ts") + assert.strictEqual(error.cause, 1) + })) +}) diff --git a/packages/effect/test/schema/fixtures/aot-build.ts b/packages/effect/test/schema/fixtures/aot-build.ts new file mode 100644 index 00000000000..b0e7363d955 --- /dev/null +++ b/packages/effect/test/schema/fixtures/aot-build.ts @@ -0,0 +1,10 @@ +import { Schema } from "effect" + +export const Port = Schema.NumberFromString + +export const User = Schema.Struct({ + name: Schema.String, + port: Port +}) + +export const ignored = "not a Schema" diff --git a/packages/effect/typetest/schema/SchemaAOTCompilerBuild.tst.ts b/packages/effect/typetest/schema/SchemaAOTCompilerBuild.tst.ts new file mode 100644 index 00000000000..55b2ea189d1 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaAOTCompilerBuild.tst.ts @@ -0,0 +1,34 @@ +import type { Effect, FileSystem, Path, PlatformError } from "effect" +import * as SchemaAOTCompilerBuild from "effect/unstable/schema/SchemaAOTCompiler/Build" +import { describe, expect, it } from "tstyche" + +describe("SchemaAOTCompilerBuild", () => { + it("build", () => { + const modules: Record Promise> = { + "./schema.js": () => Promise.resolve({}) + } + const result = SchemaAOTCompilerBuild.build({ + modules, + baseUrl: import.meta.url, + outFile: "./schema-aot.js" + }) + + expect(result).type.toBe< + Effect.Effect< + SchemaAOTCompilerBuild.BuildResult, + SchemaAOTCompilerBuild.BuildError | PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path + > + >() + expect(SchemaAOTCompilerBuild.build).type.not.toBeCallableWith({ + modules: {}, + outFile: "./schema-aot.js" + }) + expect(SchemaAOTCompilerBuild.build).type.not.toBeCallableWith({ + modules: {}, + baseUrl: import.meta.url, + outFile: "./schema-aot.js", + operations: ["parse"] + }) + }) +}) From 77744d2e13b154909c6cf72e729396c8b9c9d594 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 14 Sep 2026 10:58:50 +0200 Subject: [PATCH 07/33] Fix lazy Schema type guard resolution --- packages/effect/src/SchemaParser.ts | 43 ++++++++++--------- .../test/schema/SchemaCompilerApi.test.ts | 28 ++++++++++++ 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index a6cb770caec..2e4e624f22d 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -139,11 +139,9 @@ export function is(schema: S): (input: I) => inp return _is(schema.ast) } -/** @internal */ -export function _is(ast: SchemaAST.AST) { - const typeAST = SchemaAST.toType(ast) +function makeIs(ast: SchemaAST.AST): (input: I) => input is I & T { if (!CompilerRegistry.compilerAdaptersEnabled) { - const parser = asExit(run(typeAST)) + const parser = asExit(run(ast)) return (input: I): input is I & T => { const exit = parser(input, SchemaAST.defaultParseOptions) if (Exit.isSuccess(exit)) return true @@ -154,17 +152,10 @@ export function _is(ast: SchemaAST.AST) { return false } } - let entry: CompilerRegistry.Entry | undefined - let parser: Parser | undefined - let guard: CompilerRegistry.Entry["is"] | null - return (input: I): input is I & T => { - if (guard === undefined) { - entry = CompilerRegistry.resolve(typeAST) - guard = entry.is ?? null - // A value-producing validator can return the invalid symbol as valid data. - // Without a boolean validator, use the ordinary diagnostic fallback too. - } - if (guard !== null) { + const entry = CompilerRegistry.resolve(ast) + const guard = entry.is + if (guard !== undefined) { + return (input: I): input is I & T => { try { return guard(input, SchemaAST.defaultParseOptions) } catch (error) { @@ -175,16 +166,28 @@ export function _is(ast: SchemaAST.AST) { return false } } - const result = (parser ??= entry!.parser)(input, SchemaAST.defaultParseOptions) - const exit = Effect.runSyncExit(parserResult(result, input)) - if (Exit.isSuccess(exit)) { - return true - } + } + const parser = entry.parser + return (input: I): input is I & T => { + const exit = Effect.runSyncExit(parserResult(parser(input, SchemaAST.defaultParseOptions), input)) + if (Exit.isSuccess(exit)) return true InternalSchemaCause.getSchemaIssueOrThrow(exit.cause, "Type guard adapter can only return false for schema issues") return false } } +/** @internal */ +export function _is(ast: SchemaAST.AST) { + const typeAST = SchemaAST.toType(ast) + let guard: (input: I) => input is I & T = (input: I): input is I & T => { + guard = makeIs(typeAST) + return guard(input) + } + return (input: I): input is I & T => { + return guard(input) + } +} + /** @internal */ export function _issue(ast: SchemaAST.AST) { const parser = run(ast) diff --git a/packages/effect/test/schema/SchemaCompilerApi.test.ts b/packages/effect/test/schema/SchemaCompilerApi.test.ts index cf10230a99d..f9ea4450432 100644 --- a/packages/effect/test/schema/SchemaCompilerApi.test.ts +++ b/packages/effect/test/schema/SchemaCompilerApi.test.ts @@ -4,6 +4,34 @@ import { SchemaCompiler, SchemaJITCompiler } from "effect/unstable/schema" import { deepStrictEqual, strictEqual, throws } from "../utils/assert.ts" describe("SchemaCompiler", () => { + it("resolves a type guard on first invocation", () => { + const schema = Schema.String.annotate({ title: "lazy type guard" }) + const ast = SchemaAST.toType(schema.ast) + const guard = SchemaParser.is(schema) + let calls = 0 + + SchemaCompiler.set(ast, { + is: () => { + calls++ + return true + }, + decodeEffect: Effect.succeed + }) + + strictEqual(guard(1), true) + strictEqual(guard(2), true) + strictEqual(calls, 2) + + SchemaCompiler.set(ast, { + is: () => false, + decodeEffect: Effect.succeed + }) + + strictEqual(guard(3), true) + strictEqual(SchemaParser.is(schema)(3), false) + strictEqual(calls, 3) + }) + it("reuses interpreted candidates inside a selectively compiled Union", () => { const child = Schema.String.annotate({ title: "interpreted Union candidate" }) const schema = Schema.Union([child, Schema.Number]) From 6385ce2ada12fbfd5f3d18cbb81e855123486f6e Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 14 Sep 2026 15:55:59 +0200 Subject: [PATCH 08/33] Refactor schema compiler product parsers --- packages/effect/src/SchemaAST.ts | 124 ++++++----------- .../unstable/schema/SchemaCompiler/runtime.ts | 126 +++++++++++++++++- .../test/schema/SchemaCompilerArray.test.ts | 14 +- .../schema/SchemaCompilerConstruction.test.ts | 18 --- .../test/schema/SchemaJITCompiler.test.ts | 10 -- .../effect/test/schema/fixtures/aot-runner.ts | 8 -- 6 files changed, 164 insertions(+), 136 deletions(-) diff --git a/packages/effect/src/SchemaAST.ts b/packages/effect/src/SchemaAST.ts index 6c3614301e1..89551bd1c14 100644 --- a/packages/effect/src/SchemaAST.ts +++ b/packages/effect/src/SchemaAST.ts @@ -2224,8 +2224,7 @@ export interface Arrays extends ASTNode { getParser( compile: SchemaParser.Compiler, - compileConstructorDefault?: SchemaParser.Compiler, - generate?: (context: ArrayParserContext) => typeof parseArray + compileConstructorDefault?: SchemaParser.Compiler ): SchemaParser.Parser /** @internal */ @@ -2298,8 +2297,7 @@ export const Arrays: new( /** @internal */ getParser( compile: SchemaParser.Compiler, - compileConstructorDefault: SchemaParser.Compiler = compile, - generate?: (context: ArrayParserContext) => typeof parseArray + compileConstructorDefault: SchemaParser.Compiler = compile ): SchemaParser.Parser { // oxlint-disable-next-line @typescript-eslint/no-this-alias const ast = this @@ -2321,20 +2319,6 @@ export const Arrays: new( return rest![0] } - const run = generate !== undefined && elementLen === 0 && ast.rest.length === 1 - ? generate({ - getElement: () => rest![0].parser, - step: parseArrayOptions.step, - resume: (state, item, index, pending, end) => - Effect.flatMap( - Effect.exit(pending), - (exit) => - parseArrayOptions.step(state, item, exit, index) ?? - parseArray(state, state.input, index + 1, end) ?? Effect.void - ) - }) - : parseArray - return Effect.fnUntracedEager(function*(input, options) { if (input === InternalParser.missing) { return InternalParser.missing @@ -2363,7 +2347,7 @@ export const Arrays: new( const end = ast.rest.length === 0 ? elementLen : Math.max(len, elementLen + tailLen) const concurrency = options.concurrency === undefined ? 1 : resolveConcurrency(options.concurrency) const eff = concurrency === 1 - ? run(state, input, 0, end) + ? parseArray(state, input, 0, end) : parseArrayConcurrent(state, input, { concurrency, end }) if (eff) yield* eff @@ -2422,18 +2406,6 @@ export const Arrays: new( return "array" } } -/** @internal */ -export interface ArrayParserContext { - readonly getElement: () => SchemaParser.Parser - readonly step: typeof parseArrayOptions.step - readonly resume: ( - state: ArrayParserState, - item: unknown, - index: number, - pending: Effect.Effect, - end: number - ) => Effect.Effect -} type ArrayParserState = { readonly ast: AST @@ -2446,7 +2418,37 @@ type ArrayParserState = { readonly tailThreshold: number readonly options: ParseOptions readonly output: Array - issues: Array | undefined + issues: Arr.NonEmptyArray | undefined +} + +/** @internal */ +export function stepArray( + s: ArrayParserState, + item: unknown, + exit: Exit.Exit, + i: number +) { + if (exit._tag === "Failure") { + return wrapPropertyKeyIssue(s, s.ast, i, exit) + } + const value = exit === InternalParser.sameExit + ? item + : (exit as InternalParser.Success)[InternalParser.args] + if (value !== InternalParser.missing) { + s.output[i] = value + } else { + const p = s.getParser(s.tailThreshold, i) + if (isOptional(p.ast)) return + const issue = new SchemaIssue.Pointer([i], new SchemaIssue.MissingKey(p.ast.context?.annotations)) + if (s.options.errors === "all") { + if (s.issues) s.issues.push(issue) + else s.issues = [issue] + } else { + return Exit.fail( + new SchemaIssue.Composite(s.ast, [issue], s.input, s.options) + ) + } + } } const parseArrayOptions = { @@ -2454,32 +2456,11 @@ const parseArrayOptions = { const value = i < s.len ? item : InternalParser.missing return s.getParser(s.tailThreshold, i).parser(value, s.options) }, - step(s: ArrayParserState, item: unknown, exit: Exit.Exit, i: number) { - if (exit._tag === "Failure") { - return wrapPropertyKeyIssue(s, s.ast, i, exit) - } - const value = exit === InternalParser.sameExit - ? item - : (exit as InternalParser.Success)[InternalParser.args] - if (value !== InternalParser.missing) { - s.output[i] = value - } else { - const p = s.getParser(s.tailThreshold, i) - if (isOptional(p.ast)) return - const issue = new SchemaIssue.Pointer([i], new SchemaIssue.MissingKey(p.ast.context?.annotations)) - if (s.options.errors === "all") { - if (s.issues) s.issues.push(issue) - else s.issues = [issue] - } else { - return Exit.fail( - new SchemaIssue.Composite(s.ast, [issue], s.input, s.options) - ) - } - } - } + step: stepArray } -const parseArray = iterateEager()(parseArrayOptions) +/** @internal */ +export const parseArray = iterateEager()(parseArrayOptions) const parseArrayConcurrent = iterateConcurrent()(parseArrayOptions) const wrapPropertyKeyIssue = ( @@ -2741,8 +2722,7 @@ export interface Objects extends ASTNode { getParser( compile: SchemaParser.Compiler, - compileConstructorDefault?: SchemaParser.Compiler, - generate?: (context: ObjectParserContext) => SchemaParser.Parser + compileConstructorDefault?: SchemaParser.Compiler ): SchemaParser.Parser /** @internal */ @@ -2807,8 +2787,7 @@ export const Objects: new( /** @internal */ getParser( compile: SchemaParser.Compiler, - compileConstructorDefault: SchemaParser.Compiler = compile, - generate?: (context: ObjectParserContext) => SchemaParser.Parser + compileConstructorDefault: SchemaParser.Compiler = compile ): SchemaParser.Parser { // oxlint-disable-next-line @typescript-eslint/no-this-alias const ast = this @@ -3047,10 +3026,6 @@ export const Objects: new( }) } - if (generate !== undefined) { - return generate({ ast, getProperties: compileMembers, fallback, resume, step: stepProperty }) - } - // Fast path: a struct without index signatures, under the default parse // options, needs none of the generator the fallback runs per value. return (input, options) => { @@ -3140,25 +3115,12 @@ export const Objects: new( } } -/** @internal */ -export interface ObjectParserContext { - readonly ast: Objects - readonly getProperties: () => ReadonlyArray - readonly fallback: SchemaParser.Parser - readonly resume: ( - state: ObjectParserState, - index: number, - pending: Effect.Effect - ) => Effect.Effect - readonly step: typeof stepProperty -} - type ObjectParserState = { readonly ast: Objects readonly input: Record readonly options: ParseOptions readonly out: Record - issues: Array | undefined + issues: Arr.NonEmptyArray | undefined } type ParsedProperty = { @@ -3167,7 +3129,8 @@ type ParsedProperty = { readonly type: AST } -function stepProperty( +/** @internal */ +export function stepProperty( s: ObjectParserState, p: ParsedProperty, exit: Exit.Exit @@ -3208,7 +3171,8 @@ const parsePropertiesOptions = { step: stepProperty } -const parseProperties = iterateEager()(parsePropertiesOptions) +/** @internal */ +export const parseProperties = iterateEager()(parsePropertiesOptions) const parsePropertiesConcurrent = iterateConcurrent()(parsePropertiesOptions) function combineChecks(a: Checks | undefined, b: Checks | undefined): Checks | undefined { diff --git a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts index 61373348fa3..2e8a6df4424 100644 --- a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts +++ b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts @@ -6,17 +6,129 @@ * @since 4.0.0 */ import * as Effect from "../../../Effect.ts" -import { effectIsExit } from "../../../internal/effect.ts" +import { effectIsExit, resolveConcurrency } from "../../../internal/effect.ts" import { lazyParser, type Resolve, resolve, set, withValidation } from "../../../internal/schema/compilerRegistry.ts" import * as Interpreter from "../../../internal/schema/interpreter.ts" import * as InternalParser from "../../../internal/schema/parser.ts" import * as SchemaAST from "../../../SchemaAST.ts" import * as SchemaIssue from "../../../SchemaIssue.ts" +import type { Compiler } from "../../../SchemaParser.ts" import { invalid, type Validate } from "../SchemaCompiler.ts" -type GenerateObject = (context: SchemaAST.ObjectParserContext) => SchemaIssueParser -type GenerateArray = NonNullable[2]> type SchemaIssueParser = ReturnType +type ObjectParserState = Parameters[0] +type ParsedProperty = Parameters[1] +type ArrayParserState = Parameters[0] +type GenerateObject = (context: { + readonly ast: SchemaAST.Objects + readonly getProperties: () => ReadonlyArray + readonly fallback: SchemaIssueParser + readonly resume: ( + state: ObjectParserState, + index: number, + pending: Effect.Effect + ) => Effect.Effect + readonly step: typeof SchemaAST.stepProperty +}) => SchemaIssueParser +type GenerateArray = (context: { + readonly getElement: () => SchemaIssueParser + readonly step: typeof SchemaAST.stepArray + readonly resume: ( + state: ArrayParserState, + item: unknown, + index: number, + pending: Effect.Effect, + end: number + ) => Effect.Effect +}) => typeof SchemaAST.parseArray + +const makeObjectBase = ( + ast: SchemaAST.Objects, + compile: Compiler, + compileConstructorDefault: Compiler, + generate: GenerateObject +): SchemaIssueParser => { + let properties: Array | undefined + const getProperties = (): Array => + properties ??= ast.propertySignatures.map((property) => ({ + parser: compileConstructorDefault(property.type), + name: property.name, + type: property.type + })) + let fallback: SchemaIssueParser | undefined + const runFallback: SchemaIssueParser = (input, options) => + (fallback ??= ast.getParser(compile, compileConstructorDefault))(input, options) + const resume = ( + state: ObjectParserState, + index: number, + pending: Effect.Effect + ): Effect.Effect => { + const property = properties![index] + return Effect.flatMap(Effect.exit(pending), (exit) => { + const terminal = SchemaAST.stepProperty(state, property, exit) + if (terminal) return terminal + const done = () => InternalParser.succeed(state.out) + const effect = SchemaAST.parseProperties(state, properties!.slice(index + 1)) + return effect ? Effect.flatMapEager(effect, done) : done() + }) + } + return generate({ ast, getProperties, fallback: runFallback, resume, step: SchemaAST.stepProperty }) +} + +const makeArrayBase = ( + ast: SchemaAST.Arrays, + compile: Compiler, + compileConstructorDefault: Compiler, + generate: GenerateArray +): SchemaIssueParser => { + let element: { readonly ast: SchemaAST.AST; readonly parser: SchemaIssueParser } | undefined + const getElement = () => (element ??= { + ast: ast.rest[0], + parser: compileConstructorDefault(ast.rest[0]) + }) + let fallback: SchemaIssueParser | undefined + const runFallback: SchemaIssueParser = (input, options) => + (fallback ??= ast.getParser(compile, compileConstructorDefault))(input, options) + const run = generate({ + getElement: () => getElement().parser, + step: SchemaAST.stepArray, + resume: (state, item, index, pending, end) => + Effect.flatMap( + Effect.exit(pending), + (exit) => + SchemaAST.stepArray(state, item, exit, index) ?? + SchemaAST.parseArray(state, state.input, index + 1, end) ?? Effect.void + ) + }) + const specialized = Effect.fnUntracedEager(function*(input: unknown, options: SchemaAST.ParseOptions) { + if (input === InternalParser.missing) return InternalParser.missing + if (!Array.isArray(input)) { + return yield* Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) + } + const descriptor = getElement() + const len = input.length + const state: ArrayParserState = { + ast, + getParser: () => descriptor, + input, + len, + tailThreshold: len, + output: new globalThis.Array(len), + issues: undefined, + options + } + const effect = run(state, input, 0, len) + if (effect) yield* effect + if (state.issues) { + return yield* Effect.fail(new SchemaIssue.Composite(ast, state.issues, input, options)) + } + return state.output + }) + return (input, options) => + options.concurrency !== undefined && resolveConcurrency(options.concurrency) !== 1 + ? runFallback(input, options) + : specialized(input, options) +} const decode = ( ast: SchemaAST.AST, @@ -31,9 +143,9 @@ const decode = ( ? child : (ast: SchemaAST.AST) => lazyParser(resolve, ast, "decodeEffect") const base = ast._tag === "Objects" && generate !== undefined ? - ast.getParser(localChild, undefined, generate) + makeObjectBase(ast, localChild, localChild, generate) : ast._tag === "Arrays" && generateArray !== undefined - ? ast.getParser(localChild, undefined, generateArray) + ? makeArrayBase(ast, localChild, localChild, generateArray) : makeValidate !== undefined ? ast.getParser(localChild) : undefined @@ -57,9 +169,9 @@ const make = ( const child = (ast: SchemaAST.AST) => lazyParser(resolve, ast, "makeEffect") const field = (ast: SchemaAST.AST) => lazyParser(resolve, ast, "makeDefaulted") const base = generate !== undefined && ast._tag === "Objects" ? - ast.getParser(child, field, generate) + makeObjectBase(ast, child, field, generate) : generateArray !== undefined && ast._tag === "Arrays" - ? ast.getParser(child, field, generateArray) + ? makeArrayBase(ast, child, field, generateArray) : undefined return Interpreter.compile(ast, child, field, base) } diff --git a/packages/effect/test/schema/SchemaCompilerArray.test.ts b/packages/effect/test/schema/SchemaCompilerArray.test.ts index f70c686cf44..637f3119452 100644 --- a/packages/effect/test/schema/SchemaCompilerArray.test.ts +++ b/packages/effect/test/schema/SchemaCompilerArray.test.ts @@ -1,20 +1,8 @@ -import { assert, describe, it, vi } from "@effect/vitest" +import { assert, describe, it } from "@effect/vitest" import { Deferred, Effect, Fiber, Schema, SchemaGetter, SchemaParser } from "effect" import { SchemaCompiler, SchemaJITCompiler } from "effect/unstable/schema" describe("compiled homogeneous Array traversal", () => { - it("uses the generated sequential driver for construction", () => { - const schema = Schema.Array(Schema.Struct({ value: Schema.Number })) - const parser = vi.spyOn(schema.ast, "getParser") - try { - SchemaJITCompiler.enable(schema.ast) - assert.deepStrictEqual(SchemaParser.make(schema)([{ value: 1 }]), [{ value: 1 }]) - assert.strictEqual(typeof parser.mock.calls[0][2], "function") - } finally { - parser.mockRestore() - } - }) - it.effect("keeps detailed errors and sparse input behavior", () => Effect.gen(function*() { const schema = Schema.Array(Schema.NumberFromString) diff --git a/packages/effect/test/schema/SchemaCompilerConstruction.test.ts b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts index 8c2a9df1ac9..530f13405a7 100644 --- a/packages/effect/test/schema/SchemaCompilerConstruction.test.ts +++ b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts @@ -256,24 +256,6 @@ describe("Schema compiler construction", { concurrent: false }, () => { assert.throws(() => SchemaParser.make(required)("a"), /Schema validation failed/) }) - it("runs generated Struct construction with shared diagnostic helpers", () => { - const schema = Schema.Struct({ - a: Schema.String, - b: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1))) - }) - SchemaJITCompiler.enable(schema.ast) - const setup = vi.spyOn(schema.ast, "getParser") - const make = SchemaParser.make(schema) - try { - assert.deepStrictEqual(make({ a: "a" }), { a: "a", b: 1 }) - assert.throws(() => make({ a: 1 } as never), /Schema validation failed/) - assert.strictEqual(setup.mock.calls.length, 1) - assert.strictEqual(typeof setup.mock.calls[0][2], "function") - } finally { - setup.mockRestore() - } - }) - it.effect("executes async defaults once, including on later failure", () => Effect.gen(function*() { let defaults = 0 diff --git a/packages/effect/test/schema/SchemaJITCompiler.test.ts b/packages/effect/test/schema/SchemaJITCompiler.test.ts index e6925af8d3c..1d015a53f99 100644 --- a/packages/effect/test/schema/SchemaJITCompiler.test.ts +++ b/packages/effect/test/schema/SchemaJITCompiler.test.ts @@ -317,27 +317,17 @@ describe("SchemaJITCompiler", () => { Schema.check(Schema.makeFilter((input) => input.value !== "01")), Schema.flip ) - let interpreted = 0 - const getParser = schema.ast.getParser.bind(schema.ast) - Object.defineProperty(schema.ast, "getParser", { - value(...args: Parameters) { - interpreted++ - return getParser(...args) - } - }) const decode = SchemaParser.decodeUnknownSync(schema) deepStrictEqual(decode({ value: "1", extra: true }), { value: 1 }) throws(() => decode({ value: "-1", extra: true })) strictEqual(transformations, 2) strictEqual(checks, 2) - strictEqual(interpreted, 1) deepStrictEqual(decode({ value: "-1" }, { disableChecks: true }), { value: -1 }) strictEqual(checks, 2) throws(() => decode({ value: "-1" }, { errors: "all" })) strictEqual(transformations, 4) strictEqual(checks, 3) - strictEqual(interpreted, 1) throws(() => decode({ value: "01" })) strictEqual(transformations, 5) strictEqual(checks, 3) diff --git a/packages/effect/test/schema/fixtures/aot-runner.ts b/packages/effect/test/schema/fixtures/aot-runner.ts index 9959d398378..a56b7ece323 100644 --- a/packages/effect/test/schema/fixtures/aot-runner.ts +++ b/packages/effect/test/schema/fixtures/aot-runner.ts @@ -78,14 +78,6 @@ const snapshotConstruction = async () => { const interpretedConstruction = await snapshotConstruction() assert.equal(suspendEvaluations, 0) -const prepareProof = proof.ast.getParser.bind(proof.ast) -Object.defineProperty(proof.ast, "getParser", { - value(...args: Parameters) { - assert.equal(typeof args[2], "function", "AOT must supply the generated property loop") - return prepareProof(...args) - } -}) - const before = CompilerRegistry.resolve(schemas.struct.ast) const empty = await import(pathToFileURL(join(process.argv[2], "empty.mjs")).href) assert.equal(empty.install([]), undefined) From 080ad85936df21e0fe0d94c8a7620ef6377b3881 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 14 Sep 2026 17:55:54 +0200 Subject: [PATCH 09/33] Simplify schema constructor defaults --- .../src/internal/schema/compilerRegistry.ts | 24 ++++++------------- .../effect/src/internal/schema/interpreter.ts | 9 +++++-- .../unstable/schema/SchemaCompiler/runtime.ts | 2 +- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/packages/effect/src/internal/schema/compilerRegistry.ts b/packages/effect/src/internal/schema/compilerRegistry.ts index 7d88bfa22f6..d1a3a7d59fd 100644 --- a/packages/effect/src/internal/schema/compilerRegistry.ts +++ b/packages/effect/src/internal/schema/compilerRegistry.ts @@ -26,7 +26,7 @@ function activateCompilerAdapters(): void { const decodeChild = (ast: SchemaAST.AST): Parser => lazyParser(resolve, ast, "parser") const makeChild = (ast: SchemaAST.AST): Parser => lazyParser(resolve, ast, "makeEffect") -const makeDefaultedChild = (ast: SchemaAST.AST): Parser => lazyParser(resolve, ast, "makeDefaulted") +const makeField = (ast: SchemaAST.AST): Parser => Interpreter.compileField(ast, makeChild) /** @internal */ export interface Entry { @@ -38,7 +38,6 @@ export interface Entry { readonly decodeEffect: Parser readonly parser: Parser readonly makeEffect: Parser - readonly makeDefaulted: Parser } class InterpretedEntry implements Entry { @@ -73,24 +72,15 @@ class InterpretedEntry implements Entry { } get makeEffect(): Parser { + const child = this.resolve === resolve + ? makeChild + : (ast: SchemaAST.AST) => lazyParser(this.resolve, ast, "makeEffect") return this.save( "makeEffect", Interpreter.compile( this.ast, - this.resolve === resolve ? makeChild : (ast) => lazyParser(this.resolve, ast, "makeEffect"), - this.resolve === resolve ? makeDefaultedChild : (ast) => lazyParser(this.resolve, ast, "makeDefaulted") - ) - ) - } - - get makeDefaulted(): Parser { - const link = this.ast.context?.constructorDefault - return link === undefined ? this.makeEffect : this.save( - "makeDefaulted", - Interpreter.withDefault( - this.ast, - (input, options) => this.makeEffect(input, options), - this.resolve === resolve ? makeChild : (ast) => lazyParser(this.resolve, ast, "makeEffect") + child, + this.resolve === resolve ? makeField : (ast) => Interpreter.compileField(ast, child) ) ) } @@ -151,7 +141,7 @@ export function withValidation(validate: Validate, decode: () => Parser): Parser export function lazyParser( resolve: Resolve, ast: SchemaAST.AST, - operation: "parser" | "decodeEffect" | "makeEffect" | "makeDefaulted" + operation: "parser" | "decodeEffect" | "makeEffect" ): Parser { const entry = resolve(ast) if (entry.source === undefined || Object.hasOwn(entry, operation)) return entry[operation] diff --git a/packages/effect/src/internal/schema/interpreter.ts b/packages/effect/src/internal/schema/interpreter.ts index d26458e05e8..54d8561310e 100644 --- a/packages/effect/src/internal/schema/interpreter.ts +++ b/packages/effect/src/internal/schema/interpreter.ts @@ -52,8 +52,7 @@ function makeConstructorParser(descriptor: SchemaAST.ConstructorDescriptor, comp } } -/** @internal */ -export function withDefault(ast: SchemaAST.AST, parser: Parser, resolve: Compiler): Parser { +function withDefault(ast: SchemaAST.AST, parser: Parser, resolve: Compiler): Parser { const link = ast.context!.constructorDefault! let source: Parser | undefined return (input, options) => { @@ -80,6 +79,12 @@ export function withDefault(ast: SchemaAST.AST, parser: Parser, resolve: Compile } } +/** @internal */ +export function compileField(ast: SchemaAST.AST, compile: Compiler): Parser { + const parser = compile(ast) + return ast.context?.constructorDefault === undefined ? parser : withDefault(ast, parser, compile) +} + /** @internal */ export function compile( ast: SchemaAST.AST, diff --git a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts index 2e8a6df4424..f65881c8572 100644 --- a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts +++ b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts @@ -167,7 +167,7 @@ const make = ( generateArray?: GenerateArray ): SchemaIssueParser => { const child = (ast: SchemaAST.AST) => lazyParser(resolve, ast, "makeEffect") - const field = (ast: SchemaAST.AST) => lazyParser(resolve, ast, "makeDefaulted") + const field = (ast: SchemaAST.AST) => Interpreter.compileField(ast, child) const base = generate !== undefined && ast._tag === "Objects" ? makeObjectBase(ast, child, field, generate) : generateArray !== undefined && ast._tag === "Arrays" From de3eeac910fecfdb5919bd5a7cc72b839bb99204 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 14 Sep 2026 20:03:36 +0200 Subject: [PATCH 10/33] Reduce schema interpreter bundle overhead --- .../src/internal/schema/compilerRegistry.ts | 80 +++++++++---------- 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/packages/effect/src/internal/schema/compilerRegistry.ts b/packages/effect/src/internal/schema/compilerRegistry.ts index d1a3a7d59fd..bb67fc70f7f 100644 --- a/packages/effect/src/internal/schema/compilerRegistry.ts +++ b/packages/effect/src/internal/schema/compilerRegistry.ts @@ -15,7 +15,7 @@ export type Resolve = (ast: SchemaAST.AST) => Entry export type Compile = (ast: SchemaAST.AST, resolve: Resolve) => CompiledDecoder | undefined const cache = new WeakMap() -let compiler: ((ast: SchemaAST.AST, resolve: Resolve) => Entry | undefined) | undefined +let compiler: ((ast: SchemaAST.AST, resolve: Resolve) => Entry) | undefined /** @internal */ export let compilerAdaptersEnabled = false @@ -24,15 +24,21 @@ function activateCompilerAdapters(): void { compilerAdaptersEnabled = true } -const decodeChild = (ast: SchemaAST.AST): Parser => lazyParser(resolve, ast, "parser") -const makeChild = (ast: SchemaAST.AST): Parser => lazyParser(resolve, ast, "makeEffect") +const decodeChild = (ast: SchemaAST.AST): Parser => + compilerAdaptersEnabled + ? lazyParser(resolve, ast, "parser") + : resolve(ast).parser +const makeChild = (ast: SchemaAST.AST): Parser => + compilerAdaptersEnabled + ? lazyParser(resolve, ast, "makeEffect") + : resolve(ast).makeEffect const makeField = (ast: SchemaAST.AST): Parser => Interpreter.compileField(ast, makeChild) /** @internal */ export interface Entry { readonly ast: SchemaAST.AST readonly source?: CompiledDecoder | undefined - readonly resolve: Resolve + readonly resolve?: Resolve | undefined readonly is?: Is | undefined readonly validate?: Validate | undefined readonly decodeEffect: Parser @@ -42,14 +48,9 @@ export interface Entry { class InterpretedEntry implements Entry { readonly ast: SchemaAST.AST - readonly resolve: Resolve - constructor( - ast: SchemaAST.AST, - resolve: Resolve - ) { + constructor(ast: SchemaAST.AST) { this.ast = ast - this.resolve = resolve } protected save(key: K, value: Entry[K]): Entry[K] { @@ -60,10 +61,7 @@ class InterpretedEntry implements Entry { get decodeEffect(): Parser { return this.save( "decodeEffect", - Interpreter.compile( - this.ast, - this.resolve === resolve ? decodeChild : (ast) => lazyParser(this.resolve, ast, "parser") - ) + Interpreter.compile(this.ast, decodeChild) ) } @@ -72,38 +70,36 @@ class InterpretedEntry implements Entry { } get makeEffect(): Parser { - const child = this.resolve === resolve - ? makeChild - : (ast: SchemaAST.AST) => lazyParser(this.resolve, ast, "makeEffect") return this.save( "makeEffect", - Interpreter.compile( - this.ast, - child, - this.resolve === resolve ? makeField : (ast) => Interpreter.compileField(ast, child) - ) + Interpreter.compile(this.ast, makeChild, makeField) ) } } -class InstalledEntry extends InterpretedEntry { - readonly source: CompiledDecoder +class CompilerEntry extends InterpretedEntry { + readonly source: CompiledDecoder | undefined + readonly resolve: Resolve - constructor(ast: SchemaAST.AST, source: CompiledDecoder, resolve: Resolve) { - super(ast, resolve) + constructor(ast: SchemaAST.AST, source: CompiledDecoder | undefined, resolve: Resolve) { + super(ast) this.source = source + this.resolve = resolve } get is(): Is | undefined { - return this.save("is", this.source.is) + return this.save("is", this.source?.is) } get validate(): Validate | undefined { - return this.save("validate", this.source.validate) + return this.save("validate", this.source?.validate) } override get decodeEffect(): Parser { - return this.save("decodeEffect", this.source.decodeEffect) + return this.save( + "decodeEffect", + this.source?.decodeEffect ?? Interpreter.compile(this.ast, (ast) => lazyParser(this.resolve, ast, "parser")) + ) } override get parser(): Parser { @@ -114,10 +110,17 @@ class InstalledEntry extends InterpretedEntry { } override get makeEffect(): Parser { - const makeEffect = this.source.makeEffect - return makeEffect === undefined - ? super.makeEffect - : this.save("makeEffect", makeEffect) + const makeEffect = this.source?.makeEffect + if (makeEffect !== undefined) return this.save("makeEffect", makeEffect) + const child = (ast: SchemaAST.AST): Parser => lazyParser(this.resolve, ast, "makeEffect") + return this.save( + "makeEffect", + Interpreter.compile( + this.ast, + child, + (ast) => Interpreter.compileField(ast, child) + ) + ) } } @@ -153,7 +156,7 @@ export function lazyParser( export function resolve(ast: SchemaAST.AST): Entry { const cached = cache.get(ast) if (cached !== undefined) return cached - const entry = compiler?.(ast, resolve) ?? new InterpretedEntry(ast, resolve) + const entry = compiler === undefined ? new InterpretedEntry(ast) : compiler(ast, resolve) cache.set(ast, entry) return entry } @@ -161,9 +164,7 @@ export function resolve(ast: SchemaAST.AST): Entry { /** @internal */ export function set(ast: SchemaAST.AST, decoder: CompiledDecoder | undefined, resolveChild: Resolve = resolve): Entry { if (decoder !== undefined) activateCompilerAdapters() - const entry = decoder === undefined - ? new InterpretedEntry(ast, resolveChild) - : new InstalledEntry(ast, decoder, resolveChild) + const entry = new CompilerEntry(ast, decoder, resolveChild) cache.set(ast, entry) return entry } @@ -171,10 +172,7 @@ export function set(ast: SchemaAST.AST, decoder: CompiledDecoder | undefined, re /** @internal */ export function install(compile: Compile): void { activateCompilerAdapters() - compiler = (ast, resolve) => { - const decoder = compile(ast, resolve) - return decoder === undefined ? undefined : new InstalledEntry(ast, decoder, resolve) - } + compiler = (ast, resolve) => new CompilerEntry(ast, compile(ast, resolve), resolve) } /** @internal */ From 8032885726a7bc404622b27fffaca1d98090697d Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Mon, 14 Sep 2026 22:39:49 +0200 Subject: [PATCH 11/33] Optimize interpreted schema registry entries --- .../src/internal/schema/compilerRegistry.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/effect/src/internal/schema/compilerRegistry.ts b/packages/effect/src/internal/schema/compilerRegistry.ts index bb67fc70f7f..a178af09592 100644 --- a/packages/effect/src/internal/schema/compilerRegistry.ts +++ b/packages/effect/src/internal/schema/compilerRegistry.ts @@ -48,21 +48,15 @@ export interface Entry { class InterpretedEntry implements Entry { readonly ast: SchemaAST.AST + declare private cachedDecodeEffect: Parser | undefined + declare private cachedMakeEffect: Parser | undefined constructor(ast: SchemaAST.AST) { this.ast = ast } - protected save(key: K, value: Entry[K]): Entry[K] { - Object.defineProperty(this, key, { value }) - return value - } - get decodeEffect(): Parser { - return this.save( - "decodeEffect", - Interpreter.compile(this.ast, decodeChild) - ) + return this.cachedDecodeEffect ??= Interpreter.compile(this.ast, decodeChild) } get parser(): Parser { @@ -70,10 +64,7 @@ class InterpretedEntry implements Entry { } get makeEffect(): Parser { - return this.save( - "makeEffect", - Interpreter.compile(this.ast, makeChild, makeField) - ) + return this.cachedMakeEffect ??= Interpreter.compile(this.ast, makeChild, makeField) } } @@ -87,6 +78,11 @@ class CompilerEntry extends InterpretedEntry { this.resolve = resolve } + private save(key: K, value: Entry[K]): Entry[K] { + Object.defineProperty(this, key, { value }) + return value + } + get is(): Is | undefined { return this.save("is", this.source?.is) } From 9aa07c9bdd6e93f38966f5bdcc1e5bfba8e7d364 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Tue, 15 Sep 2026 10:26:21 +0200 Subject: [PATCH 12/33] Fix schema runtime performance benchmarks --- packages/effect/SCHEMA.md | 31 +- packages/effect/runtimeperf/README.md | 17 +- packages/effect/runtimeperf/compare.mts | 8 + packages/effect/runtimeperf/config.json | 436 +++++++++++++++++- packages/effect/runtimeperf/run.mts | 21 +- .../suites/compiler-rebuild/costs.mts | 52 ++- .../runtimeperf/suites/moltar/fixtures/aot.ts | 17 + .../suites/moltar/fixtures/cases.ts | 60 +++ .../suites/moltar/fixtures/data.ts | 31 ++ .../suites/moltar/fixtures/generate.mts | 5 + .../suites/moltar/fixtures/interpreted.ts | 1 + .../runtimeperf/suites/moltar/fixtures/jit.ts | 6 + .../suites/moltar/fixtures/valibot.ts | 53 +++ .../runtimeperf/suites/moltar/fixtures/zod.ts | 116 +++++ .../schema-benchmarks/fixtures/effect-beta.ts | 5 +- .../schema-benchmarks/fixtures/valibot.ts | 8 +- .../suites/schema-benchmarks/fixtures/zod.ts | 8 +- .../suites/schema/fixtures/adapters.ts | 15 +- .../suites/schema/fixtures/behavior.ts | 18 +- .../suites/schema/fixtures/cold.ts | 20 +- .../effect/runtimeperf/test/registry.test.mts | 30 +- packages/effect/runtimeperf/utils.mts | 16 +- 22 files changed, 909 insertions(+), 65 deletions(-) create mode 100644 packages/effect/runtimeperf/suites/moltar/fixtures/aot.ts create mode 100644 packages/effect/runtimeperf/suites/moltar/fixtures/cases.ts create mode 100644 packages/effect/runtimeperf/suites/moltar/fixtures/data.ts create mode 100644 packages/effect/runtimeperf/suites/moltar/fixtures/generate.mts create mode 100644 packages/effect/runtimeperf/suites/moltar/fixtures/interpreted.ts create mode 100644 packages/effect/runtimeperf/suites/moltar/fixtures/jit.ts create mode 100644 packages/effect/runtimeperf/suites/moltar/fixtures/valibot.ts create mode 100644 packages/effect/runtimeperf/suites/moltar/fixtures/zod.ts diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 2105c7eec56..e4827b620f2 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -51,24 +51,19 @@ Values are microseconds per operation and lower is better. Results vary between machines, so they are most useful for understanding relative costs. A dash means that the library does not provide that benchmark. -| Scenario | Effect Schema | Valibot | Zod 4 | -| ------------------------------------- | ------------: | ---------: | ---------: | -| Create a schema | 118.23 | **40.24** | 318.56 | -| Create a schema and parser | **130.50** | — | — | -| Validate valid data | **5.415** | 5.63 | — | -| Validate invalid data | 1.348 | **0.2431** | — | -| Parse valid data and collect errors | 5.366 | **5.22** | 7.16 | -| Parse invalid data and collect errors | **9.100** | 15.70 | 41.58 | -| Parse valid data and stop early | **5.294** | 5.37 | — | -| Parse invalid data and stop early | 1.352 | **0.2572** | — | -| Standard Schema, valid data | 5.935 | 5.35 | **3.83** | -| Standard Schema, invalid data | **15.203** | 16.51 | 32.85 | -| Standard Schema, valid, stop early | **5.843** | — | — | -| Standard Schema, invalid, stop early | **2.244** | — | — | -| Encode with a typed codec | 0.3420 | — | **0.0405** | -| Decode with a typed codec | 0.3762 | — | **0.0463** | -| Encode unknown input | **0.3472** | — | — | -| Decode unknown input | **0.3637** | — | — | +| Scenario | Effect Schema | Valibot 1.5.0 | Zod 4.6.2 | +| ------------------------------------- | ------------: | ------------: | --------: | +| Create a schema | 60.1297 | 1.0926 | 83.7496 | +| Validate valid data | 4.1473 | 3.5389 | — | +| Validate invalid data | 0.2571 | 0.1823 | — | +| Parse valid data and collect errors | 5.0261 | 3.5393 | 7.0073 | +| Parse invalid data and collect errors | 7.4838 | 5.7206 | 19.3064 | +| Parse valid data and stop early | 4.1559 | 3.5941 | — | +| Parse invalid data and stop early | 0.2525 | 0.1917 | — | +| Standard Schema, valid data | **5.3408** | 3.5565 | 3.5755 | +| Standard Schema, invalid data | 11.7723 | 5.4422 | 15.9974 | +| Encode with a typed codec | 0.0827 | — | 0.0441 | +| Decode with a typed codec | 0.1063 | — | 0.0427 | ## Experimental schema compilers diff --git a/packages/effect/runtimeperf/README.md b/packages/effect/runtimeperf/README.md index e59c2bab34f..bbfc32a6ef9 100644 --- a/packages/effect/runtimeperf/README.md +++ b/packages/effect/runtimeperf/README.md @@ -87,6 +87,13 @@ adapters, recursion and cold paths. The `schema-benchmarks` suite contains the complete timing matrices exposed by the upstream Effect, Valibot and Zod adapters. +The `moltar-parse-safe` and `moltar-assert-loose` suites preserve Moltar's +object shape. Their valid cases reproduce the upstream timed operation; the +extra-property and invalid cases are Effect extensions. They compare +interpreted Effect, Effect JIT and AOT, Valibot, ordinary and jitless Zod where +applicable, and Zod `compile`, using the dependency versions recorded in each +report. + The `arbitrary` suite compares the native public API with direct fast-check v4 arbitraries in separate processes. It measures derivation through the first recursive sample, steady-state recursive sampling, optional-Struct sampling, fixed-length string generation to exercise constraint pushdown, @@ -130,10 +137,12 @@ against equivalent Valibot schemas using `parse` and `is`. ## Measurement model -Each worker validates the fixture before and after measuring. Calibration finds -a batch large enough for the configured target duration. Each implementation -uses its own calibrated batch and executes in a separate process, with rotating -order within the scenario. +Each worker validates the fixture before and after measuring. The Effect +fixture calibrates one batch size that every implementation in the same +scenario uses. This keeps the enclosing loop identical across implementations; +V8 can otherwise optimize sub-10 ns callbacks differently at different batch +sizes. Each implementation executes in a separate process, with rotating order +within the scenario. Tinybench measures one synchronous batched task. The primary process result is: diff --git a/packages/effect/runtimeperf/compare.mts b/packages/effect/runtimeperf/compare.mts index b964fcf074a..1b08f7c4e65 100644 --- a/packages/effect/runtimeperf/compare.mts +++ b/packages/effect/runtimeperf/compare.mts @@ -8,6 +8,7 @@ import { analyzePairs } from "./stats.mts" import { aggregateMeasurements, calibrateFixture, + comparePath, configPath, coverageSummary, effectDir, @@ -16,6 +17,7 @@ import { libraryVersions, loadRegistry, makeRunId, + materializePath, measureFixture, parseArgs, printTable, @@ -25,6 +27,8 @@ import { resolveDefaults, selectFixtures, sha256, + statsPath, + utilsPath, workerPath, writeJson } from "./utils.mts" @@ -240,7 +244,11 @@ const main = () => { artifactMode: "repository", coverage: coverageSummary(selected), hashes: { + compare: hashFile(comparePath), config: hashFile(configPath), + materialize: hashFile(materializePath), + stats: hashFile(statsPath), + utils: hashFile(utilsPath), worker: hashFile(workerPath), fixtures: Object.fromEntries( [...new Set(selected.map((fixture) => fixture.fixturePath))] diff --git a/packages/effect/runtimeperf/config.json b/packages/effect/runtimeperf/config.json index 0c0da1657b7..60821ca8dc6 100644 --- a/packages/effect/runtimeperf/config.json +++ b/packages/effect/runtimeperf/config.json @@ -1704,6 +1704,438 @@ } ] }, + { + "name": "moltar-parse-safe", + "fixtures": [ + { + "file": "suites/moltar/fixtures/interpreted.ts", + "defaults": { + "tier": 2, + "family": "moltar-parse-safe", + "implementation": "effect", + "operation": "decode-unknown-sync", + "astTags": ["Objects", "String", "Number", "Boolean"], + "size": "moltar-object" + }, + "cases": [ + { + "name": "effect-valid", + "export": "parseValid", + "scenario": "moltar-parse-safe-valid", + "path": "valid" + }, + { + "name": "effect-extra-valid", + "export": "parseExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "path": "extra-valid" + }, + { + "name": "effect-invalid", + "export": "parseInvalid", + "scenario": "moltar-parse-safe-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/jit.ts", + "defaults": { + "tier": 2, + "family": "moltar-parse-safe", + "implementation": "effect-jit", + "operation": "decode-unknown-sync", + "astTags": ["Objects", "String", "Number", "Boolean"], + "size": "moltar-object" + }, + "cases": [ + { + "name": "effect-jit-valid", + "export": "parseValid", + "scenario": "moltar-parse-safe-valid", + "path": "valid" + }, + { + "name": "effect-jit-extra-valid", + "export": "parseExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "path": "extra-valid" + }, + { + "name": "effect-jit-invalid", + "export": "parseInvalid", + "scenario": "moltar-parse-safe-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/aot.ts", + "defaults": { + "tier": 2, + "family": "moltar-parse-safe", + "implementation": "effect-aot", + "operation": "decode-unknown-sync", + "astTags": ["Objects", "String", "Number", "Boolean"], + "size": "moltar-object" + }, + "cases": [ + { + "name": "effect-aot-valid", + "export": "parseValid", + "scenario": "moltar-parse-safe-valid", + "path": "valid" + }, + { + "name": "effect-aot-extra-valid", + "export": "parseExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "path": "extra-valid" + }, + { + "name": "effect-aot-invalid", + "export": "parseInvalid", + "scenario": "moltar-parse-safe-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/valibot.ts", + "defaults": { + "tier": 2, + "family": "moltar-parse-safe", + "implementation": "valibot", + "operation": "parse", + "astTags": [], + "size": "moltar-object" + }, + "cases": [ + { + "name": "valibot-valid", + "export": "parseValid", + "scenario": "moltar-parse-safe-valid", + "path": "valid" + }, + { + "name": "valibot-extra-valid", + "export": "parseExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "path": "extra-valid" + }, + { + "name": "valibot-invalid", + "export": "parseInvalid", + "scenario": "moltar-parse-safe-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/zod.ts", + "defaults": { + "tier": 2, + "family": "moltar-parse-safe", + "astTags": [], + "size": "moltar-object" + }, + "cases": [ + { + "name": "zod-valid", + "export": "parseValid", + "scenario": "moltar-parse-safe-valid", + "implementation": "zod4", + "operation": "parse", + "path": "valid" + }, + { + "name": "zod-extra-valid", + "export": "parseExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "implementation": "zod4", + "operation": "parse", + "path": "extra-valid" + }, + { + "name": "zod-invalid", + "export": "parseInvalid", + "scenario": "moltar-parse-safe-invalid", + "implementation": "zod4", + "operation": "parse", + "path": "invalid" + }, + { + "name": "zod-jitless-valid", + "export": "parseJitlessValid", + "scenario": "moltar-parse-safe-valid", + "implementation": "zod4-jitless", + "operation": "parse-jitless", + "path": "valid" + }, + { + "name": "zod-jitless-extra-valid", + "export": "parseJitlessExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "implementation": "zod4-jitless", + "operation": "parse-jitless", + "path": "extra-valid" + }, + { + "name": "zod-jitless-invalid", + "export": "parseJitlessInvalid", + "scenario": "moltar-parse-safe-invalid", + "implementation": "zod4-jitless", + "operation": "parse-jitless", + "path": "invalid" + }, + { + "name": "zod-compiled-valid", + "export": "parseCompiledValid", + "scenario": "moltar-parse-safe-valid", + "implementation": "zod4-compiled", + "operation": "parse-compiled", + "path": "valid" + }, + { + "name": "zod-compiled-extra-valid", + "export": "parseCompiledExtraValid", + "scenario": "moltar-parse-safe-extra-valid", + "implementation": "zod4-compiled", + "operation": "parse-compiled", + "path": "extra-valid" + }, + { + "name": "zod-compiled-invalid", + "export": "parseCompiledInvalid", + "scenario": "moltar-parse-safe-invalid", + "implementation": "zod4-compiled", + "operation": "parse-compiled", + "path": "invalid" + } + ] + } + ] + }, + { + "name": "moltar-assert-loose", + "fixtures": [ + { + "file": "suites/moltar/fixtures/interpreted.ts", + "defaults": { + "tier": 2, + "family": "moltar-assert-loose", + "implementation": "effect", + "operation": "is", + "astTags": ["Objects", "String", "Number", "Boolean"], + "size": "moltar-object" + }, + "cases": [ + { + "name": "effect-valid", + "export": "isValid", + "scenario": "moltar-assert-loose-valid", + "path": "valid" + }, + { + "name": "effect-extra-valid", + "export": "isExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "path": "extra-valid" + }, + { + "name": "effect-invalid", + "export": "isInvalid", + "scenario": "moltar-assert-loose-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/jit.ts", + "defaults": { + "tier": 2, + "family": "moltar-assert-loose", + "implementation": "effect-jit", + "operation": "is", + "astTags": ["Objects", "String", "Number", "Boolean"], + "size": "moltar-object" + }, + "cases": [ + { + "name": "effect-jit-valid", + "export": "isValid", + "scenario": "moltar-assert-loose-valid", + "path": "valid" + }, + { + "name": "effect-jit-extra-valid", + "export": "isExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "path": "extra-valid" + }, + { + "name": "effect-jit-invalid", + "export": "isInvalid", + "scenario": "moltar-assert-loose-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/aot.ts", + "defaults": { + "tier": 2, + "family": "moltar-assert-loose", + "implementation": "effect-aot", + "operation": "is", + "astTags": ["Objects", "String", "Number", "Boolean"], + "size": "moltar-object" + }, + "cases": [ + { + "name": "effect-aot-valid", + "export": "isValid", + "scenario": "moltar-assert-loose-valid", + "path": "valid" + }, + { + "name": "effect-aot-extra-valid", + "export": "isExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "path": "extra-valid" + }, + { + "name": "effect-aot-invalid", + "export": "isInvalid", + "scenario": "moltar-assert-loose-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/valibot.ts", + "defaults": { + "tier": 2, + "family": "moltar-assert-loose", + "implementation": "valibot", + "operation": "is", + "astTags": [], + "size": "moltar-object" + }, + "cases": [ + { + "name": "valibot-valid", + "export": "isValid", + "scenario": "moltar-assert-loose-valid", + "path": "valid" + }, + { + "name": "valibot-extra-valid", + "export": "isExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "path": "extra-valid" + }, + { + "name": "valibot-invalid", + "export": "isInvalid", + "scenario": "moltar-assert-loose-invalid", + "path": "invalid" + } + ] + }, + { + "file": "suites/moltar/fixtures/zod.ts", + "defaults": { + "tier": 2, + "family": "moltar-assert-loose", + "astTags": [], + "size": "moltar-object" + }, + "cases": [ + { + "name": "zod-parse-valid", + "export": "assertParseValid", + "scenario": "moltar-assert-loose-valid", + "implementation": "zod4", + "operation": "parse-and-assert", + "path": "valid" + }, + { + "name": "zod-parse-extra-valid", + "export": "assertParseExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "implementation": "zod4", + "operation": "parse-and-assert", + "path": "extra-valid" + }, + { + "name": "zod-jitless-parse-valid", + "export": "assertParseJitlessValid", + "scenario": "moltar-assert-loose-valid", + "implementation": "zod4-jitless", + "operation": "parse-and-assert-jitless", + "path": "valid" + }, + { + "name": "zod-jitless-parse-extra-valid", + "export": "assertParseJitlessExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "implementation": "zod4-jitless", + "operation": "parse-and-assert-jitless", + "path": "extra-valid" + }, + { + "name": "zod-validate-valid", + "export": "isValid", + "scenario": "moltar-assert-loose-valid", + "implementation": "zod4-validate", + "operation": "validate", + "path": "valid" + }, + { + "name": "zod-validate-extra-valid", + "export": "isExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "implementation": "zod4-validate", + "operation": "validate", + "path": "extra-valid" + }, + { + "name": "zod-validate-invalid", + "export": "isInvalid", + "scenario": "moltar-assert-loose-invalid", + "implementation": "zod4-validate", + "operation": "validate", + "path": "invalid" + }, + { + "name": "zod-compiled-valid", + "export": "isCompiledValid", + "scenario": "moltar-assert-loose-valid", + "implementation": "zod4-compiled", + "operation": "validate-compiled", + "path": "valid" + }, + { + "name": "zod-compiled-extra-valid", + "export": "isCompiledExtraValid", + "scenario": "moltar-assert-loose-extra-valid", + "implementation": "zod4-compiled", + "operation": "validate-compiled", + "path": "extra-valid" + }, + { + "name": "zod-compiled-invalid", + "export": "isCompiledInvalid", + "scenario": "moltar-assert-loose-invalid", + "implementation": "zod4-compiled", + "operation": "validate-compiled", + "path": "invalid" + } + ] + } + ] + }, { "name": "compiler-rebuild", "fixtures": [ @@ -1944,7 +2376,7 @@ "defaults": { "tier": 1, "family": "jit", - "implementation": "effect", + "implementation": "effect-jit", "astTags": [ "Objects", "String", @@ -2176,7 +2608,7 @@ "defaults": { "tier": 1, "family": "aot", - "implementation": "effect", + "implementation": "effect-aot", "astTags": [ "Objects", "String", diff --git a/packages/effect/runtimeperf/run.mts b/packages/effect/runtimeperf/run.mts index e4f1e683d5f..3cac08601d9 100644 --- a/packages/effect/runtimeperf/run.mts +++ b/packages/effect/runtimeperf/run.mts @@ -18,7 +18,11 @@ import { relativeToRepo, reportPath, resolveDefaults, + runPath, + scenarioBatchSize, selectFixtures, + statsPath, + utilsPath, workerPath, writeJson } from "./utils.mts" @@ -31,7 +35,7 @@ Options: --warmup-time --tier <0-3> --family - --implementation + --implementation ` const rotate = (items, offset) => items.map((_, index) => items[(index + offset) % items.length]) @@ -52,12 +56,12 @@ const main = () => { for (const [scenario, group] of groups) { const calibrations = new Map(group.map((fixture) => [fixture, calibrateFixture(fixture, defaults)])) + const batchSize = scenarioBatchSize(group, calibrations) const byTarget = new Map(group.map((fixture) => [fixture.target, []])) for (let round = 0; round < defaults.rounds; round++) { for (const fixture of rotate(group, round % group.length)) { - const calibration = calibrations.get(fixture) - const measurement = measureFixture(fixture, defaults, calibration.batchSize) + const measurement = measureFixture(fixture, defaults, batchSize) byTarget.get(fixture.target).push(measurement) executionOrder.push({ scenario, round: round + 1, target: fixture.target }) } @@ -68,7 +72,7 @@ const main = () => { const measurements = byTarget.get(fixture.target) results.push({ fixture, - batchSize: calibration.batchSize, + batchSize, calibration, measurements, aggregate: aggregateMeasurements(measurements) @@ -116,17 +120,14 @@ const main = () => { cpu: os.cpus()[0]?.model ?? "unknown" }, libraries: libraryVersions(), - crossLibraryDecodeApis: { - effect: "SchemaParser.decodeUnknownExit (SchemaIssue)", - valibot: "safeParser", - zod4: "safeParse ({ jitless: true })", - "zod4-compiled": "z.compile(schema, { strict: true })" - }, artifactMode: "repository", git: currentGitState(), coverage: coverageSummary(selected), hashes: { config: hashFile(configPath), + run: hashFile(runPath), + stats: hashFile(statsPath), + utils: hashFile(utilsPath), worker: hashFile(workerPath), fixtures: Object.fromEntries( [...new Set(selected.map((fixture) => fixture.fixturePath))] diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts index 7aaaf8b29f6..2d4df69a3ae 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts @@ -6,6 +6,22 @@ import { fileURLToPath, pathToFileURL } from "node:url" const [command, root, mode, operation, shape = "struct", countString = "500"] = process.argv.slice(2) const count = Number(countString) +const includes = (values: ReadonlyArray, value: string | undefined) => + value !== undefined && values.includes(value) +if (command !== "heap" && command !== "cold" && command !== "generate") { + throw new Error("command must be heap, cold or generate") +} +if (root === undefined) throw new Error("root is required") +if (!includes(["interpreted", "jit", "aot"], mode)) { + throw new Error("mode must be interpreted, jit or aot") +} +if (!includes(["decode", "invalid", "is", "make"], operation)) { + throw new Error("operation must be decode, invalid, is or make") +} +if (!includes(["struct", "array", "transform", "default"], shape)) { + throw new Error("shape must be struct, array, transform or default") +} +if (!Number.isSafeInteger(count) || count <= 0) throw new Error("count must be a positive integer") const load = (path: string) => import(pathToFileURL(join(root, "packages/effect/src", path + ".ts")).href) const Schema = await load("Schema") const Parser = await load("SchemaParser") @@ -20,9 +36,14 @@ const create = () => { } const valid = shape === "array" ? Array.from({ length: 32 }, () => ({ name: "Ada", age: 37, active: true })) : shape === "transform" ? { value: "1" } - : shape === "default" ? {} + : shape === "default" ? { value: 1 } : { name: "Ada", age: 37, active: true } -const input = operation === "invalid" ? { name: "Ada", age: "bad", active: true } : valid +const typeValid = shape === "transform" ? { value: 1 } : valid +const input = operation === "invalid" ? { name: "Ada", age: "bad", active: true } + : operation === "make" && shape === "default" ? {} + : operation === "make" || operation === "is" ? typeValid + : valid +const expected = shape === "transform" || shape === "default" ? { value: 1 } : valid if (command === "generate") { const AOT = await load("unstable/schema/SchemaAOTCompiler") @@ -54,22 +75,33 @@ if (command === "generate") { } const run = (parse: (input: unknown) => unknown) => { try { - const value = parse(input) - assert.notEqual(operation, "invalid") - return value + return parse(input) } catch (error) { if (operation !== "invalid") throw error - assert.equal((error as Error).message, "Schema validation failed") + return error + } + } + const validate = (value: unknown) => { + if (operation === "invalid") { + assert.ok(value instanceof Error) + assert.equal(value.message, "Schema validation failed") + assert.equal("cause" in value, true) } + else if (operation === "is") assert.equal(value, true) + else assert.deepEqual(value, expected) } try { - for (let i = 0; i < 20; i++) run(prepare(create())) + for (let i = 0; i < 20; i++) validate(run(prepare(create()))) if (command === "heap") { assert.equal(typeof globalThis.gc, "function") const schemas = Array.from({ length: count }, create) for (let i = 0; i < 5; i++) globalThis.gc!() const before = process.memoryUsage().heapUsed - const parsers = schemas.map((schema) => { const parse = prepare(schema); run(parse); return parse }) + const parsers = schemas.map((schema) => { + const parse = prepare(schema) + validate(run(parse)) + return parse + }) for (let i = 0; i < 5; i++) globalThis.gc!() const after = process.memoryUsage().heapUsed // Keep schemas and adapters alive across the measurement. @@ -79,9 +111,11 @@ if (command === "generate") { } else if (command === "cold") { const samples = [] for (let round = 0; round < 7; round++) { + let value: unknown const start = process.hrtime.bigint() - for (let i = 0; i < count; i++) run(prepare(create())) + for (let i = 0; i < count; i++) value = run(prepare(create())) samples.push(Number(process.hrtime.bigint() - start) / count) + validate(value) } process.stdout.write(JSON.stringify({ mode, operation, shape, count, nsPerSchema: samples })) } diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/aot.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/aot.ts new file mode 100644 index 00000000000..1a402112555 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/aot.ts @@ -0,0 +1,17 @@ +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import { roots } from "./cases.ts" + +const directory = mkdtempSync(fileURLToPath(new URL("./.aot-", import.meta.url))) +try { + const file = join(directory, "generated.mjs") + execFileSync(process.execPath, [fileURLToPath(new URL("./generate.mts", import.meta.url)), file]) + const generated = await import(pathToFileURL(file).href) + generated.install(roots) +} finally { + rmSync(directory, { recursive: true, force: true }) +} + +export { isExtraValid, isInvalid, isValid, parseExtraValid, parseInvalid, parseValid } from "./cases.ts" diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/cases.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/cases.ts new file mode 100644 index 00000000000..ec1f23a7d15 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/cases.ts @@ -0,0 +1,60 @@ +import * as Schema from "effect/Schema" +import * as SchemaAST from "effect/SchemaAST" +import * as SchemaParser from "effect/SchemaParser" +import assert from "node:assert/strict" +import { invalidData, validData, validDataWithExtras } from "./data.ts" + +export const schema = Schema.Struct({ + number: Schema.Number, + negNumber: Schema.Number, + maxNumber: Schema.Number, + string: Schema.String, + longString: Schema.String, + boolean: Schema.Boolean, + deeplyNested: Schema.Struct({ + foo: Schema.String, + num: Schema.Number, + bool: Schema.Boolean + }) +}) + +export const roots = [schema.ast, SchemaAST.toType(schema.ast), SchemaAST.flip(schema.ast)] + +const parseCase = (input: unknown, invalid = false) => () => { + const parse = SchemaParser.decodeUnknownSync(schema) + return { + run: invalid + ? () => { + try { + parse(input) + return false + } catch { + return true + } + } + : () => parse(input), + validate: invalid + ? (result: unknown) => assert.equal(result, true) + : (result: unknown) => assert.deepEqual(result, validData) + } +} + +const guardCase = (input: unknown, expected: boolean) => () => { + const guard = SchemaParser.is(schema) + return { + run: expected + ? () => { + if (!guard(input)) throw new Error("Invalid") + return true + } + : () => guard(input), + validate: (result: unknown) => assert.equal(result, expected) + } +} + +export const parseValid = parseCase(validData) +export const parseExtraValid = parseCase(validDataWithExtras) +export const parseInvalid = parseCase(invalidData, true) +export const isValid = guardCase(validData, true) +export const isExtraValid = guardCase(validDataWithExtras, true) +export const isInvalid = guardCase(invalidData, false) diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/data.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/data.ts new file mode 100644 index 00000000000..d247f4eb016 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/data.ts @@ -0,0 +1,31 @@ +// Extracted from moltar/typescript-runtime-type-benchmarks at +// d1791e68fc1108ef47da50547e80900e177a9d10. +// Upstream license: MIT, declared in package.json at that commit. +export const validData = Object.freeze({ + number: 1, + negNumber: -1, + maxNumber: Number.MAX_VALUE, + string: "string", + longString: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Vivendum intellegat et qui, ei denique consequuntur vix. Semper aeterno percipit ut his, sea ex utinam referrentur repudiandae. No epicuri hendrerit consetetur sit, sit dicta adipiscing ex, in facete detracto deterruisset duo. Quot populo ad qui. Sit fugit nostrum et. Ad per diam dicant interesset, lorem iusto sensibus ut sed. No dicam aperiam vis. Pri posse graeco definitiones cu, id eam populo quaestio adipiscing, usu quod malorum te. Ex nam agam veri, dicunt efficiantur ad qui, ad legere adversarium sit. Commune platonem mel id, brute adipiscing duo an. Vivendum intellegat et qui, ei denique consequuntur vix. Offendit eleifend moderatius ex vix, quem odio mazim et qui, purto expetendis cotidieque quo cu, veri persius vituperata ei nec. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.", + boolean: true, + deeplyNested: { + foo: "bar", + num: 1, + bool: false + } +}) + +export const validDataWithExtras = Object.freeze({ + ...validData, + extraAttribute: "foo", + deeplyNested: { + ...validData.deeplyNested, + extraNestedAttribute: "bar" + } +}) + +export const invalidData = Object.freeze({ + ...validData, + number: "invalid" +}) diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/generate.mts b/packages/effect/runtimeperf/suites/moltar/fixtures/generate.mts new file mode 100644 index 00000000000..a45d893f671 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/generate.mts @@ -0,0 +1,5 @@ +import { writeFileSync } from "node:fs" +import { compile } from "effect/unstable/schema/SchemaAOTCompiler" +import { roots } from "./cases.ts" + +writeFileSync(process.argv[2], compile(roots)) diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/interpreted.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/interpreted.ts new file mode 100644 index 00000000000..448732fdabb --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/interpreted.ts @@ -0,0 +1 @@ +export { isExtraValid, isInvalid, isValid, parseExtraValid, parseInvalid, parseValid } from "./cases.ts" diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/jit.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/jit.ts new file mode 100644 index 00000000000..005d5588a12 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/jit.ts @@ -0,0 +1,6 @@ +import { enable } from "effect/unstable/schema/SchemaJITCompiler" +import { roots } from "./cases.ts" + +for (const ast of roots) enable(ast) + +export { isExtraValid, isInvalid, isValid, parseExtraValid, parseInvalid, parseValid } from "./cases.ts" diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/valibot.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/valibot.ts new file mode 100644 index 00000000000..4de6e0cb250 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/valibot.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict" +import * as v from "valibot" +import { invalidData, validData, validDataWithExtras } from "./data.ts" + +const shape = { + number: v.number(), + negNumber: v.number(), + maxNumber: v.number(), + string: v.string(), + longString: v.string(), + boolean: v.boolean(), + deeplyNested: v.object({ + foo: v.string(), + num: v.number(), + bool: v.boolean() + }) +} + +const parseSchema = v.object(shape) +const guardSchema = v.looseObject({ ...shape, deeplyNested: v.looseObject(shape.deeplyNested.entries) }) + +const parseCase = (input: unknown, invalid = false) => () => ({ + run: invalid + ? () => { + try { + v.parse(parseSchema, input) + return false + } catch { + return true + } + } + : () => v.parse(parseSchema, input), + validate: invalid + ? (result: unknown) => assert.equal(result, true) + : (result: unknown) => assert.deepEqual(result, validData) +}) + +const guardCase = (input: unknown, expected: boolean) => () => ({ + run: expected + ? () => { + if (!v.is(guardSchema, input)) throw new Error("Invalid") + return true + } + : () => v.is(guardSchema, input), + validate: (result: unknown) => assert.equal(result, expected) +}) + +export const parseValid = parseCase(validData) +export const parseExtraValid = parseCase(validDataWithExtras) +export const parseInvalid = parseCase(invalidData, true) +export const isValid = guardCase(validData, true) +export const isExtraValid = guardCase(validDataWithExtras, true) +export const isInvalid = guardCase(invalidData, false) diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/zod.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/zod.ts new file mode 100644 index 00000000000..2d127118aa9 --- /dev/null +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/zod.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict" +import * as z from "zod/v4" +import { invalidData, validData, validDataWithExtras } from "./data.ts" + +const makeShape = () => ({ + number: z.number(), + negNumber: z.number(), + maxNumber: z.number(), + string: z.string(), + longString: z.string(), + boolean: z.boolean(), + deeplyNested: z.object({ + foo: z.string(), + num: z.number(), + bool: z.boolean() + }) +}) + +const makeParseSchema = () => z.object(makeShape()) +const makeGuardSchema = () => { + const shape = makeShape() + return z.object({ ...shape, deeplyNested: shape.deeplyNested.passthrough() }).passthrough() +} + +const parseCase = ( + compile: (schema: ReturnType) => (input: unknown) => unknown, + input: unknown, + invalid = false +) => +() => { + const parse = compile(makeParseSchema()) + return { + run: invalid + ? () => { + try { + parse(input) + return false + } catch { + return true + } + } + : () => parse(input), + validate: invalid + ? (result: unknown) => assert.equal(result, true) + : (result: unknown) => assert.deepEqual(result, validData) + } +} + +const guardCase = ( + compile: (schema: ReturnType) => (input: unknown) => boolean, + input: unknown, + expected: boolean +) => +() => { + const validate = compile(makeGuardSchema()) + return { + run: expected + ? () => { + if (!validate(input)) throw new Error("Invalid") + return true + } + : () => validate(input), + validate: (result: unknown) => assert.equal(result, expected) + } +} + +const assertParseCase = ( + compile: (schema: ReturnType) => (input: unknown) => unknown, + input: unknown +) => +() => { + const parse = compile(makeGuardSchema()) + return { + run: () => { + parse(input) + return true + }, + validate: (result: unknown) => assert.equal(result, true) + } +} + +const parse = (schema: ReturnType) => (input: unknown) => schema.parse(input) +const parseJitless = (schema: ReturnType) => (input: unknown) => + schema.parse(input, { jitless: true }) +const parseCompiled = (schema: ReturnType) => { + const compiled = z.compile(schema, { strict: true }) + return (input: unknown) => compiled.parse(input) +} +const validate = (schema: ReturnType) => (input: unknown) => z.validate(schema, input) +const validateCompiled = (schema: ReturnType) => { + const compiled = z.compile(schema, { strict: true }) + return (input: unknown) => z.validate(compiled, input) +} +const assertParse = (schema: ReturnType) => (input: unknown) => schema.parse(input) +const assertParseJitless = (schema: ReturnType) => (input: unknown) => + schema.parse(input, { jitless: true }) + +export const parseValid = parseCase(parse, validData) +export const parseExtraValid = parseCase(parse, validDataWithExtras) +export const parseInvalid = parseCase(parse, invalidData, true) +export const parseJitlessValid = parseCase(parseJitless, validData) +export const parseJitlessExtraValid = parseCase(parseJitless, validDataWithExtras) +export const parseJitlessInvalid = parseCase(parseJitless, invalidData, true) +export const parseCompiledValid = parseCase(parseCompiled, validData) +export const parseCompiledExtraValid = parseCase(parseCompiled, validDataWithExtras) +export const parseCompiledInvalid = parseCase(parseCompiled, invalidData, true) +export const isValid = guardCase(validate, validData, true) +export const isExtraValid = guardCase(validate, validDataWithExtras, true) +export const isInvalid = guardCase(validate, invalidData, false) +export const isCompiledValid = guardCase(validateCompiled, validData, true) +export const isCompiledExtraValid = guardCase(validateCompiled, validDataWithExtras, true) +export const isCompiledInvalid = guardCase(validateCompiled, invalidData, false) +export const assertParseValid = assertParseCase(assertParse, validData) +export const assertParseExtraValid = assertParseCase(assertParse, validDataWithExtras) +export const assertParseJitlessValid = assertParseCase(assertParseJitless, validData) +export const assertParseJitlessExtraValid = assertParseCase(assertParseJitless, validDataWithExtras) diff --git a/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/effect-beta.ts b/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/effect-beta.ts index b21c0dd2d6a..06ddb89a545 100644 --- a/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/effect-beta.ts +++ b/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/effect-beta.ts @@ -66,7 +66,10 @@ const parsingCase = (input, errors, success) => () => { const run = Schema.decodeUnknownOption(makeSchema()) return { run: () => run(input, { errors }), - validate: (result) => assert.equal(Option.isSome(result), success) + validate: (result) => { + assert.equal(Option.isSome(result), success) + if (success && Option.isSome(result)) assert.deepEqual(result.value, validData) + } } } diff --git a/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/valibot.ts b/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/valibot.ts index fdff6a1b55b..25676aad236 100644 --- a/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/valibot.ts +++ b/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/valibot.ts @@ -16,7 +16,7 @@ const makeSchema = () => { }) const rating = v.object({ id: v.number(), - stars: v.pipe(v.number(), v.minValue(1), v.maxValue(5)), + stars: v.pipe(v.number(), v.minValue(0), v.maxValue(5)), title: v.pipe(v.string(), v.minLength(1), v.maxLength(100)), text: v.pipe(v.string(), v.minLength(1), v.maxLength(1000)), images: v.array(image) @@ -56,7 +56,10 @@ const parsingCase = (input, options, success) => () => { const schema = makeSchema() return { run: () => v.safeParse(schema, input, options), - validate: (result) => assert.equal(result.success, success) + validate: (result) => { + assert.equal(result.success, success) + if (success) assert.deepEqual(result.output, validData) + } } } @@ -72,6 +75,7 @@ const standardCase = (input, success) => () => { validate: (result) => { assert.equal(typeof result?.then, "undefined") assert.equal(result.issues === undefined, success) + if (success) assert.deepEqual(result.value, validData) } } } diff --git a/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/zod.ts b/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/zod.ts index 81e56f48217..609f06c497c 100644 --- a/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/zod.ts +++ b/packages/effect/runtimeperf/suites/schema-benchmarks/fixtures/zod.ts @@ -29,7 +29,7 @@ const makeSchema = () => { description: z.string().min(1).max(500), price: z.number().min(1).max(10000), discount: z.number().min(1).max(100).nullable(), - quantity: z.number().min(0).max(10), + quantity: z.number().min(1).max(10), tags: z.array(z.string().min(1).max(30)), images: z.array(image), ratings: z.array(rating) @@ -46,7 +46,10 @@ const parsingCase = (input, success) => () => { const options = { jitless: true } return { run: () => schema.safeParse(input, options), - validate: (result) => assert.equal(result.success, success) + validate: (result) => { + assert.equal(result.success, success) + if (success) assert.deepEqual(result.data, validData) + } } } @@ -60,6 +63,7 @@ const standardCase = (input, success) => () => { validate: (result) => { assert.equal(typeof result?.then, "undefined") assert.equal(result.issues === undefined, success) + if (success) assert.deepEqual(result.value, validData) } } } diff --git a/packages/effect/runtimeperf/suites/schema/fixtures/adapters.ts b/packages/effect/runtimeperf/suites/schema/fixtures/adapters.ts index e5d891cd717..f9fd2ac0593 100644 --- a/packages/effect/runtimeperf/suites/schema/fixtures/adapters.ts +++ b/packages/effect/runtimeperf/suites/schema/fixtures/adapters.ts @@ -28,7 +28,10 @@ export const exitValid = () => { const run = Schema.decodeUnknownExit(schema) return { run: () => run(input), - validate: (result) => assert.equal(result._tag, "Success") + validate: (result) => { + assert.equal(result._tag, "Success") + assert.deepEqual(result.value, input) + } } } @@ -44,7 +47,10 @@ export const optionValid = () => { const run = Schema.decodeUnknownOption(schema) return { run: () => run(input), - validate: (result) => assert.equal(Option.isSome(result), true) + validate: (result) => { + assert.equal(Option.isSome(result), true) + if (Option.isSome(result)) assert.deepEqual(result.value, input) + } } } @@ -60,7 +66,10 @@ export const resultValid = () => { const run = Schema.decodeUnknownResult(schema) return { run: () => run(input), - validate: (result) => assert.equal(Result.isSuccess(result), true) + validate: (result) => { + assert.equal(Result.isSuccess(result), true) + if (Result.isSuccess(result)) assert.deepEqual(result.success, input) + } } } diff --git a/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts b/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts index 773fdce5321..697d4121818 100644 --- a/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts +++ b/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts @@ -7,25 +7,37 @@ import assert from "node:assert/strict" const decodeCase = (schema, input, success, options) => () => { const run = Schema.decodeUnknownExit(schema, options) + const isOutput = success ? Schema.is(schema) : undefined return { run: () => run(input), - validate: (result) => assert.equal(result._tag, success ? "Success" : "Failure") + validate: (result) => { + assert.equal(result._tag, success ? "Success" : "Failure") + if (result._tag === "Success") assert.equal(isOutput?.(result.value), true) + } } } const decodeParserCase = (schema, input, success, options) => () => { const run = SchemaParser.decodeUnknownExit(schema, options) + const isOutput = success ? Schema.is(schema) : undefined return { run: () => run(input), - validate: (result) => assert.equal(result._tag, success ? "Success" : "Failure") + validate: (result) => { + assert.equal(result._tag, success ? "Success" : "Failure") + if (result._tag === "Success") assert.equal(isOutput?.(result.value), true) + } } } const encodeParserCase = (schema, input, success, options) => () => { const run = SchemaParser.encodeUnknownExit(schema, options) + const isOutput = success ? Schema.is(Schema.flip(schema)) : undefined return { run: () => run(input), - validate: (result) => assert.equal(result._tag, success ? "Success" : "Failure") + validate: (result) => { + assert.equal(result._tag, success ? "Success" : "Failure") + if (result._tag === "Success") assert.equal(isOutput?.(result.value), true) + } } } diff --git a/packages/effect/runtimeperf/suites/schema/fixtures/cold.ts b/packages/effect/runtimeperf/suites/schema/fixtures/cold.ts index 59dc535f96d..7ad53b37950 100644 --- a/packages/effect/runtimeperf/suites/schema/fixtures/cold.ts +++ b/packages/effect/runtimeperf/suites/schema/fixtures/cold.ts @@ -110,7 +110,10 @@ export const effectFirstMakeObject2 = () => ({ export const effectFirstDecodeCheckedObject32 = () => ({ run: () => SchemaParser.decodeUnknownExit(makeEffectCheckedSchema())(input), - validate: (result) => assert.equal(result._tag, "Success") + validate: (result) => { + assert.equal(result._tag, "Success") + assert.deepEqual(result.value, input) + } }) export const effectFirstDecodeTemplateLiteral = () => ({ @@ -120,17 +123,26 @@ export const effectFirstDecodeTemplateLiteral = () => ({ export const effectFirstDecodeRecord32 = () => ({ run: () => SchemaParser.decodeUnknownExit(makeEffectRecordSchema())(input), - validate: (result) => assert.equal(result._tag, "Success") + validate: (result) => { + assert.equal(result._tag, "Success") + assert.deepEqual(result.value, input) + } }) export const effectFirstDecodeLiteral100 = () => ({ run: () => SchemaParser.decodeUnknownExit(makeEffectLiteral100Schema())("value99"), - validate: (result) => assert.equal(result._tag, "Success") + validate: (result) => { + assert.equal(result._tag, "Success") + assert.equal(result.value, "value99") + } }) export const effectFirstDecodeTagged100 = () => ({ run: () => SchemaParser.decodeUnknownExit(makeEffectTagged100Schema())(taggedInput), - validate: (result) => assert.equal(result._tag, "Success") + validate: (result) => { + assert.equal(result._tag, "Success") + assert.deepEqual(result.value, taggedInput) + } }) export const effectFirstDecodeEncodingChain8 = () => ({ diff --git a/packages/effect/runtimeperf/test/registry.test.mts b/packages/effect/runtimeperf/test/registry.test.mts index 408a8d89c46..d896701f147 100644 --- a/packages/effect/runtimeperf/test/registry.test.mts +++ b/packages/effect/runtimeperf/test/registry.test.mts @@ -2,14 +2,33 @@ import assert from "node:assert/strict" import { readFile } from "node:fs/promises" import { describe, it } from "node:test" import { pathToFileURL } from "node:url" -import { loadRegistry } from "../utils.mts" +import { loadRegistry, scenarioBatchSize } from "../utils.mts" describe("runtimeperf registry", () => { + it("uses the Effect calibration for every implementation in a scenario", () => { + const zod = { implementation: "zod4-compiled" } + const effect = { implementation: "effect" } + assert.equal(scenarioBatchSize([zod, effect], new Map([ + [zod, { batchSize: 4_096 }], + [effect, { batchSize: 256 }] + ])), 256) + }) + it("uses unique fixture targets and valid implementations", () => { const { fixtures } = loadRegistry() assert.equal(new Set(fixtures.map((fixture) => fixture.target)).size, fixtures.length) for (const fixture of fixtures) { - assert.ok(["effect", "fast-check-v4", "valibot", "zod4", "zod4-compiled"].includes(fixture.implementation)) + assert.ok([ + "effect", + "effect-aot", + "effect-jit", + "fast-check-v4", + "valibot", + "zod4", + "zod4-compiled", + "zod4-jitless", + "zod4-validate" + ].includes(fixture.implementation)) } }) @@ -103,7 +122,7 @@ describe("runtimeperf registry", () => { const { fixtures } = loadRegistry() const zodFiles = new Set( fixtures - .filter((fixture) => fixture.implementation === "zod4") + .filter((fixture) => fixture.suite === "schema-benchmarks" && fixture.implementation === "zod4") .map((fixture) => fixture.fixturePath) ) assert.ok(zodFiles.size > 0) @@ -117,9 +136,10 @@ describe("runtimeperf registry", () => { it("uses strict Zod compilation for the compiler comparison fixtures", async () => { const { fixtures } = loadRegistry() - const compiled = fixtures.filter((fixture) => fixture.implementation === "zod4-compiled") + const compiled = fixtures.filter((fixture) => + fixture.suite === "compiler-rebuild" && fixture.implementation === "zod4-compiled" + ) assert.equal(compiled.length, 18) - assert.equal(compiled.every((fixture) => fixture.suite === "compiler-rebuild"), true) const paths = new Set(compiled.map((fixture) => fixture.fixturePath)) assert.equal(paths.size, 1) const source = await readFile([...paths][0], "utf8") diff --git a/packages/effect/runtimeperf/utils.mts b/packages/effect/runtimeperf/utils.mts index 2dc6210b022..2df3859b580 100644 --- a/packages/effect/runtimeperf/utils.mts +++ b/packages/effect/runtimeperf/utils.mts @@ -8,6 +8,11 @@ import { aggregate } from "./stats.mts" export const runtimeperfDir = dirname(fileURLToPath(import.meta.url)) export const effectDir = resolve(runtimeperfDir, "..") export const repoRoot = resolve(effectDir, "../..") +export const runPath = join(runtimeperfDir, "run.mts") +export const comparePath = join(runtimeperfDir, "compare.mts") +export const materializePath = join(runtimeperfDir, "materialize.mts") +export const statsPath = join(runtimeperfDir, "stats.mts") +export const utilsPath = fileURLToPath(import.meta.url) export const workerPath = join(runtimeperfDir, "worker.mts") export const configPath = join(runtimeperfDir, "config.json") export const resultsRoot = join(repoRoot, "tmp", "runtimeperf", "results") @@ -147,7 +152,7 @@ export const selectFixtures = (fixtures, options, { effectOnly = false } = {}) = selected = selected.filter((fixture) => fixture.implementation === options.implementation) } if (effectOnly) { - selected = selected.filter((fixture) => fixture.implementation === "effect") + selected = selected.filter((fixture) => fixture.implementation.startsWith("effect")) } if (selected.length === 0) { throw new Error("No runtimeperf fixtures matched the selection") @@ -216,6 +221,13 @@ export const measureFixture = (fixture, defaults, batchSize, fixturePath = fixtu export const aggregateMeasurements = (measurements) => aggregate(measurements.map((item) => item.nsPerOp)) +export const scenarioBatchSize = (fixtures, calibrations) => { + const reference = fixtures.find((fixture) => fixture.implementation === "effect") ?? fixtures[0] + const calibration = calibrations.get(reference) + if (calibration === undefined) throw new Error("Missing scenario reference calibration") + return calibration.batchSize +} + export const coverageSummary = (fixtures) => ({ tiers: [...new Set(fixtures.map((fixture) => fixture.tier))].sort(), families: [...new Set(fixtures.map((fixture) => fixture.family))].sort(), @@ -223,7 +235,7 @@ export const coverageSummary = (fixtures) => ({ effectAstTags: [ ...new Set( fixtures - .filter((fixture) => fixture.implementation === "effect") + .filter((fixture) => fixture.implementation.startsWith("effect")) .flatMap((fixture) => fixture.astTags) ) ].sort() From 83500048509a7f8ba16651560b0bb1fae0a86b06 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Tue, 15 Sep 2026 22:23:07 +0200 Subject: [PATCH 13/33] Optimize synchronous schema transformations --- .changeset/add-schema-compilers.md | 18 + packages/effect/SCHEMA.md | 48 +- .../suites/schema/fixtures/behavior.ts | 2 +- packages/effect/src/Config.ts | 6 +- packages/effect/src/SchemaGetter.ts | 541 ++++++++++++------ packages/effect/src/SchemaTransformation.ts | 65 ++- .../effect/src/internal/arbitrary/schema.ts | 2 +- .../effect/src/internal/schema/codegen.ts | 106 +++- .../effect/src/internal/schema/interpreter.ts | 126 ++-- packages/effect/src/unstable/schema/Model.ts | 2 +- .../unstable/schema/SchemaCompiler/runtime.ts | 2 +- packages/effect/test/Formatter.test.ts | 5 +- packages/effect/test/schema/Schema.test.ts | 42 +- .../schema/SchemaCompilerRegression.test.ts | 6 +- .../effect/test/schema/SchemaGetter.test.ts | 71 ++- .../test/schema/SchemaJITCompiler.test.ts | 26 +- .../effect/test/schema/SchemaParser.test.ts | 38 +- .../effect/test/schema/fixtures/aot-runner.ts | 1 + packages/effect/test/schema/fixtures/aot.ts | 19 +- .../test/schema/toStandardSchemaV1.test.ts | 12 +- packages/effect/typetest/schema/Schema.tst.ts | 4 +- .../typetest/schema/SchemaGetter.tst.ts | 46 ++ 22 files changed, 840 insertions(+), 348 deletions(-) create mode 100644 packages/effect/typetest/schema/SchemaGetter.tst.ts diff --git a/.changeset/add-schema-compilers.md b/.changeset/add-schema-compilers.md index aaedf88cfb6..8ea4eb85b4e 100644 --- a/.changeset/add-schema-compilers.md +++ b/.changeset/add-schema-compilers.md @@ -10,3 +10,21 @@ requiring dynamic function construction. The new `effect/unstable/schema/SchemaAOTCompiler/Build` entrypoint discovers direct Schema exports from explicit module loaders and writes a self-installing AOT module through Effect's `FileSystem` and `Path` services. + +### Breaking changes + +`SchemaGetter.Getter` is now a tagged union that distinguishes synchronous, +optional, and effectful transformations. Getter values now expose only `pipe`. +Use the dual `SchemaGetter.map`, `SchemaGetter.compose`, and `SchemaGetter.run` +functions instead of the former methods. Keeping these operations standalone +lets bundlers remove composition code when an application does not use it. + +The public `Getter` constructor is removed. Use +`SchemaGetter.transformOptionalEffect` instead of `new SchemaGetter.Getter`. +`SchemaGetter.onSome` and `SchemaGetter.onNone` are also removed. Use +`transformEffect` for an effectful transformation of present values and +`transformOptionalEffect` when the transformation handles missing values. + +`SchemaTransformation.compose` is now a dual standalone function. Replace +`first.compose(second)` with `SchemaTransformation.compose(first, second)` or +`SchemaTransformation.compose(second)(first)`. diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index e4827b620f2..37766ba22ac 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -3184,57 +3184,65 @@ Transformation - `RD`: the context used while decoding - `RE`: the context used while encoding -A `Transformation` consists of two `Getter` functions: +A `Transformation` consists of two `Getter` values: - `decode: Getter` — transforms a value during decoding - `encode: Getter` — transforms a value during encoding -Each `Getter` receives an input and an optional context and returns either a value or an error. Getters can be composed to build more complex logic. +Each `Getter` is a tagged description of one operation: + +- `Transform` transforms a present value synchronously. +- `TransformOptional` transforms an `Option` synchronously and can handle a missing value. +- `TransformEffect` and `TransformOptionalEffect` are the corresponding effectful forms. +- `Passthrough` returns its input unchanged. + +Getter values expose `pipe`. Use the dual standalone functions `SchemaGetter.map` and `SchemaGetter.compose` to build +larger transformations. `SchemaGetter.run` executes a getter directly and always returns an `Effect`; schemas execute +their getters through `SchemaParser` instead. **Example** (Implementation of `Transformation.trim`) ```ts +import { SchemaGetter, SchemaTransformation } from "effect" + /** * @category String transformations * @since 4.0.0 */ -export function trim(): Transformation { - return new Transformation(Getter.trim(), Getter.passthrough()) +export function trim(): SchemaTransformation.Transformation { + return new SchemaTransformation.Transformation(SchemaGetter.trim(), SchemaGetter.passthrough()) } ``` In this case: -- The `decode` process uses `Getter.trim()` to remove leading and trailing whitespace. -- The `encode` process uses `Getter.passthrough()`, which returns the input as is. +- The `decode` process uses `SchemaGetter.trim()` to remove leading and trailing whitespace. +- The `encode` process uses `SchemaGetter.passthrough()`, which returns the input as is. ## Composing Transformations -You can combine transformations using the `.compose` method. The resulting transformation applies the `decode` and `encode` logic of both transformations in sequence. +You can combine transformations using `SchemaTransformation.compose`. The resulting transformation applies the `decode` and `encode` logic of both transformations in sequence. **Example** (Trim and lowercase a string) ```ts -import { Option, SchemaTransformation } from "effect" +import { Schema, SchemaTransformation } from "effect" // Compose two transformations: trim followed by toLowerCase -const trimToLowerCase = SchemaTransformation.trim().compose(SchemaTransformation.toLowerCase()) +const trimToLowerCase = SchemaTransformation.compose( + SchemaTransformation.trim(), + SchemaTransformation.toLowerCase() +) +const schema = Schema.String.pipe(Schema.decode(trimToLowerCase)) -// Run the decode logic manually to inspect the result -console.log(trimToLowerCase.decode.run(Option.some(" Abc"), {})) -/* -{ - _id: 'Exit', - _tag: 'Success', - value: { _id: 'Option', _tag: 'Some', value: 'abc' } -} -*/ +Schema.decodeUnknownSync(schema)(" Abc") +// "abc" ``` In this example: -- The `decode` logic applies `Getter.trim()` followed by `Getter.toLowerCase()`, producing a string that is trimmed and lowercased. -- The `encode` logic is `Getter.passthrough()`, which simply returns the input as-is. +- The `decode` logic applies `SchemaGetter.trim()` followed by `SchemaGetter.toLowerCase()`, producing a string that is trimmed and lowercased. +- The `encode` logic is `SchemaGetter.passthrough()`, which returns the input unchanged. ## Transforming One Schema into Another diff --git a/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts b/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts index 697d4121818..dc7895913bf 100644 --- a/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts +++ b/packages/effect/runtimeperf/suites/schema/fixtures/behavior.ts @@ -158,7 +158,7 @@ export const optionalPresentValid = decodeCase( export const optionalPresentInvalid = decodeCase(optionalStruct, { required: "value", optionalKey: 1 }, false) const suspendedString = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter((input) => Effect.suspend(() => Effect.succeed(input))), + decode: SchemaGetter.transformOptionalEffect((input) => Effect.suspend(() => Effect.succeed(input))), encode: SchemaGetter.passthrough() })) const suspendedObjectFields = Object.fromEntries( diff --git a/packages/effect/src/Config.ts b/packages/effect/src/Config.ts index c8a764b94ec..3db34fb2208 100644 --- a/packages/effect/src/Config.ts +++ b/packages/effect/src/Config.ts @@ -1193,7 +1193,8 @@ export function Array>( const arrayString = Schema.String.pipe( Schema.decodeTo(Schema.toCodecStringTree(array), { decode: SchemaGetter.split(resolvedOptions), - encode: SchemaGetter.passthrough, Schema.StringTree>({ strict: false }).compose( + encode: SchemaGetter.compose( + SchemaGetter.passthrough, Schema.StringTree>({ strict: false }), SchemaGetter.transform((input) => input.join(separator)) ) }) @@ -1270,7 +1271,8 @@ export function Record< const recordString = Schema.String.pipe( Schema.decodeTo(Schema.toCodecStringTree(record), { decode: split.decode, - encode: SchemaGetter.passthrough, Schema.StringTree>({ strict: false }).compose( + encode: SchemaGetter.compose( + SchemaGetter.passthrough, Schema.StringTree>({ strict: false }), split.encode ) }) diff --git a/packages/effect/src/SchemaGetter.ts b/packages/effect/src/SchemaGetter.ts index 00bf6cdb623..006381036d0 100644 --- a/packages/effect/src/SchemaGetter.ts +++ b/packages/effect/src/SchemaGetter.ts @@ -15,6 +15,7 @@ import * as Arr from "./Array.ts" import * as DateTime from "./DateTime.ts" import * as Effect from "./Effect.ts" import * as Encoding from "./Encoding.ts" +import { dual } from "./Function.ts" import * as InternalRecord from "./internal/record.ts" import * as Option from "./Option.ts" import * as Pipeable from "./Pipeable.ts" @@ -25,6 +26,66 @@ import type * as SchemaAST from "./SchemaAST.ts" import * as SchemaIssue from "./SchemaIssue.ts" import * as Str from "./String.ts" +/** + * A transformation that returns its input unchanged. + * + * @category models + * @since 4.0.0 + */ +export interface Passthrough extends Pipeable.Pipeable { + readonly _tag: "Passthrough" +} + +/** + * A synchronous transformation of present values. + * + * @category models + * @since 4.0.0 + */ +export interface Transform extends Pipeable.Pipeable { + readonly _tag: "Transform" + readonly transform: (input: E) => T +} + +/** + * A synchronous transformation of optional values. + * + * @category models + * @since 4.0.0 + */ +export interface TransformOptional extends Pipeable.Pipeable { + readonly _tag: "TransformOptional" + readonly transform: (input: Option.Option) => Option.Option +} + +/** + * An effectful transformation of present values. + * + * @category models + * @since 4.0.0 + */ +export interface TransformEffect extends Pipeable.Pipeable { + readonly _tag: "TransformEffect" + readonly transform: ( + input: E, + options: SchemaAST.ParseOptions + ) => Effect.Effect +} + +/** + * An effectful transformation of optional values. + * + * @category models + * @since 4.0.0 + */ +export interface TransformOptionalEffect extends Pipeable.Pipeable { + readonly _tag: "TransformOptionalEffect" + readonly transform: ( + input: Option.Option, + options: SchemaAST.ParseOptions + ) => Effect.Effect, SchemaIssue.Issue, R> +} + /** * Represents a composable transformation from an encoded type `E` to a decoded type `T`. * @@ -35,14 +96,14 @@ import * as Str from "./String.ts" * * **Details** * - * A getter wraps a function `Option -> Effect, Issue, R>`. It - * receives `Option.None` when the encoded key is absent, such as a missing - * struct field, and returns `Option.None` to omit the value from the decoded - * output. It fails with `Issue` on invalid input and may require Effect - * services via `R`. `.map(f)` applies `f` to the decoded value inside `Some` - * while leaving `None` unchanged. `.compose(other)` chains two getters by - * feeding the output of `this` into `other`; passthrough getters on either side - * are optimized away. + * A getter receives an `Option` and produces an `Option`. `Option.none()` + * represents a missing struct field and can also omit a field from the output. + * A getter may fail with a schema issue or require services through `R`. The + * tagged representation distinguishes synchronous transformations from + * transformations that return an `Effect`, allowing schema parsers to select + * the corresponding execution path when they are built. Getter values expose + * `pipe`; use the standalone {@link map}, {@link compose}, and {@link run} + * functions to operate on them. * * **Example** (Creating and composing getters) * @@ -51,8 +112,8 @@ import * as Str from "./String.ts" * * const parseNumber = SchemaGetter.transform((s) => Number(s)) * const double = SchemaGetter.transform((n) => n * 2) - * const composed = parseNumber.compose(double) - * await Effect.runPromise(composed.run(Option.some("21"), {})) // => Option.some(42) + * const composed = SchemaGetter.compose(parseNumber, double) + * Effect.runSync(SchemaGetter.run(composed, Option.some("21"), {})) // => Option.some(42) * ``` * * @see {@link transform} to create a getter from a pure function @@ -62,54 +123,197 @@ import * as Str from "./String.ts" * @category models * @since 4.0.0 */ -export interface Getter extends Pipeable.Pipeable { - readonly run: ( - input: Option.Option, - options: SchemaAST.ParseOptions - ) => Effect.Effect, SchemaIssue.Issue, R> - map(f: (t: T) => T2): Getter - compose(other: Getter): Getter +export type Getter = + | Passthrough + | Transform + | TransformOptional + | TransformEffect + | TransformOptionalEffect + +const runGetter = ( + self: Getter, + input: Option.Option, + options: SchemaAST.ParseOptions +): Effect.Effect, SchemaIssue.Issue, R> => { + switch (self._tag) { + case "Passthrough": + return Effect.succeed(input as unknown as Option.Option) + case "Transform": + return Effect.succeed(Option.map(input, self.transform)) + case "TransformOptional": + return Effect.succeed(self.transform(input)) + case "TransformEffect": + return Option.isNone(input) + ? Effect.succeedNone + : Effect.mapEager(self.transform(input.value, options), Option.some) + case "TransformOptionalEffect": + return self.transform(input, options) + } } /** - * Constructs a composable schema getter. + * Runs a getter directly. * - * @category constructors + * **Details** + * + * This is a convenience API for executing a getter outside a schema. When the + * getter belongs to a schema, use the corresponding `SchemaParser` API. The + * result is an `Effect` for every getter variant, including synchronous ones. + * + * **Example** (Running a getter) + * + * ```ts import.meta.vitest + * import { Effect, Option, SchemaGetter } from "effect" + * + * const getter = SchemaGetter.transform(Number) + * + * const result = Effect.runSync( + * SchemaGetter.run(getter, Option.some("42"), {}) + * ) + * result // => Option.some(42) + * ``` + * + * @category converting * @since 4.0.0 */ -export const Getter: new( - run: ( +export const run: { + ( input: Option.Option, options: SchemaAST.ParseOptions - ) => Effect.Effect, SchemaIssue.Issue, R> -) => Getter = class extends Pipeable.Class { - readonly run: ( + ): (self: Getter) => Effect.Effect, SchemaIssue.Issue, R> + ( + self: Getter, input: Option.Option, options: SchemaAST.ParseOptions - ) => Effect.Effect, SchemaIssue.Issue, R> + ): Effect.Effect, SchemaIssue.Issue, R> +} = dual(3, runGetter) + +const makeGetter = (fields: A): A & Pipeable.Pipeable => + Object.assign(Object.create(Pipeable.Prototype), fields) + +const composeOptionalEffect = ( + first: Getter, + second: Getter +): Getter => + transformOptionalEffect((input, options) => + Effect.flatMapEager(runGetter(first, input, options), (output) => runGetter(second, output, options)) + ) - constructor( - run: ( - input: Option.Option, - options: SchemaAST.ParseOptions - ) => Effect.Effect, SchemaIssue.Issue, R> - ) { - super() - this.run = run - } - map(f: (t: T) => T2): Getter { - return new Getter((oe, options) => this.run(oe, options).pipe(Effect.mapEager(Option.map(f)))) - } - compose(other: Getter): Getter { - if (isPassthrough(this)) { - return other as any +/** + * Composes two getters by passing the output of the first to the second. + * + * **When to use** + * + * Use when a schema conversion requires multiple transformation steps. + * + * **Details** + * + * Composition forwards `Option.none()` to getters that handle optional values + * and skips getters that operate only on present values. Composing with + * {@link passthrough} returns the other getter unchanged. The function supports + * both `compose(first, second)` and `first.pipe(compose(second))`. + * + * **Example** (Parsing and normalizing a number) + * + * ```ts import.meta.vitest + * import { Effect, Option, SchemaGetter } from "effect" + * + * const getter = SchemaGetter.compose( + * SchemaGetter.transform(Number), + * SchemaGetter.transform((n) => Math.max(0, n)) + * ) + * + * Effect.runSync(SchemaGetter.run(getter, Option.some("-1"), {})) // => Option.some(0) + * ``` + * + * @category combining + * @since 4.0.0 + */ +export const compose: { + (other: Getter): (self: Getter) => Getter + (self: Getter, other: Getter): Getter +} = dual(2, ( + self: Getter, + other: Getter +): Getter => { + if (self._tag === "Passthrough") return other as Getter + if (other._tag === "Passthrough") return self as unknown as Getter + switch (self._tag) { + case "Transform": { + switch (other._tag) { + case "Transform": + return transform((input: E) => other.transform(self.transform(input))) + case "TransformOptional": + return transformOptional((input) => other.transform(Option.map(input, self.transform))) + case "TransformEffect": + return transformEffect((input: E, options) => other.transform(self.transform(input), options)) + case "TransformOptionalEffect": + return composeOptionalEffect(self, other) + } } - if (isPassthrough(other)) { - return this as any + case "TransformOptional": { + switch (other._tag) { + case "Transform": + return transformOptional((input) => Option.map(self.transform(input), other.transform)) + case "TransformOptional": + return transformOptional((input) => other.transform(self.transform(input))) + case "TransformEffect": + case "TransformOptionalEffect": + return composeOptionalEffect(self, other) + } + } + case "TransformEffect": { + switch (other._tag) { + case "Transform": + return transformEffect((input: E, options) => + Effect.mapEager(self.transform(input, options), other.transform) + ) + case "TransformOptional": + return composeOptionalEffect(self, other) + case "TransformEffect": + return transformEffect((input: E, options) => + Effect.flatMapEager(self.transform(input, options), (output) => other.transform(output, options)) + ) + case "TransformOptionalEffect": + return composeOptionalEffect(self, other) + } } - return new Getter((oe, options) => this.run(oe, options).pipe(Effect.flatMapEager((ot) => other.run(ot, options)))) + case "TransformOptionalEffect": + return composeOptionalEffect(self, other) } -} +}) + +/** + * Maps the output of a getter while preserving missing values. + * + * **When to use** + * + * Use to add a synchronous transformation after an existing getter. + * + * **Details** + * + * The mapping function runs only for `Option.some`. The function supports both + * `map(self, f)` and `self.pipe(map(f))`. + * + * **Example** (Mapping a getter result) + * + * ```ts import.meta.vitest + * import { Effect, Option, SchemaGetter } from "effect" + * + * const getter = SchemaGetter.transform(Number).pipe( + * SchemaGetter.map((n) => n * 2) + * ) + * + * Effect.runSync(SchemaGetter.run(getter, Option.some("21"), {})) // => Option.some(42) + * ``` + * + * @category mapping + * @since 4.0.0 + */ +export const map: { + (f: (value: T) => T2): (self: Getter) => Getter + (self: Getter, f: (value: T) => T2): Getter +} = dual(2, (self: Getter, f: (value: T) => T2): Getter => compose(self, transform(f))) /** * Creates a getter that always produces the given constant value, ignoring the input. @@ -130,7 +334,7 @@ export const Getter: new( * import { Effect, Option, SchemaGetter } from "effect" * * const alwaysZero = SchemaGetter.succeed(0) - * await Effect.runPromise(alwaysZero.run(Option.none(), {})) // => Option.some(0) + * Effect.runSync(SchemaGetter.run(alwaysZero, Option.none(), {})) // => Option.some(0) * ``` * * @see {@link transform} when you need to use the input value @@ -140,7 +344,7 @@ export const Getter: new( * @since 4.0.0 */ export function succeed(t: T): Getter { - return new Getter(() => Effect.succeedSome(t)) + return transformOptional(() => Option.some(t)) } /** @@ -165,7 +369,9 @@ export function succeed(t: T): Getter { * const rejectAll = SchemaGetter.fail( * () => new SchemaIssue.InvalidValue({ message: "not allowed" }) * ) - * const issue = await Effect.runPromise(Effect.flip(rejectAll.run(Option.some("x"), {}))) + * const issue = await Effect.runPromise( + * Effect.flip(SchemaGetter.run(rejectAll, Option.some("x"), {})) + * ) * issue._tag // => "InvalidValue" * ``` * @@ -178,7 +384,7 @@ export function succeed(t: T): Getter { export function fail( f: (oe: Option.Option, options: SchemaAST.ParseOptions) => SchemaIssue.Issue ): Getter { - return new Getter((oe, options) => Effect.fail(f(oe, options))) + return transformOptionalEffect((oe, options) => Effect.fail(f(oe, options))) } /** @@ -203,7 +409,9 @@ export function fail( * const noEncode = SchemaGetter.forbidden( * () => "encoding is not supported" * ) - * const issue = await Effect.runPromise(Effect.flip(noEncode.run(Option.some(1), {}))) + * const issue = await Effect.runPromise( + * Effect.flip(SchemaGetter.run(noEncode, Option.some(1), {})) + * ) * issue._tag // => "Forbidden" * ``` * @@ -239,7 +447,7 @@ export function forbidden(message: (oe: Option.Option) => string): Gett * import { Effect, Option, SchemaGetter } from "effect" * * const issue = await Effect.runPromise( - * Effect.flip(SchemaGetter.forbiddenEncoding.run(Option.some("value"), {})) + * Effect.flip(SchemaGetter.run(SchemaGetter.forbiddenEncoding, Option.some("value"), {})) * ) * issue._tag // => "Forbidden" * ``` @@ -251,11 +459,7 @@ export function forbidden(message: (oe: Option.Option) => string): Gett */ export const forbiddenEncoding: Getter = forbidden(() => "Encoding is not supported") -const passthrough_ = new Getter(Effect.succeed) - -function isPassthrough(getter: Getter): getter is typeof passthrough_ { - return getter.run === passthrough_.run -} +const passthrough_: Passthrough = makeGetter({ _tag: "Passthrough" }) /** * Returns the identity getter — passes the value through unchanged. @@ -268,7 +472,7 @@ function isPassthrough(getter: Getter): getter is typeof passt * **Details** * * - Pure, no allocation (singleton instance). - * - Optimized away during `.compose()` — composing with a passthrough is free. + * - Optimized away by {@link compose} — composing with a passthrough is free. * - The default overload requires `T === E`. Pass `{ strict: false }` to opt * out of the type constraint. * @@ -319,7 +523,7 @@ export function passthrough(): Getter { * * // string extends string, so this is valid * const g = SchemaGetter.passthroughSupertype() - * await Effect.runPromise(g.run(Option.some("hello"), {})) // => Option.some("hello") + * Effect.runSync(SchemaGetter.run(g, Option.some("hello"), {})) // => Option.some("hello") * ``` * * @see {@link passthrough} when types are identical @@ -352,7 +556,7 @@ export function passthroughSupertype(): Getter { * * // "hello" extends string, so E extends T * const g = SchemaGetter.passthroughSubtype() - * await Effect.runPromise(g.run(Option.some("hello"), {})) // => Option.some("hello") + * Effect.runSync(SchemaGetter.run(g, Option.some("hello"), {})) // => Option.some("hello") * ``` * * @see {@link passthrough} when types are identical @@ -366,45 +570,6 @@ export function passthroughSubtype(): Getter { return passthrough_ } -/** - * Creates a getter that handles the case when the input is absent (`Option.None`). - * - * **When to use** - * - * Use when you need a schema getter to provide a fallback or computed value for - * missing struct keys. - * - Building custom "default value" logic more complex than {@link withDefault}. - * - * **Details** - * - * - When input is `None`, calls `f` to produce the result. - * - When input is `Some`, passes it through unchanged. - * - `f` receives the parse options and may return `None` to keep the value absent. - * - * **Example** (Providing a default timestamp for a missing field) - * - * ```ts import.meta.vitest - * import { Effect, Option, SchemaGetter } from "effect" - * - * const withTimestamp = SchemaGetter.onNone(() => - * Effect.succeed(Option.some(0)) - * ) - * await Effect.runPromise(withTimestamp.run(Option.none(), {})) // => Option.some(0) - * ``` - * - * @see {@link required} when absent input should fail - * @see {@link withDefault} for a simpler default value for undefined inputs - * @see {@link onSome} to handle only present values - * - * @category transforming - * @since 4.0.0 - */ -export function onNone( - f: (options: SchemaAST.ParseOptions) => Effect.Effect, SchemaIssue.Issue, R> -): Getter { - return new Getter((ot, options) => Option.isNone(ot) ? f(options) : Effect.succeed(ot)) -} - /** * Creates a getter that fails with `MissingKey` if the input is absent (`Option.None`). * @@ -425,57 +590,21 @@ export function onNone( * import { Effect, Option, SchemaGetter } from "effect" * * const mustExist = SchemaGetter.required() - * const issue = await Effect.runPromise(Effect.flip(mustExist.run(Option.none(), {}))) + * const issue = await Effect.runPromise( + * Effect.flip(SchemaGetter.run(mustExist, Option.none(), {})) + * ) * issue._tag // => "MissingKey" * ``` * - * @see {@link onNone} to provide a fallback instead of failing * @see {@link withDefault} to substitute a default for undefined values * * @category validation * @since 4.0.0 */ export function required(annotations?: Schema.Annotations.Key): Getter { - return onNone(() => Effect.fail(new SchemaIssue.MissingKey(annotations))) -} - -/** - * Creates a getter that handles present values (`Option.Some`), passing `None` through. - * - * **When to use** - * - * Use when you need a schema getter to transform or validate only when a field - * value is present. - * - Missing keys should remain absent in the output. - * - * **Details** - * - * - When input is `None`, returns `None` (no-op). - * - When input is `Some(e)`, calls `f(e, options)` to produce the result. - * - `f` may return `None` to omit the value, or fail with an `Issue`. - * - * **Example** (Transforming only present values) - * - * ```ts import.meta.vitest - * import { Effect, Option, SchemaGetter } from "effect" - * - * const parseIfPresent = SchemaGetter.onSome( - * (s) => Effect.succeed(Option.some(Number(s))) - * ) - * await Effect.runPromise(parseIfPresent.run(Option.some("42"), {})) // => Option.some(42) - * ``` - * - * @see {@link onNone} to handle only absent values - * @see {@link transform} for a simpler pure transformation of present values - * @see {@link transformEffect} for effectful transformation of present values - * - * @category transforming - * @since 4.0.0 - */ -export function onSome( - f: (e: E, options: SchemaAST.ParseOptions) => Effect.Effect, SchemaIssue.Issue, R> -): Getter { - return new Getter((oe, options) => Option.isNone(oe) ? Effect.succeedNone : f(oe.value, options)) + return transformOptionalEffect((input) => + Option.isNone(input) ? Effect.fail(new SchemaIssue.MissingKey(annotations)) : Effect.succeed(input) + ) } /** @@ -506,7 +635,7 @@ export function onSome( * const nonNegative = SchemaGetter.checkEffect((n) => * Effect.succeed(n >= 0 ? undefined : "must be non-negative") * ) - * await Effect.runPromise(nonNegative.run(Option.some(1), {})) // => Option.some(1) + * await Effect.runPromise(SchemaGetter.run(nonNegative, Option.some(1), {})) // => Option.some(1) * ``` * * @see {@link transform} when you need to change the value, not just validate @@ -522,12 +651,12 @@ export function checkEffect( R > ): Getter { - return onSome((t, options) => { + return transformEffect((t, options) => { return f(t, options).pipe(Effect.flatMapEager((out) => { const issue = SchemaIssue.makeSingle(out, t, options) return issue ? Effect.fail(issue) : - Effect.succeed(Option.some(t)) + Effect.succeed(t) })) }) } @@ -570,7 +699,7 @@ export function checkEffect( * @since 4.0.0 */ export function transform(f: (e: E) => T): Getter { - return transformOptional(Option.map(f)) + return makeGetter({ _tag: "Transform", transform: f }) } /** @@ -600,11 +729,11 @@ export function transform(f: (e: E) => T): Getter { * : Effect.succeed(n) * } * ) - * await Effect.runPromise(safeParseInt.run(Option.some("42"), {})) // => Option.some(42) + * await Effect.runPromise(SchemaGetter.run(safeParseInt, Option.some("42"), {})) // => Option.some(42) * ``` * * @see {@link transform} when transformation cannot fail - * @see {@link onSome} when you need full `Option` control over the output + * @see {@link transformOptionalEffect} when you need full `Option` control over the output * * @category transforming * @since 4.0.0 @@ -612,7 +741,7 @@ export function transform(f: (e: E) => T): Getter { export function transformEffect( f: (e: E, options: SchemaAST.ParseOptions) => Effect.Effect ): Getter { - return onSome((e, options) => f(e, options).pipe(Effect.mapEager(Option.some))) + return makeGetter({ _tag: "TransformEffect", transform: f }) } /** @@ -636,7 +765,7 @@ export function transformEffect( * const skipEmpty = SchemaGetter.transformOptional((o) => * Option.filter(o, (s) => s.length > 0) * ) - * await Effect.runPromise(skipEmpty.run(Option.some(""), {})) // => Option.none() + * Effect.runSync(SchemaGetter.run(skipEmpty, Option.some(""), {})) // => Option.none() * ``` * * @see {@link transform} when you only need to transform present values @@ -646,7 +775,22 @@ export function transformEffect( * @since 4.0.0 */ export function transformOptional(f: (oe: Option.Option) => Option.Option): Getter { - return new Getter((oe) => Effect.succeed(f(oe))) + return makeGetter({ _tag: "TransformOptional", transform: f }) +} + +/** + * Creates a getter that effectfully transforms the full `Option`. + * + * @category transforming + * @since 4.0.0 + */ +export function transformOptionalEffect( + f: ( + input: Option.Option, + options: SchemaAST.ParseOptions + ) => Effect.Effect, SchemaIssue.Issue, R> +): Getter { + return makeGetter({ _tag: "TransformOptionalEffect", transform: f }) } /** @@ -668,7 +812,7 @@ export function transformOptional(f: (oe: Option.Option) => Option.Opti * import { Effect, Option, SchemaGetter } from "effect" * * const omitField = SchemaGetter.omit() - * await Effect.runPromise(omitField.run(Option.some("hidden"), {})) // => Option.none() + * Effect.runSync(SchemaGetter.run(omitField, Option.some("hidden"), {})) // => Option.none() * ``` * * @see {@link transformOptional} when you want conditional omission @@ -678,7 +822,7 @@ export function transformOptional(f: (oe: Option.Option) => Option.Opti * @since 4.0.0 */ export function omit(): Getter { - return new Getter(() => Effect.succeedNone) + return transformOptional(() => Option.none()) } /** @@ -701,10 +845,10 @@ export function omit(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const withZero = SchemaGetter.withDefault(Effect.succeed(0)) - * await Effect.runPromise(withZero.run(Option.some(undefined), {})) // => Option.some(0) + * await Effect.runPromise(SchemaGetter.run(withZero, Option.some(undefined), {})) // => Option.some(0) * ``` * - * @see {@link onNone} to handle only absent keys (not `undefined` values) + * @see {@link transformOptionalEffect} for custom effectful missing-key handling * @see {@link required} when absent input should fail instead of using a default * * @category transforming @@ -713,7 +857,7 @@ export function omit(): Getter { export function withDefault( defaultValue: Effect.Effect ): Getter { - return new Getter((o) => { + return transformOptionalEffect((o) => { const filtered = Option.filter(o, Predicate.isNotUndefined) return Option.isSome(filtered) ? Effect.succeed(filtered) : Effect.mapEager(defaultValue, Option.some) }) @@ -737,7 +881,7 @@ export function withDefault( * import { Effect, Option, SchemaGetter } from "effect" * * const toString = SchemaGetter.String() - * await Effect.runPromise(toString.run(Option.some(42), {})) // => Option.some("42") + * Effect.runSync(SchemaGetter.run(toString, Option.some(42), {})) // => Option.some("42") * ``` * * @see {@link transform} for custom string conversions @@ -768,7 +912,7 @@ export function String(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const toNumber = SchemaGetter.Number() - * await Effect.runPromise(toNumber.run(Option.some("42"), {})) // => Option.some(42) + * Effect.runSync(SchemaGetter.run(toNumber, Option.some("42"), {})) // => Option.some(42) * ``` * * @see {@link transformEffect} for effectful or validated number parsing @@ -798,7 +942,7 @@ export function Number(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const toBool = SchemaGetter.Boolean() - * await Effect.runPromise(toBool.run(Option.some("true"), {})) // => Option.some(true) + * Effect.runSync(SchemaGetter.run(toBool, Option.some("true"), {})) // => Option.some(true) * ``` * * @category converting @@ -827,7 +971,7 @@ export function Boolean(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const toBigInt = SchemaGetter.BigInt() - * await Effect.runPromise(toBigInt.run(Option.some("42"), {})) // => Option.some(42n) + * Effect.runSync(SchemaGetter.run(toBigInt, Option.some("42"), {})) // => Option.some(42n) * ``` * * @category converting @@ -856,7 +1000,7 @@ export function BigInt(): Getter() - * const result = await Effect.runPromise(toDate.run(Option.some("1970-01-01"), {})) + * const result = Effect.runSync(SchemaGetter.run(toDate, Option.some("1970-01-01"), {})) * Option.map(result, (date) => date.toISOString()) // => Option.some("1970-01-01T00:00:00.000Z") * ``` * @@ -882,7 +1026,7 @@ export function Date(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const trimmed = SchemaGetter.trim() - * await Effect.runPromise(trimmed.run(Option.some(" hello "), {})) // => Option.some("hello") + * Effect.runSync(SchemaGetter.run(trimmed, Option.some(" hello "), {})) // => Option.some("hello") * ``` * * @category transforming @@ -905,7 +1049,7 @@ export function trim(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const cap = SchemaGetter.capitalize() - * await Effect.runPromise(cap.run(Option.some("hello"), {})) // => Option.some("Hello") + * Effect.runSync(SchemaGetter.run(cap, Option.some("hello"), {})) // => Option.some("Hello") * ``` * * @category transforming @@ -928,7 +1072,7 @@ export function capitalize(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const uncap = SchemaGetter.uncapitalize() - * await Effect.runPromise(uncap.run(Option.some("Hello"), {})) // => Option.some("hello") + * Effect.runSync(SchemaGetter.run(uncap, Option.some("Hello"), {})) // => Option.some("hello") * ``` * * @category transforming @@ -951,7 +1095,7 @@ export function uncapitalize(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const toCamel = SchemaGetter.snakeToCamel() - * await Effect.runPromise(toCamel.run(Option.some("user_name"), {})) // => Option.some("userName") + * Effect.runSync(SchemaGetter.run(toCamel, Option.some("user_name"), {})) // => Option.some("userName") * ``` * * @see {@link camelToSnake} for the inverse operation @@ -976,7 +1120,7 @@ export function snakeToCamel(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const toSnake = SchemaGetter.camelToSnake() - * await Effect.runPromise(toSnake.run(Option.some("userName"), {})) // => Option.some("user_name") + * Effect.runSync(SchemaGetter.run(toSnake, Option.some("userName"), {})) // => Option.some("user_name") * ``` * * @see {@link snakeToCamel} for the inverse operation @@ -1001,7 +1145,7 @@ export function camelToSnake(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const lower = SchemaGetter.toLowerCase() - * await Effect.runPromise(lower.run(Option.some("HELLO"), {})) // => Option.some("hello") + * Effect.runSync(SchemaGetter.run(lower, Option.some("HELLO"), {})) // => Option.some("hello") * ``` * * @see {@link toUpperCase} for the inverse operation @@ -1026,7 +1170,7 @@ export function toLowerCase(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const upper = SchemaGetter.toUpperCase() - * await Effect.runPromise(upper.run(Option.some("hello"), {})) // => Option.some("HELLO") + * Effect.runSync(SchemaGetter.run(upper, Option.some("hello"), {})) // => Option.some("HELLO") * ``` * * @see {@link toLowerCase} for the inverse operation @@ -1065,7 +1209,8 @@ type ParseJsonOptions = { * import { Effect, Option, SchemaGetter } from "effect" * * const parse = SchemaGetter.parseJson() - * await Effect.runPromise(parse.run(Option.some("{\"a\":1}"), {})) // => Option.some({ a: 1 }) + * const result = await Effect.runPromise(SchemaGetter.run(parse, Option.some("{\"a\":1}"), {})) + * result // => Option.some({ a: 1 }) * ``` * * @see {@link stringifyJson} for the inverse operation @@ -1076,9 +1221,9 @@ type ParseJsonOptions = { export function parseJson(): Getter export function parseJson(options: ParseJsonOptions): Getter export function parseJson(options?: ParseJsonOptions | undefined): Getter { - return onSome((input, parseOptions) => + return transformEffect((input, parseOptions) => Effect.try({ - try: () => Option.some(JSON.parse(input, options?.reviver)), + try: () => JSON.parse(input, options?.reviver), catch: () => new SchemaIssue.InvalidValue( { expected: "a valid JSON string" }, @@ -1127,7 +1272,8 @@ type StringifyJsonOptions = { * import { Effect, Option, SchemaGetter } from "effect" * * const stringify = SchemaGetter.stringifyJson() - * await Effect.runPromise(stringify.run(Option.some({ a: 1 }), {})) // => Option.some("{\"a\":1}") + * const result = await Effect.runPromise(SchemaGetter.run(stringify, Option.some({ a: 1 }), {})) + * result // => Option.some("{\"a\":1}") * ``` * * @see {@link parseJson} for the inverse operation @@ -1136,14 +1282,14 @@ type StringifyJsonOptions = { * @since 4.0.0 */ export function stringifyJson(options?: StringifyJsonOptions): Getter { - return onSome((input, parseOptions) => + return transformEffect((input, parseOptions) => Effect.try({ try: () => { const output = JSON.stringify(input, options?.replacer as any, options?.space) if (output === undefined) { throw new TypeError("Value cannot be represented as JSON") } - return Option.some(output) + return output }, catch: () => new SchemaIssue.InvalidValue( @@ -1175,7 +1321,8 @@ export function stringifyJson(options?: StringifyJsonOptions): Getter() - * await Effect.runPromise(parse.run(Option.some("a=1,b=2"), {})) // => Option.some({ a: "1", b: "2" }) + * const result = Effect.runSync(SchemaGetter.run(parse, Option.some("a=1,b=2"), {})) + * result // => Option.some({ a: "1", b: "2" }) * ``` * * @see {@link joinKeyValue} for the inverse operation @@ -1221,7 +1368,8 @@ export function splitKeyValue(options?: { * import { Effect, Option, SchemaGetter } from "effect" * * const join = SchemaGetter.joinKeyValue() - * await Effect.runPromise(join.run(Option.some({ a: "1", b: "2" }), {})) // => Option.some("a=1,b=2") + * const result = Effect.runSync(SchemaGetter.run(join, Option.some({ a: "1", b: "2" }), {})) + * result // => Option.some("a=1,b=2") * ``` * * @see {@link splitKeyValue} for the inverse operation @@ -1259,7 +1407,8 @@ export function joinKeyValue>(options?: { * import { Effect, Option, SchemaGetter } from "effect" * * const splitComma = SchemaGetter.split() - * await Effect.runPromise(splitComma.run(Option.some("a,b,c"), {})) // => Option.some(["a", "b", "c"]) + * const result = Effect.runSync(SchemaGetter.run(splitComma, Option.some("a,b,c"), {})) + * result // => Option.some(["a", "b", "c"]) * ``` * * @see {@link splitKeyValue} when values are key-value pairs @@ -1287,7 +1436,8 @@ export function split(options?: { * import { Effect, Option, SchemaGetter } from "effect" * * const encode = SchemaGetter.encodeBase64() - * await Effect.runPromise(encode.run(Option.some(new Uint8Array([1, 2, 3])), {})) // => Option.some("AQID") + * const result = Effect.runSync(SchemaGetter.run(encode, Option.some(new Uint8Array([1, 2, 3])), {})) + * result // => Option.some("AQID") * ``` * * @see {@link decodeBase64} for the inverse operation to `Uint8Array` @@ -1314,7 +1464,8 @@ export function encodeBase64(): Getter * import { Effect, Option, SchemaGetter } from "effect" * * const encode = SchemaGetter.encodeBase64Url() - * await Effect.runPromise(encode.run(Option.some(new Uint8Array([251, 255])), {})) // => Option.some("-_8") + * const result = Effect.runSync(SchemaGetter.run(encode, Option.some(new Uint8Array([251, 255])), {})) + * result // => Option.some("-_8") * ``` * * @see {@link decodeBase64Url} for the inverse operation to `Uint8Array` @@ -1341,7 +1492,8 @@ export function encodeBase64Url(): Getter() - * await Effect.runPromise(encode.run(Option.some(new Uint8Array([1, 2, 3])), {})) // => Option.some("010203") + * const result = Effect.runSync(SchemaGetter.run(encode, Option.some(new Uint8Array([1, 2, 3])), {})) + * result // => Option.some("010203") * ``` * * @see {@link decodeHex} for the inverse operation to `Uint8Array` @@ -1367,7 +1519,7 @@ export function encodeHex(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeBase64() - * const result = await Effect.runPromise(decode.run(Option.some("AQID"), {})) + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("AQID"), {})) * Option.map(result, Array.from) // => Option.some([1, 2, 3]) * ``` * @@ -1404,7 +1556,8 @@ export function decodeBase64(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeBase64String() - * await Effect.runPromise(decode.run(Option.some("aGVsbG8="), {})) // => Option.some("hello") + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("aGVsbG8="), {})) + * result // => Option.some("hello") * ``` * * @see {@link decodeBase64} to decode to `Uint8Array` instead @@ -1442,7 +1595,7 @@ export function decodeBase64String(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeBase64Url() - * const result = await Effect.runPromise(decode.run(Option.some("-_8="), {})) + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("-_8="), {})) * Option.map(result, Array.from) // => Option.some([251, 255]) * ``` * @@ -1481,7 +1634,8 @@ export function decodeBase64Url(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeBase64UrlString() - * await Effect.runPromise(decode.run(Option.some("aGVsbG8"), {})) // => Option.some("hello") + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("aGVsbG8"), {})) + * result // => Option.some("hello") * ``` * * @see {@link decodeBase64Url} to decode to `Uint8Array` instead @@ -1519,7 +1673,7 @@ export function decodeBase64UrlString(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeHex() - * const result = await Effect.runPromise(decode.run(Option.some("010203"), {})) + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("010203"), {})) * Option.map(result, Array.from) // => Option.some([1, 2, 3]) * ``` * @@ -1558,7 +1712,8 @@ export function decodeHex(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeHexString() - * await Effect.runPromise(decode.run(Option.some("68656c6c6f"), {})) // => Option.some("hello") + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("68656c6c6f"), {})) + * result // => Option.some("hello") * ``` * * @see {@link decodeHex} to decode to `Uint8Array` instead @@ -1598,7 +1753,8 @@ export function decodeHexString(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const encode = SchemaGetter.encodeUriComponent() - * await Effect.runPromise(encode.run(Option.some("hello world"), {})) // => Option.some("hello%20world") + * const result = Effect.runSync(SchemaGetter.run(encode, Option.some("hello world"), {})) + * result // => Option.some("hello%20world") * ``` * * @see {@link decodeUriComponent} for the inverse operation @@ -1623,7 +1779,8 @@ export function encodeUriComponent(): Getter { * import { Effect, Option, SchemaGetter } from "effect" * * const decode = SchemaGetter.decodeUriComponent() - * await Effect.runPromise(decode.run(Option.some("hello%20world"), {})) // => Option.some("hello world") + * const result = await Effect.runPromise(SchemaGetter.run(decode, Option.some("hello%20world"), {})) + * result // => Option.some("hello world") * ``` * * @see {@link encodeUriComponent} for the inverse operation @@ -1670,7 +1827,9 @@ export function decodeUriComponent(): Getter { * import { DateTime, Effect, Option, SchemaGetter } from "effect" * * const parseDate = SchemaGetter.dateTimeUtcFromInput() - * const result = await Effect.runPromise(parseDate.run(Option.some("2024-01-01T00:00:00Z"), {})) + * const result = await Effect.runPromise( + * SchemaGetter.run(parseDate, Option.some("2024-01-01T00:00:00Z"), {}) + * ) * Option.map(result, DateTime.toEpochMillis) // => Option.some(1704067200000) * ``` * @@ -1713,7 +1872,8 @@ export function dateTimeUtcFromInput(): Gette * const decode = SchemaGetter.decodeFormData() * const formData = new FormData() * formData.append("user[name]", "Alice") - * await Effect.runPromise(decode.run(Option.some(formData), {})) // => Option.some({ user: { name: "Alice" } }) + * const result = Effect.runSync(SchemaGetter.run(decode, Option.some(formData), {})) + * result // => Option.some({ user: { name: "Alice" } }) * ``` * * @see {@link encodeFormData} for the corresponding encoder @@ -1751,7 +1911,7 @@ const collectFormDataEntries = collectBracketPathEntries((value): value is strin * import { Effect, Option, SchemaGetter } from "effect" * * const encode = SchemaGetter.encodeFormData() - * const result = await Effect.runPromise(encode.run(Option.some({ name: "Alice" }), {})) + * const result = Effect.runSync(SchemaGetter.run(encode, Option.some({ name: "Alice" }), {})) * Option.map(result, (formData) => formData.get("name")) // => Option.some("Alice") * ``` * @@ -1796,7 +1956,8 @@ export function encodeFormData(): Getter { * * const decode = SchemaGetter.decodeURLSearchParams() * const params = new URLSearchParams("user[name]=Alice") - * await Effect.runPromise(decode.run(Option.some(params), {})) // => Option.some({ user: { name: "Alice" } }) + * const result = Effect.runSync(SchemaGetter.run(decode, Option.some(params), {})) + * result // => Option.some({ user: { name: "Alice" } }) * ``` * * @see {@link encodeURLSearchParams} for the corresponding encoder @@ -1831,7 +1992,7 @@ const collectURLSearchParamsEntries = collectBracketPathEntries(Predicate.isStri * import { Effect, Option, SchemaGetter } from "effect" * * const encode = SchemaGetter.encodeURLSearchParams() - * const result = await Effect.runPromise(encode.run(Option.some({ name: "Alice" }), {})) + * const result = Effect.runSync(SchemaGetter.run(encode, Option.some({ name: "Alice" }), {})) * Option.map(result, (params) => params.toString()) // => Option.some("name=Alice") * ``` * diff --git a/packages/effect/src/SchemaTransformation.ts b/packages/effect/src/SchemaTransformation.ts index 836e87ed21b..8a882c2d111 100644 --- a/packages/effect/src/SchemaTransformation.ts +++ b/packages/effect/src/SchemaTransformation.ts @@ -18,6 +18,7 @@ import * as DateTime from "./DateTime.ts" import * as Duration from "./Duration.ts" import * as Effect from "./Effect.ts" import { format, formatDate, formatJson } from "./Formatter.ts" +import { dual } from "./Function.ts" import * as Option from "./Option.ts" import * as Predicate from "./Predicate.ts" import type { ErrorOptions, Json } from "./Schema.ts" @@ -145,17 +146,18 @@ const TypeId = "~effect/SchemaTransformation/Transformation" * `Schema.decode`, `Schema.encode`, and `Schema.link`. Each direction is a * `SchemaGetter.Getter` that handles optionality, failure, and Effect services. * - * - Immutable — `flip()` and `compose()` return new instances. + * - Immutable — `flip()` and {@link compose} return new instances. * - `flip()` swaps the decode and encode getters. - * - `compose(other)` chains: `this.decode` then `other.decode` for decoding, - * `other.encode` then `this.encode` for encoding. + * - `compose(self, other)` chains: `self.decode` then `other.decode` for decoding, + * `other.encode` then `self.encode` for encoding. * * **Example** (Composing two transformations) * * ```ts import.meta.vitest * import { SchemaTransformation } from "effect" * - * const trimAndLower = SchemaTransformation.trim().compose( + * const trimAndLower = SchemaTransformation.compose( + * SchemaTransformation.trim(), * SchemaTransformation.toLowerCase() * ) * trimAndLower._tag // => "Transformation" @@ -175,7 +177,6 @@ export interface Transformation { readonly decode: SchemaGetter.Getter readonly encode: SchemaGetter.Getter flip(): Transformation - compose(other: Transformation): Transformation } /** @@ -203,14 +204,56 @@ export const Transformation: new( flip(): Transformation { return new Transformation(this.encode, this.decode) } - compose(other: Transformation): Transformation { - return new Transformation( - this.decode.compose(other.decode), - other.encode.compose(this.encode) - ) - } } +/** + * Composes two schema transformations into a single bidirectional conversion. + * + * **When to use** + * + * Use when decoding and encoding require the same sequence of conversion + * steps in opposite directions. + * + * **Details** + * + * Decoding applies `self.decode` followed by `other.decode`. Encoding applies + * `other.encode` followed by `self.encode`. The function supports both + * `compose(self, other)` and `compose(other)(self)`. + * + * **Example** (Trimming and lowercasing a string) + * + * ```ts import.meta.vitest + * import { Schema, SchemaTransformation } from "effect" + * + * const transformation = SchemaTransformation.compose( + * SchemaTransformation.trim(), + * SchemaTransformation.toLowerCase() + * ) + * const schema = Schema.String.pipe(Schema.decode(transformation)) + * + * Schema.decodeUnknownSync(schema)(" HELLO ") // => "hello" + * ``` + * + * @category combining + * @since 4.0.0 + */ +export const compose: { + ( + other: Transformation + ): (self: Transformation) => Transformation + ( + self: Transformation, + other: Transformation + ): Transformation +} = dual(2, ( + self: Transformation, + other: Transformation +): Transformation => + new Transformation( + SchemaGetter.compose(self.decode, other.decode), + SchemaGetter.compose(other.encode, self.encode) + )) + /** * Returns `true` if `u` is a `Transformation` instance. * diff --git a/packages/effect/src/internal/arbitrary/schema.ts b/packages/effect/src/internal/arbitrary/schema.ts index be19cced950..3013da9e5fa 100644 --- a/packages/effect/src/internal/arbitrary/schema.ts +++ b/packages/effect/src/internal/arbitrary/schema.ts @@ -1664,7 +1664,7 @@ export function compile(schema: S): Model.Compiled< const decodeDeclaration = SchemaParser.run(ast) const decode = (value: unknown): Model.Computation> => { const transformed = link.transformation._tag === "Transformation" - ? link.transformation.decode.run(Option.some(value), SchemaAST.defaultParseOptions) + ? SchemaGetter.run(link.transformation.decode, Option.some(value), SchemaAST.defaultParseOptions) : link.transformation.decode(Effect.succeed(Option.some(value)), SchemaAST.defaultParseOptions) return Model.flatMapComputation(optionComputation(transformed), (outer) => { if (Option.isNone(outer) || Option.isNone(outer.value)) return Option.none() diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts index 798ae307227..12261d5e297 100644 --- a/packages/effect/src/internal/schema/codegen.ts +++ b/packages/effect/src/internal/schema/codegen.ts @@ -656,7 +656,55 @@ const emitArray = (): string => } }}` +const inlineIdentityPredicate = (ast: SchemaAST.AST, input: string, path: string): string | undefined => { + if (ast.checks !== undefined || getEncodingChecks(ast) !== undefined) return undefined + switch (ast._tag) { + case "Null": + return `${input}===null` + case "Undefined": + return `${input}===void 0` + case "Never": + return "false" + case "Any": + case "Unknown": + return "true" + case "ObjectKeyword": + return `((${input}!==null&&typeof ${input}==="object")||typeof ${input}==="function")` + case "UniqueSymbol": + return `${input}===${path}.symbol` + case "Literal": + return `${input}===${path}.literal` + case "String": + return `typeof ${input}==="string"` + case "Number": + return `typeof ${input}==="number"` + case "Boolean": + return `typeof ${input}==="boolean"` + case "Symbol": + return `typeof ${input}==="symbol"` + case "BigInt": + return `typeof ${input}==="bigint"` + case "TemplateLiteral": + return `R.matchesTemplateLiteral(${path},${input},o)` + default: + return undefined + } +} + +const canInlineEncoding = (ast: SchemaAST.AST): ast is SchemaAST.AST & { readonly encoding: SchemaAST.Encoding } => + ast.encoding !== undefined && + ast.checks === undefined && + getEncodingChecks(ast) === undefined && + inlineIdentityPredicate(ast, "v", "ast") !== undefined && + ast.encoding.every((link) => + link.to.encoding === undefined && + inlineIdentityPredicate(link.to, "v", "ast") !== undefined && + link.transformation._tag === "Transformation" && + (link.transformation.decode._tag === "Passthrough" || link.transformation.decode._tag === "Transform") + ) + const emitObject = (ast: SchemaAST.Objects): string => { + const initializers: Array = [] const statements = [ "if(i===R.missing)return R.missingExit", "if(o.errors===\"all\"||o.onExcessProperty!==void 0||(o.concurrency!==void 0&&o.concurrency!==1))return fallback(i,o)", @@ -668,17 +716,57 @@ const emitObject = (ast: SchemaAST.Objects): string => { ast.propertySignatures.forEach((property, index) => { const key = typeof property.name === "symbol" ? `properties[${index}].name` : JSON.stringify(String(property.name)) const present = property.name === "__proto__" ? `Object.hasOwn(i,${key})` : `${key} in i` - statements.push( - `const p${index}=properties[${index}],h${index}=${present},v${index}=h${index}?i[${key}]:R.missing`, - `r=p${index}.parser(v${index},o)`, - `if(r===R.sameExit){if(h${index}){${assignProperty("out", key, `v${index}`, property.name)}}}else{` + - `if(!R.effectIsExit(r))return resume(state,${index},r);` + - `if(r._tag==="Success"&&(value=r[R.args])!==R.missing){${assignProperty("out", key, "value", property.name)}}` + - `else{t=step(state,p${index},r);if(t)return t}}` - ) + const handle = `if(r===R.sameExit){if(h${index}){${assignProperty("out", key, `v${index}`, property.name)}}}else{` + + `if(!R.effectIsExit(r))return resume(state,${index},r);` + + `if(r._tag==="Success"&&(value=r[R.args])!==R.missing){${assignProperty("out", key, "value", property.name)}}` + + `else{t=step(state,p${index},r);if(t)return t}}` + const propertyPath = `ast.propertySignatures[${index}].type` + if (canInlineEncoding(property.type)) { + const links = property.type.encoding + const sourcePath = `${propertyPath}.encoding[${links.length - 1}].to` + const source = inlineIdentityPredicate(links[links.length - 1].to, `v${index}`, sourcePath)! + const fast: Array = [`let x${index}=v${index};r=void 0;l${index}:{`] + for (let linkIndex = links.length - 1; linkIndex >= 0; linkIndex--) { + const transformation = links[linkIndex].transformation + if (transformation._tag === "Transformation" && transformation.decode._tag === "Transform") { + const transform = `t${index}_${linkIndex}` + initializers.push( + `const ${transform}=${propertyPath}.encoding[${linkIndex}].transformation.decode.transform` + ) + fast.push(`x${index}=${transform}(x${index})`) + } + const targetPath = linkIndex === 0 + ? propertyPath + : `${propertyPath}.encoding[${linkIndex - 1}].to` + const target = linkIndex === 0 ? property.type : links[linkIndex - 1].to + const predicate = inlineIdentityPredicate(target, `x${index}`, targetPath)! + const failure = linkIndex === 0 + ? `R.invalidType(${propertyPath},x${index},o)` + : `R.wrapEncoding(${propertyPath},v${index},o,R.invalidType(${targetPath},x${index},o))` + fast.push( + `if(x${index}===R.missing){r=R.missingExit;break l${index}}else if(!(${predicate})){r=${failure};break l${index}}` + ) + } + fast.push( + `};if(r!==void 0){${handle}}else{${assignProperty("out", key, `x${index}`, property.name)}}` + ) + const run = `if(v${index}!==R.missing&&(${source})){${ + fast.join(";") + }}else{r=p${index}.parser(v${index},o);${handle}}` + statements.push( + `const p${index}=properties[${index}],h${index}=${present},v${index}=h${index}?i[${key}]:R.missing`, + run + ) + } else { + statements.push( + `const p${index}=properties[${index}],h${index}=${present},v${index}=h${index}?i[${key}]:R.missing`, + `r=p${index}.parser(v${index},o)`, + handle + ) + } }) statements.push("return R.succeed(out)") - return `function({ast,getProperties,fallback,resume,step}){return function(i,o){try{${ + return `function({ast,getProperties,fallback,resume,step}){${initializers.join(";")};return function(i,o){try{${ statements.join(";") }}catch(e){return R.die(e)}}}` } diff --git a/packages/effect/src/internal/schema/interpreter.ts b/packages/effect/src/internal/schema/interpreter.ts index 54d8561310e..55734984932 100644 --- a/packages/effect/src/internal/schema/interpreter.ts +++ b/packages/effect/src/internal/schema/interpreter.ts @@ -8,58 +8,97 @@ import type { Compiler, Parser } from "../../SchemaParser.ts" import { effectIsExit } from "../effect.ts" import * as InternalParser from "./parser.ts" -function applyTransformation( +type ApplyTransformation = ( result: Effect.Effect, current: unknown, - transformation: SchemaAST.Link["transformation"], options: SchemaAST.ParseOptions -): Effect.Effect { - let transformed: Effect.Effect, SchemaIssue.Issue, unknown> - if (effectIsExit(result) && result._tag === "Success") { - const optional = InternalParser.toOption( - result === InternalParser.sameExit - ? current - : (result as InternalParser.Success)[InternalParser.args] - ) - transformed = transformation._tag === "Transformation" - ? transformation.decode.run(optional, options) - : transformation.decode(InternalParser.succeed(optional), options) - } else if (transformation._tag === "Transformation") { - transformed = Effect.flatMapEager( - result, - (value) => transformation.decode.run(InternalParser.toOption(value), options) - ) - } else { - transformed = transformation.decode( - Effect.mapEager(result, InternalParser.toOption), - options - ) +) => Effect.Effect + +const flatMapTransformation = ( + result: Effect.Effect, + current: unknown, + f: (value: unknown) => Effect.Effect +): Effect.Effect => + result === InternalParser.sameExit ? f(current) : Effect.flatMapEager(result, f) + +function compileTransformation(transformation: SchemaAST.Link["transformation"]): ApplyTransformation { + if (transformation._tag === "Middleware") { + return (result, current, options) => { + const transformed = result === InternalParser.sameExit + ? transformation.decode(InternalParser.succeed(InternalParser.toOption(current)), options) + : transformation.decode(Effect.mapEager(result, InternalParser.toOption), options) + return fromOptionalEffect(transformed) + } + } + + const getter = transformation.decode + switch (getter._tag) { + case "Passthrough": + return (result, current) => result === InternalParser.sameExit ? InternalParser.succeed(current) : result + case "Transform": { + const transform = (value: unknown) => + value === InternalParser.missing + ? InternalParser.missingExit + : InternalParser.succeed(getter.transform(value)) + return (result, current) => flatMapTransformation(result, current, transform) + } + case "TransformOptional": { + const transform = (value: unknown) => + InternalParser.fromOptionExit(getter.transform(InternalParser.toOption(value))) + return (result, current) => flatMapTransformation(result, current, transform) + } + case "TransformEffect": + return (result, current, options) => + flatMapTransformation(result, current, (value) => + value === InternalParser.missing + ? InternalParser.missingExit + : getter.transform(value, options)) + case "TransformOptionalEffect": + return (result, current, options) => + flatMapTransformation( + result, + current, + (value) => fromOptionalEffect(getter.transform(InternalParser.toOption(value), options)) + ) } - return effectIsExit(transformed) && transformed._tag === "Success" - ? InternalParser.fromOptionExit( - (transformed as InternalParser.Success, SchemaIssue.Issue>)[InternalParser.args] - ) - : Effect.flatMapEager(transformed, InternalParser.fromOptionExit) } +const fromOptionalEffect = ( + effect: Effect.Effect, SchemaIssue.Issue, unknown> +): Effect.Effect => Effect.flatMapEager(effect, InternalParser.fromOptionExit) + +/** @internal */ +export const wrapEncoding = ( + ast: SchemaAST.AST, + input: unknown, + options: SchemaAST.ParseOptions, + effect: Effect.Effect +): Effect.Effect => + Effect.catchCause( + effect, + (cause) => + Effect.failCauseSync(() => Cause.map(cause, (issue) => new SchemaIssue.Encoding(ast, issue, input, options))) + ) + function makeConstructorParser(descriptor: SchemaAST.ConstructorDescriptor, compile: Compiler): Parser { + const transform = compileTransformation(descriptor.link.transformation) let sourceParser: Parser return (input, options) => { if (input === InternalParser.missing) return InternalParser.missingExit if (descriptor.isConstructed(input)) return InternalParser.sameExit const result = (sourceParser ??= compile(descriptor.link.to))(input, options) - return applyTransformation(result, input, descriptor.link.transformation, options) + return transform(result, input, options) } } function withDefault(ast: SchemaAST.AST, parser: Parser, resolve: Compiler): Parser { const link = ast.context!.constructorDefault! + const transform = compileTransformation(link.transformation) let source: Parser | undefined return (input, options) => { - const result = applyTransformation( + const result = transform( (source ??= resolve(link.to))(input, options), input, - link.transformation, options ) if (effectIsExit(result) && result._tag === "Success") { @@ -67,10 +106,7 @@ function withDefault(ast: SchemaAST.AST, parser: Parser, resolve: Compiler): Par return local === InternalParser.sameExit ? result : local } return Effect.flatMapEager( - Effect.catchCause(result, (cause) => - Effect.failCause( - Cause.map(cause, (issue) => new SchemaIssue.Encoding(ast, issue, input, options)) - )), + wrapEncoding(ast, input, options, result), (value) => { const local = parser(value, options) return local === InternalParser.sameExit ? InternalParser.succeed(value) : local @@ -104,6 +140,7 @@ export function compile( : base ?? ast.getParser(compile, compileConstructorDefault) const checks = ast.checks const links = ast.encoding + const transformations = links?.map((link) => compileTransformation(link.transformation)) const encodingChecks = (ast as any).encodingChecks if (!links && !checks && !encodingChecks) { return parser @@ -179,7 +216,7 @@ export function compile( let current = input let result = parsers[parsers.length - 1](input, options) for (let i = links.length - 1; i >= 0; i--) { - result = applyTransformation(result, current, links[i].transformation, options) + result = transformations![i](result, current, options) if (i !== 0) { const next = parsers[i - 1] if ((result as Exit.Exit)._tag === "Success") { @@ -198,22 +235,7 @@ export function compile( const local = parseLocal(value, options) return local === InternalParser.sameExit ? result : local } - result = Effect.catchCause( - result, - (cause) => - Effect.failCauseSync(() => - Cause.map( - cause, - (issue) => - new SchemaIssue.Encoding( - ast, - issue, - input, - options - ) - ) - ) - ) + result = wrapEncoding(ast, input, options, result) return Effect.flatMapEager(result, (value) => { const local = parseLocal(value, options) return local === InternalParser.sameExit ? InternalParser.succeed(value) : local diff --git a/packages/effect/src/unstable/schema/Model.ts b/packages/effect/src/unstable/schema/Model.ts index 83ed597d3c9..f216d5cbfe9 100644 --- a/packages/effect/src/unstable/schema/Model.ts +++ b/packages/effect/src/unstable/schema/Model.ts @@ -427,7 +427,7 @@ export interface Date extends Schema.decodeTo, S */ export const Date: Date = Schema.String.pipe( Schema.decodeTo(Schema.DateTimeUtc, { - decode: SchemaGetter.dateTimeUtcFromInput().map(DateTime.removeTime), + decode: SchemaGetter.map(SchemaGetter.dateTimeUtcFromInput(), DateTime.removeTime), encode: SchemaGetter.transform(DateTime.formatIsoDate) }) ) diff --git a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts index f65881c8572..85bfe6e97c9 100644 --- a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts +++ b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts @@ -41,7 +41,6 @@ type GenerateArray = (context: { end: number ) => Effect.Effect }) => typeof SchemaAST.parseArray - const makeObjectBase = ( ast: SchemaAST.Objects, compile: Compiler, @@ -217,6 +216,7 @@ export const runtime = { die: Effect.die, invalidType: (ast: SchemaAST.AST, input: unknown, options: SchemaAST.ParseOptions) => Effect.fail(new SchemaIssue.InvalidType(ast, input, options)), + wrapEncoding: Interpreter.wrapEncoding, failsChecks, getExpectedKeys: (ast: SchemaAST.Objects) => ast.propertySignatures.map((p) => typeof p.name === "number" ? String(p.name) : p.name), diff --git a/packages/effect/test/Formatter.test.ts b/packages/effect/test/Formatter.test.ts index acbfcbabdf7..ae3db53703e 100644 --- a/packages/effect/test/Formatter.test.ts +++ b/packages/effect/test/Formatter.test.ts @@ -598,13 +598,14 @@ describe("Formatter", () => { it.effect("distinguishes present undefined from absent input in forbidden", () => Effect.gen(function*() { const getter = SchemaGetter.forbidden(() => "not allowed") - const present = yield* getter.run(Option.some(undefined), { reportInput: true }).pipe(Effect.flip) + assertTrue(getter._tag === "TransformOptionalEffect") + const present = yield* getter.transform(Option.some(undefined), { reportInput: true }).pipe(Effect.flip) assertTrue(present._tag === "Forbidden") assertTrue(SchemaIssue.hasInput(present)) strictEqual(present.input, undefined) strictEqual(formatIssue(present), "not allowed") - const absent = yield* getter.run(Option.none(), { reportInput: true }).pipe(Effect.flip) + const absent = yield* getter.transform(Option.none(), { reportInput: true }).pipe(Effect.flip) assertTrue(absent._tag === "Forbidden") assertFalse(SchemaIssue.hasInput(absent)) })) diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index 0dce68d6afd..8a67678b429 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -2406,7 +2406,8 @@ Expected a value between -2147483648 and 2147483647` it("double transformation", async () => { const schema = Schema.String.pipe( Schema.decode( - SchemaTransformation.trim().compose( + SchemaTransformation.compose( + SchemaTransformation.trim(), SchemaTransformation.toLowerCase() ) ) @@ -2554,7 +2555,8 @@ Expected a value between -2147483648 and 2147483647` it("double transformation", async () => { const schema = Schema.String.pipe( Schema.encode( - SchemaTransformation.trim().compose( + SchemaTransformation.compose( + SchemaTransformation.trim(), SchemaTransformation.toLowerCase() ).flip() ) @@ -8296,12 +8298,12 @@ Expected a value between -2147483648 and 2147483647` Cause.die(new Error("defect")) ) const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(cause)), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(cause)) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)) })) const r1 = await Schema.decodeUnknownPromise(decodeSchema)("a").then(Result.succeed, Result.fail) @@ -8339,12 +8341,12 @@ Expected a value between -2147483648 and 2147483647` it("should throw an error when the cause is not a schema issue", () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.die(new Error("decode defect"))), + decode: SchemaGetter.transformOptionalEffect(() => Effect.die(new Error("decode defect"))), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.die(new Error("encode defect"))) + encode: SchemaGetter.transformOptionalEffect(() => Effect.die(new Error("encode defect"))) })) throws(() => Schema.decodeUnknownOption(decodeSchema)("a"), (e) => { @@ -8365,12 +8367,12 @@ Expected a value between -2147483648 and 2147483647` Cause.die(new Error("defect")) ) const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(cause)), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(cause)) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)) })) throws(() => Schema.decodeUnknownOption(decodeSchema)("a"), (e) => { @@ -8427,12 +8429,12 @@ Expected a value between -2147483648 and 2147483647` Cause.die(new Error("defect")) ) const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(cause)), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(cause)) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)) })) throws(() => Schema.decodeUnknownResult(decodeSchema)("a"), (e) => { @@ -8480,12 +8482,12 @@ Expected a value between -2147483648 and 2147483647` Cause.die(new Error("defect")) ) const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(cause)), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(cause)) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)) })) throws(() => Schema.decodeUnknownSync(decodeSchema)("a"), (e) => { @@ -8504,7 +8506,7 @@ Expected a value between -2147483648 and 2147483647` describe("decodeUnknownResult", () => { it("should throw on async decoding", () => { const AsyncString = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter((os: Option.Option) => + decode: SchemaGetter.transformOptionalEffect((os: Option.Option) => Effect.gen(function*() { yield* Effect.sleep("10 millis") return os @@ -8520,10 +8522,10 @@ Expected a value between -2147483648 and 2147483647` it("should throw on missing dependency", () => { class MagicNumber extends Context.Service()("MagicNumber") {} const DepString = Schema.Number.pipe(Schema.decode({ - decode: SchemaGetter.onSome((n) => + decode: SchemaGetter.transformEffect((n) => Effect.gen(function*() { const magicNumber = yield* MagicNumber - return Option.some(n * magicNumber) + return n * magicNumber }) ), encode: SchemaGetter.passthrough() @@ -8537,7 +8539,7 @@ Expected a value between -2147483648 and 2147483647` describe("decodeUnknownExit", () => { it("should die on async decoding", () => { const AsyncString = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter((os: Option.Option) => + decode: SchemaGetter.transformOptionalEffect((os: Option.Option) => Effect.gen(function*() { yield* Effect.sleep("10 millis") return os @@ -8554,10 +8556,10 @@ Expected a value between -2147483648 and 2147483647` it("should die on missing dependency", () => { class MagicNumber extends Context.Service()("MagicNumber") {} const DepString = Schema.Number.pipe(Schema.decode({ - decode: SchemaGetter.onSome((n) => + decode: SchemaGetter.transformEffect((n) => Effect.gen(function*() { const magicNumber = yield* MagicNumber - return Option.some(n * magicNumber) + return n * magicNumber }) ), encode: SchemaGetter.passthrough() @@ -8575,12 +8577,12 @@ Expected a value between -2147483648 and 2147483647` Cause.die(new Error("defect")) ) const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(cause)), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(cause)) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)) })) const decodeExit = Schema.decodeUnknownExit(decodeSchema)("a") diff --git a/packages/effect/test/schema/SchemaCompilerRegression.test.ts b/packages/effect/test/schema/SchemaCompilerRegression.test.ts index 065001c0c0e..5defd1caf15 100644 --- a/packages/effect/test/schema/SchemaCompilerRegression.test.ts +++ b/packages/effect/test/schema/SchemaCompilerRegression.test.ts @@ -34,7 +34,7 @@ describe("compiler regression contracts", () => { const schema = Schema.Struct({ value: Schema.Unknown.pipe( Schema.decode({ - decode: new SchemaGetter.Getter((input) => { + decode: SchemaGetter.transformOptionalEffect((input) => { seen.push(input) const output = Option.isNone(input) || input.value === "omit" ? Option.none() : Option.some(undefined) return suspended ? Effect.sync(() => output) : Effect.succeed(output) @@ -161,7 +161,9 @@ describe("compiler regression contracts", () => { it.effect("preserves unchanged fields before and after asynchronous transformations", () => Effect.gen(function*() { const number = Schema.String.pipe(Schema.decodeTo(Schema.Number, { - decode: new SchemaGetter.Getter((input) => Effect.yieldNow.pipe(Effect.as(Option.map(input, Number)))), + decode: SchemaGetter.transformOptionalEffect((input) => + Effect.yieldNow.pipe(Effect.as(Option.map(input, Number))) + ), encode: SchemaGetter.transform(String) })) const tuple = Schema.Tuple([Schema.String, number, Schema.Undefined]).check( diff --git a/packages/effect/test/schema/SchemaGetter.test.ts b/packages/effect/test/schema/SchemaGetter.test.ts index 42697e8006d..40d477c0d2a 100644 --- a/packages/effect/test/schema/SchemaGetter.test.ts +++ b/packages/effect/test/schema/SchemaGetter.test.ts @@ -7,7 +7,7 @@ const formatIssue = SchemaIssue.makeFormatterDefault() function makeAsserts(getter: SchemaGetter.Getter) { return async (input: E, expected: T) => { const r = await Effect.runPromise( - getter.run(Option.some(input), {}).pipe( + SchemaGetter.run(getter, Option.some(input), {}).pipe( Effect.mapError(formatIssue), Effect.result ) @@ -20,22 +20,79 @@ describe("SchemaGetter", () => { it.effect("forbiddenEncoding", () => Effect.gen(function*() { const getter: SchemaGetter.Getter = SchemaGetter.forbiddenEncoding - const issue = yield* getter.run(Option.some(1), {}).pipe(Effect.flip) + const issue = yield* SchemaGetter.run(getter, Option.some(1), {}).pipe(Effect.flip) assert.strictEqual(issue._tag, "Forbidden") assert.strictEqual(formatIssue(issue), "Encoding is not supported") })) it.effect("stringifyJson fails when JSON.stringify returns undefined", () => - SchemaGetter.stringifyJson().run(Option.some(undefined), {}).pipe( + SchemaGetter.run(SchemaGetter.stringifyJson(), Option.some(undefined), {}).pipe( Effect.flip, Effect.map((issue) => assert.strictEqual(issue._tag, "InvalidValue")) )) - it("map", () => { - const getter = SchemaGetter.succeed(1).map((t) => t + 1) - const result = Effect.runSync(getter.run(Option.some(1), {})) - assertSome(result, 2) + it.effect("map", () => + Effect.gen(function*() { + const getter = SchemaGetter.map(SchemaGetter.succeed(1), (t) => t + 1) + const result = yield* SchemaGetter.run(Option.some(1), {})(getter) + assertSome(result, 2) + })) + + it("map preserves the specialized execution mode", () => { + assert.strictEqual(SchemaGetter.map(SchemaGetter.passthrough(), String)._tag, "Transform") + assert.strictEqual(SchemaGetter.map(SchemaGetter.transform(Number), String)._tag, "Transform") + assert.strictEqual( + SchemaGetter.map( + SchemaGetter.transformOptional((input: Option.Option) => Option.map(input, Number)), + String + )._tag, + "TransformOptional" + ) + assert.strictEqual( + SchemaGetter.map(SchemaGetter.transformEffect((input: string) => Effect.succeed(Number(input))), String)._tag, + "TransformEffect" + ) + assert.strictEqual( + SchemaGetter.map( + SchemaGetter.transformOptionalEffect((input: Option.Option) => + Effect.succeed(Option.map(input, Number)) + ), + String + )._tag, + "TransformOptionalEffect" + ) + }) + + it.effect("compose", () => + Effect.gen(function*() { + const first = SchemaGetter.transform(Number) + const second = SchemaGetter.transform((value: number) => value * 2) + const composed = SchemaGetter.compose(first, second) + + assert.strictEqual(composed._tag, "Transform") + assertSome(yield* SchemaGetter.run(composed, Option.some("2"), {}), 4) + assert.strictEqual(SchemaGetter.compose(SchemaGetter.passthrough(), first), first) + assert.strictEqual(SchemaGetter.compose(first, SchemaGetter.passthrough()), first) + })) + + it("compose preserves the specialized execution mode", () => { + const transform = SchemaGetter.transform(Number) + const transformNumber = SchemaGetter.transform((value: number) => value + 1) + const transformOptional = SchemaGetter.transformOptional(Option.map((value) => value + 1)) + const transformEffect = SchemaGetter.transformEffect((value: number) => Effect.succeed(value + 1)) + const transformOptionalEffect = SchemaGetter.transformOptionalEffect((input: Option.Option) => + Effect.succeed(Option.map(input, (value) => value + 1)) + ) + + assert.strictEqual(SchemaGetter.compose(transform, transformOptional)._tag, "TransformOptional") + assert.strictEqual(SchemaGetter.compose(transform, transformEffect)._tag, "TransformEffect") + assert.strictEqual(SchemaGetter.compose(transformOptional, transformNumber)._tag, "TransformOptional") + assert.strictEqual(SchemaGetter.compose(transformEffect, transformNumber)._tag, "TransformEffect") + assert.strictEqual(SchemaGetter.compose(transformEffect, transformEffect)._tag, "TransformEffect") + assert.strictEqual(SchemaGetter.compose(transformOptional, transformEffect)._tag, "TransformOptionalEffect") + assert.strictEqual(SchemaGetter.compose(transformEffect, transformOptional)._tag, "TransformOptionalEffect") + assert.strictEqual(SchemaGetter.compose(transformOptionalEffect, transformNumber)._tag, "TransformOptionalEffect") }) it("dateTimeUtcFromInput", async () => { diff --git a/packages/effect/test/schema/SchemaJITCompiler.test.ts b/packages/effect/test/schema/SchemaJITCompiler.test.ts index 1d015a53f99..7ebcd99e59b 100644 --- a/packages/effect/test/schema/SchemaJITCompiler.test.ts +++ b/packages/effect/test/schema/SchemaJITCompiler.test.ts @@ -294,6 +294,30 @@ describe("SchemaJITCompiler", () => { } }) + it("does not replay inlined Struct transformations", () => { + let transformations = 0 + const transformed = Schema.String.pipe( + Schema.decodeTo( + Schema.Number, + SchemaTransformation.transform({ + decode: (value) => { + transformations++ + return value === "invalid" ? "invalid" as any : Number(value) + }, + encode: String + }) + ) + ) + const decode = SchemaParser.decodeUnknownSync(Schema.Struct({ value: transformed })) + + deepStrictEqual(decode({ value: "1" }), { value: 1 }) + strictEqual(transformations, 1) + throws(() => decode({ value: false })) + strictEqual(transformations, 1) + throws(() => decode({ value: "invalid" })) + strictEqual(transformations, 2) + }) + it("applies Struct output and encoding checks after compiled fields without replay", () => { let transformations = 0 let checks = 0 @@ -736,7 +760,7 @@ Expected no excess property Cause.die(new Error("defect")) ) const schema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(cause)), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(cause)), encode: SchemaGetter.passthrough() })) diff --git a/packages/effect/test/schema/SchemaParser.test.ts b/packages/effect/test/schema/SchemaParser.test.ts index f9f3e9f1caa..f37d78abac8 100644 --- a/packages/effect/test/schema/SchemaParser.test.ts +++ b/packages/effect/test/schema/SchemaParser.test.ts @@ -530,12 +530,12 @@ describe("SchemaParser", () => { it("should throw an error when the cause contains both an Issue and a defect", () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())) })) throws(() => SchemaParser.decodeUnknownSync(decodeSchema)("a"), (e) => { @@ -564,12 +564,12 @@ describe("SchemaParser", () => { it("should reject with an error when the cause contains both an Issue and a defect", async () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())) })) const r1 = await SchemaParser.decodeUnknownPromise(decodeSchema)("a").then(Result.succeed, Result.fail) @@ -597,12 +597,12 @@ describe("SchemaParser", () => { it("should throw an error when the cause is not an Issue", () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.die(new Error("decode defect"))), + decode: SchemaGetter.transformOptionalEffect(() => Effect.die(new Error("decode defect"))), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.die(new Error("encode defect"))) + encode: SchemaGetter.transformOptionalEffect(() => Effect.die(new Error("encode defect"))) })) throws(() => SchemaParser.decodeUnknownOption(decodeSchema)("a"), (e) => { @@ -619,12 +619,12 @@ describe("SchemaParser", () => { it("should throw an error when the cause contains both an Issue and a defect", () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())) })) throws(() => SchemaParser.decodeUnknownOption(decodeSchema)("a"), (e) => { @@ -705,12 +705,12 @@ describe("SchemaParser", () => { describe("decodeUnknownResult / encodeUnknownResult", () => { it("should throw an error when the cause is not an Issue", () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.die(new Error("decode defect"))), + decode: SchemaGetter.transformOptionalEffect(() => Effect.die(new Error("decode defect"))), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.die(new Error("encode defect"))) + encode: SchemaGetter.transformOptionalEffect(() => Effect.die(new Error("encode defect"))) })) throws(() => SchemaParser.decodeUnknownResult(decodeSchema)("a"), (e) => { @@ -727,12 +727,12 @@ describe("SchemaParser", () => { it("should throw an error when the cause contains both an Issue and a defect", () => { const decodeSchema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())), encode: SchemaGetter.passthrough() })) const encodeSchema = Schema.String.pipe(Schema.encode({ decode: SchemaGetter.passthrough(), - encode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())) + encode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())) })) throws(() => SchemaParser.decodeUnknownResult(decodeSchema)("a"), (e) => { @@ -833,7 +833,7 @@ describe("SchemaParser", () => { let rejectedReads = 0 const rejectedKey = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.fail(new SchemaIssue.InvalidValue())), + decode: SchemaGetter.transformOptionalEffect(() => Effect.fail(new SchemaIssue.InvalidValue())), encode: SchemaGetter.passthrough() })) const rejectedInput = { @@ -856,7 +856,7 @@ describe("SchemaParser", () => { it("rejects a missing root output", () => { const schema = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.succeedNone), + decode: SchemaGetter.transformOptionalEffect(() => Effect.succeedNone), encode: SchemaGetter.passthrough() })) const result = SchemaParser.decodeUnknownExit(schema)("value") @@ -865,7 +865,7 @@ describe("SchemaParser", () => { }) it("rejects a missing root output after parsing structural schemas", () => { - const missing = new SchemaGetter.Getter(() => Effect.succeedNone) + const missing = SchemaGetter.transformOptionalEffect(() => Effect.succeedNone) const array = Schema.Array(Schema.String).pipe(Schema.decode({ decode: missing, encode: missing @@ -929,7 +929,7 @@ describe("SchemaParser", () => { it.effect("wraps an asynchronous failure from a uniquely selected union member", () => Effect.gen(function*() { const failing = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => + decode: SchemaGetter.transformOptionalEffect(() => Effect.yieldNow.pipe( Effect.andThen(Effect.fail(new SchemaIssue.InvalidValue())) ) @@ -953,7 +953,7 @@ describe("SchemaParser", () => { it.effect("resolves an unchanged union candidate after an asynchronous failure", () => Effect.gen(function*() { const delayedFailure = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => + decode: SchemaGetter.transformOptionalEffect(() => Effect.yieldNow.pipe( Effect.andThen(Effect.fail(new SchemaIssue.InvalidValue())) ) @@ -972,7 +972,7 @@ describe("SchemaParser", () => { const calls: Array = [] const field = (name: string, suspended = false) => Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter((input) => { + decode: SchemaGetter.transformOptionalEffect((input) => { calls.push(name) return suspended ? Effect.suspend(() => Effect.succeed(input)) : Effect.succeed(input) }), @@ -1153,7 +1153,7 @@ describe("SchemaParser", () => { it("should preserve mixed causes in union candidates instead of trying later candidates", () => { const failure = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter(() => Effect.failCause(makeMixedCause())), + decode: SchemaGetter.transformOptionalEffect(() => Effect.failCause(makeMixedCause())), encode: SchemaGetter.passthrough() })) const schema = Schema.Union([ diff --git a/packages/effect/test/schema/fixtures/aot-runner.ts b/packages/effect/test/schema/fixtures/aot-runner.ts index a56b7ece323..3869dade495 100644 --- a/packages/effect/test/schema/fixtures/aot-runner.ts +++ b/packages/effect/test/schema/fixtures/aot-runner.ts @@ -108,6 +108,7 @@ for ( "record", "transformed", "transformedStruct", + "pureTransformedStruct", "checkedTransformedStruct", "encodingCheckedTransformedStruct", "asynchronous", diff --git a/packages/effect/test/schema/fixtures/aot.ts b/packages/effect/test/schema/fixtures/aot.ts index 3841ef9fcc0..a760e955a9a 100644 --- a/packages/effect/test/schema/fixtures/aot.ts +++ b/packages/effect/test/schema/fixtures/aot.ts @@ -19,6 +19,19 @@ const transformed = Schema.String.pipe( ) ) +const pureTransformed = Schema.String.pipe( + Schema.decodeTo( + Schema.Number, + SchemaTransformation.transform({ + decode: (input) => { + events.push("pure transform") + return input === "invalid" ? "invalid" as any : Number(input) + }, + encode: String + }) + ) +) + const middleware = transformed.pipe( Schema.middlewareDecoding((effect) => { events.push("middleware") @@ -31,7 +44,7 @@ const middleware = transformed.pipe( const asynchronous = Schema.String.pipe( Schema.decodeTo(Schema.Number.check(Schema.isGreaterThan(0)), { - decode: new SchemaGetter.Getter((input) => { + decode: SchemaGetter.transformOptionalEffect((input) => { events.push("async") return Effect.yieldNow.pipe(Effect.as(Option.map(input, Number))) }), @@ -160,6 +173,10 @@ export const synchronous = { { before: "a", value: "2", after: "invalid" } ] }, + pureTransformedStruct: { + schema: Schema.Struct({ value: pureTransformed }), + inputs: [{ value: "2" }, { value: false }, { value: "invalid" }] + }, middleware: { schema: middleware, inputs: ["2", "-1", false] diff --git a/packages/effect/test/schema/toStandardSchemaV1.test.ts b/packages/effect/test/schema/toStandardSchemaV1.test.ts index 88ee43577fc..5521a68ec7f 100644 --- a/packages/effect/test/schema/toStandardSchemaV1.test.ts +++ b/packages/effect/test/schema/toStandardSchemaV1.test.ts @@ -1,5 +1,5 @@ import { assertTrue, deepStrictEqual, strictEqual } from "@effect/vitest/utils" -import { Context, Effect, Option, Predicate, Schema, SchemaGetter, SchemaIssue } from "effect" +import { Context, Effect, type Option, Predicate, Schema, SchemaGetter, SchemaIssue } from "effect" import type { StandardSchemaV1 } from "effect/StandardSchema" import { describe, it } from "vitest" @@ -90,7 +90,7 @@ const expectAsyncFailure = async ( } const AsyncString = Schema.String.pipe(Schema.decode({ - decode: new SchemaGetter.Getter((os: Option.Option) => + decode: SchemaGetter.transformOptionalEffect((os: Option.Option) => Effect.gen(function*() { yield* Effect.sleep("10 millis") return os @@ -155,10 +155,10 @@ describe("toStandardSchemaV1", () => { it("sync decoding should throw", () => { const DepString = Schema.Number.pipe(Schema.decode({ - decode: SchemaGetter.onSome((n) => + decode: SchemaGetter.transformEffect((n) => Effect.gen(function*() { const magicNumber = yield* MagicNumber - return Option.some(n * magicNumber) + return n * magicNumber }) ), encode: SchemaGetter.passthrough() @@ -175,11 +175,11 @@ describe("toStandardSchemaV1", () => { it("async decoding should report a missing dependency", () => { const DepString = Schema.Number.pipe(Schema.decode({ - decode: SchemaGetter.onSome((n) => + decode: SchemaGetter.transformEffect((n) => Effect.gen(function*() { const magicNumber = yield* MagicNumber yield* Effect.sleep("10 millis") - return Option.some(n * magicNumber) + return n * magicNumber }) ), encode: SchemaGetter.passthrough() diff --git a/packages/effect/typetest/schema/Schema.tst.ts b/packages/effect/typetest/schema/Schema.tst.ts index 6f2c585830f..68f639327e6 100644 --- a/packages/effect/typetest/schema/Schema.tst.ts +++ b/packages/effect/typetest/schema/Schema.tst.ts @@ -1804,10 +1804,10 @@ describe("Schema", () => { it("asStandardSchemaV1 should not be callable with a schema with DecodingServices", () => { class MagicNumber extends Context.Service()("MagicNumber") {} const DepString = Schema.Number.pipe(Schema.decode({ - decode: SchemaGetter.onSome((n) => + decode: SchemaGetter.transformEffect((n) => Effect.gen(function*() { const magicNumber = yield* MagicNumber - return Option.some(n * magicNumber) + return n * magicNumber }) ), encode: SchemaGetter.passthrough() diff --git a/packages/effect/typetest/schema/SchemaGetter.tst.ts b/packages/effect/typetest/schema/SchemaGetter.tst.ts new file mode 100644 index 00000000000..90f527a6cd1 --- /dev/null +++ b/packages/effect/typetest/schema/SchemaGetter.tst.ts @@ -0,0 +1,46 @@ +import type { Effect, Option, SchemaIssue } from "effect" +import { SchemaGetter, SchemaTransformation } from "effect" +import { describe, expect, it } from "tstyche" + +describe("SchemaGetter", () => { + it("map", () => { + const getter = null as unknown as SchemaGetter.Getter + + expect(SchemaGetter.map(getter, String)).type.toBe>() + expect(SchemaGetter.map(String)(getter)).type.toBe>() + }) + + it("compose", () => { + const first = null as unknown as SchemaGetter.Getter + const second = null as unknown as SchemaGetter.Getter + + expect(SchemaGetter.compose(first, second)).type.toBe>() + expect(SchemaGetter.compose(second)(first)).type.toBe>() + }) + + it("run", () => { + const getter = null as unknown as SchemaGetter.Getter + const input = null as unknown as Option.Option + + expect(SchemaGetter.run(getter, input, {})).type.toBe< + Effect.Effect, SchemaIssue.Issue, "R"> + >() + expect(SchemaGetter.run(input, {})(getter)).type.toBe< + Effect.Effect, SchemaIssue.Issue, "R"> + >() + }) +}) + +describe("SchemaTransformation", () => { + it("compose", () => { + const first = null as unknown as SchemaTransformation.Transformation + const second = null as unknown as SchemaTransformation.Transformation + + expect(SchemaTransformation.compose(first, second)).type.toBe< + SchemaTransformation.Transformation + >() + expect(SchemaTransformation.compose(second)(first)).type.toBe< + SchemaTransformation.Transformation + >() + }) +}) From 5e9f0decb13d1496e2f5383b10b8cd92e4232988 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 16 Sep 2026 05:20:59 +0200 Subject: [PATCH 14/33] Optimize compiled sync parser hot path --- packages/effect/src/SchemaParser.ts | 42 ++++++++++++++++++----------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index 2e4e624f22d..18f81f9b7d7 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -1047,16 +1047,24 @@ function makeSync( ast: SchemaAST.AST, options?: SchemaAST.ParseOptions ): (input: unknown, options?: SchemaAST.ParseOptions) => T { - let entry: CompilerRegistry.Entry | undefined + let run: ((input: unknown, options?: SchemaAST.ParseOptions) => T) | undefined + return (input, overrideOptions) => + (run ??= makeSyncEntry(CompilerRegistry.resolve(ast), options))(input, overrideOptions) +} + +function makeSyncEntry( + entry: CompilerRegistry.Entry, + options?: SchemaAST.ParseOptions +): (input: unknown, options?: SchemaAST.ParseOptions) => T { + const validate = entry.validate let detailed: ((input: unknown, options?: SchemaAST.ParseOptions) => T) | undefined - let parser: Parser | undefined - return (input, overrideOptions) => { - entry ??= CompilerRegistry.resolve(ast) - const parseOptions = options === undefined - ? overrideOptions ?? SchemaAST.defaultParseOptions - : mergeParseOptions(options, overrideOptions) - const validate = entry.validate - if (validate !== undefined && input !== InternalParser.missing) { + const run = validate === undefined + ? (input: unknown, parseOptions = SchemaAST.defaultParseOptions): T => + (detailed ??= makeDetailedSync(entry))(input, parseOptions) + : (input: unknown, parseOptions = SchemaAST.defaultParseOptions): T => { + if (input === InternalParser.missing) { + return (detailed ??= makeDetailedSync(entry))(input, parseOptions) + } let output: unknown try { output = validate(input, parseOptions) @@ -1065,13 +1073,17 @@ function makeSync( throw error } if (output !== CompilerRegistry.invalid) return output as T + return (detailed ??= makeDetailedSync(entry))(input, parseOptions) } - if (entry.source === undefined) { - const result = (parser ??= entry.decodeEffect)(input, parseOptions) - return runSync(parserResult(result, input), "Sync adapter can only throw schema issues") - } - return (detailed ??= asSync(runWithCompiler(() => entry!.decodeEffect, ast)))(input, parseOptions) - } + return options === undefined + ? run + : (input, overrideOptions) => run(input, mergeParseOptions(options, overrideOptions)) +} + +function makeDetailedSync( + entry: CompilerRegistry.Entry +): (input: unknown, options?: SchemaAST.ParseOptions) => T { + return asSync(runWithCompiler(() => entry.decodeEffect, entry.ast)) } function asSync( From 741ba9203f1f458c4c528b4ab42b58ea0af8cba5 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 16 Sep 2026 08:17:02 +0200 Subject: [PATCH 15/33] Optimize compiled Array construction --- .changeset/add-schema-compilers.md | 5 +- packages/effect/SCHEMA.md | 24 +++--- packages/effect/src/SchemaGetter.ts | 5 +- packages/effect/src/SchemaParser.ts | 11 +++ .../effect/src/internal/schema/codegen.ts | 70 +++++++++++++++- .../src/internal/schema/compilerRegistry.ts | 7 +- .../src/unstable/schema/SchemaAOTCompiler.ts | 14 ++-- .../src/unstable/schema/SchemaCompiler.ts | 43 ++++++++-- .../src/unstable/schema/SchemaJITCompiler.ts | 9 ++- .../schema/SchemaCompilerConstruction.test.ts | 81 ++++++++++++++++++- .../typetest/schema/SchemaCompiler.tst.ts | 1 + 11 files changed, 234 insertions(+), 36 deletions(-) diff --git a/.changeset/add-schema-compilers.md b/.changeset/add-schema-compilers.md index 8ea4eb85b4e..419142164a6 100644 --- a/.changeset/add-schema-compilers.md +++ b/.changeset/add-schema-compilers.md @@ -9,7 +9,10 @@ Add experimental JIT and AOT schema compilers that work through the existing requiring dynamic function construction. The new `effect/unstable/schema/SchemaAOTCompiler/Build` entrypoint discovers direct Schema exports from explicit module loaders and writes a self-installing AOT -module through Effect's `FileSystem` and `Path` services. +module through Effect's `FileSystem` and `Path` services. Compiled decoders can +provide an optional synchronous `make` operation for construction-safe schemas; +normal `SchemaParser.make` calls consume it transparently and retain +`makeEffect` as the detailed fallback. ### Breaking changes diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 37766ba22ac..e93fe97aa1f 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -130,6 +130,7 @@ stores functions, never parsing results. The interpreter, JIT, AOT and | `decodeEffect` | `Effect` with output or detailed issues | Required complete decoding, including asynchronous work and transformations. | | `validate` | Output or `SchemaCompiler.invalid` | Optional synchronous fast path without detailed diagnostics. | | `is` | Boolean | Optional validation without constructing output. | +| `make` | Output or `SchemaCompiler.invalid` | Optional synchronous construction fast path without detailed diagnostics. | | `makeEffect` | `Effect` with a constructed value or issues | Optional specialized construction. The registry caches the interpreted constructor when absent. | Decoding tries `validate` when available. Success provides the output directly; @@ -141,11 +142,13 @@ marker is also a possible input value. Composite checks can require stripped, reconstructed values, so `is` is omitted when it cannot avoid constructing those values safely. -Each operation initializes independently. Construction calls `makeEffect` -directly, without validation replay, so defaults and Class constructors execute -once. Field defaults belong to the parent occurrence, not to construction of the -root. Runtime parse options, including product concurrency, retain the -interpreter's semantics. +Each operation initializes independently. Synchronous construction tries `make` +when available and falls back to `makeEffect` for detailed issues. Compilers omit +`make` whenever replay could repeat defaults, Class constructors, +transformations, middleware, or other effects. `makeEffect` itself never uses +validation replay. Field defaults belong to the parent occurrence, not to +construction of the root. Runtime parse options, including product concurrency, +retain the interpreter's semantics. Installing a decoder replaces the entry for that AST. Existing consumers that already captured an entry retain it. Late installation is allowed, but startup @@ -156,10 +159,13 @@ installation is needed to optimize every consumer. Custom decoders supplied to Encoding-free graphs of supported primitives, Objects, Arrays, tuples, Unions and template literals can use generated validators. Struct and homogeneous Array -decoding and construction also have generated loops. These loops share the -interpreter's diagnostic and asynchronous continuation helpers. Other detailed -traversals and constructors use the existing interpreter with registry-resolved -children; there is no separate diagnostic interpreter in the compiler. +decoding and construction also have generated loops. Pure fixed Struct and +homogeneous Array constructors can additionally use the synchronous `make` fast +path; composite children are resolved through the same registry. These loops +share the interpreter's diagnostic and asynchronous continuation helpers. +Other detailed traversals and constructors use the existing interpreter with +registry-resolved children; there is no separate diagnostic interpreter in the +compiler. Transformations and middleware never participate in validation replay. Their orchestration uses the same implementation as interpreted parsing, and pure diff --git a/packages/effect/src/SchemaGetter.ts b/packages/effect/src/SchemaGetter.ts index 006381036d0..4cc3750ca42 100644 --- a/packages/effect/src/SchemaGetter.ts +++ b/packages/effect/src/SchemaGetter.ts @@ -116,8 +116,8 @@ export interface TransformOptionalEffect extends Pipeable.Pipeab * Effect.runSync(SchemaGetter.run(composed, Option.some("21"), {})) // => Option.some(42) * ``` * - * @see {@link transform} to create a getter from a pure function * @see {@link passthrough} for the identity getter + * @see {@link transform} to create a getter from a pure function * @see {@link transformEffect} for effectful transformation * * @category models @@ -269,13 +269,12 @@ export const compose: { Effect.mapEager(self.transform(input, options), other.transform) ) case "TransformOptional": + case "TransformOptionalEffect": return composeOptionalEffect(self, other) case "TransformEffect": return transformEffect((input: E, options) => Effect.flatMapEager(self.transform(input, options), (output) => other.transform(output, options)) ) - case "TransformOptionalEffect": - return composeOptionalEffect(self, other) } } case "TransformOptionalEffect": diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index 18f81f9b7d7..c293a657832 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -1119,6 +1119,17 @@ function makeConstructorSync( const parseOptions = options?.disableChecks ? options.parseOptions ? { ...options.parseOptions, disableChecks: true } : { disableChecks: true } : options?.parseOptions ?? SchemaAST.defaultParseOptions + const make = entry.make + if (make !== undefined && input !== InternalParser.missing) { + let output: unknown + try { + output = make(input, parseOptions) + } catch (error) { + InternalSchemaCause.getSchemaIssueOrThrow(Cause.die(error), "Constructor adapter can only throw schema issues") + throw error + } + if (output !== CompilerRegistry.invalid && output !== InternalParser.missing) return output as T + } const result = (parser ??= entry.makeEffect)(input, parseOptions) return runSync(parserResult(result, input), "Constructor adapter can only throw schema issues") } diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts index 12261d5e297..67bd9b6ace2 100644 --- a/packages/effect/src/internal/schema/codegen.ts +++ b/packages/effect/src/internal/schema/codegen.ts @@ -96,6 +96,57 @@ const getEmission = ( const canEmit = (ast: SchemaAST.AST, depth = 0): boolean => getEmission(ast, depth) !== "unsupported" +const isMakeSafe = ( + ast: SchemaAST.AST, + depth = 0, + budget = { remaining: maxGeneratedNodes } +): boolean => { + if ( + --budget.remaining < 0 || + depth > maxGeneratedDepth || + ast.encoding !== undefined || + ast.context?.constructorDefault !== undefined || + SchemaAST.getConstructorDescriptor(ast) !== undefined + ) { + return false + } + switch (ast._tag) { + case "Null": + case "Undefined": + case "Void": + case "Never": + case "Any": + case "Unknown": + case "ObjectKeyword": + case "Enum": + case "UniqueSymbol": + case "Literal": + case "String": + case "Number": + case "Boolean": + case "Symbol": + case "BigInt": + return true + case "TemplateLiteral": + return ast.parts.every((part) => isMakeSafe(part, depth + 1, budget)) + case "Arrays": + return ast.elements.every((element) => isMakeSafe(element, depth + 1, budget)) && + ast.rest.every((element) => isMakeSafe(element, depth + 1, budget)) + case "Objects": + return ast.indexSignatures.length === 0 && + ast.propertySignatures.every((property) => isMakeSafe(property.type, depth + 1, budget)) + case "Union": + case "Declaration": + case "Suspend": + return false + } +} + +const shouldCompileMake = (ast: SchemaAST.AST): boolean => + (ast._tag === "Arrays" && ast.elements.length === 0 && ast.rest.length === 1 || + ast._tag === "Objects" && ast.propertySignatures.length > 0 && ast.indexSignatures.length === 0) && + isMakeSafe(ast) + type Emitter = { readonly statements: Array readonly helpers: Array @@ -590,7 +641,7 @@ export interface GeneratedOperation { readonly bindings: ReadonlyArray } -const emitOperation = (ast: SchemaAST.AST, operation: Operation): GeneratedOperation => { +const emitOperation = (ast: SchemaAST.AST, operation: Operation, path = "ast"): GeneratedOperation => { const emitter: Emitter = { statements: [], helpers: [], @@ -601,7 +652,7 @@ const emitOperation = (ast: SchemaAST.AST, operation: Operation): GeneratedOpera constantIndexes: new Map(), next: 0 } - const output = emit(ast, "i", emitter.statements, emitter, operation, "ast") + const output = emit(ast, "i", emitter.statements, emitter, operation, path) const bindings = { K: "failsChecks", T: "matchesTemplateLiteral", @@ -624,6 +675,21 @@ const renderOperation = (emitted: GeneratedOperation): string => /** @internal */ export function generate(ast: SchemaAST.AST, operation: DecoderOperation): string | undefined { + if (operation === "make") { + if (!shouldCompileMake(ast)) return undefined + if (ast._tag === "Objects") { + return `if(ast.propertySignatures.some(p=>resolve(p.type).source!==void 0))return;return ${ + renderOperation(emitOperation(ast, "validate")) + }` + } + if (ast._tag !== "Arrays") return undefined + const element = renderOperation(emitOperation(ast.rest[0], "validate", "ast.rest[0]")) + return `const e=resolve(ast.rest[0]),m=e.source===void 0?${element}:e.make;if(m===void 0)return;` + + "return function(i,o){if(i===R.missing)return R.missing;if(!Array.isArray(i))return R.invalid;" + + "const out=new Array(i.length);for(let x=0;x `R.${name}` const decoder = (ast: SchemaAST.AST): string | undefined => { if (!Codegen.shouldCompileParser(ast)) return undefined - const operations: ReadonlyArray = ["is", "validate", "decodeEffect", "makeEffect"] + const operations: ReadonlyArray = ["is", "validate", "make", "decodeEffect", "makeEffect"] return "{" + operations.flatMap((key) => { const source = Codegen.generate(ast, key) return source === undefined ? [] : [`get ${key}(){${source}}`] @@ -43,11 +43,13 @@ const decoder = (ast: SchemaAST.AST): string | undefined => { * Repeated ASTs and shared dependencies are installed once by identity. Fast * paths can still inline dependency code into multiple parent decoders. * An empty array generates a module whose installation does nothing. - * Construction uses independently lazy `makeEffect` operations. Struct and - * homogeneous Array loops are emitted as static functions; tuple, Record, Union, leaf, and Class - * constructors use the existing interpreter. Constructor defaults - * and Class source schemas are read from the supplied ASTs, not serialized or - * executed during generation. Construction never runs a validation-and-replay pass. + * Construction uses independently lazy `make` and `makeEffect` operations. + * Pure fixed Struct and homogeneous Array constructors can use `make` for a + * synchronous fast path; failures delegate to `makeEffect` for detailed issues. + * Tuple, Record, Union, leaf, and Class constructors use the existing + * interpreter. Constructor defaults and Class source schemas are read from the + * supplied ASTs, not serialized or executed during generation. `make` is + * omitted whenever replay could repeat observable construction work. * * **Gotchas** * diff --git a/packages/effect/src/unstable/schema/SchemaCompiler.ts b/packages/effect/src/unstable/schema/SchemaCompiler.ts index afb7363b01d..35303456fa8 100644 --- a/packages/effect/src/unstable/schema/SchemaCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaCompiler.ts @@ -7,7 +7,7 @@ * The cache associates each exact AST with an entry containing decoder * operations, never parsing results. The interpreter uses the same registry * with lazy `decodeEffect` and constructor fallback; JIT, AOT, and manual - * installations may supply `makeEffect` and the optional validation fast paths. + * installations may supply `makeEffect` and optional synchronous fast paths. * * @since 4.0.0 */ @@ -82,6 +82,29 @@ export interface Validate { (input: unknown, options: SchemaAST.ParseOptions): unknown | typeof invalid } +/** + * A compiled constructor that returns the constructed value without detailed + * diagnostic issues. + * + * **Details** + * + * This optional synchronous fast path lets construction return directly when + * it succeeds. Return {@link invalid} to let `makeEffect` produce the normal + * detailed result. Implementations must be deterministic and free of side + * effects because a failed construction can be repeated by `makeEffect`. + * + * Omit this operation when construction can execute defaults, Class + * constructors, transformations, middleware, or other effects that cannot be + * replayed safely. The operation must honor the supplied parse options and + * propagate {@link missing} when no value is produced. + * + * @category models + * @since 4.0.0 + */ +export interface Make { + (input: unknown, options: SchemaAST.ParseOptions): unknown | typeof invalid +} + /** * A compiled decoder that returns detailed Schema issues on failure. * @@ -105,8 +128,8 @@ export interface Decode { * * **Details** * - * `decodeEffect` is required for complete decoding and detailed failures. `validate` - * and `is` are optional optimizations, not requirements for an AST to be usable. + * `decodeEffect` is required for complete decoding and detailed failures. `validate`, + * `is`, and `make` are optional optimizations, not requirements for an AST to be usable. * The interpreter supplies only `decodeEffect` in this same format. * An optional `makeEffect` supplies complete node construction. Otherwise the * registry prepares and caches the interpreted constructor, never the decoder, @@ -122,9 +145,11 @@ export interface Decode { * `validate` output directly, without wrapping it in an intermediate Effect. * Each operation is resolved lazily on first use, so unused fast paths need * not be compiled. - * Construction calls `makeEffect` directly, without `is` or `validate`, so defaults - * and Class constructors are not replayed after a failure. Field/element defaults - * belong to the parent occurrence, not to the root node or a Union member. + * Synchronous construction tries `make` when present, returning its output on + * success or calling `makeEffect` after {@link invalid}. Compilers must omit + * `make` when replay could repeat observable construction work. Construction + * never uses `is` or `validate`. Field/element defaults belong to the parent + * occurrence, not to the root node or a Union member. * Runtime options apply to construction too; Union candidate selection preserves * the constructor's conservative handling of absent discriminants. * @@ -134,6 +159,7 @@ export interface Decode { export interface CompiledDecoder { readonly is?: Is | undefined readonly validate?: Validate | undefined + readonly make?: Make | undefined readonly decodeEffect: Decode /** * Constructs this node without replay, including Class construction and child @@ -160,8 +186,9 @@ export interface CompiledDecoder { * retain the supplied decoder as their receiver. The supplied object is not * mutated. JIT installation uses these same rules. * Replacement includes construction: omitting `makeEffect` in the replacement - * selects interpreted construction for new consumers, without merging the old - * operation into the new entry. Already captured constructors keep their entry. + * selects interpreted detailed construction for new consumers, without merging + * the old operation into the new entry. An installed `make` can still handle + * synchronous successes. Already captured constructors keep their entry. * * @category registry * @since 4.0.0 diff --git a/packages/effect/src/unstable/schema/SchemaJITCompiler.ts b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts index beb96330a4c..9a7b7ca65c2 100644 --- a/packages/effect/src/unstable/schema/SchemaJITCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts @@ -29,15 +29,17 @@ export const compiler: Registry.Compile = (ast, resolve) => { if (!supported) return undefined let decodeFailed = false let makeFailed = false + let makeEffectFailed = false const operation = (key: DecoderOperation) => { - if (!(key === "makeEffect" ? makeFailed : decodeFailed)) { + if (!(key === "make" ? makeFailed : key === "makeEffect" ? makeEffectFailed : decodeFailed)) { try { const source = generate(ast, key) if (source === undefined) return undefined return globalThis.Function("ast", "R", "resolve", source)(ast, runtime, resolve) } catch { // Only code generation and initialization are inside this catch. - if (key === "makeEffect") makeFailed = true + if (key === "make") makeFailed = true + else if (key === "makeEffect") makeEffectFailed = true else decodeFailed = true } } @@ -54,6 +56,9 @@ export const compiler: Registry.Compile = (ast, resolve) => { get validate() { return operation("validate") }, + get make() { + return operation("make") + }, get decodeEffect() { return operation("decodeEffect") }, diff --git a/packages/effect/test/schema/SchemaCompilerConstruction.test.ts b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts index 530f13405a7..35da2f6f144 100644 --- a/packages/effect/test/schema/SchemaCompilerConstruction.test.ts +++ b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts @@ -42,7 +42,7 @@ describe("Schema compiler construction", { concurrent: false }, () => { assert.strictEqual(forced, 1) }) - it("does not compile validators when only construction is used", () => { + it("compiles only the raw constructor when construction succeeds", () => { const schema = Schema.Struct({ a: Schema.String }) const emit = vi.spyOn(Codegen, "generate") try { @@ -52,6 +52,8 @@ describe("Schema compiler construction", { concurrent: false }, () => { emit.mock.calls.filter(([, operation]) => operation === "validate" || operation === "is").length, 0 ) + assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "make").length, 1) + assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "makeEffect").length, 0) } finally { emit.mockRestore() } @@ -65,7 +67,8 @@ describe("Schema compiler construction", { concurrent: false }, () => { assert.deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ a: "a" }), { a: "a" }) assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "makeEffect").length, 0) assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) - assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "makeEffect").length, 1) + assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "make").length, 1) + assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "makeEffect").length, 0) } finally { emit.mockRestore() } @@ -136,7 +139,7 @@ describe("Schema compiler construction", { concurrent: false }, () => { SchemaJITCompiler.enable(schema.ast) const generate = Codegen.generate const failed = vi.spyOn(Codegen, "generate").mockImplementation((ast, key) => { - if (ast === schema.ast && key === (operation === "make" ? "makeEffect" : "validate")) { + if (ast === schema.ast && key === (operation === "make" ? "make" : "validate")) { throw new Error("compile failed") } return generate(ast, key) @@ -148,7 +151,7 @@ describe("Schema compiler construction", { concurrent: false }, () => { assert.deepStrictEqual(second({ a: "a" }), { a: "a" }) assert( failed.mock.calls.some(([ast, key]) => - ast === schema.ast && key === (operation === "make" ? "validate" : "makeEffect") + ast === schema.ast && key === (operation === "make" ? "validate" : "make") ) ) } finally { @@ -186,6 +189,76 @@ describe("Schema compiler construction", { concurrent: false }, () => { assert.strictEqual(calls, 2) }) + it("uses an installed synchronous constructor without resolving makeEffect", () => { + const schema = Schema.Struct({ a: Schema.String }) + let calls = 0 + SchemaCompiler.set(schema.ast, { + decodeEffect: Effect.succeed, + make: (input, options) => { + calls++ + assert.strictEqual(options, SchemaAST.defaultParseOptions) + return { a: `${(input as { readonly a: string }).a}!` } + }, + get makeEffect(): SchemaCompiler.Decode { + throw new Error("unused detailed constructor") + } + }) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a!" }) + assert.strictEqual(calls, 1) + }) + + it("falls back to makeEffect after an installed synchronous constructor fails", () => { + const schema = Schema.Struct({ a: Schema.String }) + let fast = 0 + let detailed = 0 + SchemaCompiler.set(schema.ast, { + decodeEffect: Effect.succeed, + make: () => { + fast++ + return SchemaCompiler.invalid + }, + makeEffect: (input) => { + detailed++ + return Effect.succeed(input) + } + }) + assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) + assert.strictEqual(fast, 1) + assert.strictEqual(detailed, 1) + }) + + it("does not return a missing result from an installed synchronous constructor", () => { + const schema = Schema.Struct({ a: Schema.String }) + SchemaCompiler.set(schema.ast, { + decodeEffect: Effect.succeed, + make: () => SchemaCompiler.missing, + makeEffect: () => Effect.succeed(SchemaCompiler.missing) + }) + assert.throws(() => SchemaParser.make(schema)({ a: "a" }), /Schema validation failed/) + }) + + it("composes an installed synchronous child constructor in a compiled Array", () => { + const child = Schema.Struct({ a: Schema.String }) + SchemaCompiler.set(child.ast, { + decodeEffect: Effect.succeed, + make: (input) => ({ a: `${(input as { readonly a: string }).a}!` }), + makeEffect: () => { + throw new Error("unused detailed child constructor") + } + }) + const schema = Schema.Array(child) + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(SchemaParser.make(schema)([{ a: "a" }, { a: "b" }]), [{ a: "a!" }, { a: "b!" }]) + }) + + it("falls back to detailed construction for an invalid compiled Array", () => { + const schema = Schema.Array(Schema.Struct({ a: Schema.String })) + SchemaJITCompiler.enable(schema.ast) + const make = SchemaParser.make(schema) + assert.deepStrictEqual(make([{ a: "a" }]), [{ a: "a" }]) + assert.throws(() => make([{ a: 1 } as never]), /Schema validation failed/) + }) + it("caches interpreted construction when an installed bundle omits it", () => { const schema = Schema.Struct({ a: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(1))) }) SchemaCompiler.set(schema.ast, { diff --git a/packages/effect/typetest/schema/SchemaCompiler.tst.ts b/packages/effect/typetest/schema/SchemaCompiler.tst.ts index e271b4790ba..05d178ad790 100644 --- a/packages/effect/typetest/schema/SchemaCompiler.tst.ts +++ b/packages/effect/typetest/schema/SchemaCompiler.tst.ts @@ -7,6 +7,7 @@ describe("SchemaCompiler", () => { const decoder = { is: (input, _options) => typeof input === "string", validate: (input, _options) => typeof input === "string" ? input : SchemaCompiler.invalid, + make: (input, _options) => typeof input === "string" ? input : SchemaCompiler.invalid, decodeEffect: (input, _options) => Effect.succeed(input), makeEffect: (input, _options) => Effect.succeed(input) } satisfies SchemaCompiler.CompiledDecoder From f9fe96636e907674d430a8abce70e4cb4a2015ae Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 16 Sep 2026 08:38:49 +0200 Subject: [PATCH 16/33] Rename compiled schema validation to decode --- .changeset/add-schema-compilers.md | 6 +-- packages/effect/SCHEMA.md | 6 +-- packages/effect/src/SchemaParser.ts | 6 +-- .../effect/src/internal/schema/codegen.ts | 38 +++++++++--------- .../src/internal/schema/compilerRegistry.ts | 20 +++++----- .../src/unstable/schema/SchemaAOTCompiler.ts | 2 +- .../src/unstable/schema/SchemaCompiler.ts | 40 ++++++++++++------- .../unstable/schema/SchemaCompiler/runtime.ts | 14 +++---- .../src/unstable/schema/SchemaJITCompiler.ts | 4 +- .../test/schema/SchemaCompilerApi.test.ts | 18 ++++----- .../schema/SchemaCompilerConstruction.test.ts | 14 +++---- .../schema/SchemaCompilerRegression.test.ts | 24 +++++------ .../typetest/schema/SchemaCompiler.tst.ts | 2 +- 13 files changed, 102 insertions(+), 92 deletions(-) diff --git a/.changeset/add-schema-compilers.md b/.changeset/add-schema-compilers.md index 419142164a6..eb8c60e7285 100644 --- a/.changeset/add-schema-compilers.md +++ b/.changeset/add-schema-compilers.md @@ -10,9 +10,9 @@ requiring dynamic function construction. The new `effect/unstable/schema/SchemaAOTCompiler/Build` entrypoint discovers direct Schema exports from explicit module loaders and writes a self-installing AOT module through Effect's `FileSystem` and `Path` services. Compiled decoders can -provide an optional synchronous `make` operation for construction-safe schemas; -normal `SchemaParser.make` calls consume it transparently and retain -`makeEffect` as the detailed fallback. +provide optional synchronous `decode` and `make` operations; normal +`SchemaParser` calls consume them transparently and retain `decodeEffect` and +`makeEffect` as the detailed fallbacks. ### Breaking changes diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index e93fe97aa1f..9b19328ac6d 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -128,15 +128,15 @@ stores functions, never parsing results. The interpreter, JIT, AOT and | Operation | Result | Purpose | | -------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `decodeEffect` | `Effect` with output or detailed issues | Required complete decoding, including asynchronous work and transformations. | -| `validate` | Output or `SchemaCompiler.invalid` | Optional synchronous fast path without detailed diagnostics. | +| `decode` | Output or `SchemaCompiler.invalid` | Optional synchronous decoding fast path without detailed diagnostics. | | `is` | Boolean | Optional validation without constructing output. | | `make` | Output or `SchemaCompiler.invalid` | Optional synchronous construction fast path without detailed diagnostics. | | `makeEffect` | `Effect` with a constructed value or issues | Optional specialized construction. The registry caches the interpreted constructor when absent. | -Decoding tries `validate` when available. Success provides the output directly; +Decoding tries `decode` when available. Success provides the output directly; failure calls `decodeEffect` for diagnostics. The diagnostic traversal uses child decoders directly, without restarting their validation fast paths. A boolean -guard prefers `is`, otherwise it uses ordinary decoding (including `validate` +guard prefers `is`, otherwise it uses ordinary decoding (including `decode` when available). An `invalid` result needs that diagnostic fallback because the marker is also a possible input value. Composite checks can require stripped, reconstructed values, so `is` is omitted when it cannot diff --git a/packages/effect/src/SchemaParser.ts b/packages/effect/src/SchemaParser.ts index c293a657832..2c955e9d78e 100644 --- a/packages/effect/src/SchemaParser.ts +++ b/packages/effect/src/SchemaParser.ts @@ -1056,9 +1056,9 @@ function makeSyncEntry( entry: CompilerRegistry.Entry, options?: SchemaAST.ParseOptions ): (input: unknown, options?: SchemaAST.ParseOptions) => T { - const validate = entry.validate + const decode = entry.decode let detailed: ((input: unknown, options?: SchemaAST.ParseOptions) => T) | undefined - const run = validate === undefined + const run = decode === undefined ? (input: unknown, parseOptions = SchemaAST.defaultParseOptions): T => (detailed ??= makeDetailedSync(entry))(input, parseOptions) : (input: unknown, parseOptions = SchemaAST.defaultParseOptions): T => { @@ -1067,7 +1067,7 @@ function makeSyncEntry( } let output: unknown try { - output = validate(input, parseOptions) + output = decode(input, parseOptions) } catch (error) { InternalSchemaCause.getSchemaIssueOrThrow(Cause.die(error), "Sync adapter can only throw schema issues") throw error diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts index 67bd9b6ace2..c9063b3b17f 100644 --- a/packages/effect/src/internal/schema/codegen.ts +++ b/packages/effect/src/internal/schema/codegen.ts @@ -13,10 +13,10 @@ const maxGeneratedDepth = 256 /** @internal */ export const maxGeneratedNodes = 2048 -type Emission = "unsupported" | "validate" | "is" -type Operation = "validate" | "is" +type Emission = "unsupported" | "decode" | "is" +type Operation = "decode" | "is" -const failureExpression = (operation: Operation): string => operation === "validate" ? "I" : "false" +const failureExpression = (operation: Operation): string => operation === "decode" ? "I" : "false" /** @internal */ const getEmission = ( @@ -55,38 +55,38 @@ const getEmission = ( for (const element of ast.elements) { const emission = getEmission(element, depth + 1, false, budget) if (emission === "unsupported") return "unsupported" - if (emission === "validate") isOutputFree = false + if (emission === "decode") isOutputFree = false } for (const element of ast.rest) { const emission = getEmission(element, depth + 1, false, budget) if (emission === "unsupported") return "unsupported" - if (emission === "validate") isOutputFree = false + if (emission === "decode") isOutputFree = false } - return isOutputFree ? "is" : "validate" + return isOutputFree ? "is" : "decode" } case "Objects": { let isOutputFree = ast.checks === undefined for (const property of ast.propertySignatures) { const emission = getEmission(property.type, depth + 1, false, budget) if (emission === "unsupported") return "unsupported" - if (emission === "validate") isOutputFree = false + if (emission === "decode") isOutputFree = false } for (const signature of ast.indexSignatures) { const key = getEmission(SchemaAST.parameterFromPropertyKey(signature.parameter), depth + 1, false, budget) const value = getEmission(signature.type, depth + 1, false, budget) if (key === "unsupported" || value === "unsupported") return "unsupported" - if (key === "validate" || value === "validate") isOutputFree = false + if (key === "decode" || value === "decode") isOutputFree = false } - return isOutputFree ? "is" : "validate" + return isOutputFree ? "is" : "decode" } case "Union": { let isOutputFree = ast.checks === undefined for (const type of ast.types) { const emission = getEmission(type, depth + 1, false, budget) if (emission === "unsupported") return "unsupported" - if (emission === "validate") isOutputFree = false + if (emission === "decode") isOutputFree = false } - return isOutputFree ? "is" : "validate" + return isOutputFree ? "is" : "decode" } case "Declaration": case "Suspend": @@ -271,13 +271,13 @@ function emit( if (encodingChecks !== undefined) { statements.push(`if(K(${astConstant},${input},1,o))return ${invalid}`) } - if (ast.checks === undefined) return operation === "validate" ? output : "true" + if (ast.checks === undefined) return operation === "decode" ? output : "true" const checked = variable(emitter) statements.push( `const ${checked}=${output}`, `if(K(${astConstant},${checked},0,o))return ${invalid}` ) - return operation === "validate" ? checked : "true" + return operation === "decode" ? checked : "true" } const emitDecoderHelper = (ast: SchemaAST.AST, emitter: Emitter, operation: Operation, path: string): string => { @@ -372,7 +372,7 @@ const emitBase = ( operation: Operation, path: string ): string => { - const needsValue = operation === "validate" + const needsValue = operation === "decode" const invalid = failureExpression(operation) switch (ast._tag) { case "Null": @@ -661,7 +661,7 @@ const emitOperation = (ast: SchemaAST.AST, operation: Operation, path = "ast"): D: "defaultParseOptions", E: "hasExcessProperties" } as const - const source = `"use strict";${runtimeBindings(operation === "validate" ? { I: "invalid", ...bindings } : bindings)}${ + const source = `"use strict";${runtimeBindings(operation === "decode" ? { I: "invalid", ...bindings } : bindings)}${ emitter.helpers.join(";") };${emitter.initializers.join(";")};return function(i,o){${emitter.statements.join(";")};return ${output}}` return { source, bindings: emitter.bindings } @@ -679,18 +679,18 @@ export function generate(ast: SchemaAST.AST, operation: DecoderOperation): strin if (!shouldCompileMake(ast)) return undefined if (ast._tag === "Objects") { return `if(ast.propertySignatures.some(p=>resolve(p.type).source!==void 0))return;return ${ - renderOperation(emitOperation(ast, "validate")) + renderOperation(emitOperation(ast, "decode")) }` } if (ast._tag !== "Arrays") return undefined - const element = renderOperation(emitOperation(ast.rest[0], "validate", "ast.rest[0]")) + const element = renderOperation(emitOperation(ast.rest[0], "decode", "ast.rest[0]")) return `const e=resolve(ast.rest[0]),m=e.source===void 0?${element}:e.make;if(m===void 0)return;` + "return function(i,o){if(i===R.missing)return R.missing;if(!Array.isArray(i))return R.invalid;" + "const out=new Array(i.length);for(let x=0;x${renderOperation(emitOperation(ast, "validate"))}` + ? `()=>${renderOperation(emitOperation(ast, "decode"))}` : "undefined" return `return R.decode(ast,resolve,${object},${getEmission(ast) !== "unsupported"},${checkpoint},${array})` } diff --git a/packages/effect/src/internal/schema/compilerRegistry.ts b/packages/effect/src/internal/schema/compilerRegistry.ts index 4f7187ec7bd..698309e2815 100644 --- a/packages/effect/src/internal/schema/compilerRegistry.ts +++ b/packages/effect/src/internal/schema/compilerRegistry.ts @@ -1,7 +1,7 @@ import * as Effect from "../../Effect.ts" import type * as SchemaAST from "../../SchemaAST.ts" import type { Parser } from "../../SchemaParser.ts" -import type { CompiledDecoder, Is, Make, Validate } from "../../unstable/schema/SchemaCompiler.ts" +import type { CompiledDecoder, Decode, Is, Make } from "../../unstable/schema/SchemaCompiler.ts" import * as Interpreter from "./interpreter.ts" import * as InternalParser from "./parser.ts" @@ -40,7 +40,7 @@ export interface Entry { readonly source?: CompiledDecoder | undefined readonly resolve?: Resolve | undefined readonly is?: Is | undefined - readonly validate?: Validate | undefined + readonly decode?: Decode | undefined readonly make?: Make | undefined readonly decodeEffect: Parser readonly parser: Parser @@ -88,8 +88,8 @@ class CompilerEntry extends InterpretedEntry { return this.save("is", this.source?.is) } - get validate(): Validate | undefined { - return this.save("validate", this.source?.validate) + get decode(): Decode | undefined { + return this.save("decode", this.source?.decode) } get make(): Make | undefined { @@ -104,10 +104,10 @@ class CompilerEntry extends InterpretedEntry { } override get parser(): Parser { - const validate = this.validate - return validate === undefined + const decode = this.decode + return decode === undefined ? this.decodeEffect - : this.save("parser", withValidation(validate, () => this.decodeEffect)) + : this.save("parser", withDecode(decode, () => this.decodeEffect)) } override get makeEffect(): Parser { @@ -126,18 +126,18 @@ class CompilerEntry extends InterpretedEntry { } /** @internal */ -export function withValidation(validate: Validate, decode: () => Parser): Parser { +export function withDecode(fastDecode: Decode, decodeEffect: () => Parser): Parser { let detailed: Parser | undefined return (input, options) => { if (input !== InternalParser.missing) { try { - const value = validate(input, options) + const value = fastDecode(input, options) if (value !== invalid) return value === input ? InternalParser.sameExit : InternalParser.succeed(value) } catch (error) { return Effect.die(error) } } - return (detailed ??= decode())(input, options) + return (detailed ??= decodeEffect())(input, options) } } diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts index 3964f56fd2d..0dae4b30e2d 100644 --- a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts @@ -13,7 +13,7 @@ const helper = (name: keyof typeof runtime): string => `R.${name}` const decoder = (ast: SchemaAST.AST): string | undefined => { if (!Codegen.shouldCompileParser(ast)) return undefined - const operations: ReadonlyArray = ["is", "validate", "make", "decodeEffect", "makeEffect"] + const operations: ReadonlyArray = ["is", "decode", "make", "decodeEffect", "makeEffect"] return "{" + operations.flatMap((key) => { const source = Codegen.generate(ast, key) return source === undefined ? [] : [`get ${key}(){${source}}`] diff --git a/packages/effect/src/unstable/schema/SchemaCompiler.ts b/packages/effect/src/unstable/schema/SchemaCompiler.ts index 35303456fa8..b7f91131a46 100644 --- a/packages/effect/src/unstable/schema/SchemaCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaCompiler.ts @@ -18,7 +18,7 @@ import type * as SchemaAST from "../../SchemaAST.ts" import type * as SchemaIssue from "../../SchemaIssue.ts" /** - * The result returned by {@link Validate} when validation fails. + * The result returned by {@link Decode} or {@link Make} when the fast path fails. * * @category symbols * @since 4.0.0 @@ -44,7 +44,7 @@ export const missing = InternalParser.missing * This optional fast path avoids constructing output. Omit it when validation * requires reconstructed values, such as a Struct check that must see the * object after excess properties are removed. Type guards then use ordinary - * decoding, including `validate` and its diagnostic fallback when available. + * decoding, including `decode` and its diagnostic fallback when available. * It must honor the supplied parse options; public `Schema.is` and * `SchemaParser.is` use the defaults. * @@ -56,7 +56,7 @@ export interface Is { } /** - * A compiled validator that returns the decoded value without constructing + * A compiled decoder that returns the decoded value without constructing * diagnostic issues. * * **Details** @@ -78,7 +78,7 @@ export interface Is { * @category models * @since 4.0.0 */ -export interface Validate { +export interface Decode { (input: unknown, options: SchemaAST.ParseOptions): unknown | typeof invalid } @@ -113,13 +113,23 @@ export interface Make { * This required operation implements complete decoding for its AST, including * transformations, middleware, and asynchronous work when present. It makes * every parser API usable without optional fast paths and provides diagnostics - * after `validate` returns `invalid`. The implementation can also be interpreted; + * after `decode` returns `invalid`. The implementation can also be interpreted; * invoking `decodeEffect` does not imply a switch from compiled to interpreted parsing. * * @category models * @since 4.0.0 */ -export interface Decode { +export interface DecodeEffect { + (input: unknown, options: SchemaAST.ParseOptions): Effect.Effect +} + +/** + * A complete constructor that returns detailed Schema issues on failure. + * + * @category models + * @since 4.0.0 + */ +export interface MakeEffect { (input: unknown, options: SchemaAST.ParseOptions): Effect.Effect } @@ -128,7 +138,7 @@ export interface Decode { * * **Details** * - * `decodeEffect` is required for complete decoding and detailed failures. `validate`, + * `decodeEffect` is required for complete decoding and detailed failures. `decode`, * `is`, and `make` are optional optimizations, not requirements for an AST to be usable. * The interpreter supplies only `decodeEffect` in this same format. * An optional `makeEffect` supplies complete node construction. Otherwise the @@ -136,19 +146,19 @@ export interface Decode { * for that operation. Public makers resolve the schema's exact type-side AST. * * The registry wraps these operations in an internal entry. - * Decoding tries `validate` when present, returning its output on success or - * calling `decodeEffect` after `invalid`. Without `validate`, or for the {@link missing} + * Decoding tries `decode` when present, returning its output on success or + * calling `decodeEffect` after `invalid`. Without `decode`, or for the {@link missing} * sentinel, it calls `decodeEffect` directly. Type guards prefer `is`; otherwise - * they use ordinary decoding with the same validation/diagnostic fallback. + * they use ordinary decoding with the same fast-path/diagnostic fallback. * A boolean `false` from `is` needs no diagnostic replay. * Synchronous decoding and encoding share an adapter that returns successful - * `validate` output directly, without wrapping it in an intermediate Effect. + * `decode` output directly, without wrapping it in an intermediate Effect. * Each operation is resolved lazily on first use, so unused fast paths need * not be compiled. * Synchronous construction tries `make` when present, returning its output on * success or calling `makeEffect` after {@link invalid}. Compilers must omit * `make` when replay could repeat observable construction work. Construction - * never uses `is` or `validate`. Field/element defaults belong to the parent + * never uses `is` or `decode`. Field/element defaults belong to the parent * occurrence, not to the root node or a Union member. * Runtime options apply to construction too; Union candidate selection preserves * the constructor's conservative handling of absent discriminants. @@ -158,9 +168,9 @@ export interface Decode { */ export interface CompiledDecoder { readonly is?: Is | undefined - readonly validate?: Validate | undefined + readonly decode?: Decode | undefined readonly make?: Make | undefined - readonly decodeEffect: Decode + readonly decodeEffect: DecodeEffect /** * Constructs this node without replay, including Class construction and child * defaults. Omit it to use the lazy interpreted constructor. This operation @@ -168,7 +178,7 @@ export interface CompiledDecoder { * Propagate `missing` as a success when no value is produced; the parent handles * optional omission or missing-key issues. A present `undefined` is not `missing`. */ - readonly makeEffect?: Decode | undefined + readonly makeEffect?: MakeEffect | undefined } /** diff --git a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts index 85bfe6e97c9..ca203698f7e 100644 --- a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts +++ b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts @@ -7,13 +7,13 @@ */ import * as Effect from "../../../Effect.ts" import { effectIsExit, resolveConcurrency } from "../../../internal/effect.ts" -import { lazyParser, type Resolve, resolve, set, withValidation } from "../../../internal/schema/compilerRegistry.ts" +import { lazyParser, type Resolve, resolve, set, withDecode } from "../../../internal/schema/compilerRegistry.ts" import * as Interpreter from "../../../internal/schema/interpreter.ts" import * as InternalParser from "../../../internal/schema/parser.ts" import * as SchemaAST from "../../../SchemaAST.ts" import * as SchemaIssue from "../../../SchemaIssue.ts" import type { Compiler } from "../../../SchemaParser.ts" -import { invalid, type Validate } from "../SchemaCompiler.ts" +import { type Decode, invalid } from "../SchemaCompiler.ts" type SchemaIssueParser = ReturnType type ObjectParserState = Parameters[0] @@ -134,23 +134,23 @@ const decode = ( resolve: Resolve, generate?: GenerateObject, detailed = false, - makeValidate?: () => Validate, + makeDecode?: () => Decode, generateArray?: GenerateArray ): SchemaIssueParser => { const child = (ast: SchemaAST.AST) => lazyParser(resolve, ast, detailed ? "decodeEffect" : "parser") - const localChild = makeValidate === undefined + const localChild = makeDecode === undefined ? child : (ast: SchemaAST.AST) => lazyParser(resolve, ast, "decodeEffect") const base = ast._tag === "Objects" && generate !== undefined ? makeObjectBase(ast, localChild, localChild, generate) : ast._tag === "Arrays" && generateArray !== undefined ? makeArrayBase(ast, localChild, localChild, generateArray) - : makeValidate !== undefined + : makeDecode !== undefined ? ast.getParser(localChild) : undefined - const specialize = makeValidate === undefined ? undefined : (local: SchemaIssueParser): SchemaIssueParser => { + const specialize = makeDecode === undefined ? undefined : (local: SchemaIssueParser): SchemaIssueParser => { try { - return withValidation(makeValidate(), () => local) + return withDecode(makeDecode(), () => local) } catch { // Initialization failure selects the local interpreter, without parsing again. return local diff --git a/packages/effect/src/unstable/schema/SchemaJITCompiler.ts b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts index 9a7b7ca65c2..6de143d90a1 100644 --- a/packages/effect/src/unstable/schema/SchemaJITCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts @@ -53,8 +53,8 @@ export const compiler: Registry.Compile = (ast, resolve) => { get is() { return operation("is") }, - get validate() { - return operation("validate") + get decode() { + return operation("decode") }, get make() { return operation("make") diff --git a/packages/effect/test/schema/SchemaCompilerApi.test.ts b/packages/effect/test/schema/SchemaCompilerApi.test.ts index f9ea4450432..66bc4a6c981 100644 --- a/packages/effect/test/schema/SchemaCompilerApi.test.ts +++ b/packages/effect/test/schema/SchemaCompilerApi.test.ts @@ -55,7 +55,7 @@ describe("SchemaCompiler", () => { let decodes = 0 SchemaCompiler.set(schema.ast, { is: () => true, - validate: (_input, options) => + decode: (_input, options) => options.reportInput === true ? { value: "compiled" } : SchemaCompiler.invalid, @@ -82,7 +82,7 @@ describe("SchemaCompiler", () => { strictEqual(options, SchemaAST.defaultParseOptions) return (input as { readonly value?: unknown }).value === "accepted" }, - validate: (input) => { + decode: (input) => { validations++ return input }, @@ -108,7 +108,7 @@ describe("SchemaCompiler", () => { deepStrictEqual(decode(input, firstOptions), input) SchemaCompiler.set(schema.ast, { - validate: () => ({ value: "replacement" }), + decode: () => ({ value: "replacement" }), decodeEffect: () => Effect.succeed({ value: "replacement" }) }) @@ -123,7 +123,7 @@ describe("SchemaCompiler", () => { const child = Schema.String.annotate({ title: "lazy child" }) let reads = 0 SchemaCompiler.set(child.ast, { - get validate() { + get decode() { reads++ return undefined }, @@ -145,7 +145,7 @@ describe("SchemaCompiler", () => { it("uses an installed child decoder from an interpreted Array", () => { const child = Schema.String.annotate({ title: "installed array child" }) SchemaCompiler.set(child.ast, { - validate: (input) => typeof input === "string" ? `${input}!` : SchemaCompiler.invalid, + decode: (input) => typeof input === "string" ? `${input}!` : SchemaCompiler.invalid, decodeEffect: (input) => Effect.succeed(`${input}!`) }) @@ -161,7 +161,7 @@ describe("SchemaCompiler", () => { const value = schema.ast.propertySignatures[0].type let sawMissing = false SchemaCompiler.set(value, { - validate: (input) => typeof input === "string" ? input : SchemaCompiler.invalid, + decode: (input) => typeof input === "string" ? input : SchemaCompiler.invalid, decodeEffect: (input) => { sawMissing = input === SchemaCompiler.missing return Effect.succeed(input) @@ -175,7 +175,7 @@ describe("SchemaCompiler", () => { it("installs encoders on the flipped AST", () => { const schema = Schema.FiniteFromString SchemaCompiler.set(SchemaAST.flip(schema.ast), { - validate: () => "aot", + decode: () => "aot", decodeEffect: () => Effect.succeed("detailed") }) @@ -302,7 +302,7 @@ describe("SchemaJITCompiler", () => { const child = Schema.Struct({ value: Schema.String }) let reads = 0 SchemaCompiler.set(child.ast, { - get validate() { + get decode() { reads++ return undefined }, @@ -324,7 +324,7 @@ describe("SchemaJITCompiler", () => { it("preserves an installed decoder when dynamic code generation is unavailable", () => { const schema = Schema.Struct({ value: Schema.String }) SchemaCompiler.set(schema.ast, { - validate: () => ({ value: "installed" }), + decode: () => ({ value: "installed" }), decodeEffect: () => Effect.succeed({ value: "installed" }) }) const Function = globalThis.Function diff --git a/packages/effect/test/schema/SchemaCompilerConstruction.test.ts b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts index 35da2f6f144..52af3d0304a 100644 --- a/packages/effect/test/schema/SchemaCompilerConstruction.test.ts +++ b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts @@ -49,7 +49,7 @@ describe("Schema compiler construction", { concurrent: false }, () => { SchemaJITCompiler.enable(schema.ast) assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) assert.strictEqual( - emit.mock.calls.filter(([, operation]) => operation === "validate" || operation === "is").length, + emit.mock.calls.filter(([, operation]) => operation === "decode" || operation === "is").length, 0 ) assert.strictEqual(emit.mock.calls.filter(([, operation]) => operation === "make").length, 1) @@ -139,7 +139,7 @@ describe("Schema compiler construction", { concurrent: false }, () => { SchemaJITCompiler.enable(schema.ast) const generate = Codegen.generate const failed = vi.spyOn(Codegen, "generate").mockImplementation((ast, key) => { - if (ast === schema.ast && key === (operation === "make" ? "make" : "validate")) { + if (ast === schema.ast && key === (operation === "make" ? "make" : "decode")) { throw new Error("compile failed") } return generate(ast, key) @@ -151,7 +151,7 @@ describe("Schema compiler construction", { concurrent: false }, () => { assert.deepStrictEqual(second({ a: "a" }), { a: "a" }) assert( failed.mock.calls.some(([ast, key]) => - ast === schema.ast && key === (operation === "make" ? "validate" : "make") + ast === schema.ast && key === (operation === "make" ? "decode" : "make") ) ) } finally { @@ -164,11 +164,11 @@ describe("Schema compiler construction", { concurrent: false }, () => { let reads = 0 let calls = 0 SchemaCompiler.set(schema.ast, { - get decodeEffect(): SchemaCompiler.Decode { + get decodeEffect(): SchemaCompiler.DecodeEffect { throw new Error("unused decoder") }, - get validate(): SchemaCompiler.Validate { - throw new Error("unused validator") + get decode(): SchemaCompiler.Decode { + throw new Error("unused fast decoder") }, get is(): SchemaCompiler.Is { throw new Error("unused guard") @@ -199,7 +199,7 @@ describe("Schema compiler construction", { concurrent: false }, () => { assert.strictEqual(options, SchemaAST.defaultParseOptions) return { a: `${(input as { readonly a: string }).a}!` } }, - get makeEffect(): SchemaCompiler.Decode { + get makeEffect(): SchemaCompiler.MakeEffect { throw new Error("unused detailed constructor") } }) diff --git a/packages/effect/test/schema/SchemaCompilerRegression.test.ts b/packages/effect/test/schema/SchemaCompilerRegression.test.ts index 5defd1caf15..a76106f0e41 100644 --- a/packages/effect/test/schema/SchemaCompilerRegression.test.ts +++ b/packages/effect/test/schema/SchemaCompilerRegression.test.ts @@ -195,18 +195,18 @@ describe("compiler regression contracts", () => { seen.push(options) return true } - const validate: SchemaCompiler.Validate = (input, options) => { + const decode: SchemaCompiler.Decode = (input, options) => { seen.push(options) return input } - for (const operation of [is, validate]) { + for (const operation of [is, decode]) { Object.defineProperty(operation, "default", { get() { throw new Error("Not part of the compiled decoder contract") } }) } - SchemaCompiler.set(schema.ast, { is, validate, decodeEffect: Effect.succeed }) + SchemaCompiler.set(schema.ast, { is, decode, decodeEffect: Effect.succeed }) const input = { value: "a" } strictEqual(SchemaParser.is(schema)(input), true) strictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) @@ -357,14 +357,14 @@ describe("compiler regression contracts", () => { reads.push("is") return (_input: unknown) => true }, - get validate() { + get decode() { strictEqual(this, decoder) - reads.push("validate") + reads.push("decode") return (input: unknown) => input }, get decodeEffect() { strictEqual(this, decoder) - reads.push("decode") + reads.push("decodeEffect") return Effect.succeed } } @@ -376,7 +376,7 @@ describe("compiler regression contracts", () => { const input = { value: "a" } strictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) strictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) - deepStrictEqual(reads, ["is", "validate"]) + deepStrictEqual(reads, ["is", "decode"]) }) it("memoizes an absent optional operation", () => { @@ -387,7 +387,7 @@ describe("compiler regression contracts", () => { reads++ return undefined }, - validate: (input) => input, + decode: (input) => input, decodeEffect: Effect.succeed }) strictEqual(SchemaParser.is(schema)({ value: "a" }), true) @@ -399,14 +399,14 @@ describe("compiler regression contracts", () => { const schema = Schema.Struct({ value: Schema.String }) const reads: Array = [] const decoder = Object.freeze({ - get validate() { + get decode() { strictEqual(this, decoder) - reads.push("validate") + reads.push("decode") return () => SchemaCompiler.invalid }, get decodeEffect() { strictEqual(this, decoder) - reads.push("decode") + reads.push("decodeEffect") return Effect.succeed } }) @@ -415,7 +415,7 @@ describe("compiler regression contracts", () => { strictEqual(SchemaParser.decodeUnknownSync(schema)(input), input) deepStrictEqual(SchemaParser.decodeUnknownResult(schema)(input), Result.succeed(input)) strictEqual(SchemaParser.decodeUnknownSync(schema, { reportInput: true })(input), input) - deepStrictEqual(reads, ["validate", "decode"]) + deepStrictEqual(reads, ["decode", "decodeEffect"]) }) it("does not restart validation inside the detailed decoder", () => { diff --git a/packages/effect/typetest/schema/SchemaCompiler.tst.ts b/packages/effect/typetest/schema/SchemaCompiler.tst.ts index 05d178ad790..55035ab43e0 100644 --- a/packages/effect/typetest/schema/SchemaCompiler.tst.ts +++ b/packages/effect/typetest/schema/SchemaCompiler.tst.ts @@ -6,7 +6,7 @@ describe("SchemaCompiler", () => { it("set", () => { const decoder = { is: (input, _options) => typeof input === "string", - validate: (input, _options) => typeof input === "string" ? input : SchemaCompiler.invalid, + decode: (input, _options) => typeof input === "string" ? input : SchemaCompiler.invalid, make: (input, _options) => typeof input === "string" ? input : SchemaCompiler.invalid, decodeEffect: (input, _options) => Effect.succeed(input), makeEffect: (input, _options) => Effect.succeed(input) From 59ca0076c61918bd58386ea62b240fd28456345f Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 16 Sep 2026 12:16:04 +0200 Subject: [PATCH 17/33] Clarify constructor field compilation --- packages/effect/src/SchemaAST.ts | 22 +++++++++---------- .../effect/src/internal/schema/interpreter.ts | 8 ++++--- .../unstable/schema/SchemaCompiler/runtime.ts | 12 +++++----- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/packages/effect/src/SchemaAST.ts b/packages/effect/src/SchemaAST.ts index 89551bd1c14..baca4210828 100644 --- a/packages/effect/src/SchemaAST.ts +++ b/packages/effect/src/SchemaAST.ts @@ -2224,7 +2224,7 @@ export interface Arrays extends ASTNode { getParser( compile: SchemaParser.Compiler, - compileConstructorDefault?: SchemaParser.Compiler + compileField?: SchemaParser.Compiler ): SchemaParser.Parser /** @internal */ @@ -2297,7 +2297,7 @@ export const Arrays: new( /** @internal */ getParser( compile: SchemaParser.Compiler, - compileConstructorDefault: SchemaParser.Compiler = compile + compileField: SchemaParser.Compiler = compile ): SchemaParser.Parser { // oxlint-disable-next-line @typescript-eslint/no-this-alias const ast = this @@ -2329,8 +2329,8 @@ export const Arrays: new( return yield* Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) } if (!elements) { - elements = ast.elements.map((ast) => ({ ast, parser: compileConstructorDefault(ast) })) - rest = ast.rest.map((ast) => ({ ast, parser: compileConstructorDefault(ast) })) + elements = ast.elements.map((ast) => ({ ast, parser: compileField(ast) })) + rest = ast.rest.map((ast) => ({ ast, parser: compileField(ast) })) } const len = input.length @@ -2722,7 +2722,7 @@ export interface Objects extends ASTNode { getParser( compile: SchemaParser.Compiler, - compileConstructorDefault?: SchemaParser.Compiler + compileField?: SchemaParser.Compiler ): SchemaParser.Parser /** @internal */ @@ -2787,7 +2787,7 @@ export const Objects: new( /** @internal */ getParser( compile: SchemaParser.Compiler, - compileConstructorDefault: SchemaParser.Compiler = compile + compileField: SchemaParser.Compiler = compile ): SchemaParser.Parser { // oxlint-disable-next-line @typescript-eslint/no-this-alias const ast = this @@ -2882,7 +2882,7 @@ export const Objects: new( const compileMembers = (): Array => { if (!properties) { properties = ast.propertySignatures.map((ps) => ({ - parser: compileConstructorDefault(ps.type), + parser: compileField(ps.type), name: ps.name, type: ps.type })) @@ -2890,7 +2890,7 @@ export const Objects: new( ? ast.indexSignatures.map((is) => ({ is, parserKey: compile(parameterFromPropertyKey(is.parameter)), - parserValue: compileConstructorDefault(is.type) + parserValue: compileField(is.type) })) : undefined } @@ -3602,7 +3602,7 @@ export interface Union extends ASTNode { readonly encodingChecks: Checks | undefined /** @internal */ - getParser(compile: SchemaParser.Compiler, compileConstructorDefault?: SchemaParser.Compiler): SchemaParser.Parser + getParser(compile: SchemaParser.Compiler, compileField?: SchemaParser.Compiler): SchemaParser.Parser /** @internal */ recur(recur: (ast: AST) => AST): Union @@ -3665,7 +3665,7 @@ export const Union: new( /** @internal */ getParser( compile: SchemaParser.Compiler, - compileConstructorDefault?: SchemaParser.Compiler + compileField?: SchemaParser.Compiler ): SchemaParser.Parser { // oxlint-disable-next-line @typescript-eslint/no-this-alias const ast = this @@ -3674,7 +3674,7 @@ export const Union: new( if (input === InternalParser.missing) { return InternalParser.missingExit } - const candidates = getCandidates(input, ast.types, compileConstructorDefault !== undefined) + const candidates = getCandidates(input, ast.types, compileField !== undefined) if (candidates.length === 0) { return Effect.fail(new SchemaIssue.AnyOf(ast, [], input, options)) diff --git a/packages/effect/src/internal/schema/interpreter.ts b/packages/effect/src/internal/schema/interpreter.ts index 55734984932..c0ad06d7544 100644 --- a/packages/effect/src/internal/schema/interpreter.ts +++ b/packages/effect/src/internal/schema/interpreter.ts @@ -125,7 +125,7 @@ export function compileField(ast: SchemaAST.AST, compile: Compiler): Parser { export function compile( ast: SchemaAST.AST, compile: Compiler, - compileConstructorDefault?: Compiler, + compileField?: Compiler, base?: Parser, specialize?: (local: Parser) => Parser ): Parser { @@ -134,10 +134,12 @@ export function compile( // Register those ASTs with the same resolver before invoking the callback. for (const parameter of ast.typeParameters) compile(parameter) } - const descriptor = compileConstructorDefault ? SchemaAST.getConstructorDescriptor(ast) : undefined + // Construction supplies compileField for parent-owned defaults. Its presence + // also selects the constructor semantics of declarations and Unions. + const descriptor = compileField ? SchemaAST.getConstructorDescriptor(ast) : undefined const parser = descriptor ? makeConstructorParser(descriptor, compile) - : base ?? ast.getParser(compile, compileConstructorDefault) + : base ?? ast.getParser(compile, compileField) const checks = ast.checks const links = ast.encoding const transformations = links?.map((link) => compileTransformation(link.transformation)) diff --git a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts index ca203698f7e..da7d78a945f 100644 --- a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts +++ b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts @@ -44,19 +44,19 @@ type GenerateArray = (context: { const makeObjectBase = ( ast: SchemaAST.Objects, compile: Compiler, - compileConstructorDefault: Compiler, + compileField: Compiler, generate: GenerateObject ): SchemaIssueParser => { let properties: Array | undefined const getProperties = (): Array => properties ??= ast.propertySignatures.map((property) => ({ - parser: compileConstructorDefault(property.type), + parser: compileField(property.type), name: property.name, type: property.type })) let fallback: SchemaIssueParser | undefined const runFallback: SchemaIssueParser = (input, options) => - (fallback ??= ast.getParser(compile, compileConstructorDefault))(input, options) + (fallback ??= ast.getParser(compile, compileField))(input, options) const resume = ( state: ObjectParserState, index: number, @@ -77,17 +77,17 @@ const makeObjectBase = ( const makeArrayBase = ( ast: SchemaAST.Arrays, compile: Compiler, - compileConstructorDefault: Compiler, + compileField: Compiler, generate: GenerateArray ): SchemaIssueParser => { let element: { readonly ast: SchemaAST.AST; readonly parser: SchemaIssueParser } | undefined const getElement = () => (element ??= { ast: ast.rest[0], - parser: compileConstructorDefault(ast.rest[0]) + parser: compileField(ast.rest[0]) }) let fallback: SchemaIssueParser | undefined const runFallback: SchemaIssueParser = (input, options) => - (fallback ??= ast.getParser(compile, compileConstructorDefault))(input, options) + (fallback ??= ast.getParser(compile, compileField))(input, options) const run = generate({ getElement: () => getElement().parser, step: SchemaAST.stepArray, From 516f65c7f4ee36f5453bcd0258321b194ce4531a Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 16 Sep 2026 14:59:05 +0200 Subject: [PATCH 18/33] Simplify schema compiler initialization --- packages/effect/src/internal/schema/codegen.ts | 3 +-- packages/effect/src/unstable/schema/SchemaAOTCompiler.ts | 2 +- packages/effect/src/unstable/schema/SchemaJITCompiler.ts | 9 +++------ 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts index c9063b3b17f..e6a243aac8d 100644 --- a/packages/effect/src/internal/schema/codegen.ts +++ b/packages/effect/src/internal/schema/codegen.ts @@ -142,7 +142,7 @@ const isMakeSafe = ( } } -const shouldCompileMake = (ast: SchemaAST.AST): boolean => +const shouldCompileMake = (ast: SchemaAST.AST): ast is SchemaAST.Arrays | SchemaAST.Objects => (ast._tag === "Arrays" && ast.elements.length === 0 && ast.rest.length === 1 || ast._tag === "Objects" && ast.propertySignatures.length > 0 && ast.indexSignatures.length === 0) && isMakeSafe(ast) @@ -682,7 +682,6 @@ export function generate(ast: SchemaAST.AST, operation: DecoderOperation): strin renderOperation(emitOperation(ast, "decode")) }` } - if (ast._tag !== "Arrays") return undefined const element = renderOperation(emitOperation(ast.rest[0], "decode", "ast.rest[0]")) return `const e=resolve(ast.rest[0]),m=e.source===void 0?${element}:e.make;if(m===void 0)return;` + "return function(i,o){if(i===R.missing)return R.missing;if(!Array.isArray(i))return R.invalid;" + diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts index 0dae4b30e2d..591ba42437e 100644 --- a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts @@ -10,10 +10,10 @@ import * as SchemaAST from "../../SchemaAST.ts" import type { runtime } from "./SchemaCompiler/runtime.ts" const helper = (name: keyof typeof runtime): string => `R.${name}` +const operations: ReadonlyArray = ["is", "decode", "make", "decodeEffect", "makeEffect"] const decoder = (ast: SchemaAST.AST): string | undefined => { if (!Codegen.shouldCompileParser(ast)) return undefined - const operations: ReadonlyArray = ["is", "decode", "make", "decodeEffect", "makeEffect"] return "{" + operations.flatMap((key) => { const source = Codegen.generate(ast, key) return source === undefined ? [] : [`get ${key}(){${source}}`] diff --git a/packages/effect/src/unstable/schema/SchemaJITCompiler.ts b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts index 6de143d90a1..dd681df7327 100644 --- a/packages/effect/src/unstable/schema/SchemaJITCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts @@ -28,19 +28,16 @@ export const compiler: Registry.Compile = (ast, resolve) => { } if (!supported) return undefined let decodeFailed = false - let makeFailed = false - let makeEffectFailed = false const operation = (key: DecoderOperation) => { - if (!(key === "make" ? makeFailed : key === "makeEffect" ? makeEffectFailed : decodeFailed)) { + const isConstruction = key === "make" || key === "makeEffect" + if (isConstruction || !decodeFailed) { try { const source = generate(ast, key) if (source === undefined) return undefined return globalThis.Function("ast", "R", "resolve", source)(ast, runtime, resolve) } catch { // Only code generation and initialization are inside this catch. - if (key === "make") makeFailed = true - else if (key === "makeEffect") makeEffectFailed = true - else decodeFailed = true + if (!isConstruction) decodeFailed = true } } return key === "decodeEffect" From d6a8864bba661b25b37c9ddd1b12c064c5444dee Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 16 Sep 2026 16:02:55 +0200 Subject: [PATCH 19/33] Optimize schema constructor defaults --- .changeset/add-schema-compilers.md | 7 +++++++ packages/effect/src/SchemaAST.ts | 21 +++++++------------ .../effect/src/internal/schema/interpreter.ts | 15 +++++-------- .../src/unstable/schema/SchemaAOTCompiler.ts | 3 --- packages/effect/test/schema/SchemaAST.test.ts | 8 ++++++- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.changeset/add-schema-compilers.md b/.changeset/add-schema-compilers.md index eb8c60e7285..79db5b6275c 100644 --- a/.changeset/add-schema-compilers.md +++ b/.changeset/add-schema-compilers.md @@ -31,3 +31,10 @@ The public `Getter` constructor is removed. Use `SchemaTransformation.compose` is now a dual standalone function. Replace `first.compose(second)` with `SchemaTransformation.compose(first, second)` or `SchemaTransformation.compose(second)(first)`. + +`SchemaAST.Context.constructorDefault` now stores the constructor-default +`Effect` directly instead of wrapping it in a `SchemaAST.Link`. Constructor +defaults apply only during construction, so the direct representation avoids +giving them encoding semantics and lets construction reuse already-completed +synchronous Effects. Code that constructs or inspects `SchemaAST.Context` +should pass or read the default `Effect` directly. diff --git a/packages/effect/src/SchemaAST.ts b/packages/effect/src/SchemaAST.ts index baca4210828..97f13b8070b 100644 --- a/packages/effect/src/SchemaAST.ts +++ b/packages/effect/src/SchemaAST.ts @@ -579,8 +579,8 @@ export const defaultParseOptions: ParseOptions = {} * * - `isOptional` — the property key may be absent from the input. * - `isMutable` — the property is `readonly` when `false`. - * - `constructorDefault` — a {@link Link} applied during construction to - * supply missing values. + * - `constructorDefault` — an effect evaluated during construction to supply + * missing values. * - `annotations` — key-level annotations (e.g. description of the key * itself). * @@ -593,7 +593,7 @@ export interface Context { readonly isOptional: boolean readonly isMutable: boolean /** Used for constructor default values (e.g. `withConstructorDefault` API) */ - readonly constructorDefault: Link | undefined + readonly constructorDefault: Effect.Effect | undefined readonly annotations: Schema.Annotations.Key | undefined } @@ -606,20 +606,20 @@ export interface Context { export const Context: new( isOptional: boolean, isMutable: boolean, /** Used for constructor default values (e.g. `withConstructorDefault` API) */ - constructorDefault?: Link | undefined, + constructorDefault?: Effect.Effect | undefined, annotations?: Schema.Annotations.Key | undefined ) => Context = class { readonly isOptional: boolean readonly isMutable: boolean /** Used for constructor default values (e.g. `withConstructorDefault` API) */ - readonly constructorDefault: Link | undefined + readonly constructorDefault: Effect.Effect | undefined readonly annotations: Schema.Annotations.Key | undefined constructor( isOptional: boolean, isMutable: boolean, /** Used for constructor default values (e.g. `withConstructorDefault` API) */ - constructorDefault: Link | undefined = undefined, + constructorDefault: Effect.Effect | undefined = undefined, annotations: Schema.Annotations.Key | undefined = undefined ) { this.isOptional = isOptional @@ -4432,14 +4432,9 @@ export function withConstructorDefault( ast: A, defaultValue: Effect.Effect ): A { - const transformation = new SchemaTransformation.Transformation( - SchemaGetter.withDefault(defaultValue), - SchemaGetter.passthrough() - ) - const constructorDefault = new Link(unknown, transformation) const context = ast.context ? - new Context(ast.context.isOptional, ast.context.isMutable, constructorDefault, ast.context.annotations) : - new Context(false, false, constructorDefault) + new Context(ast.context.isOptional, ast.context.isMutable, defaultValue, ast.context.annotations) : + new Context(false, false, defaultValue) return replaceContext(ast, context) } diff --git a/packages/effect/src/internal/schema/interpreter.ts b/packages/effect/src/internal/schema/interpreter.ts index c0ad06d7544..a4600d77a12 100644 --- a/packages/effect/src/internal/schema/interpreter.ts +++ b/packages/effect/src/internal/schema/interpreter.ts @@ -91,16 +91,11 @@ function makeConstructorParser(descriptor: SchemaAST.ConstructorDescriptor, comp } } -function withDefault(ast: SchemaAST.AST, parser: Parser, resolve: Compiler): Parser { - const link = ast.context!.constructorDefault! - const transform = compileTransformation(link.transformation) - let source: Parser | undefined +function withDefault(ast: SchemaAST.AST, parser: Parser): Parser { + const defaultValue = ast.context!.constructorDefault! return (input, options) => { - const result = transform( - (source ??= resolve(link.to))(input, options), - input, - options - ) + if (input !== InternalParser.missing && input !== undefined) return parser(input, options) + const result = defaultValue if (effectIsExit(result) && result._tag === "Success") { const local = parser((result as InternalParser.Success)[InternalParser.args], options) return local === InternalParser.sameExit ? result : local @@ -118,7 +113,7 @@ function withDefault(ast: SchemaAST.AST, parser: Parser, resolve: Compiler): Par /** @internal */ export function compileField(ast: SchemaAST.AST, compile: Compiler): Parser { const parser = compile(ast) - return ast.context?.constructorDefault === undefined ? parser : withDefault(ast, parser, compile) + return ast.context?.constructorDefault === undefined ? parser : withDefault(ast, parser) } /** @internal */ diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts index 591ba42437e..862d4511818 100644 --- a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts @@ -111,9 +111,6 @@ export const compile = (asts: ReadonlyArray): string => { break } node.encoding?.forEach((link, index) => visit(link.to, `${name}.encoding[${index}].to`)) - if (node.context?.constructorDefault !== undefined) { - visit(node.context.constructorDefault.to, `${name}.context.constructorDefault.to`) - } const descriptor = SchemaAST.getConstructorDescriptor(node) if (descriptor !== undefined) { visit(descriptor.link.to, `${helper("getConstructorDescriptor")}(${name}).link.to`) diff --git a/packages/effect/test/schema/SchemaAST.test.ts b/packages/effect/test/schema/SchemaAST.test.ts index 714aafb574d..d3d32f0ac9d 100644 --- a/packages/effect/test/schema/SchemaAST.test.ts +++ b/packages/effect/test/schema/SchemaAST.test.ts @@ -1,9 +1,15 @@ -import { Schema, SchemaAST, SchemaGetter, SchemaTransformation } from "effect" +import { Effect, Schema, SchemaAST, SchemaGetter, SchemaTransformation } from "effect" import { runInNewContext } from "node:vm" import { describe, it } from "vitest" import { deepStrictEqual, doesNotThrow, strictEqual, throws } from "../utils/assert.ts" describe("SchemaAST", () => { + it("stores constructor defaults directly in the context", () => { + const defaultValue = Effect.succeed("default") + const ast = SchemaAST.withConstructorDefault(SchemaAST.string, defaultValue) + strictEqual(ast.context?.constructorDefault, defaultValue) + }) + describe("Suspend", () => { it("memoizes the thunk", () => { let calls = 0 From c0d5166918a3a2fe13ca9b3234f81811039e13c6 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 16 Sep 2026 19:33:06 +0200 Subject: [PATCH 20/33] Add schema compiler resource benchmarks --- packages/effect/runtimeperf/config.json | 96 +++- .../suites/compiler-rebuild/README.md | 26 +- .../compiler-rebuild/fixtures/zod-cases.ts | 117 +++++ .../compiler-rebuild/fixtures/zod-compiled.ts | 101 +--- .../compiler-rebuild/fixtures/zod-jitless.ts | 12 + .../compiler-rebuild/report-resources.mts | 235 +++++++++ .../suites/compiler-rebuild/resources.mts | 477 ++++++++++++++++++ .../suites/compiler-rebuild/run-resources.mts | 93 ++++ .../runtimeperf/suites/moltar/fixtures/zod.ts | 9 +- .../effect/runtimeperf/test/registry.test.mts | 19 +- 10 files changed, 1071 insertions(+), 114 deletions(-) create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-cases.ts create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-jitless.ts create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/report-resources.mts create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/resources.mts create mode 100644 packages/effect/runtimeperf/suites/compiler-rebuild/run-resources.mts diff --git a/packages/effect/runtimeperf/config.json b/packages/effect/runtimeperf/config.json index 60821ca8dc6..607012584f3 100644 --- a/packages/effect/runtimeperf/config.json +++ b/packages/effect/runtimeperf/config.json @@ -2069,21 +2069,29 @@ "path": "extra-valid" }, { - "name": "zod-jitless-parse-valid", - "export": "assertParseJitlessValid", + "name": "zod-jitless-validate-valid", + "export": "isJitlessValid", "scenario": "moltar-assert-loose-valid", "implementation": "zod4-jitless", - "operation": "parse-and-assert-jitless", + "operation": "validate-jitless", "path": "valid" }, { - "name": "zod-jitless-parse-extra-valid", - "export": "assertParseJitlessExtraValid", + "name": "zod-jitless-validate-extra-valid", + "export": "isJitlessExtraValid", "scenario": "moltar-assert-loose-extra-valid", "implementation": "zod4-jitless", - "operation": "parse-and-assert-jitless", + "operation": "validate-jitless", "path": "extra-valid" }, + { + "name": "zod-jitless-validate-invalid", + "export": "isJitlessInvalid", + "scenario": "moltar-assert-loose-invalid", + "implementation": "zod4-jitless", + "operation": "validate-jitless", + "path": "invalid" + }, { "name": "zod-validate-valid", "export": "isValid", @@ -2911,6 +2919,82 @@ } ] }, + { + "file": "suites/compiler-rebuild/fixtures/zod-jitless.ts", + "defaults": { + "tier": 1, + "family": "zod-jitless", + "implementation": "zod4-jitless", + "astTags": [], + "operation": "decode", + "path": "valid", + "size": 3 + }, + "cases": [ + { + "name": "zod-jitless-parseValid", + "export": "parseValid", + "scenario": "compiler-rebuild-parseValid" + }, + { + "name": "zod-jitless-parseInvalid", + "export": "parseInvalid", + "scenario": "compiler-rebuild-parseInvalid", + "path": "invalid" + }, + { + "name": "zod-jitless-isValid", + "export": "isValid", + "scenario": "compiler-rebuild-isValid", + "operation": "is" + }, + { + "name": "zod-jitless-isInvalid", + "export": "isInvalid", + "scenario": "compiler-rebuild-isInvalid", + "operation": "is", + "path": "invalid" + }, + { + "name": "zod-jitless-array", + "export": "array", + "scenario": "compiler-rebuild-array", + "size": 32 + }, + { + "name": "zod-jitless-record", + "export": "record", + "scenario": "compiler-rebuild-record", + "size": 32 + }, + { + "name": "zod-jitless-union", + "export": "union", + "scenario": "compiler-rebuild-union", + "size": 8 + }, + { + "name": "zod-jitless-transform", + "export": "transform", + "scenario": "compiler-rebuild-transform", + "size": 32 + }, + { + "name": "zod-jitless-makeStruct", + "export": "makeStruct", + "scenario": "compiler-rebuild-makeStruct", + "operation": "make", + "size": 32 + }, + { + "name": "zod-jitless-makeArray", + "export": "makeArray", + "scenario": "compiler-rebuild-makeArray", + "operation": "make", + "size": 32 + } + ] + }, { "file": "suites/compiler-rebuild/fixtures/zod-compiled.ts", "defaults": { diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/README.md b/packages/effect/runtimeperf/suites/compiler-rebuild/README.md index 2157f6bbf26..0a8ccc20d20 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/README.md +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/README.md @@ -2,8 +2,9 @@ The `compiler-rebuild` suite measures 22 public SchemaParser operations with the interpreter, selective JIT and generated AOT modules. Eighteen cases also run -against `z.compile(schema, { strict: true })`; ten representative cases also -run against Valibot. The fixture families name the execution mode. Cases +against `z.compile(schema, { strict: true })`; ten representative cases run +against Valibot and Zod with `{ jitless: true }`. The fixture families name the +execution mode. Cases cover simple and nested Structs, Arrays, tuples, Records, anyOf/oneOf Unions, transformations, middleware, recursive schemas, Declarations, construction, and successful and failing validation. @@ -34,6 +35,27 @@ the paired base/head comparison is the regression evidence. ## Retained heap and first use +For a current cross-library comparison of retained heap, V8 code memory, peak +RSS and preparation CPU, run: + +```sh +node packages/effect/runtimeperf/suites/compiler-rebuild/run-resources.mts \ + tmp/schema-compiler-resources.json 5 100,500 +node packages/effect/runtimeperf/suites/compiler-rebuild/report-resources.mts \ + tmp/schema-compiler-resources.json +``` + +Each sample runs in a fresh process. The report calculates median per-schema +slopes between 100 and 500 distinct schemas, which removes fixed module and +lazy initialization costs. Fixture inputs are released before retained-memory +measurement. Preparation CPU includes adapter creation and first use; AOT also +includes loading and installing the generated module. AOT source generation is +reported separately because it runs at build time. Hot synchronous CPU is +already represented by the normal runtime measurements. + +The older Effect-only probe below remains available for comparisons with its +existing archived results. + The standalone cost probe accepts a checkout root, mode, operation, shape and schema count. Run several fresh processes per combination and alternate the checkout order. Supported modes are `interpreted`, `jit` and `aot`; operations diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-cases.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-cases.ts new file mode 100644 index 00000000000..7e270ac7628 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-cases.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict" +import * as z from "zod/v4" + +const person = () => z.object({ name: z.string(), age: z.number(), active: z.boolean() }) +const input = { name: "Ada", age: 37, active: true } +const small = person() +const nestedSchema = z.object({ user: person(), tags: z.array(z.string()) }) +const arraySchema = z.array(person()) +const arrayInput = Array.from({ length: 32 }, () => ({ ...input })) +const recordSchema = z.record(z.string(), person()) +const recordInput = Object.fromEntries(arrayInput.map((value, i) => [String(i), value])) +const unionSchema = z.union( + Array.from({ length: 8 }, (_, i) => z.object({ tag: z.literal(i), value: z.number() })) as [ + z.ZodObject, + z.ZodObject, + ...Array + ] +) +const fields = Object.fromEntries( + Array.from({ length: 32 }, (_, i) => [`v${i}`, z.string().transform(Number)]) +) +const transformedSchema = z.object(fields) +const transformedInput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, String(i)])) +const transformedOutput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, i])) +const checkedTransform = z.string().transform(Number).pipe(z.number().positive()) +const declarationSchema = z.set(person()) +const declarationInput = new Set(arrayInput) +const defaultsSchema = z.object( + Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, z.number().default(i)])) +) + +type Case = { + readonly schema: z.ZodType + readonly input: unknown + readonly expected: unknown + readonly operation?: "is" | "make" | "encode" + readonly invalid?: boolean +} + +const cases: Record = { + parseValid: { schema: small, input, expected: input }, + parseExtra: { schema: small, input: { ...input, extra: 1 }, expected: input }, + parseInvalid: { schema: small, input: { ...input, age: "bad" }, expected: true, invalid: true }, + isValid: { schema: small, input, expected: true, operation: "is" }, + isInvalid: { schema: small, input: { ...input, age: "bad" }, expected: false, operation: "is" }, + encode: { schema: small, input, expected: input, operation: "encode" }, + strict: { + schema: z.strictObject({ name: z.string(), age: z.number(), active: z.boolean() }), + input, + expected: input + }, + nested: { + schema: nestedSchema, + input: { user: input, tags: ["a", "b"] }, + expected: { user: input, tags: ["a", "b"] } + }, + array: { schema: arraySchema, input: arrayInput, expected: arrayInput }, + arrayInvalid: { + schema: arraySchema, + input: [...arrayInput, { ...input, age: "bad" }], + expected: true, + invalid: true + }, + record: { schema: recordSchema, input: recordInput, expected: recordInput }, + union: { schema: unionSchema, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 } }, + transform: { schema: transformedSchema, input: transformedInput, expected: transformedOutput }, + transformInvalid: { schema: checkedTransform, input: "-1", expected: true, invalid: true }, + declaration: { schema: declarationSchema, input: declarationInput, expected: declarationInput }, + makeStruct: { schema: defaultsSchema, input: {}, expected: transformedOutput, operation: "make" }, + makeArray: { schema: arraySchema, input: arrayInput, expected: arrayInput, operation: "make" }, + makeUnion: { + schema: unionSchema, + input: { tag: 7, value: 1 }, + expected: { tag: 7, value: 1 }, + operation: "make" + } +} + +const makeFixture = ( + { input, expected, invalid }: Case, + parse: (input: unknown) => unknown +) => { + return { + run: invalid ? + () => { + try { + parse(input) + return false + } catch { + return true + } + } : + () => parse(input), + validate: (result: unknown) => assert.deepEqual(result, expected) + } +} + +export const compiledFixture = (name: string) => { + const value = cases[name] + const schema = z.compile(value.schema, { strict: true }) + const parse = value.operation === "is" ? + (input: unknown) => z.validate(schema, input) + : value.operation === "encode" ? + (input: unknown) => z.encode(schema, input) + : (input: unknown) => schema.parse(input) + return makeFixture(value, parse) +} + +export const jitlessFixture = (name: string) => { + const value = cases[name] + const parse = value.operation === "is" ? + (input: unknown) => z.validate(value.schema, input, { jitless: true }) + : value.operation === "encode" ? + (input: unknown) => z.encode(value.schema, input, { jitless: true }) + : (input: unknown) => value.schema.parse(input, { jitless: true }) + return makeFixture(value, parse) +} diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts index 395d03a902e..881019e8103 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts @@ -1,103 +1,4 @@ -import assert from "node:assert/strict" -import * as z from "zod/v4" - -const person = () => z.object({ name: z.string(), age: z.number(), active: z.boolean() }) -const input = { name: "Ada", age: 37, active: true } -const small = person() -const nestedSchema = z.object({ user: person(), tags: z.array(z.string()) }) -const arraySchema = z.array(person()) -const arrayInput = Array.from({ length: 32 }, () => ({ ...input })) -const recordSchema = z.record(z.string(), person()) -const recordInput = Object.fromEntries(arrayInput.map((value, i) => [String(i), value])) -const unionSchema = z.union( - Array.from({ length: 8 }, (_, i) => z.object({ tag: z.literal(i), value: z.number() })) as [ - z.ZodObject, - z.ZodObject, - ...Array - ] -) -const fields = Object.fromEntries( - Array.from({ length: 32 }, (_, i) => [`v${i}`, z.string().transform(Number)]) -) -const transformedSchema = z.object(fields) -const transformedInput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, String(i)])) -const transformedOutput = Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, i])) -const checkedTransform = z.string().transform(Number).pipe(z.number().positive()) -const declarationSchema = z.set(person()) -const declarationInput = new Set(arrayInput) -const defaultsSchema = z.object( - Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`v${i}`, z.number().default(i)])) -) - -type Case = { - readonly schema: z.ZodType - readonly input: unknown - readonly expected: unknown - readonly operation?: "is" | "make" | "encode" - readonly invalid?: boolean -} - -const cases: Record = { - parseValid: { schema: small, input, expected: input }, - parseExtra: { schema: small, input: { ...input, extra: 1 }, expected: input }, - parseInvalid: { schema: small, input: { ...input, age: "bad" }, expected: true, invalid: true }, - isValid: { schema: small, input, expected: true, operation: "is" }, - isInvalid: { schema: small, input: { ...input, age: "bad" }, expected: false, operation: "is" }, - encode: { schema: small, input, expected: input, operation: "encode" }, - strict: { - schema: z.strictObject({ name: z.string(), age: z.number(), active: z.boolean() }), - input, - expected: input - }, - nested: { - schema: nestedSchema, - input: { user: input, tags: ["a", "b"] }, - expected: { user: input, tags: ["a", "b"] } - }, - array: { schema: arraySchema, input: arrayInput, expected: arrayInput }, - arrayInvalid: { - schema: arraySchema, - input: [...arrayInput, { ...input, age: "bad" }], - expected: true, - invalid: true - }, - record: { schema: recordSchema, input: recordInput, expected: recordInput }, - union: { schema: unionSchema, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 } }, - transform: { schema: transformedSchema, input: transformedInput, expected: transformedOutput }, - transformInvalid: { schema: checkedTransform, input: "-1", expected: true, invalid: true }, - declaration: { schema: declarationSchema, input: declarationInput, expected: declarationInput }, - makeStruct: { schema: defaultsSchema, input: {}, expected: transformedOutput, operation: "make" }, - makeArray: { schema: arraySchema, input: arrayInput, expected: arrayInput, operation: "make" }, - makeUnion: { - schema: unionSchema, - input: { tag: 7, value: 1 }, - expected: { tag: 7, value: 1 }, - operation: "make" - } -} - -const fixture = (name: string) => { - const { schema: source, input, expected, operation, invalid } = cases[name] - const schema = z.compile(source, { strict: true }) - const parse = operation === "is" ? - (input: unknown) => z.validate(schema, input) - : operation === "encode" ? - (input: unknown) => z.encode(schema, input) - : (input: unknown) => schema.parse(input) - return { - run: invalid ? - () => { - try { - parse(input) - return false - } catch { - return true - } - } : - () => parse(input), - validate: (result: unknown) => assert.deepEqual(result, expected) - } -} +import { compiledFixture as fixture } from "./zod-cases.ts" export const parseValid = () => fixture("parseValid") export const parseExtra = () => fixture("parseExtra") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-jitless.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-jitless.ts new file mode 100644 index 00000000000..cda75669642 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-jitless.ts @@ -0,0 +1,12 @@ +import { jitlessFixture as fixture } from "./zod-cases.ts" + +export const parseValid = () => fixture("parseValid") +export const parseInvalid = () => fixture("parseInvalid") +export const isValid = () => fixture("isValid") +export const isInvalid = () => fixture("isInvalid") +export const array = () => fixture("array") +export const record = () => fixture("record") +export const union = () => fixture("union") +export const transform = () => fixture("transform") +export const makeStruct = () => fixture("makeStruct") +export const makeArray = () => fixture("makeArray") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/report-resources.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/report-resources.mts new file mode 100644 index 00000000000..99e2aa6f0a1 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/report-resources.mts @@ -0,0 +1,235 @@ +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +type Sample = { + readonly aotBuild?: { + readonly generateCpu: { readonly cpuMicros: number } + readonly maxRssBytes: number + readonly schemaCpu: { readonly cpuMicros: number } + readonly sourceBytes: number + } + readonly case: string + readonly count: number + readonly cpu: { + readonly aotModule?: { readonly cpuMicros: number } + readonly firstCall: { readonly cpuMicros: number } + readonly prepare: { readonly cpuMicros: number } + } + readonly implementation: string + readonly memory: { + readonly compilerPerSchema: Memory + readonly module: Memory + readonly retainedLibraryPerSchema: Memory + } + readonly round: number +} + +type Memory = { + readonly bytecodeBytes: number + readonly codeBytes: number + readonly externalSourceBytes: number + readonly heapBytes: number + readonly maxRssBytes: number +} + +type Report = { + readonly environment: Record + readonly measurement: { + readonly cases: ReadonlyArray + readonly counts: ReadonlyArray + readonly implementations: ReadonlyArray + readonly rounds: number + } + readonly results: ReadonlyArray +} + +const path = resolve(process.argv[2] ?? "tmp/schema-compiler-resources.json") +const report = JSON.parse(readFileSync(path, "utf8")) as Report +const lowCount = Math.min(...report.measurement.counts) +const highCount = Math.max(...report.measurement.counts) +if (lowCount === highCount) throw new Error("the report needs at least two schema counts") + +const median = (values: ReadonlyArray) => { + const sorted = values.toSorted((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +const samples = (implementation: string, caseName: string) => { + const pairs = [] + for (let round = 0; round < report.measurement.rounds; round++) { + const low = report.results.find((sample) => + sample.round === round && sample.implementation === implementation && sample.case === caseName && + sample.count === lowCount + ) + const high = report.results.find((sample) => + sample.round === round && sample.implementation === implementation && sample.case === caseName && + sample.count === highCount + ) + if (low === undefined || high === undefined) throw new Error(`missing samples for ${implementation}/${caseName}`) + pairs.push({ high, low }) + } + return pairs +} + +const perSchemaSlope = ( + implementation: string, + caseName: string, + value: (sample: Sample) => number +) => median(samples(implementation, caseName).map(({ high, low }) => + (value(high) * highCount - value(low) * lowCount) / (highCount - lowCount))) + +const totalSlope = ( + implementation: string, + caseName: string, + value: (sample: Sample) => number +) => median(samples(implementation, caseName).map(({ high, low }) => + (value(high) - value(low)) / (highCount - lowCount))) + +const heap = (implementation: string, caseName: string) => + perSchemaSlope(implementation, caseName, (sample) => sample.memory.retainedLibraryPerSchema.heapBytes) / 1024 +const code = (implementation: string, caseName: string) => + perSchemaSlope( + implementation, + caseName, + (sample) => { + const memory = sample.memory.retainedLibraryPerSchema + return memory.bytecodeBytes + memory.codeBytes + memory.externalSourceBytes + } + ) / 1024 +const peakRss = (implementation: string, caseName: string) => + perSchemaSlope(implementation, caseName, (sample) => sample.memory.retainedLibraryPerSchema.maxRssBytes) / 1024 +const startupCpu = (implementation: string, caseName: string) => + totalSlope( + implementation, + caseName, + (sample) => + sample.cpu.prepare.cpuMicros + sample.cpu.firstCall.cpuMicros + + (implementation === "effect-aot" ? sample.cpu.aotModule?.cpuMicros ?? 0 : 0) + ) + +const fixedDelta = ( + left: string, + right: string, + value: (sample: Sample) => number +) => { + const differences = [] + for (const caseName of report.measurement.cases) { + for (let round = 0; round < report.measurement.rounds; round++) { + for (const count of report.measurement.counts) { + const find = (implementation: string) => report.results.find((sample) => + sample.implementation === implementation && sample.case === caseName && sample.round === round && + sample.count === count + )! + differences.push(value(find(left)) - value(find(right))) + } + } + } + return median(differences) +} + +const names: Record = { + "array-decode": "Array of 32 Structs", + "default-make": "Construct 32 defaults", + "struct-decode": "Struct decode", + "struct-invalid": "Struct invalid decode", + "struct-is": "Struct guard", + "transform-decode": "32 transformations", + "union-decode": "Discriminated Union (8)" +} + +const table = ( + implementations: ReadonlyArray, + value: (implementation: string, caseName: string) => number, + unit: string, + digits: number +) => { + const labels: Record = { + "effect-aot": "Effect AOT", + "effect-interpreted": "Effect interpreted", + "effect-jit": "Effect JIT", + "valibot": "Valibot", + "zod-compiled": "Zod compile", + "zod-jitless": "Zod jitless" + } + const lines = [ + `| Case | ${implementations.map((implementation) => labels[implementation]).join(" | ")} |`, + `|---|${implementations.map(() => "---:").join("|")}|` + ] + for (const caseName of report.measurement.cases) { + lines.push( + `| ${names[caseName]} | ${ + implementations.map((implementation) => `${value(implementation, caseName).toFixed(digits)} ${unit}`).join(" | ") + } |` + ) + } + return lines.join("\n") +} + +const interpreted = ["effect-interpreted", "valibot", "zod-jitless"] +const compiled = ["effect-jit", "effect-aot", "zod-compiled"] +const environment = report.environment + +process.stdout.write(`# Schema compiler resource comparison + +Incremental costs are median per-schema slopes between ${lowCount} and ${highCount} distinct schemas across ${ + report.measurement.rounds +} fresh-process rounds. Lower is better. Fixed module imports and fixture inputs are excluded. + +Environment: Node ${environment.node}, V8 ${environment.v8}, ${environment.cpu}, ${environment.platform} ${ + environment.arch +}; Valibot ${environment.valibot}, Zod ${environment.zod}. + +Importing the JIT compiler retains ${ + (fixedDelta("effect-jit", "effect-interpreted", (sample) => sample.memory.module.heapBytes) / 1024).toFixed(1) +} KiB of additional fixed JavaScript heap and ${ + (fixedDelta("effect-jit", "effect-interpreted", (sample) => + sample.memory.module.codeBytes + sample.memory.module.bytecodeBytes + sample.memory.module.externalSourceBytes) / 1024) + .toFixed(1) +} KiB of V8 code, bytecode and source in this source-tree setup. + +## Retained JavaScript heap + +### Interpreted + +${table(interpreted, heap, "KiB/schema", 2)} + +### Compiled + +${table(compiled, heap, "KiB/schema", 2)} + +## Retained V8 code, bytecode and source + +${table(compiled, code, "KiB/schema", 2)} + +## Runtime preparation CPU + +This includes adapter creation and the first call. AOT also includes importing and installing the generated module. AOT build-time generation is excluded. + +### Interpreted + +${table(interpreted, startupCpu, "µs/schema", 1)} + +### Compiled + +${table(compiled, startupCpu, "µs/schema", 1)} + +## Peak runtime RSS growth + +${table(compiled, peakRss, "KiB/schema", 1)} + +## AOT build cost + +| Case | CPU | Generated source | Peak RSS growth | +|---|---:|---:|---:| +${report.measurement.cases.map((caseName) => { + const cpu = totalSlope( + "effect-aot", + caseName, + (sample) => (sample.aotBuild?.schemaCpu.cpuMicros ?? 0) + (sample.aotBuild?.generateCpu.cpuMicros ?? 0) + ) + const source = totalSlope("effect-aot", caseName, (sample) => sample.aotBuild?.sourceBytes ?? 0) / 1024 + const rss = totalSlope("effect-aot", caseName, (sample) => sample.aotBuild?.maxRssBytes ?? 0) / 1024 + return `| ${names[caseName]} | ${cpu.toFixed(1)} µs/schema | ${source.toFixed(2)} KiB/schema | ${rss.toFixed(1)} KiB/schema |` +}).join("\n")} +`) diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/resources.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/resources.mts new file mode 100644 index 00000000000..32b0caa25ef --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/resources.mts @@ -0,0 +1,477 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import * as v8 from "node:v8" + +const implementations = [ + "effect-interpreted", + "effect-jit", + "effect-aot", + "valibot", + "zod-jitless", + "zod-compiled" +] as const +type Implementation = typeof implementations[number] + +const cases = [ + "struct-decode", + "struct-invalid", + "struct-is", + "array-decode", + "union-decode", + "transform-decode", + "default-make" +] as const +type CaseName = typeof cases[number] + +type BuiltCase = { + readonly schema: unknown + input: unknown + expected: unknown + readonly invalid?: boolean + readonly ast?: unknown +} + +type Parser = (input: unknown) => unknown + +const [command, root, implementationArgument, caseArgument, countArgument, output] = process.argv.slice(2) +const includes = (values: ReadonlyArray, value: string | undefined): value is A => + value !== undefined && values.includes(value as A) + +if (command !== "measure" && command !== "generate") { + throw new Error("command must be measure or generate") +} +if (root === undefined) throw new Error("root is required") +if (!includes(implementations, implementationArgument)) { + throw new Error(`implementation must be one of: ${implementations.join(", ")}`) +} +if (!includes(cases, caseArgument)) { + throw new Error(`case must be one of: ${cases.join(", ")}`) +} +const implementation: Implementation = implementationArgument +const caseName: CaseName = caseArgument +const count = Number(countArgument) +if (!Number.isSafeInteger(count) || count <= 0) throw new Error("count must be a positive integer") + +const forceGc = () => { + assert.equal(typeof globalThis.gc, "function", "run this probe with --expose-gc") + for (let i = 0; i < 5; i++) globalThis.gc!() +} + +type CpuSample = { + readonly cpuMicros: number + readonly systemMicros: number + readonly userMicros: number + readonly wallNanos: number +} + +const measureCpu = async (f: () => A | Promise): Promise => { + const cpuStart = process.cpuUsage() + const wallStart = process.hrtime.bigint() + const value = await f() + const wallNanos = Number(process.hrtime.bigint() - wallStart) + const cpu = process.cpuUsage(cpuStart) + return [value, { + cpuMicros: cpu.user + cpu.system, + systemMicros: cpu.system, + userMicros: cpu.user, + wallNanos + }] +} + +type MemorySample = { + readonly bytecodeBytes: number + readonly codeBytes: number + readonly externalSourceBytes: number + readonly heapBytes: number + readonly maxRssBytes: number + readonly rssBytes: number +} + +const memory = (): MemorySample => { + const usage = process.memoryUsage() + const code = v8.getHeapCodeStatistics() + return { + bytecodeBytes: code.bytecode_and_metadata_size, + codeBytes: code.code_and_metadata_size, + externalSourceBytes: code.external_script_source_size, + heapBytes: usage.heapUsed, + maxRssBytes: process.resourceUsage().maxRSS * 1024, + rssBytes: usage.rss + } +} + +const delta = (after: MemorySample, before: MemorySample) => ({ + bytecodeBytes: after.bytecodeBytes - before.bytecodeBytes, + codeBytes: after.codeBytes - before.codeBytes, + externalSourceBytes: after.externalSourceBytes - before.externalSourceBytes, + heapBytes: after.heapBytes - before.heapBytes, + maxRssBytes: after.maxRssBytes - before.maxRssBytes, + rssBytes: after.rssBytes - before.rssBytes +}) + +const perSchema = (sample: ReturnType) => ({ + bytecodeBytes: sample.bytecodeBytes / count, + codeBytes: sample.codeBytes / count, + externalSourceBytes: sample.externalSourceBytes / count, + heapBytes: sample.heapBytes / count, + maxRssBytes: sample.maxRssBytes / count, + rssBytes: sample.rssBytes / count +}) + +const loadEffectModule = (path: string) => + import(pathToFileURL(join(root, "packages/effect/src", `${path}.ts`)).href) + +const loadEffectSchemaModules = async () => ({ + Effect: await loadEffectModule("Effect"), + Schema: await loadEffectModule("Schema"), + SchemaAST: await loadEffectModule("SchemaAST") +}) + +type EffectSchemaModules = Awaited> + +const buildEffectCase = ({ Effect, Schema, SchemaAST }: EffectSchemaModules, index: number): BuiltCase => { + const suffix = String(index) + const name = `name${suffix}` + const age = `age${suffix}` + const active = `active${suffix}` + const person = () => Schema.Struct({ [name]: Schema.String, [age]: Schema.Number, [active]: Schema.Boolean }) + const value = { [name]: "Ada", [age]: 37, [active]: true } + switch (caseName) { + case "struct-decode": { + const schema = person() + return { schema, ast: schema.ast, input: value, expected: value } + } + case "struct-invalid": { + const schema = person() + return { schema, ast: schema.ast, input: { ...value, [age]: "bad" }, expected: true, invalid: true } + } + case "struct-is": { + const schema = person() + return { schema, ast: SchemaAST.toType(schema.ast), input: value, expected: true } + } + case "array-decode": { + const schema = Schema.Array(person()) + const input = Array.from({ length: 32 }, () => ({ ...value })) + return { schema, ast: schema.ast, input, expected: input } + } + case "union-decode": { + const tag = `tag${suffix}` + const schema = Schema.Union( + Array.from({ length: 8 }, (_, member) => + Schema.Struct({ [tag]: Schema.Literal(member), [`value${suffix}`]: Schema.Number })) + ) + const input = { [tag]: 7, [`value${suffix}`]: 1 } + return { schema, ast: schema.ast, input, expected: input } + } + case "transform-decode": { + const schema = Schema.Struct( + Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, Schema.NumberFromString])) + ) + const input = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, String(field)])) + const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) + return { schema, ast: schema.ast, input, expected } + } + case "default-make": { + const schema = Schema.Struct( + Object.fromEntries( + Array.from( + { length: 32 }, + (_, field) => [ + `v${suffix}_${field}`, + Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(field))) + ] + ) + ) + ) + const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) + return { schema, ast: SchemaAST.toType(schema.ast), input: {}, expected } + } + } +} + +const loadEffect = async (jit: boolean) => { + const modules = await loadEffectSchemaModules() + const SchemaParser = await loadEffectModule("SchemaParser") + const enable = jit ? (await loadEffectModule("unstable/schema/SchemaJITCompiler")).enable : undefined + + const prepare = (built: ReadonlyArray): ReadonlyArray => { + return built.map(({ ast, schema }) => { + if (enable !== undefined) enable(ast) + if (caseName === "struct-is") return SchemaParser.is(schema) + if (caseName === "default-make") return SchemaParser.make(schema) + return SchemaParser.decodeUnknownSync(schema) + }) + } + + return { build: (index: number) => buildEffectCase(modules, index), prepare } +} + +const loadZod = async (compiled: boolean) => { + const z = await import("zod/v4") + + const build = (index: number): BuiltCase => { + const suffix = String(index) + const name = `name${suffix}` + const age = `age${suffix}` + const active = `active${suffix}` + const person = () => z.object({ [name]: z.string(), [age]: z.number(), [active]: z.boolean() }) + const value = { [name]: "Ada", [age]: 37, [active]: true } + switch (caseName) { + case "struct-decode": + return { schema: person(), input: value, expected: value } + case "struct-invalid": + return { schema: person(), input: { ...value, [age]: "bad" }, expected: true, invalid: true } + case "struct-is": + return { schema: person(), input: value, expected: true } + case "array-decode": { + const input = Array.from({ length: 32 }, () => ({ ...value })) + return { schema: z.array(person()), input, expected: input } + } + case "union-decode": { + const tag = `tag${suffix}` + const schema = z.union( + Array.from({ length: 8 }, (_, member) => + z.object({ [tag]: z.literal(member), [`value${suffix}`]: z.number() })) as [ + ReturnType, + ReturnType, + ...Array> + ] + ) + const input = { [tag]: 7, [`value${suffix}`]: 1 } + return { schema, input, expected: input } + } + case "transform-decode": { + const schema = z.object( + Object.fromEntries( + Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, z.string().transform(Number)]) + ) + ) + const input = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, String(field)])) + const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) + return { schema, input, expected } + } + case "default-make": { + const schema = z.object( + Object.fromEntries( + Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, z.number().default(field)]) + ) + ) + const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) + return { schema, input: {}, expected } + } + } + } + + const prepare = (built: ReadonlyArray): ReadonlyArray => { + return built.map(({ schema }) => { + const target = compiled ? z.compile(schema, { strict: true }) : schema + if (caseName === "struct-is") { + return compiled + ? (input: unknown) => z.validate(target, input) + : (input: unknown) => z.validate(target, input, { jitless: true }) + } + return compiled + ? (input: unknown) => target.parse(input) + : (input: unknown) => target.parse(input, { jitless: true }) + }) + } + + return { build, prepare } +} + +const loadValibot = async () => { + const v = await import("valibot") + + const build = (index: number): BuiltCase => { + const suffix = String(index) + const name = `name${suffix}` + const age = `age${suffix}` + const active = `active${suffix}` + const person = () => v.object({ [name]: v.string(), [age]: v.number(), [active]: v.boolean() }) + const value = { [name]: "Ada", [age]: 37, [active]: true } + switch (caseName) { + case "struct-decode": + return { schema: person(), input: value, expected: value } + case "struct-invalid": + return { schema: person(), input: { ...value, [age]: "bad" }, expected: true, invalid: true } + case "struct-is": + return { schema: person(), input: value, expected: true } + case "array-decode": { + const input = Array.from({ length: 32 }, () => ({ ...value })) + return { schema: v.array(person()), input, expected: input } + } + case "union-decode": { + const tag = `tag${suffix}` + const schema = v.variant( + tag, + Array.from({ length: 8 }, (_, member) => + v.object({ [tag]: v.literal(member), [`value${suffix}`]: v.number() })) + ) + const input = { [tag]: 7, [`value${suffix}`]: 1 } + return { schema, input, expected: input } + } + case "transform-decode": { + const schema = v.object( + Object.fromEntries( + Array.from({ length: 32 }, (_, field) => [ + `v${suffix}_${field}`, + v.pipe(v.string(), v.transform(Number)) + ]) + ) + ) + const input = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, String(field)])) + const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) + return { schema, input, expected } + } + case "default-make": { + const schema = v.object( + Object.fromEntries( + Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, v.optional(v.number(), field)]) + ) + ) + const expected = Object.fromEntries(Array.from({ length: 32 }, (_, field) => [`v${suffix}_${field}`, field])) + return { schema, input: {}, expected } + } + } + } + + const prepare = (built: ReadonlyArray): ReadonlyArray => { + return built.map(({ schema }) => + caseName === "struct-is" + ? (input: unknown) => v.is(schema, input) + : (input: unknown) => v.parse(schema, input)) + } + + return { build, prepare } +} + +const runParser = (parser: Parser, built: BuiltCase): unknown => { + if (!built.invalid) return parser(built.input) + try { + parser(built.input) + return false + } catch { + return true + } +} + +const validate = (actual: unknown, built: BuiltCase) => assert.deepEqual(actual, built.expected) + +if (command === "generate") { + if (output === undefined) throw new Error("output is required for generate") + const [modules, moduleCpu] = await measureCpu(async () => { + return { + ...await loadEffectSchemaModules(), + AOT: await loadEffectModule("unstable/schema/SchemaAOTCompiler") + } + }) + const [built, schemaCpu] = await measureCpu(() => + Array.from({ length: count }, (_, index) => buildEffectCase(modules, index).ast) + ) + const [source, generateCpu] = await measureCpu(() => modules.AOT.compile(built)) + writeFileSync(output, source) + process.stdout.write(JSON.stringify({ + generateCpu, + heapBytes: process.memoryUsage().heapUsed, + maxRssBytes: process.resourceUsage().maxRSS * 1024, + moduleCpu, + schemaCpu, + sourceBytes: Buffer.byteLength(source) + })) +} else { + forceGc() + const beforeModule = memory() + const [library, moduleCpu] = await measureCpu(() => { + switch (implementation) { + case "effect-interpreted": + return loadEffect(false) + case "effect-jit": + case "effect-aot": + return loadEffect(implementation === "effect-jit") + case "zod-jitless": + case "zod-compiled": + return loadZod(implementation === "zod-compiled") + case "valibot": + return loadValibot() + } + }) + forceGc() + const afterModule = memory() + + const [built, schemaCpu] = await measureCpu(() => Array.from({ length: count }, (_, index) => library.build(index))) + forceGc() + const afterSchemas = memory() + + let aotBuild: unknown + let aotModuleCpu: CpuSample | undefined + let directory: string | undefined + let parsers: ReadonlyArray + let prepareCpu: CpuSample + try { + if (implementation === "effect-aot") { + directory = mkdtempSync(join(root, "packages/effect/.compiler-resources-")) + const generated = join(directory, "generated.mjs") + aotBuild = JSON.parse(execFileSync( + process.execPath, + ["--expose-gc", fileURLToPath(import.meta.url), "generate", root, implementation, caseName, String(count), generated], + { encoding: "utf8" } + )) + const [installed, measuredModuleCpu] = await measureCpu(async () => { + const generatedModule = await import(pathToFileURL(generated).href) + generatedModule.install(built.map((value) => value.ast)) + }) + void installed + aotModuleCpu = measuredModuleCpu + } + const prepared = await measureCpu(() => library.prepare(built)) + parsers = prepared[0] + prepareCpu = prepared[1] + forceGc() + const afterPrepare = memory() + + const [results, firstCallCpu] = await measureCpu(() => parsers.map((parser, index) => runParser(parser, built[index]))) + results.forEach((actual, index) => validate(actual, built[index])) + results.length = 0 + forceGc() + const afterFirstCall = memory() + + for (const value of built) { + value.input = undefined + value.expected = undefined + } + forceGc() + const afterRelease = memory() + + assert.equal(built.length, count) + assert.equal(parsers.length, count) + process.stdout.write(JSON.stringify({ + aotBuild, + case: caseName, + count, + cpu: { + aotModule: aotModuleCpu, + firstCall: firstCallCpu, + module: moduleCpu, + prepare: prepareCpu, + schema: schemaCpu + }, + implementation, + memory: { + compilerPerSchema: perSchema(delta(afterFirstCall, afterSchemas)), + firstCallPerSchema: perSchema(delta(afterFirstCall, afterPrepare)), + module: delta(afterModule, beforeModule), + preparePerSchema: perSchema(delta(afterPrepare, afterSchemas)), + retainedLibraryPerSchema: perSchema(delta(afterRelease, afterModule)), + schemaPerSchema: perSchema(delta(afterSchemas, afterModule)), + totalWithFixturePerSchema: perSchema(delta(afterFirstCall, afterModule)) + } + })) + } finally { + if (directory !== undefined) { + rmSync(directory, { recursive: true, force: true }) + } + } +} diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/run-resources.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/run-resources.mts new file mode 100644 index 00000000000..daa0ccb4721 --- /dev/null +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/run-resources.mts @@ -0,0 +1,93 @@ +import { execFileSync } from "node:child_process" +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import os from "node:os" +import { dirname, join, resolve } from "node:path" +import { createRequire } from "node:module" +import { fileURLToPath } from "node:url" + +const implementations = [ + "effect-interpreted", + "valibot", + "zod-jitless", + "effect-jit", + "effect-aot", + "zod-compiled" +] as const + +const cases = [ + "struct-decode", + "struct-invalid", + "struct-is", + "array-decode", + "union-decode", + "transform-decode", + "default-make" +] as const + +const [outputArgument = "tmp/schema-compiler-resources.json", roundsArgument = "5", countsArgument = "100,500"] = + process.argv.slice(2) +const rounds = Number(roundsArgument) +const counts = countsArgument.split(",").map(Number) +if (!Number.isSafeInteger(rounds) || rounds <= 0) throw new Error("rounds must be a positive integer") +if (counts.some((count) => !Number.isSafeInteger(count) || count <= 0)) { + throw new Error("counts must be comma-separated positive integers") +} + +const root = process.cwd() +const output = resolve(outputArgument) +const worker = fileURLToPath(new URL("./resources.mts", import.meta.url)) +const require = createRequire(import.meta.url) +const packageVersion = (name: string) => { + let directory = dirname(require.resolve(name)) + while (true) { + try { + const metadata = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")) + if (metadata.name === name) return metadata.version as string + } catch { + // Continue at the parent directory. + } + const parent = dirname(directory) + if (parent === directory) throw new Error(`package.json not found for ${name}`) + directory = parent + } +} + +const results: Array = [] +for (let round = 0; round < rounds; round++) { + const orderedImplementations = round % 2 === 0 ? implementations : implementations.toReversed() + const orderedCounts = round % 2 === 0 ? counts : counts.toReversed() + for (const count of orderedCounts) { + for (const caseName of cases) { + for (const implementation of orderedImplementations) { + const sample = JSON.parse(execFileSync( + process.execPath, + ["--expose-gc", worker, "measure", root, implementation, caseName, String(count)], + { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 } + )) + results.push({ round, ...sample }) + } + } + process.stderr.write(`resource round ${round + 1}/${rounds}, count ${count} complete\n`) + } +} + +mkdirSync(dirname(output), { recursive: true }) +writeFileSync(output, JSON.stringify({ + environment: { + arch: process.arch, + cpu: os.cpus()[0]?.model ?? "unknown", + node: process.version, + platform: process.platform, + valibot: packageVersion("valibot"), + v8: process.versions.v8, + zod: packageVersion("zod") + }, + measurement: { + cases, + counts, + implementations, + rounds + }, + results +}, null, 2)) +process.stdout.write(`${output}\n`) diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/zod.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/zod.ts index 2d127118aa9..245da3a858b 100644 --- a/packages/effect/runtimeperf/suites/moltar/fixtures/zod.ts +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/zod.ts @@ -87,13 +87,13 @@ const parseCompiled = (schema: ReturnType) => { return (input: unknown) => compiled.parse(input) } const validate = (schema: ReturnType) => (input: unknown) => z.validate(schema, input) +const validateJitless = (schema: ReturnType) => (input: unknown) => + z.validate(schema, input, { jitless: true }) const validateCompiled = (schema: ReturnType) => { const compiled = z.compile(schema, { strict: true }) return (input: unknown) => z.validate(compiled, input) } const assertParse = (schema: ReturnType) => (input: unknown) => schema.parse(input) -const assertParseJitless = (schema: ReturnType) => (input: unknown) => - schema.parse(input, { jitless: true }) export const parseValid = parseCase(parse, validData) export const parseExtraValid = parseCase(parse, validDataWithExtras) @@ -107,10 +107,11 @@ export const parseCompiledInvalid = parseCase(parseCompiled, invalidData, true) export const isValid = guardCase(validate, validData, true) export const isExtraValid = guardCase(validate, validDataWithExtras, true) export const isInvalid = guardCase(validate, invalidData, false) +export const isJitlessValid = guardCase(validateJitless, validData, true) +export const isJitlessExtraValid = guardCase(validateJitless, validDataWithExtras, true) +export const isJitlessInvalid = guardCase(validateJitless, invalidData, false) export const isCompiledValid = guardCase(validateCompiled, validData, true) export const isCompiledExtraValid = guardCase(validateCompiled, validDataWithExtras, true) export const isCompiledInvalid = guardCase(validateCompiled, invalidData, false) export const assertParseValid = assertParseCase(assertParse, validData) export const assertParseExtraValid = assertParseCase(assertParse, validDataWithExtras) -export const assertParseJitlessValid = assertParseCase(assertParseJitless, validData) -export const assertParseJitlessExtraValid = assertParseCase(assertParseJitless, validDataWithExtras) diff --git a/packages/effect/runtimeperf/test/registry.test.mts b/packages/effect/runtimeperf/test/registry.test.mts index d896701f147..52612f88dfa 100644 --- a/packages/effect/runtimeperf/test/registry.test.mts +++ b/packages/effect/runtimeperf/test/registry.test.mts @@ -143,8 +143,23 @@ describe("runtimeperf registry", () => { const paths = new Set(compiled.map((fixture) => fixture.fixturePath)) assert.equal(paths.size, 1) const source = await readFile([...paths][0], "utf8") - assert.match(source, /from "zod\/v4"/) - assert.match(source, /z\.compile\(source, \{ strict: true \}\)/) + assert.match(source, /from "\.\/zod-cases\.ts"/) + const shared = await readFile(new URL("../suites/compiler-rebuild/fixtures/zod-cases.ts", import.meta.url), "utf8") + assert.match(shared, /from "zod\/v4"/) + assert.match(shared, /z\.compile\(value\.schema, \{ strict: true \}\)/) + }) + + it("uses interpreted Zod for the jitless compiler comparison fixtures", async () => { + const { fixtures } = loadRegistry() + const jitless = fixtures.filter((fixture) => + fixture.suite === "compiler-rebuild" && fixture.implementation === "zod4-jitless" + ) + assert.equal(jitless.length, 10) + const paths = new Set(jitless.map((fixture) => fixture.fixturePath)) + assert.equal(paths.size, 1) + const source = await readFile(new URL("../suites/compiler-rebuild/fixtures/zod-cases.ts", import.meta.url), "utf8") + assert.match(source, /z\.validate\(value\.schema, input, \{ jitless: true \}\)/) + assert.match(source, /value\.schema\.parse\(input, \{ jitless: true \}\)/) }) it("loads, runs and validates every fixture export", async () => { From 77870d0a9fc145aab1b98544ddaca84a961aa6b0 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 16 Sep 2026 21:05:35 +0200 Subject: [PATCH 21/33] Compile only requested AOT schema operations --- .changeset/add-schema-compilers.md | 4 +- packages/effect/SCHEMA.md | 24 ++- .../suites/compiler-rebuild/costs.mts | 6 +- .../suites/compiler-rebuild/fixtures/cases.ts | 11 +- .../compiler-rebuild/fixtures/generate.mts | 4 +- .../suites/compiler-rebuild/resources.mts | 4 +- .../suites/moltar/fixtures/cases.ts | 7 +- .../suites/moltar/fixtures/generate.mts | 4 +- .../src/internal/schema/compilerRegistry.ts | 13 +- .../src/unstable/schema/SchemaAOTCompiler.ts | 171 ++++++++++++++---- .../schema/SchemaAOTCompiler/Build.ts | 44 +++-- .../test/schema/SchemaAOTCompiler.test.ts | 69 ++++++- .../schema/SchemaAOTCompilerBuild.test.ts | 3 + .../typetest/schema/SchemaAOTCompiler.tst.ts | 16 +- 14 files changed, 291 insertions(+), 89 deletions(-) diff --git a/.changeset/add-schema-compilers.md b/.changeset/add-schema-compilers.md index 79db5b6275c..a7292af247d 100644 --- a/.changeset/add-schema-compilers.md +++ b/.changeset/add-schema-compilers.md @@ -12,7 +12,9 @@ Schema exports from explicit module loaders and writes a self-installing AOT module through Effect's `FileSystem` and `Path` services. Compiled decoders can provide optional synchronous `decode` and `make` operations; normal `SchemaParser` calls consume them transparently and retain `decodeEffect` and -`makeEffect` as the detailed fallbacks. +`makeEffect` as the detailed fallbacks. AOT targets declare the operations to +prepare, so generated modules contain only those operation families and use +the interpreter if an omitted operation is later called. ### Breaking changes diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 9b19328ac6d..0f1112453b5 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -81,15 +81,25 @@ fails, the interpreter remains available. Exceptions from executing a parser are not treated as compilation failures and do not trigger a retry. JIT and AOT use the same source generator. To generate an AOT module at build -time, call `SchemaAOTCompiler.compile(asts)` with an ordered array of ASTs and -save the returned JavaScript. The module exports `install(asts)`. Call it with -the corresponding runtime ASTs before using normal `SchemaParser` functions. -Use a one-element array for a single schema. Generated modules do not import -the generator and work where `new Function` is forbidden. +time, call `SchemaAOTCompiler.compile(targets)` with an ordered array of ASTs +and the operations to prepare: + +```ts +SchemaAOTCompiler.compile([ + { ast: User.ast, operations: ["decode"] }, + { ast: SchemaAST.toType(User.ast), operations: ["is", "make"] } +]) +``` + +The module exports `install(asts)`. Call it with the target ASTs in the same +order before using normal `SchemaParser` functions. Generated modules contain +only the requested operation families and their dependencies. Operations that +were not requested use the interpreter if they are called. Generated modules +do not import the generator and work where `new Function` is forbidden. The low-level installation trusts the supplied root order and AST definitions. -Include `SchemaAST.toType(schema.ast)` for guards and construction, and -`SchemaAST.flip(schema.ast)` for encoding, when those are distinct ASTs. +Target `SchemaAST.toType(schema.ast)` with `is` or `make` for guards and +construction. Target `SchemaAST.flip(schema.ast)` with `decode` for encoding. `effect/unstable/schema/SchemaAOTCompiler/Build` provides the higher-level workflow. Its `build` function loads direct Schema exports, writes a diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts index 2d4df69a3ae..1280c99218d 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/costs.mts @@ -48,7 +48,9 @@ const expected = shape === "transform" || shape === "default" ? { value: 1 } : v if (command === "generate") { const AOT = await load("unstable/schema/SchemaAOTCompiler") const schema = create() - writeFileSync(process.argv[8], AOT.compile([schema.ast, AST.toType(schema.ast)])) + const targetOperation = operation === "make" ? "make" : operation === "is" ? "is" : "decode" + const ast = targetOperation === "decode" ? schema.ast : AST.toType(schema.ast) + writeFileSync(process.argv[8], AOT.compile([{ ast, operations: [targetOperation] }])) } else { for (let i = 0; i < 5; i++) globalThis.gc?.() const beforeImport = process.memoryUsage().heapUsed @@ -68,7 +70,7 @@ if (command === "generate") { const prepare = (schema: any) => { const ast = operation === "make" || operation === "is" ? AST.toType(schema.ast) : schema.ast if (enable) enable(ast) - if (install) install([schema.ast, AST.toType(schema.ast)]) + if (install) install([ast]) return operation === "make" ? Parser.make(schema) : operation === "is" ? Parser.is(schema) : Parser.decodeUnknownSync(schema) diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts index 73f98399054..c7fe4262222 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts @@ -90,9 +90,14 @@ export const cases: Record = { makeUnion: { schema: union, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 }, operation: "make" } } -export const roots = Object.values(cases).flatMap(( - { schema } -) => [schema.ast, SchemaAST.toType(schema.ast), SchemaAST.flip(schema.ast)]) +export const targets = Object.values(cases).map(({ operation, schema }) => + operation === "encode" + ? { ast: SchemaAST.flip(schema.ast), operations: ["decode"] as const } + : operation === "is" || operation === "make" + ? { ast: SchemaAST.toType(schema.ast), operations: [operation] as const } + : { ast: schema.ast, operations: ["decode"] as const } +) +export const roots = targets.map((target) => target.ast) export const fixture = (name: string) => { const { schema, input, expected, operation, invalid, options } = cases[name] diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/generate.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/generate.mts index a45d893f671..defdd093e59 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/generate.mts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/generate.mts @@ -1,5 +1,5 @@ import { writeFileSync } from "node:fs" import { compile } from "effect/unstable/schema/SchemaAOTCompiler" -import { roots } from "./cases.ts" +import { targets } from "./cases.ts" -writeFileSync(process.argv[2], compile(roots)) +writeFileSync(process.argv[2], compile(targets)) diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/resources.mts b/packages/effect/runtimeperf/suites/compiler-rebuild/resources.mts index 32b0caa25ef..8de96f956fe 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/resources.mts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/resources.mts @@ -371,7 +371,9 @@ if (command === "generate") { const [built, schemaCpu] = await measureCpu(() => Array.from({ length: count }, (_, index) => buildEffectCase(modules, index).ast) ) - const [source, generateCpu] = await measureCpu(() => modules.AOT.compile(built)) + const operation = caseName === "struct-is" ? "is" : caseName === "default-make" ? "make" : "decode" + const [source, generateCpu] = await measureCpu(() => + modules.AOT.compile(built.map((ast) => ({ ast, operations: [operation] })))) writeFileSync(output, source) process.stdout.write(JSON.stringify({ generateCpu, diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/cases.ts b/packages/effect/runtimeperf/suites/moltar/fixtures/cases.ts index ec1f23a7d15..ba38d5ea0b0 100644 --- a/packages/effect/runtimeperf/suites/moltar/fixtures/cases.ts +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/cases.ts @@ -18,7 +18,12 @@ export const schema = Schema.Struct({ }) }) -export const roots = [schema.ast, SchemaAST.toType(schema.ast), SchemaAST.flip(schema.ast)] +export const targets = [ + { ast: schema.ast, operations: ["decode"] }, + { ast: SchemaAST.toType(schema.ast), operations: ["is"] }, + { ast: SchemaAST.flip(schema.ast), operations: ["decode"] } +] as const +export const roots = targets.map((target) => target.ast) const parseCase = (input: unknown, invalid = false) => () => { const parse = SchemaParser.decodeUnknownSync(schema) diff --git a/packages/effect/runtimeperf/suites/moltar/fixtures/generate.mts b/packages/effect/runtimeperf/suites/moltar/fixtures/generate.mts index a45d893f671..defdd093e59 100644 --- a/packages/effect/runtimeperf/suites/moltar/fixtures/generate.mts +++ b/packages/effect/runtimeperf/suites/moltar/fixtures/generate.mts @@ -1,5 +1,5 @@ import { writeFileSync } from "node:fs" import { compile } from "effect/unstable/schema/SchemaAOTCompiler" -import { roots } from "./cases.ts" +import { targets } from "./cases.ts" -writeFileSync(process.argv[2], compile(roots)) +writeFileSync(process.argv[2], compile(targets)) diff --git a/packages/effect/src/internal/schema/compilerRegistry.ts b/packages/effect/src/internal/schema/compilerRegistry.ts index 698309e2815..5f2ca4d1dd0 100644 --- a/packages/effect/src/internal/schema/compilerRegistry.ts +++ b/packages/effect/src/internal/schema/compilerRegistry.ts @@ -12,7 +12,10 @@ export const invalid = Symbol() export type Resolve = (ast: SchemaAST.AST) => Entry /** @internal */ -export type Compile = (ast: SchemaAST.AST, resolve: Resolve) => CompiledDecoder | undefined +export type DecoderSource = Partial + +/** @internal */ +export type Compile = (ast: SchemaAST.AST, resolve: Resolve) => DecoderSource | undefined const cache = new WeakMap() let compiler: ((ast: SchemaAST.AST, resolve: Resolve) => Entry) | undefined @@ -37,7 +40,7 @@ const makeField = (ast: SchemaAST.AST): Parser => Interpreter.compileField(ast, /** @internal */ export interface Entry { readonly ast: SchemaAST.AST - readonly source?: CompiledDecoder | undefined + readonly source?: DecoderSource | undefined readonly resolve?: Resolve | undefined readonly is?: Is | undefined readonly decode?: Decode | undefined @@ -70,10 +73,10 @@ class InterpretedEntry implements Entry { } class CompilerEntry extends InterpretedEntry { - readonly source: CompiledDecoder | undefined + readonly source: DecoderSource | undefined readonly resolve: Resolve - constructor(ast: SchemaAST.AST, source: CompiledDecoder | undefined, resolve: Resolve) { + constructor(ast: SchemaAST.AST, source: DecoderSource | undefined, resolve: Resolve) { super(ast) this.source = source this.resolve = resolve @@ -163,7 +166,7 @@ export function resolve(ast: SchemaAST.AST): Entry { } /** @internal */ -export function set(ast: SchemaAST.AST, decoder: CompiledDecoder | undefined, resolveChild: Resolve = resolve): Entry { +export function set(ast: SchemaAST.AST, decoder: DecoderSource | undefined, resolveChild: Resolve = resolve): Entry { if (decoder !== undefined) activateCompilerAdapters() const entry = new CompilerEntry(ast, decoder, resolveChild) cache.set(ast, entry) diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts index 862d4511818..2aae57b117f 100644 --- a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts @@ -10,26 +10,54 @@ import * as SchemaAST from "../../SchemaAST.ts" import type { runtime } from "./SchemaCompiler/runtime.ts" const helper = (name: keyof typeof runtime): string => `R.${name}` -const operations: ReadonlyArray = ["is", "decode", "make", "decodeEffect", "makeEffect"] +const decoderOperationOrder: ReadonlyArray = [ + "is", + "decode", + "make", + "decodeEffect", + "makeEffect" +] +const operationOrder: ReadonlyArray = ["decode", "is", "make"] -const decoder = (ast: SchemaAST.AST): string | undefined => { - if (!Codegen.shouldCompileParser(ast)) return undefined - return "{" + operations.flatMap((key) => { - const source = Codegen.generate(ast, key) +const decoder = (sources: ReadonlyMap): string | undefined => { + const members = decoderOperationOrder.flatMap((key) => { + const source = sources.get(key) return source === undefined ? [] : [`get ${key}(){${source}}`] - }).join(",") + "}" + }).join(",") + return members.length === 0 ? undefined : `{${members}}` +} + +/** + * A parser operation prepared by {@link compile}. + * + * @category models + * @since 4.0.0 + */ +export type Operation = "decode" | "is" | "make" + +/** + * An exact AST and the parser operations to prepare for it. + * + * @category models + * @since 4.0.0 + */ +export interface Target { + /** The registry key installed by the generated module. */ + readonly ast: SchemaAST.AST + /** The operations that should be compiled for this AST. */ + readonly operations: ReadonlyArray } /** * Generates a JavaScript ES module exporting `install(asts): void` for an - * ordered array of ASTs and their statically reachable parsing and construction dependencies. + * ordered array of compilation targets. * * **When to use** * * Use to prepare decoders at build time for environments that disallow dynamic * function construction. Save the returned source as a JavaScript module, then - * call its `install` export with the corresponding runtime ASTs in the same - * order before using parsers. Use a one-element array for a single schema. + * call its `install` export with the target ASTs in the same order before using + * parsers. Use a one-element array for a single schema. * * **Details** * @@ -40,9 +68,11 @@ const decoder = (ast: SchemaAST.AST): string | undefined => { * asynchronous continuation helpers with the interpreter. Other detailed * traversals and transformation orchestration use the interpreter with * registry-resolved children. Transformations and middleware are not replayed. - * Repeated ASTs and shared dependencies are installed once by identity. Fast - * paths can still inline dependency code into multiple parent decoders. - * An empty array generates a module whose installation does nothing. + * Only the requested operation families and their static dependencies are + * emitted. Missing operations retain the lazy interpreter fallback in the + * shared registry. Repeated ASTs and shared dependencies are installed once by + * identity. Fast paths can still inline dependency code into multiple parent + * decoders. An empty array generates a module whose installation does nothing. * Construction uses independently lazy `make` and `makeEffect` operations. * Pure fixed Struct and homogeneous Array constructors can use `make` for a * synchronous fast path; failures delegate to `makeEffect` for detailed issues. @@ -55,7 +85,7 @@ const decoder = (ast: SchemaAST.AST): string | undefined => { * * Regenerate the module whenever the schema definition or Effect version * changes. Installation trusts that the runtime array has the same length and - * root order, and its ASTs have the same definitions and sharing as at build + * target order, and its ASTs have the same definitions and sharing as at build * time. Functions and symbols are read from those ASTs, not serialized. * Suspend thunks are not evaluated during generation; their contents and other * unsupported nodes use the interpreter. @@ -63,66 +93,127 @@ const decoder = (ast: SchemaAST.AST): string | undefined => { * older entries keep them. Type-side and flipped ASTs are separate registry * keys; generate and install them separately when needed. Importing the * generated module alone does not install anything. - * In particular, include `SchemaAST.toType(schema.ast)` to prepare construction - * when it differs from the encoded root. Unsupported constructors use the - * interpreter while statically installed children remain available. + * In particular, target `SchemaAST.toType(schema.ast)` with `make` to prepare + * construction when it differs from the encoded root. Target + * `SchemaAST.flip(schema.ast)` with `decode` to prepare encoding. Unsupported + * operations use the interpreter while statically installed children remain + * available. * * @category compilation * @since 4.0.0 */ -export const compile = (asts: ReadonlyArray): string => { - const seen = new Map() +export const compile = (targets: ReadonlyArray): string => { + interface PlannedNode { + readonly index: number + readonly name: string + readonly reference: string + readonly requested: Set + readonly sources: Map + readonly attempted: Set + readonly compilable: boolean + } + + const seen = new Map() const bindings: Array = [] const factories: Array = [] const installations: Array = [] - const visit = (node: SchemaAST.AST, reference: string): void => { - if (seen.has(node)) return - const index = seen.size - const name = `a${index}` - seen.set(node, name) - bindings.push(`const ${name}=${reference};`) + const addSource = (node: SchemaAST.AST, plan: PlannedNode, operation: Codegen.DecoderOperation): boolean => { + if (plan.attempted.has(operation)) return plan.sources.has(operation) + plan.attempted.add(operation) + if (!plan.compilable) return false + const source = Codegen.generate(node, operation) + if (source === undefined) return false + plan.sources.set(operation, source) + return true + } + const visitDependencies = (node: SchemaAST.AST, name: string, operation: Operation): void => { switch (node._tag) { case "Declaration": - node.typeParameters.forEach((child, index) => visit(child, `${name}.typeParameters[${index}]`)) + node.typeParameters.forEach((child, index) => visit(child, `${name}.typeParameters[${index}]`, operation)) break case "TemplateLiteral": - node.parts.forEach((child, index) => visit(child, `${name}.parts[${index}]`)) + node.parts.forEach((child, index) => visit(child, `${name}.parts[${index}]`, operation)) break case "Arrays": - node.elements.forEach((child, index) => visit(child, `${name}.elements[${index}]`)) - node.rest.forEach((child, index) => visit(child, `${name}.rest[${index}]`)) + node.elements.forEach((child, index) => visit(child, `${name}.elements[${index}]`, operation)) + node.rest.forEach((child, index) => visit(child, `${name}.rest[${index}]`, operation)) break case "Objects": node.propertySignatures.forEach((property, index) => - visit(property.type, `${name}.propertySignatures[${index}].type`) + visit(property.type, `${name}.propertySignatures[${index}].type`, operation) ) node.indexSignatures.forEach((signature, index) => { visit( SchemaAST.parameterFromPropertyKey(signature.parameter), - `${helper("parameterFromPropertyKey")}(${name}.indexSignatures[${index}].parameter)` + `${helper("parameterFromPropertyKey")}(${name}.indexSignatures[${index}].parameter)`, + operation ) - visit(signature.type, `${name}.indexSignatures[${index}].type`) + visit(signature.type, `${name}.indexSignatures[${index}].type`, operation) }) break case "Union": - node.types.forEach((child, index) => visit(child, `${name}.types[${index}]`)) + node.types.forEach((child, index) => visit(child, `${name}.types[${index}]`, operation)) break } - node.encoding?.forEach((link, index) => visit(link.to, `${name}.encoding[${index}].to`)) - const descriptor = SchemaAST.getConstructorDescriptor(node) - if (descriptor !== undefined) { - visit(descriptor.link.to, `${helper("getConstructorDescriptor")}(${name}).link.to`) + node.encoding?.forEach((link, index) => visit(link.to, `${name}.encoding[${index}].to`, operation)) + if (operation === "make") { + const descriptor = SchemaAST.getConstructorDescriptor(node) + if (descriptor !== undefined) { + visit(descriptor.link.to, `${helper("getConstructorDescriptor")}(${name}).link.to`, operation) + } + } + } + + function visit(node: SchemaAST.AST, reference: string, operation: Operation): void { + let plan = seen.get(node) + if (plan === undefined) { + const index = seen.size + plan = { + index, + name: `a${index}`, + reference, + requested: new Set(), + sources: new Map(), + attempted: new Set(), + compilable: Codegen.shouldCompileParser(node) + } + seen.set(node, plan) + } + if (plan.requested.has(operation)) return + plan.requested.add(operation) + + switch (operation) { + case "decode": + addSource(node, plan, "decode") + addSource(node, plan, "decodeEffect") + visitDependencies(node, plan.name, operation) + break + case "is": + if (!addSource(node, plan, "is")) visit(node, reference, "decode") + break + case "make": + addSource(node, plan, "make") + addSource(node, plan, "makeEffect") + visitDependencies(node, plan.name, operation) + break } + } - const source = decoder(node) + targets.forEach((target, index) => { + for (const operation of operationOrder) { + if (target.operations.includes(operation)) visit(target.ast, `asts[${index}]`, operation) + } + }) + for (const plan of seen.values()) { + bindings.push(`const ${plan.name}=${plan.reference};`) + const source = decoder(plan.sources) if (source !== undefined) { - factories.push(`function d${index}(ast,R,resolve){return ${source}}`) - installations.push(`${helper("set")}(${name},d${index}(${name},R,R.resolve));`) + factories.push(`function d${plan.index}(ast,R,resolve){return ${source}}`) + installations.push(`${helper("set")}(${plan.name},d${plan.index}(${plan.name},R,R.resolve));`) } } - asts.forEach((ast, index) => visit(ast, `asts[${index}]`)) return [ "// Generated by SchemaAOTCompiler. Regenerate after schema or Effect changes.", "import { runtime as R } from \"effect/unstable/schema/SchemaCompiler/runtime\";", diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler/Build.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler/Build.ts index 438956edf2b..10e167bbed5 100644 --- a/packages/effect/src/unstable/schema/SchemaAOTCompiler/Build.ts +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler/Build.ts @@ -130,19 +130,28 @@ const importPath = ( const root = ( exported: ExportedSchema, operation: Operation -): readonly [ast: SchemaAST.AST, expression: string, derived: boolean] => { +): readonly [ + ast: SchemaAST.AST, + operation: SchemaAOTCompiler.Operation, + expression: string, + derived: boolean +] => { const expression = `${exported.alias}[${JSON.stringify(exported.exportName)}].ast` switch (operation) { case "decode": - return [exported.schema.ast, expression, false] + return [exported.schema.ast, "decode", expression, false] case "encode": { const ast = SchemaAST.flip(exported.schema.ast) - return ast === exported.schema.ast ? [ast, expression, false] : [ast, `A.flip(${expression})`, true] + return ast === exported.schema.ast + ? [ast, "decode", expression, false] + : [ast, "decode", `A.flip(${expression})`, true] } case "is": case "make": { const ast = SchemaAST.toType(exported.schema.ast) - return ast === exported.schema.ast ? [ast, expression, false] : [ast, `A.toType(${expression})`, true] + return ast === exported.schema.ast + ? [ast, operation, expression, false] + : [ast, operation, `A.toType(${expression})`, true] } } } @@ -232,25 +241,32 @@ export const build: ( const source = yield* Effect.try({ try: () => { - const asts: Array = [] - const expressions: Array = [] - const seen = new Set() + const targets: Array<{ + readonly ast: SchemaAST.AST + readonly operations: Array + readonly expression: string + }> = [] + const seen = new Map() let needsSchemaASTImport = false for (const exported of exportedSchemas) { for (const operation of operations) { - const [ast, expression, isDerived] = root(exported, operation) - if (seen.has(ast)) continue - seen.add(ast) - asts.push(ast) - expressions.push(expression) + const [ast, targetOperation, expression, isDerived] = root(exported, operation) + const index = seen.get(ast) + if (index !== undefined) { + const targetOperations = targets[index].operations + if (!targetOperations.includes(targetOperation)) targetOperations.push(targetOperation) + continue + } + seen.set(ast, targets.length) + targets.push({ ast, operations: [targetOperation], expression }) needsSchemaASTImport ||= isDerived } } return [ ...imports, ...(needsSchemaASTImport ? ["import * as A from \"effect/SchemaAST\";"] : []), - SchemaAOTCompiler.compile(asts), - `install([${expressions.join(",")}]);`, + SchemaAOTCompiler.compile(targets), + `install([${targets.map((target) => target.expression).join(",")}]);`, "" ].join("\n") }, diff --git a/packages/effect/test/schema/SchemaAOTCompiler.test.ts b/packages/effect/test/schema/SchemaAOTCompiler.test.ts index bcb46da7a1e..77e5928ae70 100644 --- a/packages/effect/test/schema/SchemaAOTCompiler.test.ts +++ b/packages/effect/test/schema/SchemaAOTCompiler.test.ts @@ -1,11 +1,11 @@ import { assert, describe, it } from "@effect/vitest" -import { Schema } from "effect" +import { Schema, SchemaParser } from "effect" import * as CompilerRegistry from "effect/internal/schema/compilerRegistry" import * as SchemaAOTCompiler from "effect/unstable/schema/SchemaAOTCompiler" import { execFileSync } from "node:child_process" import { mkdtempSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" -import { fileURLToPath } from "node:url" +import { fileURLToPath, pathToFileURL } from "node:url" import { roots, schemas, suspendEvaluations } from "./fixtures/aot.ts" describe("SchemaAOTCompiler", { concurrent: false }, () => { @@ -18,8 +18,9 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { })) }) const before = CompilerRegistry.resolve(schema.ast) - const source = SchemaAOTCompiler.compile([schema.ast]) - assert.strictEqual(SchemaAOTCompiler.compile([schema.ast]), source) + const targets = [{ ast: schema.ast, operations: ["decode"] }] as const + const source = SchemaAOTCompiler.compile(targets) + assert.strictEqual(SchemaAOTCompiler.compile(targets), source) assert.strictEqual(CompilerRegistry.resolve(schema.ast), before) assert.strictEqual(checks, 0) assert.include(source, "effect/unstable/schema/SchemaCompiler/runtime") @@ -27,13 +28,59 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { assert.notInclude(source, "SchemaJITCompiler") }) + it("emits only the requested operation family", () => { + const schema = Schema.Struct({ value: Schema.String }) + const decode = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }]) + assert.include(decode, "get decode(){") + assert.include(decode, "get decodeEffect(){") + assert.notInclude(decode, "get is(){") + assert.notInclude(decode, "get make(){") + assert.notInclude(decode, "get makeEffect(){") + + const make = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["make"] }]) + assert.include(make, "get make(){") + assert.include(make, "get makeEffect(){") + assert.notInclude(make, "get is(){") + assert.notInclude(make, "get decode(){") + }) + + it("uses registry fallbacks for operations that were not requested", async () => { + const schema = Schema.Struct({ value: Schema.String }) + const directory = mkdtempSync(fileURLToPath(new URL("../../.schema-aot-operations-test-", import.meta.url))) + try { + const file = join(directory, "decode.mjs") + writeFileSync(file, SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }])) + const generated = await import(`${pathToFileURL(file).href}?test=${Date.now()}`) + generated.install([schema.ast]) + + const source = CompilerRegistry.resolve(schema.ast).source + assert.isDefined(source?.decode) + assert.isUndefined(source?.is) + assert.isUndefined(source?.make) + assert.isUndefined(source?.makeEffect) + assert.strictEqual(SchemaParser.is(schema)({ value: "a" }), true) + assert.deepStrictEqual(SchemaParser.make(schema)({ value: "a" }), { value: "a" }) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + it("deduplicates repeated roots and dependencies shared across roots", () => { const child = Schema.Struct({ value: Schema.String }) const first = Schema.Struct({ child }) const second = Schema.Array(child) - const source = SchemaAOTCompiler.compile([first.ast, second.ast]) + const source = SchemaAOTCompiler.compile([ + { ast: first.ast, operations: ["decode"] }, + { ast: second.ast, operations: ["decode"] } + ]) assert.strictEqual( - SchemaAOTCompiler.compile([first.ast, second.ast, child.ast, first.ast, second.ast]), + SchemaAOTCompiler.compile([ + { ast: first.ast, operations: ["decode"] }, + { ast: second.ast, operations: ["decode"] }, + { ast: child.ast, operations: ["decode"] }, + { ast: first.ast, operations: ["decode"] }, + { ast: second.ast, operations: ["decode"] } + ]), source ) }) @@ -42,9 +89,15 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { const directory = mkdtempSync(fileURLToPath(new URL("../../.schema-aot-test-", import.meta.url))) try { for (const [name, schema] of Object.entries(schemas)) { - writeFileSync(join(directory, `${name}.mjs`), SchemaAOTCompiler.compile([schema.ast])) + writeFileSync( + join(directory, `${name}.mjs`), + SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode", "is", "make"] }]) + ) } - writeFileSync(join(directory, "all.mjs"), SchemaAOTCompiler.compile(roots)) + writeFileSync( + join(directory, "all.mjs"), + SchemaAOTCompiler.compile(roots.map((ast) => ({ ast, operations: ["decode", "is", "make"] }))) + ) writeFileSync(join(directory, "empty.mjs"), SchemaAOTCompiler.compile([])) assert.strictEqual(suspendEvaluations, 0) for (const mode of ["single", "multiple"]) { diff --git a/packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts b/packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts index 620f620767f..fb1d71363d6 100644 --- a/packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts +++ b/packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts @@ -100,6 +100,9 @@ describe("SchemaAOTCompilerBuild", { concurrent: false }, () => { }) assert.notInclude(written, "import * as A from \"effect/SchemaAST\"") assert.notInclude(written, "ignored.ts") + assert.notInclude(written, "get is(){") + assert.notInclude(written, "get make(){") + assert.notInclude(written, "get makeEffect(){") assert.include(written, "install([m0[\"Port\"].ast,m0[\"User\"].ast]);") })) diff --git a/packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts b/packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts index 613675606ec..6fb337a8695 100644 --- a/packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts +++ b/packages/effect/typetest/schema/SchemaAOTCompiler.tst.ts @@ -4,11 +4,21 @@ import { describe, expect, it } from "tstyche" describe("SchemaAOTCompiler", () => { it("compiles ASTs to module source", () => { - expect(SchemaAOTCompiler.compile([Schema.String.ast])).type.toBe() - expect(SchemaAOTCompiler.compile).type.toBeCallableWith([SchemaAST.string, SchemaAST.number] as const) + expect(SchemaAOTCompiler.compile([{ ast: Schema.String.ast, operations: ["decode"] }])).type.toBe() + expect(SchemaAOTCompiler.compile).type.toBeCallableWith( + [ + { ast: SchemaAST.string, operations: ["decode"] }, + { ast: SchemaAST.number, operations: ["is", "make"] } + ] as const + ) expect(SchemaAOTCompiler.compile).type.toBeCallableWith([]) expect(SchemaAOTCompiler.compile).type.not.toBeCallableWith(SchemaAST.string) expect(SchemaAOTCompiler.compile).type.not.toBeCallableWith(Schema.String) - expect(SchemaAOTCompiler.compile).type.not.toBeCallableWith([Schema.String]) + expect(SchemaAOTCompiler.compile).type.not.toBeCallableWith([SchemaAST.string]) + expect(SchemaAOTCompiler.compile).type.not.toBeCallableWith( + [ + { ast: SchemaAST.string, operations: ["encode"] } + ] as const + ) }) }) From df0041644e5a1625e906552ca940bc06a1926c13 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 16 Sep 2026 21:58:46 +0200 Subject: [PATCH 22/33] Deduplicate AOT decoder factories --- .../effect/src/unstable/schema/SchemaAOTCompiler.ts | 10 ++++++++-- packages/effect/test/schema/SchemaAOTCompiler.test.ts | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts index 2aae57b117f..568b8a89ba3 100644 --- a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts @@ -116,6 +116,7 @@ export const compile = (targets: ReadonlyArray): string => { const seen = new Map() const bindings: Array = [] const factories: Array = [] + const factoryNames = new Map() const installations: Array = [] const addSource = (node: SchemaAST.AST, plan: PlannedNode, operation: Codegen.DecoderOperation): boolean => { @@ -210,8 +211,13 @@ export const compile = (targets: ReadonlyArray): string => { bindings.push(`const ${plan.name}=${plan.reference};`) const source = decoder(plan.sources) if (source !== undefined) { - factories.push(`function d${plan.index}(ast,R,resolve){return ${source}}`) - installations.push(`${helper("set")}(${plan.name},d${plan.index}(${plan.name},R,R.resolve));`) + let factory = factoryNames.get(source) + if (factory === undefined) { + factory = `d${factoryNames.size}` + factoryNames.set(source, factory) + factories.push(`function ${factory}(ast,R,resolve){return ${source}}`) + } + installations.push(`${helper("set")}(${plan.name},${factory}(${plan.name},R,R.resolve));`) } } return [ diff --git a/packages/effect/test/schema/SchemaAOTCompiler.test.ts b/packages/effect/test/schema/SchemaAOTCompiler.test.ts index 77e5928ae70..e4a1e39b54b 100644 --- a/packages/effect/test/schema/SchemaAOTCompiler.test.ts +++ b/packages/effect/test/schema/SchemaAOTCompiler.test.ts @@ -85,6 +85,14 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { ) }) + it("reuses identical decoder factories", () => { + const schema = Schema.Union( + Array.from({ length: 8 }, (_, tag) => Schema.Struct({ tag: Schema.Literal(tag), value: Schema.Number })) + ) + const source = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }]) + assert.strictEqual(source.match(/function d\d+\(ast,R,resolve\)/g)?.length, 2) + }) + it("runs generated decoders without dynamic code generation", () => { const directory = mkdtempSync(fileURLToPath(new URL("../../.schema-aot-test-", import.meta.url))) try { From 3cb8c6c21ac28f3ae1dd803cd29170ab203f95ba Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Wed, 16 Sep 2026 22:30:48 +0200 Subject: [PATCH 23/33] Avoid redundant AOT dependency fast paths --- .../src/unstable/schema/SchemaAOTCompiler.ts | 11 ++++++++--- .../test/schema/SchemaAOTCompiler.test.ts | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts index 568b8a89ba3..1fdbc3f0f23 100644 --- a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts @@ -35,6 +35,8 @@ const decoder = (sources: ReadonlyMap): string */ export type Operation = "decode" | "is" | "make" +type PlannedOperation = Operation | "decodeEffect" + /** * An exact AST and the parser operations to prepare for it. * @@ -107,7 +109,7 @@ export const compile = (targets: ReadonlyArray): string => { readonly index: number readonly name: string readonly reference: string - readonly requested: Set + readonly requested: Set readonly sources: Map readonly attempted: Set readonly compilable: boolean @@ -129,7 +131,7 @@ export const compile = (targets: ReadonlyArray): string => { return true } - const visitDependencies = (node: SchemaAST.AST, name: string, operation: Operation): void => { + const visitDependencies = (node: SchemaAST.AST, name: string, operation: PlannedOperation): void => { switch (node._tag) { case "Declaration": node.typeParameters.forEach((child, index) => visit(child, `${name}.typeParameters[${index}]`, operation)) @@ -167,7 +169,7 @@ export const compile = (targets: ReadonlyArray): string => { } } - function visit(node: SchemaAST.AST, reference: string, operation: Operation): void { + function visit(node: SchemaAST.AST, reference: string, operation: PlannedOperation): void { let plan = seen.get(node) if (plan === undefined) { const index = seen.size @@ -188,6 +190,9 @@ export const compile = (targets: ReadonlyArray): string => { switch (operation) { case "decode": addSource(node, plan, "decode") + visit(node, reference, "decodeEffect") + break + case "decodeEffect": addSource(node, plan, "decodeEffect") visitDependencies(node, plan.name, operation) break diff --git a/packages/effect/test/schema/SchemaAOTCompiler.test.ts b/packages/effect/test/schema/SchemaAOTCompiler.test.ts index e4a1e39b54b..49d4a00568a 100644 --- a/packages/effect/test/schema/SchemaAOTCompiler.test.ts +++ b/packages/effect/test/schema/SchemaAOTCompiler.test.ts @@ -44,6 +44,21 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { assert.notInclude(make, "get decode(){") }) + it("omits fast decode operations from diagnostic-only dependencies", () => { + const child = Schema.Struct({ value: Schema.String }) + const schema = Schema.Array(child) + const source = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }]) + assert.strictEqual(source.match(/get decode\(\)\{/g)?.length, 1) + assert.strictEqual(source.match(/get decodeEffect\(\)\{/g)?.length, 2) + + const targeted = SchemaAOTCompiler.compile([ + { ast: schema.ast, operations: ["decode"] }, + { ast: child.ast, operations: ["decode"] } + ]) + assert.strictEqual(targeted.match(/get decode\(\)\{/g)?.length, 2) + assert.strictEqual(targeted.match(/get decodeEffect\(\)\{/g)?.length, 2) + }) + it("uses registry fallbacks for operations that were not requested", async () => { const schema = Schema.Struct({ value: Schema.String }) const directory = mkdtempSync(fileURLToPath(new URL("../../.schema-aot-operations-test-", import.meta.url))) @@ -77,12 +92,12 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { SchemaAOTCompiler.compile([ { ast: first.ast, operations: ["decode"] }, { ast: second.ast, operations: ["decode"] }, - { ast: child.ast, operations: ["decode"] }, { ast: first.ast, operations: ["decode"] }, { ast: second.ast, operations: ["decode"] } ]), source ) + assert.strictEqual(source.match(/R\.set\(/g)?.length, 3) }) it("reuses identical decoder factories", () => { From 05db58c0056febd96766ebfeb8c00c9354575c9e Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Thu, 17 Sep 2026 07:57:33 +0200 Subject: [PATCH 24/33] Reduce generated transformation decoder size --- packages/effect/src/internal/schema/codegen.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts index e6a243aac8d..78f155f1d19 100644 --- a/packages/effect/src/internal/schema/codegen.ts +++ b/packages/effect/src/internal/schema/codegen.ts @@ -768,8 +768,12 @@ const canInlineEncoding = (ast: SchemaAST.AST): ast is SchemaAST.AST & { readonl (link.transformation.decode._tag === "Passthrough" || link.transformation.decode._tag === "Transform") ) +const inlinePropertyHandler = + `const handle=(state,index,property,present,input,result)=>{let value;if(result===R.sameExit){if(!present)return;value=input}else{if(!R.effectIsExit(result))return resume(state,index,result);if(result._tag!=="Success"||(value=result[R.args])===R.missing)return step(state,property,result)}const name=property.name;if(name==="__proto__")Object.defineProperty(state.out,name,{value,writable:true,enumerable:true,configurable:true});else state.out[name]=value}` + const emitObject = (ast: SchemaAST.Objects): string => { const initializers: Array = [] + let usesInlinePropertyHandler = false const statements = [ "if(i===R.missing)return R.missingExit", "if(o.errors===\"all\"||o.onExcessProperty!==void 0||(o.concurrency!==void 0&&o.concurrency!==1))return fallback(i,o)", @@ -787,6 +791,8 @@ const emitObject = (ast: SchemaAST.Objects): string => { `else{t=step(state,p${index},r);if(t)return t}}` const propertyPath = `ast.propertySignatures[${index}].type` if (canInlineEncoding(property.type)) { + usesInlinePropertyHandler = true + const handleInline = `t=handle(state,${index},p${index},h${index},v${index},r);if(t)return t` const links = property.type.encoding const sourcePath = `${propertyPath}.encoding[${links.length - 1}].to` const source = inlineIdentityPredicate(links[links.length - 1].to, `v${index}`, sourcePath)! @@ -813,11 +819,11 @@ const emitObject = (ast: SchemaAST.Objects): string => { ) } fast.push( - `};if(r!==void 0){${handle}}else{${assignProperty("out", key, `x${index}`, property.name)}}` + `};if(r!==void 0){${handleInline}}else{${assignProperty("out", key, `x${index}`, property.name)}}` ) const run = `if(v${index}!==R.missing&&(${source})){${ fast.join(";") - }}else{r=p${index}.parser(v${index},o);${handle}}` + }}else{r=p${index}.parser(v${index},o);${handleInline}}` statements.push( `const p${index}=properties[${index}],h${index}=${present},v${index}=h${index}?i[${key}]:R.missing`, run @@ -831,6 +837,9 @@ const emitObject = (ast: SchemaAST.Objects): string => { } }) statements.push("return R.succeed(out)") + if (usesInlinePropertyHandler) { + initializers.unshift(inlinePropertyHandler) + } return `function({ast,getProperties,fallback,resume,step}){${initializers.join(";")};return function(i,o){try{${ statements.join(";") }}catch(e){return R.die(e)}}}` From f70d81d298b101ce91b4e0082320368cb5eb66da Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Thu, 17 Sep 2026 08:19:34 +0200 Subject: [PATCH 25/33] Reduce compiled transformation failure overhead --- .../effect/src/internal/schema/codegen.ts | 5 +---- .../unstable/schema/SchemaCompiler/runtime.ts | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts index 78f155f1d19..4b5084b8883 100644 --- a/packages/effect/src/internal/schema/codegen.ts +++ b/packages/effect/src/internal/schema/codegen.ts @@ -811,11 +811,8 @@ const emitObject = (ast: SchemaAST.Objects): string => { : `${propertyPath}.encoding[${linkIndex - 1}].to` const target = linkIndex === 0 ? property.type : links[linkIndex - 1].to const predicate = inlineIdentityPredicate(target, `x${index}`, targetPath)! - const failure = linkIndex === 0 - ? `R.invalidType(${propertyPath},x${index},o)` - : `R.wrapEncoding(${propertyPath},v${index},o,R.invalidType(${targetPath},x${index},o))` fast.push( - `if(x${index}===R.missing){r=R.missingExit;break l${index}}else if(!(${predicate})){r=${failure};break l${index}}` + `if(x${index}===R.missing){r=R.missingExit;break l${index}}else if(!(${predicate})){r=R.invalidEncoding(p${index}.type,${linkIndex},v${index},x${index},o);break l${index}}` ) } fast.push( diff --git a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts index da7d78a945f..8944b4d877a 100644 --- a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts +++ b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts @@ -200,6 +200,20 @@ const hasExcessProperties = ( return Reflect.ownKeys(input).some((key) => !covered.has(key)) } +const invalidType = (ast: SchemaAST.AST, input: unknown, options: SchemaAST.ParseOptions) => + Effect.fail(new SchemaIssue.InvalidType(ast, input, options)) + +const invalidEncoding = ( + ast: SchemaAST.AST & { readonly encoding: SchemaAST.Encoding }, + index: number, + input: unknown, + value: unknown, + options: SchemaAST.ParseOptions +) => + index === 0 + ? invalidType(ast, value, options) + : Interpreter.wrapEncoding(ast, input, options, invalidType(ast.encoding[index - 1].to, value, options)) + /** @internal */ export const runtime = { decode, @@ -214,9 +228,8 @@ export const runtime = { succeed: InternalParser.succeed, effectIsExit, die: Effect.die, - invalidType: (ast: SchemaAST.AST, input: unknown, options: SchemaAST.ParseOptions) => - Effect.fail(new SchemaIssue.InvalidType(ast, input, options)), - wrapEncoding: Interpreter.wrapEncoding, + invalidType, + invalidEncoding, failsChecks, getExpectedKeys: (ast: SchemaAST.Objects) => ast.propertySignatures.map((p) => typeof p.name === "number" ? String(p.name) : p.name), From 3c886e857fe33830644bc232afd2200cbc22bab6 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Thu, 17 Sep 2026 08:38:31 +0200 Subject: [PATCH 26/33] Deduplicate compiled transformation bindings --- .../effect/src/internal/schema/codegen.ts | 13 ++++++++---- packages/effect/test/schema/fixtures/aot.ts | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts index 4b5084b8883..fa70c3ddb05 100644 --- a/packages/effect/src/internal/schema/codegen.ts +++ b/packages/effect/src/internal/schema/codegen.ts @@ -773,6 +773,7 @@ const inlinePropertyHandler = const emitObject = (ast: SchemaAST.Objects): string => { const initializers: Array = [] + const transforms = new Map() let usesInlinePropertyHandler = false const statements = [ "if(i===R.missing)return R.missingExit", @@ -800,10 +801,14 @@ const emitObject = (ast: SchemaAST.Objects): string => { for (let linkIndex = links.length - 1; linkIndex >= 0; linkIndex--) { const transformation = links[linkIndex].transformation if (transformation._tag === "Transformation" && transformation.decode._tag === "Transform") { - const transform = `t${index}_${linkIndex}` - initializers.push( - `const ${transform}=${propertyPath}.encoding[${linkIndex}].transformation.decode.transform` - ) + let transform = transforms.get(transformation.decode.transform) + if (transform === undefined) { + transform = `t${transforms.size}` + transforms.set(transformation.decode.transform, transform) + initializers.push( + `const ${transform}=${propertyPath}.encoding[${linkIndex}].transformation.decode.transform` + ) + } fast.push(`x${index}=${transform}(x${index})`) } const targetPath = linkIndex === 0 diff --git a/packages/effect/test/schema/fixtures/aot.ts b/packages/effect/test/schema/fixtures/aot.ts index a760e955a9a..d10f3b6ac52 100644 --- a/packages/effect/test/schema/fixtures/aot.ts +++ b/packages/effect/test/schema/fixtures/aot.ts @@ -32,6 +32,15 @@ const pureTransformed = Schema.String.pipe( ) ) +const pureTransform = (decode: (input: string) => number) => + Schema.String.pipe( + Schema.decodeTo( + Schema.Number, + SchemaTransformation.transform({ decode, encode: String }) + ) + ) +const sharedPureTransform = pureTransform(Number) + const middleware = transformed.pipe( Schema.middlewareDecoding((effect) => { events.push("middleware") @@ -177,6 +186,18 @@ export const synchronous = { schema: Schema.Struct({ value: pureTransformed }), inputs: [{ value: "2" }, { value: false }, { value: "invalid" }] }, + pureTransformedFields: { + schema: Schema.Struct({ + first: sharedPureTransform, + second: sharedPureTransform, + incremented: pureTransform((input) => Number(input) + 1), + doubled: pureTransform((input) => Number(input) * 2) + }), + inputs: [ + { first: "1", second: "2", incremented: "3", doubled: "4" }, + { first: false, second: "2", incremented: "3", doubled: "4" } + ] + }, middleware: { schema: middleware, inputs: ["2", "-1", false] From d32389edc837ac91cbf6f70f2885f42c2b78c29d Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Thu, 17 Sep 2026 08:57:53 +0200 Subject: [PATCH 27/33] Reuse parsers for shared compiled fields --- .../unstable/schema/SchemaCompiler/runtime.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts index 8944b4d877a..6b7f9b5c12f 100644 --- a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts +++ b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts @@ -48,12 +48,18 @@ const makeObjectBase = ( generate: GenerateObject ): SchemaIssueParser => { let properties: Array | undefined - const getProperties = (): Array => - properties ??= ast.propertySignatures.map((property) => ({ - parser: compileField(property.type), - name: property.name, - type: property.type - })) + const getProperties = (): Array => { + if (properties !== undefined) return properties + const parsers = new Map() + return properties = ast.propertySignatures.map((property) => { + let parser = parsers.get(property.type) + if (parser === undefined) { + parser = compileField(property.type) + parsers.set(property.type, parser) + } + return { parser, name: property.name, type: property.type } + }) + } let fallback: SchemaIssueParser | undefined const runFallback: SchemaIssueParser = (input, options) => (fallback ??= ast.getParser(compile, compileField))(input, options) From c5c0007358462c672f0b7641f973ae32e809b34d Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Thu, 17 Sep 2026 13:32:08 +0200 Subject: [PATCH 28/33] Refine SchemaTransformation APIs --- .changeset/add-schema-compilers.md | 12 +++++-- packages/effect/SCHEMA.md | 4 +-- packages/effect/src/Schema.ts | 6 ++-- packages/effect/src/SchemaTransformation.ts | 35 +++++++++++-------- packages/effect/test/schema/Schema.test.ts | 4 +-- .../test/schema/SchemaTransformation.test.ts | 24 +++++++++++-- .../typetest/schema/SchemaGetter.tst.ts | 34 ++++++++++++++++-- 7 files changed, 89 insertions(+), 30 deletions(-) diff --git a/.changeset/add-schema-compilers.md b/.changeset/add-schema-compilers.md index a7292af247d..5a88e849b20 100644 --- a/.changeset/add-schema-compilers.md +++ b/.changeset/add-schema-compilers.md @@ -30,9 +30,15 @@ The public `Getter` constructor is removed. Use `transformEffect` for an effectful transformation of present values and `transformOptionalEffect` when the transformation handles missing values. -`SchemaTransformation.compose` is now a dual standalone function. Replace -`first.compose(second)` with `SchemaTransformation.compose(first, second)` or -`SchemaTransformation.compose(second)(first)`. +`Transformation#compose` is replaced by the dual standalone function +`SchemaTransformation.composeTransformation`. Replace `first.compose(second)` +with `SchemaTransformation.composeTransformation(first, second)` or +`SchemaTransformation.composeTransformation(second)(first)`. + +`SchemaTransformation.make` is renamed to +`SchemaTransformation.makeTransformation`. `SchemaTransformation.Transformation` +and `SchemaTransformation.Middleware` now implement `Pipeable`, so both values +can be passed through standalone combinators with `.pipe(...)`. `SchemaAST.Context.constructorDefault` now stores the constructor-default `Effect` directly instead of wrapping it in a `SchemaAST.Link`. Constructor diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 0f1112453b5..d4ab66fa33b 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -3237,7 +3237,7 @@ In this case: ## Composing Transformations -You can combine transformations using `SchemaTransformation.compose`. The resulting transformation applies the `decode` and `encode` logic of both transformations in sequence. +You can combine transformations using `SchemaTransformation.composeTransformation`. The resulting transformation applies the `decode` and `encode` logic of both transformations in sequence. **Example** (Trim and lowercase a string) @@ -3245,7 +3245,7 @@ You can combine transformations using `SchemaTransformation.compose`. The result import { Schema, SchemaTransformation } from "effect" // Compose two transformations: trim followed by toLowerCase -const trimToLowerCase = SchemaTransformation.compose( +const trimToLowerCase = SchemaTransformation.composeTransformation( SchemaTransformation.trim(), SchemaTransformation.toLowerCase() ) diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index c6897d77b38..99c50960945 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -5404,7 +5404,7 @@ export function decodeTo() { readonly encode: SchemaGetter.Getter, T> } ): SchemaAST.Link => { - return new SchemaAST.Link(encodeTo.ast, SchemaTransformation.make(transformation)) + return new SchemaAST.Link(encodeTo.ast, SchemaTransformation.makeTransformation(transformation)) } } /** @@ -14774,7 +14774,7 @@ export function overrideToCodecIso( return (schema: S): overrideToCodecIso => { return make( SchemaAST.annotate(schema.ast, { - toCodecIso: () => new SchemaAST.Link(to.ast, SchemaTransformation.make(transformation)) + toCodecIso: () => new SchemaAST.Link(to.ast, SchemaTransformation.makeTransformation(transformation)) }), { schema } ) diff --git a/packages/effect/src/SchemaTransformation.ts b/packages/effect/src/SchemaTransformation.ts index 8a882c2d111..d02d894a840 100644 --- a/packages/effect/src/SchemaTransformation.ts +++ b/packages/effect/src/SchemaTransformation.ts @@ -20,6 +20,7 @@ import * as Effect from "./Effect.ts" import { format, formatDate, formatJson } from "./Formatter.ts" import { dual } from "./Function.ts" import * as Option from "./Option.ts" +import * as Pipeable from "./Pipeable.ts" import * as Predicate from "./Predicate.ts" import type { ErrorOptions, Json } from "./Schema.ts" import type * as SchemaAST from "./SchemaAST.ts" @@ -48,6 +49,7 @@ import * as SchemaIssue from "./SchemaIssue.ts" * `Effect, Issue, REE>`. * - `flip()` swaps the decode and encode functions, producing a * `Middleware`. + * - Middleware values implement `Pipeable`. * * Typically constructed indirectly via `Schema.middlewareDecoding` or * `Schema.middlewareEncoding` rather than by using `new Middleware` directly. @@ -70,7 +72,7 @@ import * as SchemaIssue from "./SchemaIssue.ts" * @category models * @since 4.0.0 */ -export interface Middleware { +export interface Middleware extends Pipeable.Pipeable { readonly _tag: "Middleware" readonly decode: ( effect: Effect.Effect, SchemaIssue.Issue, RDE>, @@ -98,7 +100,7 @@ export const Middleware: new( effect: Effect.Effect, SchemaIssue.Issue, RET>, options: SchemaAST.ParseOptions ) => Effect.Effect, SchemaIssue.Issue, REE> -) => Middleware = class { +) => Middleware = class extends Pipeable.Class { readonly _tag = "Middleware" readonly decode: ( effect: Effect.Effect, SchemaIssue.Issue, RDE>, @@ -119,6 +121,7 @@ export const Middleware: new( options: SchemaAST.ParseOptions ) => Effect.Effect, SchemaIssue.Issue, REE> ) { + super() this.decode = decode this.encode = encode } @@ -146,9 +149,10 @@ const TypeId = "~effect/SchemaTransformation/Transformation" * `Schema.decode`, `Schema.encode`, and `Schema.link`. Each direction is a * `SchemaGetter.Getter` that handles optionality, failure, and Effect services. * - * - Immutable — `flip()` and {@link compose} return new instances. + * - Immutable — `flip()` and {@link composeTransformation} return new instances. + * - Transformation values implement `Pipeable`. * - `flip()` swaps the decode and encode getters. - * - `compose(self, other)` chains: `self.decode` then `other.decode` for decoding, + * - `composeTransformation(self, other)` chains: `self.decode` then `other.decode` for decoding, * `other.encode` then `self.encode` for encoding. * * **Example** (Composing two transformations) @@ -156,14 +160,14 @@ const TypeId = "~effect/SchemaTransformation/Transformation" * ```ts import.meta.vitest * import { SchemaTransformation } from "effect" * - * const trimAndLower = SchemaTransformation.compose( + * const trimAndLower = SchemaTransformation.composeTransformation( * SchemaTransformation.trim(), * SchemaTransformation.toLowerCase() * ) * trimAndLower._tag // => "Transformation" * ``` * - * @see {@link make} — construct from `{ decode, encode }` getters + * @see {@link makeTransformation} — construct from `{ decode, encode }` getters * @see {@link transform} — construct from pure functions * @see {@link transformEffect} — construct from effectful functions * @see {@link Middleware} — effect-pipeline-level alternative @@ -171,7 +175,7 @@ const TypeId = "~effect/SchemaTransformation/Transformation" * @category models * @since 4.0.0 */ -export interface Transformation { +export interface Transformation extends Pipeable.Pipeable { readonly [TypeId]: typeof TypeId readonly _tag: "Transformation" readonly decode: SchemaGetter.Getter @@ -188,7 +192,7 @@ export interface Transformation { export const Transformation: new( decode: SchemaGetter.Getter, encode: SchemaGetter.Getter -) => Transformation = class { +) => Transformation = class extends Pipeable.Class { readonly [TypeId] = TypeId readonly _tag = "Transformation" readonly decode: SchemaGetter.Getter @@ -198,6 +202,7 @@ export const Transformation: new( decode: SchemaGetter.Getter, encode: SchemaGetter.Getter ) { + super() this.decode = decode this.encode = encode } @@ -218,14 +223,14 @@ export const Transformation: new( * * Decoding applies `self.decode` followed by `other.decode`. Encoding applies * `other.encode` followed by `self.encode`. The function supports both - * `compose(self, other)` and `compose(other)(self)`. + * `composeTransformation(self, other)` and `composeTransformation(other)(self)`. * * **Example** (Trimming and lowercasing a string) * * ```ts import.meta.vitest * import { Schema, SchemaTransformation } from "effect" * - * const transformation = SchemaTransformation.compose( + * const transformation = SchemaTransformation.composeTransformation( * SchemaTransformation.trim(), * SchemaTransformation.toLowerCase() * ) @@ -237,7 +242,7 @@ export const Transformation: new( * @category combining * @since 4.0.0 */ -export const compose: { +export const composeTransformation: { ( other: Transformation ): (self: Transformation) => Transformation @@ -277,7 +282,7 @@ export const compose: { * ``` * * @see {@link Transformation} - * @see {@link make} + * @see {@link makeTransformation} * * @category guards * @since 4.0.0 @@ -305,7 +310,7 @@ export function isTransformation(u: unknown): u is Transformation((s) => Number(s)), * encode: SchemaGetter.transform((n) => String(n)) * }) @@ -319,7 +324,7 @@ export function isTransformation(u: unknown): u is Transformation(options: { +export const makeTransformation = (options: { readonly decode: SchemaGetter.Getter readonly encode: SchemaGetter.Getter }): Transformation => { @@ -367,7 +372,7 @@ export const make = (options: { * * @see {@link transform} — for infallible, pure transformations * @see {@link transformOptional} — for transformations that handle missing keys - * @see {@link make} — for transformations from existing Getters + * @see {@link makeTransformation} — for transformations from existing Getters * * @category transforming * @since 3.10.0 diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index 8a67678b429..03f85e516c7 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -2406,7 +2406,7 @@ Expected a value between -2147483648 and 2147483647` it("double transformation", async () => { const schema = Schema.String.pipe( Schema.decode( - SchemaTransformation.compose( + SchemaTransformation.composeTransformation( SchemaTransformation.trim(), SchemaTransformation.toLowerCase() ) @@ -2555,7 +2555,7 @@ Expected a value between -2147483648 and 2147483647` it("double transformation", async () => { const schema = Schema.String.pipe( Schema.encode( - SchemaTransformation.compose( + SchemaTransformation.composeTransformation( SchemaTransformation.trim(), SchemaTransformation.toLowerCase() ).flip() diff --git a/packages/effect/test/schema/SchemaTransformation.test.ts b/packages/effect/test/schema/SchemaTransformation.test.ts index 33029305dd0..de4195f4ebe 100644 --- a/packages/effect/test/schema/SchemaTransformation.test.ts +++ b/packages/effect/test/schema/SchemaTransformation.test.ts @@ -1,10 +1,30 @@ -import { SchemaTransformation } from "effect" +import { SchemaGetter, SchemaTransformation } from "effect" import { describe, it } from "vitest" -import { assertFalse, assertTrue } from "../utils/assert.ts" +import { assertFalse, assertTrue, strictEqual } from "../utils/assert.ts" describe("SchemaTransformation", () => { it("isTransformation", () => { assertTrue(SchemaTransformation.isTransformation(SchemaTransformation.passthrough())) assertFalse(SchemaTransformation.isTransformation({ "~effect/SchemaTransformation/Transformation": false })) }) + + it("pipe", () => { + const transformation = SchemaTransformation.passthrough() + const middleware = new SchemaTransformation.Middleware( + (effect) => effect, + (effect) => effect + ) + + strictEqual(transformation.pipe((self) => self), transformation) + strictEqual(middleware.pipe((self) => self), middleware) + }) + + it("makeTransformation preserves an existing transformation", () => { + const transformation = SchemaTransformation.makeTransformation({ + decode: SchemaGetter.transform(Number), + encode: SchemaGetter.transform(String) + }) + + strictEqual(SchemaTransformation.makeTransformation(transformation), transformation) + }) }) diff --git a/packages/effect/typetest/schema/SchemaGetter.tst.ts b/packages/effect/typetest/schema/SchemaGetter.tst.ts index 90f527a6cd1..42c1ee24438 100644 --- a/packages/effect/typetest/schema/SchemaGetter.tst.ts +++ b/packages/effect/typetest/schema/SchemaGetter.tst.ts @@ -32,14 +32,42 @@ describe("SchemaGetter", () => { }) describe("SchemaTransformation", () => { - it("compose", () => { + it("makeTransformation", () => { + const decode = null as unknown as SchemaGetter.Getter + const encode = null as unknown as SchemaGetter.Getter + + expect(SchemaTransformation.makeTransformation({ decode, encode })).type.toBe< + SchemaTransformation.Transformation + >() + }) + + it("pipe", () => { + const transformation = null as unknown as SchemaTransformation.Transformation + const middleware = null as unknown as SchemaTransformation.Middleware< + number, + string, + "RDE", + "RDT", + "RET", + "REE" + > + + expect(transformation.pipe((self) => self)).type.toBe< + SchemaTransformation.Transformation + >() + expect(middleware.pipe((self) => self)).type.toBe< + SchemaTransformation.Middleware + >() + }) + + it("composeTransformation", () => { const first = null as unknown as SchemaTransformation.Transformation const second = null as unknown as SchemaTransformation.Transformation - expect(SchemaTransformation.compose(first, second)).type.toBe< + expect(SchemaTransformation.composeTransformation(first, second)).type.toBe< SchemaTransformation.Transformation >() - expect(SchemaTransformation.compose(second)(first)).type.toBe< + expect(SchemaTransformation.composeTransformation(second)(first)).type.toBe< SchemaTransformation.Transformation >() }) From 1882532be4b7eaca30b9509ebfa376c838ddae2d Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Thu, 17 Sep 2026 14:45:25 +0200 Subject: [PATCH 29/33] Reduce schema compiler preparation costs --- packages/effect/SCHEMA.md | 6 +++ .../effect/src/internal/schema/codegen.ts | 17 +++++--- .../src/internal/schema/compilerRegistry.ts | 22 ++++++++-- .../src/unstable/schema/SchemaAOTCompiler.ts | 22 +++++++--- .../unstable/schema/SchemaCompiler/runtime.ts | 17 +++++++- .../test/schema/SchemaAOTCompiler.test.ts | 41 ++++++++++++++++++- 6 files changed, 109 insertions(+), 16 deletions(-) diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index d4ab66fa33b..13651212abe 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -165,6 +165,12 @@ already captured an entry retain it. Late installation is allowed, but startup installation is needed to optimize every consumer. Custom decoders supplied to `set` are trusted to implement the AST's semantics. +An AOT module materializes explicitly requested roots during installation. It +also installs entries for their static dependencies immediately, but creates a +dependency's decoder source only when an operation first needs it. Both kinds +of entry live in the same `WeakMap`: there is no separate AOT cache, and a later +`SchemaCompiler.set` for the same AST replaces either kind in the same way. + ### What is specialized Encoding-free graphs of supported primitives, Objects, Arrays, tuples, Unions diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts index fa70c3ddb05..5a1ff5cffe7 100644 --- a/packages/effect/src/internal/schema/codegen.ts +++ b/packages/effect/src/internal/schema/codegen.ts @@ -775,11 +775,15 @@ const emitObject = (ast: SchemaAST.Objects): string => { const initializers: Array = [] const transforms = new Map() let usesInlinePropertyHandler = false + const lazyProperties = ast.propertySignatures.every((property) => + typeof property.name !== "symbol" && canInlineEncoding(property.type) + ) + if (lazyProperties) initializers.push("const p=i=>getProperties()[i]") const statements = [ "if(i===R.missing)return R.missingExit", "if(o.errors===\"all\"||o.onExcessProperty!==void 0||(o.concurrency!==void 0&&o.concurrency!==1))return fallback(i,o)", "if(typeof i!==\"object\"||i===null||Array.isArray(i))return R.invalidType(ast,i,o)", - "const properties=getProperties(),out={}", + lazyProperties ? "const out={}" : "const properties=getProperties(),out={}", "const state={ast,input:i,out,options:o,issues:void 0}", "let r,t,value" ] @@ -793,7 +797,8 @@ const emitObject = (ast: SchemaAST.Objects): string => { const propertyPath = `ast.propertySignatures[${index}].type` if (canInlineEncoding(property.type)) { usesInlinePropertyHandler = true - const handleInline = `t=handle(state,${index},p${index},h${index},v${index},r);if(t)return t` + const descriptor = lazyProperties ? `p(${index})` : `p${index}` + const handleInline = `t=handle(state,${index},${descriptor},h${index},v${index},r);if(t)return t` const links = property.type.encoding const sourcePath = `${propertyPath}.encoding[${links.length - 1}].to` const source = inlineIdentityPredicate(links[links.length - 1].to, `v${index}`, sourcePath)! @@ -817,7 +822,7 @@ const emitObject = (ast: SchemaAST.Objects): string => { const target = linkIndex === 0 ? property.type : links[linkIndex - 1].to const predicate = inlineIdentityPredicate(target, `x${index}`, targetPath)! fast.push( - `if(x${index}===R.missing){r=R.missingExit;break l${index}}else if(!(${predicate})){r=R.invalidEncoding(p${index}.type,${linkIndex},v${index},x${index},o);break l${index}}` + `if(x${index}===R.missing){r=R.missingExit;break l${index}}else if(!(${predicate})){r=R.invalidEncoding(${descriptor}.type,${linkIndex},v${index},x${index},o);break l${index}}` ) } fast.push( @@ -825,9 +830,11 @@ const emitObject = (ast: SchemaAST.Objects): string => { ) const run = `if(v${index}!==R.missing&&(${source})){${ fast.join(";") - }}else{r=p${index}.parser(v${index},o);${handleInline}}` + }}else{r=${descriptor}.parser(v${index},o);${handleInline}}` statements.push( - `const p${index}=properties[${index}],h${index}=${present},v${index}=h${index}?i[${key}]:R.missing`, + lazyProperties + ? `const h${index}=${present},v${index}=h${index}?i[${key}]:R.missing` + : `const p${index}=properties[${index}],h${index}=${present},v${index}=h${index}?i[${key}]:R.missing`, run ) } else { diff --git a/packages/effect/src/internal/schema/compilerRegistry.ts b/packages/effect/src/internal/schema/compilerRegistry.ts index 5f2ca4d1dd0..f646b688b75 100644 --- a/packages/effect/src/internal/schema/compilerRegistry.ts +++ b/packages/effect/src/internal/schema/compilerRegistry.ts @@ -73,13 +73,13 @@ class InterpretedEntry implements Entry { } class CompilerEntry extends InterpretedEntry { - readonly source: DecoderSource | undefined readonly resolve: Resolve + private sourceValue: DecoderSource | Compile | undefined - constructor(ast: SchemaAST.AST, source: DecoderSource | undefined, resolve: Resolve) { + constructor(ast: SchemaAST.AST, source: DecoderSource | Compile | undefined, resolve: Resolve) { super(ast) - this.source = source this.resolve = resolve + this.sourceValue = source } private save(key: K, value: Entry[K]): Entry[K] { @@ -87,6 +87,12 @@ class CompilerEntry extends InterpretedEntry { return value } + get source(): DecoderSource | undefined { + const source = this.sourceValue + if (typeof source !== "function") return source + return this.save("source", this.sourceValue = source(this.ast, this.resolve)) + } + get is(): Is | undefined { return this.save("is", this.source?.is) } @@ -173,6 +179,16 @@ export function set(ast: SchemaAST.AST, decoder: DecoderSource | undefined, reso return entry } +/** @internal */ +export function setFactory(ast: SchemaAST.AST, compile: Compile, resolveChild: Resolve = resolve): Entry { + // Install the entry immediately so that replacement order remains identical + // to `set`; only materialization of its decoder source is deferred. + activateCompilerAdapters() + const entry = new CompilerEntry(ast, compile, resolveChild) + cache.set(ast, entry) + return entry +} + /** @internal */ export function install(compile: Compile): void { activateCompilerAdapters() diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts index 1fdbc3f0f23..9a7c56b6988 100644 --- a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts @@ -71,10 +71,13 @@ export interface Target { * traversals and transformation orchestration use the interpreter with * registry-resolved children. Transformations and middleware are not replayed. * Only the requested operation families and their static dependencies are - * emitted. Missing operations retain the lazy interpreter fallback in the - * shared registry. Repeated ASTs and shared dependencies are installed once by - * identity. Fast paths can still inline dependency code into multiple parent - * decoders. An empty array generates a module whose installation does nothing. + * emitted. Installation materializes each requested root immediately. Its + * dependency entries are installed in the same registry at the same time, but + * their decoder sources are materialized only on first use. Missing operations + * retain the lazy interpreter fallback. Repeated ASTs and shared dependencies + * are installed once by identity. Fast paths can still inline dependency code + * into multiple parent decoders. An empty array generates a module whose + * installation does nothing. * Construction uses independently lazy `make` and `makeEffect` operations. * Pure fixed Struct and homogeneous Array constructors can use `make` for a * synchronous fast path; failures delegate to `makeEffect` for detailed issues. @@ -113,8 +116,10 @@ export const compile = (targets: ReadonlyArray): string => { readonly sources: Map readonly attempted: Set readonly compilable: boolean + readonly targeted: boolean } + const targetsByAst = new Set(targets.map((target) => target.ast)) const seen = new Map() const bindings: Array = [] const factories: Array = [] @@ -180,7 +185,8 @@ export const compile = (targets: ReadonlyArray): string => { requested: new Set(), sources: new Map(), attempted: new Set(), - compilable: Codegen.shouldCompileParser(node) + compilable: Codegen.shouldCompileParser(node), + targeted: targetsByAst.has(node) } seen.set(node, plan) } @@ -222,7 +228,11 @@ export const compile = (targets: ReadonlyArray): string => { factoryNames.set(source, factory) factories.push(`function ${factory}(ast,R,resolve){return ${source}}`) } - installations.push(`${helper("set")}(${plan.name},${factory}(${plan.name},R,R.resolve));`) + installations.push( + plan.targeted + ? `${helper("set")}(${plan.name},${factory}(${plan.name},R,R.resolve));` + : `${helper("setFactory")}(${plan.name},${factory},R);` + ) } } return [ diff --git a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts index 6b7f9b5c12f..697db32f8f7 100644 --- a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts +++ b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts @@ -7,7 +7,15 @@ */ import * as Effect from "../../../Effect.ts" import { effectIsExit, resolveConcurrency } from "../../../internal/effect.ts" -import { lazyParser, type Resolve, resolve, set, withDecode } from "../../../internal/schema/compilerRegistry.ts" +import { + type DecoderSource, + lazyParser, + type Resolve, + resolve, + set, + setFactory as setCompilerFactory, + withDecode +} from "../../../internal/schema/compilerRegistry.ts" import * as Interpreter from "../../../internal/schema/interpreter.ts" import * as InternalParser from "../../../internal/schema/parser.ts" import * as SchemaAST from "../../../SchemaAST.ts" @@ -181,6 +189,12 @@ const make = ( return Interpreter.compile(ast, child, field, base) } +const setFactory = ( + ast: SchemaAST.AST, + factory: (ast: SchemaAST.AST, context: A, resolve: Resolve) => DecoderSource | undefined, + context: A +) => setCompilerFactory(ast, (ast, resolve) => factory(ast, context, resolve)) + const failsChecks = ( ast: SchemaAST.AST, value: unknown, @@ -226,6 +240,7 @@ export const runtime = { make, resolve, set, + setFactory, invalid, missing: InternalParser.missing, missingExit: InternalParser.missingExit, diff --git a/packages/effect/test/schema/SchemaAOTCompiler.test.ts b/packages/effect/test/schema/SchemaAOTCompiler.test.ts index 49d4a00568a..d84c6af1af2 100644 --- a/packages/effect/test/schema/SchemaAOTCompiler.test.ts +++ b/packages/effect/test/schema/SchemaAOTCompiler.test.ts @@ -1,6 +1,7 @@ import { assert, describe, it } from "@effect/vitest" import { Schema, SchemaParser } from "effect" import * as CompilerRegistry from "effect/internal/schema/compilerRegistry" +import * as SchemaTransformation from "effect/SchemaTransformation" import * as SchemaAOTCompiler from "effect/unstable/schema/SchemaAOTCompiler" import { execFileSync } from "node:child_process" import { mkdtempSync, rmSync, writeFileSync } from "node:fs" @@ -44,6 +45,13 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { assert.notInclude(make, "get decode(){") }) + it("allows a target without requested operations", () => { + const schema = Schema.Struct({ value: Schema.String }) + const source = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: [] }]) + assert.notInclude(source, "R.set(") + assert.notInclude(source, "R.setFactory(") + }) + it("omits fast decode operations from diagnostic-only dependencies", () => { const child = Schema.Struct({ value: Schema.String }) const schema = Schema.Array(child) @@ -80,6 +88,36 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { } }) + it("materializes dependency decoders on first use", async () => { + const child = Schema.String.pipe( + Schema.decodeTo( + Schema.Number, + SchemaTransformation.transform({ decode: Number, encode: String }) + ), + Schema.annotate({ title: "lazy AOT dependency" }) + ) + const schema = Schema.Struct({ child }) + const directory = mkdtempSync(fileURLToPath(new URL("../../.schema-aot-lazy-test-", import.meta.url))) + try { + const file = join(directory, "decode.mjs") + writeFileSync(file, SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }])) + const generated = await import(`${pathToFileURL(file).href}?test=${Date.now()}`) + generated.install([schema.ast]) + + const dependency = CompilerRegistry.resolve(child.ast) + assert.isFalse(Object.hasOwn(dependency, "source")) + + const decode = SchemaParser.decodeUnknownSync(schema) + assert.deepStrictEqual(decode({ child: "1" }), { child: 1 }) + assert.isFalse(Object.hasOwn(dependency, "source")) + + assert.throws(() => decode({ child: false })) + assert.isTrue(Object.hasOwn(dependency, "source")) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + it("deduplicates repeated roots and dependencies shared across roots", () => { const child = Schema.Struct({ value: Schema.String }) const first = Schema.Struct({ child }) @@ -97,7 +135,8 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { ]), source ) - assert.strictEqual(source.match(/R\.set\(/g)?.length, 3) + assert.strictEqual(source.match(/R\.set\(/g)?.length, 2) + assert.strictEqual(source.match(/R\.setFactory\(/g)?.length, 1) }) it("reuses identical decoder factories", () => { From 02734510a9a67c21af42c1b4f266f8407e812dcc Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Thu, 17 Sep 2026 16:03:16 +0200 Subject: [PATCH 30/33] Reduce compiled transformation preparation costs --- .../effect/src/internal/schema/codegen.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts index 5a1ff5cffe7..ac17a767004 100644 --- a/packages/effect/src/internal/schema/codegen.ts +++ b/packages/effect/src/internal/schema/codegen.ts @@ -775,6 +775,7 @@ const emitObject = (ast: SchemaAST.Objects): string => { const initializers: Array = [] const transforms = new Map() let usesInlinePropertyHandler = false + let usesInlinePropertyFailure = false const lazyProperties = ast.propertySignatures.every((property) => typeof property.name !== "symbol" && canInlineEncoding(property.type) ) @@ -802,6 +803,30 @@ const emitObject = (ast: SchemaAST.Objects): string => { const links = property.type.encoding const sourcePath = `${propertyPath}.encoding[${links.length - 1}].to` const source = inlineIdentityPredicate(links[links.length - 1].to, `v${index}`, sourcePath)! + // Share cold diagnostics without adding a call to the successful path. + if (lazyProperties && links.length === 1 && property.name !== "__proto__") { + usesInlinePropertyFailure = true + const transformation = links[0].transformation + let decoded = `v${index}` + if (transformation._tag === "Transformation" && transformation.decode._tag === "Transform") { + let transform = transforms.get(transformation.decode.transform) + if (transform === undefined) { + transform = `t${transforms.size}` + transforms.set(transformation.decode.transform, transform) + initializers.push(`const ${transform}=${propertyPath}.encoding[0].transformation.decode.transform`) + } + decoded = `${transform}(v${index})` + } + const predicate = inlineIdentityPredicate(property.type, `x${index}`, propertyPath)! + statements.push( + `const h${index}=${present},v${index}=h${index}?i[${key}]:R.missing`, + `if(v${index}!==R.missing&&(${source})){const x${index}=${decoded};` + + `if(x${index}!==R.missing&&(${predicate})){out[${key}]=x${index}}` + + `else{t=invalid(state,${index},h${index},v${index},x${index},o);if(t)return t}}` + + `else{t=fallbackProperty(state,${index},h${index},v${index},o);if(t)return t}` + ) + return + } const fast: Array = [`let x${index}=v${index};r=void 0;l${index}:{`] for (let linkIndex = links.length - 1; linkIndex >= 0; linkIndex--) { const transformation = links[linkIndex].transformation @@ -849,6 +874,12 @@ const emitObject = (ast: SchemaAST.Objects): string => { if (usesInlinePropertyHandler) { initializers.unshift(inlinePropertyHandler) } + if (usesInlinePropertyFailure) { + initializers.push( + "const invalid=(state,index,present,input,output,o)=>{const property=p(index);return handle(state,index,property,present,input,output===R.missing?R.missingExit:R.invalidEncoding(property.type,0,input,output,o))}", + "const fallbackProperty=(state,index,present,input,o)=>{const property=p(index);return handle(state,index,property,present,input,property.parser(input,o))}" + ) + } return `function({ast,getProperties,fallback,resume,step}){${initializers.join(";")};return function(i,o){try{${ statements.join(";") }}catch(e){return R.die(e)}}}` From 2ae9c6404795d6325a5c5a98e8961ef66ddc97f5 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Thu, 17 Sep 2026 17:39:32 +0200 Subject: [PATCH 31/33] Add schema compiler coverage benchmarks --- packages/effect/runtimeperf/config.json | 111 ++++++++++++++++++ .../suites/compiler-rebuild/README.md | 11 +- .../suites/compiler-rebuild/fixtures/aot.ts | 3 + .../suites/compiler-rebuild/fixtures/cases.ts | 19 +++ .../compiler-rebuild/fixtures/interpreted.ts | 3 + .../suites/compiler-rebuild/fixtures/jit.ts | 3 + .../compiler-rebuild/fixtures/valibot.ts | 17 +++ .../compiler-rebuild/fixtures/zod-cases.ts | 16 +++ .../compiler-rebuild/fixtures/zod-compiled.ts | 1 + .../compiler-rebuild/fixtures/zod-jitless.ts | 1 + .../effect/runtimeperf/test/registry.test.mts | 4 +- 11 files changed, 182 insertions(+), 7 deletions(-) diff --git a/packages/effect/runtimeperf/config.json b/packages/effect/runtimeperf/config.json index 607012584f3..4258f608351 100644 --- a/packages/effect/runtimeperf/config.json +++ b/packages/effect/runtimeperf/config.json @@ -2263,6 +2263,16 @@ "scenario": "compiler-rebuild-record", "size": 32 }, + { + "name": "interpreted-recordTransformedKeys", + "export": "recordTransformedKeys", + "scenario": "compiler-rebuild-recordTransformedKeys", + "astTags": [ + "Objects", + "String" + ], + "size": 32 + }, { "name": "interpreted-union", "export": "union", @@ -2309,6 +2319,16 @@ ], "size": 1 }, + { + "name": "interpreted-rootTransform", + "export": "rootTransform", + "scenario": "compiler-rebuild-rootTransform", + "astTags": [ + "String", + "Number" + ], + "size": 1 + }, { "name": "interpreted-middleware", "export": "middleware", @@ -2326,6 +2346,17 @@ ], "size": 31 }, + { + "name": "interpreted-suspendRoot", + "export": "suspendRoot", + "scenario": "compiler-rebuild-suspendRoot", + "astTags": [ + "Suspend", + "Objects", + "Number" + ], + "size": 1 + }, { "name": "interpreted-declaration", "export": "declaration", @@ -2495,6 +2526,16 @@ "scenario": "compiler-rebuild-record", "size": 32 }, + { + "name": "jit-recordTransformedKeys", + "export": "recordTransformedKeys", + "scenario": "compiler-rebuild-recordTransformedKeys", + "astTags": [ + "Objects", + "String" + ], + "size": 32 + }, { "name": "jit-union", "export": "union", @@ -2541,6 +2582,16 @@ ], "size": 1 }, + { + "name": "jit-rootTransform", + "export": "rootTransform", + "scenario": "compiler-rebuild-rootTransform", + "astTags": [ + "String", + "Number" + ], + "size": 1 + }, { "name": "jit-middleware", "export": "middleware", @@ -2558,6 +2609,17 @@ ], "size": 31 }, + { + "name": "jit-suspendRoot", + "export": "suspendRoot", + "scenario": "compiler-rebuild-suspendRoot", + "astTags": [ + "Suspend", + "Objects", + "Number" + ], + "size": 1 + }, { "name": "jit-declaration", "export": "declaration", @@ -2727,6 +2789,16 @@ "scenario": "compiler-rebuild-record", "size": 32 }, + { + "name": "aot-recordTransformedKeys", + "export": "recordTransformedKeys", + "scenario": "compiler-rebuild-recordTransformedKeys", + "astTags": [ + "Objects", + "String" + ], + "size": 32 + }, { "name": "aot-union", "export": "union", @@ -2773,6 +2845,16 @@ ], "size": 1 }, + { + "name": "aot-rootTransform", + "export": "rootTransform", + "scenario": "compiler-rebuild-rootTransform", + "astTags": [ + "String", + "Number" + ], + "size": 1 + }, { "name": "aot-middleware", "export": "middleware", @@ -2790,6 +2872,17 @@ ], "size": 31 }, + { + "name": "aot-suspendRoot", + "export": "suspendRoot", + "scenario": "compiler-rebuild-suspendRoot", + "astTags": [ + "Suspend", + "Objects", + "Number" + ], + "size": 1 + }, { "name": "aot-declaration", "export": "declaration", @@ -2891,6 +2984,12 @@ "scenario": "compiler-rebuild-record", "size": 32 }, + { + "name": "valibot-recordTransformedKeys", + "export": "recordTransformedKeys", + "scenario": "compiler-rebuild-recordTransformedKeys", + "size": 32 + }, { "name": "valibot-union", "export": "union", @@ -2967,6 +3066,12 @@ "scenario": "compiler-rebuild-record", "size": 32 }, + { + "name": "zod-jitless-recordTransformedKeys", + "export": "recordTransformedKeys", + "scenario": "compiler-rebuild-recordTransformedKeys", + "size": 32 + }, { "name": "zod-jitless-union", "export": "union", @@ -3073,6 +3178,12 @@ "scenario": "compiler-rebuild-record", "size": 32 }, + { + "name": "zod-compiled-recordTransformedKeys", + "export": "recordTransformedKeys", + "scenario": "compiler-rebuild-recordTransformedKeys", + "size": 32 + }, { "name": "zod-compiled-union", "export": "union", diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/README.md b/packages/effect/runtimeperf/suites/compiler-rebuild/README.md index 0a8ccc20d20..25997e2c25b 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/README.md +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/README.md @@ -1,12 +1,13 @@ # Compiler rebuild comparison -The `compiler-rebuild` suite measures 22 public SchemaParser operations with the -interpreter, selective JIT and generated AOT modules. Eighteen cases also run -against `z.compile(schema, { strict: true })`; ten representative cases run +The `compiler-rebuild` suite measures 25 public SchemaParser operations with the +interpreter, selective JIT and generated AOT modules. Nineteen cases also run +against `z.compile(schema, { strict: true })`; eleven representative cases run against Valibot and Zod with `{ jitless: true }`. The fixture families name the execution mode. Cases -cover simple and nested Structs, Arrays, tuples, Records, anyOf/oneOf Unions, -transformations, middleware, recursive schemas, Declarations, construction, +cover simple and nested Structs, Arrays, tuples, Records, transformed Record +keys, anyOf/oneOf Unions, transformations, middleware, recursive and suspended +schemas, Declarations, construction, and successful and failing validation. The Effect tuple has a trailing element after its rest element, Zod compilation diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/aot.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/aot.ts index 2129564b51b..cb95c7d3fdc 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/aot.ts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/aot.ts @@ -24,12 +24,15 @@ export const array = () => fixture("array") export const arrayInvalid = () => fixture("arrayInvalid") export const tuple = () => fixture("tuple") export const record = () => fixture("record") +export const recordTransformedKeys = () => fixture("recordTransformedKeys") export const union = () => fixture("union") export const oneOf = () => fixture("oneOf") export const transform = () => fixture("transform") export const transformInvalid = () => fixture("transformInvalid") +export const rootTransform = () => fixture("rootTransform") export const middleware = () => fixture("middleware") export const recursive = () => fixture("recursive") +export const suspendRoot = () => fixture("suspendRoot") export const declaration = () => fixture("declaration") export const makeStruct = () => fixture("makeStruct") export const makeArray = () => fixture("makeArray") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts index c7fe4262222..16e22ae4fc9 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/cases.ts @@ -15,6 +15,16 @@ const tuple = Schema.TupleWithRest(Schema.Tuple([Schema.String]), [Schema.Number const tupleInput = ["head", ...Array.from({ length: 32 }, (_, i) => i), true] const record = Schema.Record(Schema.String, person()) const recordInput = Object.fromEntries(arrayInput.map((value, i) => [String(i), value])) +const transformedKeyRecord = Schema.Record( + Schema.String.pipe(Schema.decode(SchemaTransformation.snakeToCamel())), + Schema.String +) +const transformedKeyRecordInput = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`field_${index}_value`, `value${index}`]) +) +const transformedKeyRecordOutput = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`field${index}Value`, `value${index}`]) +) const union = Schema.Union( Array.from({ length: 8 }, (_, i) => Schema.Struct({ tag: Schema.Literal(i), value: Schema.Number })) ) @@ -31,6 +41,7 @@ const checkedTransform = Schema.String.pipe( SchemaTransformation.transform({ decode: Number, encode: String }) ) ) +const rootTransform = Schema.FiniteFromString const middleware = small.pipe(Schema.middlewareDecoding((effect) => effect)) const declaration = Schema.ReadonlySet(person()) const declarationInput = new Set(arrayInput) @@ -47,6 +58,7 @@ const node = (depth: number): Node => ({ children: depth === 0 ? [] : [node(depth - 1), node(depth - 1)] }) const recursiveInput = node(4) +const suspendRoot = Schema.suspend(() => Schema.Struct({ value: Schema.Number })) const defaults = Schema.Struct( Object.fromEntries( Array.from( @@ -78,12 +90,19 @@ export const cases: Record = { arrayInvalid: { schema: array, input: [...arrayInput, { ...input, age: "bad" }], expected: true, invalid: true }, tuple: { schema: tuple, input: tupleInput, expected: tupleInput }, record: { schema: record, input: recordInput, expected: recordInput }, + recordTransformedKeys: { + schema: transformedKeyRecord, + input: transformedKeyRecordInput, + expected: transformedKeyRecordOutput + }, union: { schema: union, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 } }, oneOf: { schema: oneOf, input: { b: 1 }, expected: { b: 1 } }, transform: { schema: transformed, input: transformedInput, expected: transformedOutput }, transformInvalid: { schema: checkedTransform, input: "-1", expected: true, invalid: true }, + rootTransform: { schema: rootTransform, input: "123", expected: 123 }, middleware: { schema: middleware, input, expected: input }, recursive: { schema: recursive, input: recursiveInput, expected: recursiveInput }, + suspendRoot: { schema: suspendRoot, input: { value: 1 }, expected: { value: 1 } }, declaration: { schema: declaration, input: declarationInput, expected: declarationInput }, makeStruct: { schema: defaults, input: {}, expected: transformedOutput, operation: "make" }, makeArray: { schema: array, input: arrayInput, expected: arrayInput, operation: "make" }, diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/interpreted.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/interpreted.ts index 7fafae2fb89..8412c30a956 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/interpreted.ts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/interpreted.ts @@ -11,12 +11,15 @@ export const array = () => fixture("array") export const arrayInvalid = () => fixture("arrayInvalid") export const tuple = () => fixture("tuple") export const record = () => fixture("record") +export const recordTransformedKeys = () => fixture("recordTransformedKeys") export const union = () => fixture("union") export const oneOf = () => fixture("oneOf") export const transform = () => fixture("transform") export const transformInvalid = () => fixture("transformInvalid") +export const rootTransform = () => fixture("rootTransform") export const middleware = () => fixture("middleware") export const recursive = () => fixture("recursive") +export const suspendRoot = () => fixture("suspendRoot") export const declaration = () => fixture("declaration") export const makeStruct = () => fixture("makeStruct") export const makeArray = () => fixture("makeArray") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/jit.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/jit.ts index 0cea982625f..8c4894735e5 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/jit.ts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/jit.ts @@ -13,12 +13,15 @@ export const array = () => fixture("array") export const arrayInvalid = () => fixture("arrayInvalid") export const tuple = () => fixture("tuple") export const record = () => fixture("record") +export const recordTransformedKeys = () => fixture("recordTransformedKeys") export const union = () => fixture("union") export const oneOf = () => fixture("oneOf") export const transform = () => fixture("transform") export const transformInvalid = () => fixture("transformInvalid") +export const rootTransform = () => fixture("rootTransform") export const middleware = () => fixture("middleware") export const recursive = () => fixture("recursive") +export const suspendRoot = () => fixture("suspendRoot") export const declaration = () => fixture("declaration") export const makeStruct = () => fixture("makeStruct") export const makeArray = () => fixture("makeArray") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/valibot.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/valibot.ts index 6d856a3a6ea..e729d5fffb6 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/valibot.ts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/valibot.ts @@ -1,3 +1,4 @@ +import * as Str from "effect/String" import assert from "node:assert/strict" import * as v from "valibot" @@ -8,6 +9,16 @@ const arraySchema = v.array(person()) const arrayInput = Array.from({ length: 32 }, () => ({ ...input })) const recordSchema = v.record(v.string(), person()) const recordInput = Object.fromEntries(arrayInput.map((value, i) => [String(i), value])) +const transformedKeyRecordSchema = v.record( + v.pipe(v.string(), v.transform(Str.snakeToCamel)), + v.string() +) +const transformedKeyRecordInput = Object.fromEntries( + Array.from({ length: 32 }, (_, i) => [`field_${i}_value`, String(i)]) +) +const transformedKeyRecordOutput = Object.fromEntries( + Array.from({ length: 32 }, (_, i) => [`field${i}Value`, String(i)]) +) const unionSchema = v.variant( "tag", Array.from({ length: 8 }, (_, i) => v.object({ tag: v.literal(i), value: v.number() })) @@ -38,6 +49,11 @@ const cases: Record = { isInvalid: { schema: small, input: { ...input, age: "bad" }, expected: false, operation: "is" }, array: { schema: arraySchema, input: arrayInput, expected: arrayInput }, record: { schema: recordSchema, input: recordInput, expected: recordInput }, + recordTransformedKeys: { + schema: transformedKeyRecordSchema, + input: transformedKeyRecordInput, + expected: transformedKeyRecordOutput + }, union: { schema: unionSchema, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 } }, transform: { schema: transformedSchema, input: transformedInput, expected: transformedOutput }, makeStruct: { schema: defaultsSchema, input: {}, expected: transformedOutput, operation: "make" }, @@ -70,6 +86,7 @@ export const isValid = () => fixture("isValid") export const isInvalid = () => fixture("isInvalid") export const array = () => fixture("array") export const record = () => fixture("record") +export const recordTransformedKeys = () => fixture("recordTransformedKeys") export const union = () => fixture("union") export const transform = () => fixture("transform") export const makeStruct = () => fixture("makeStruct") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-cases.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-cases.ts index 7e270ac7628..b2e271b59e1 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-cases.ts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-cases.ts @@ -1,3 +1,4 @@ +import * as Str from "effect/String" import assert from "node:assert/strict" import * as z from "zod/v4" @@ -9,6 +10,16 @@ const arraySchema = z.array(person()) const arrayInput = Array.from({ length: 32 }, () => ({ ...input })) const recordSchema = z.record(z.string(), person()) const recordInput = Object.fromEntries(arrayInput.map((value, i) => [String(i), value])) +const transformedKeyRecordSchema = z.record( + z.string().overwrite(Str.snakeToCamel), + z.string() +) +const transformedKeyRecordInput = Object.fromEntries( + Array.from({ length: 32 }, (_, i) => [`field_${i}_value`, String(i)]) +) +const transformedKeyRecordOutput = Object.fromEntries( + Array.from({ length: 32 }, (_, i) => [`field${i}Value`, String(i)]) +) const unionSchema = z.union( Array.from({ length: 8 }, (_, i) => z.object({ tag: z.literal(i), value: z.number() })) as [ z.ZodObject, @@ -62,6 +73,11 @@ const cases: Record = { invalid: true }, record: { schema: recordSchema, input: recordInput, expected: recordInput }, + recordTransformedKeys: { + schema: transformedKeyRecordSchema, + input: transformedKeyRecordInput, + expected: transformedKeyRecordOutput + }, union: { schema: unionSchema, input: { tag: 7, value: 1 }, expected: { tag: 7, value: 1 } }, transform: { schema: transformedSchema, input: transformedInput, expected: transformedOutput }, transformInvalid: { schema: checkedTransform, input: "-1", expected: true, invalid: true }, diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts index 881019e8103..4fa0b3f8d1f 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-compiled.ts @@ -11,6 +11,7 @@ export const nested = () => fixture("nested") export const array = () => fixture("array") export const arrayInvalid = () => fixture("arrayInvalid") export const record = () => fixture("record") +export const recordTransformedKeys = () => fixture("recordTransformedKeys") export const union = () => fixture("union") export const transform = () => fixture("transform") export const transformInvalid = () => fixture("transformInvalid") diff --git a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-jitless.ts b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-jitless.ts index cda75669642..fc84ea47315 100644 --- a/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-jitless.ts +++ b/packages/effect/runtimeperf/suites/compiler-rebuild/fixtures/zod-jitless.ts @@ -6,6 +6,7 @@ export const isValid = () => fixture("isValid") export const isInvalid = () => fixture("isInvalid") export const array = () => fixture("array") export const record = () => fixture("record") +export const recordTransformedKeys = () => fixture("recordTransformedKeys") export const union = () => fixture("union") export const transform = () => fixture("transform") export const makeStruct = () => fixture("makeStruct") diff --git a/packages/effect/runtimeperf/test/registry.test.mts b/packages/effect/runtimeperf/test/registry.test.mts index 52612f88dfa..4596e8bc9b7 100644 --- a/packages/effect/runtimeperf/test/registry.test.mts +++ b/packages/effect/runtimeperf/test/registry.test.mts @@ -139,7 +139,7 @@ describe("runtimeperf registry", () => { const compiled = fixtures.filter((fixture) => fixture.suite === "compiler-rebuild" && fixture.implementation === "zod4-compiled" ) - assert.equal(compiled.length, 18) + assert.equal(compiled.length, 19) const paths = new Set(compiled.map((fixture) => fixture.fixturePath)) assert.equal(paths.size, 1) const source = await readFile([...paths][0], "utf8") @@ -154,7 +154,7 @@ describe("runtimeperf registry", () => { const jitless = fixtures.filter((fixture) => fixture.suite === "compiler-rebuild" && fixture.implementation === "zod4-jitless" ) - assert.equal(jitless.length, 10) + assert.equal(jitless.length, 11) const paths = new Set(jitless.map((fixture) => fixture.fixturePath)) assert.equal(paths.size, 1) const source = await readFile(new URL("../suites/compiler-rebuild/fixtures/zod-cases.ts", import.meta.url), "utf8") From 2b5d7ce2e5a2f75f2d36afbbfa99febdd4ee0976 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Thu, 17 Sep 2026 19:59:11 +0200 Subject: [PATCH 32/33] Compile synchronous root transformations --- packages/effect/SCHEMA.md | 14 +++--- .../effect/src/internal/schema/codegen.ts | 44 +++++++++++++++++-- .../src/unstable/schema/SchemaAOTCompiler.ts | 7 +-- .../unstable/schema/SchemaCompiler/runtime.ts | 23 +++++++--- 4 files changed, 70 insertions(+), 18 deletions(-) diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 13651212abe..7ce8262338a 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -183,12 +183,14 @@ Other detailed traversals and constructors use the existing interpreter with registry-resolved children; there is no separate diagnostic interpreter in the compiler. -Transformations and middleware never participate in validation replay. Their -orchestration uses the same implementation as interpreted parsing, and pure -child checkpoints can still use generated validators. Suspend is resolved lazily -by JIT. AOT does not evaluate Suspend thunks at build time, so dynamically reached -schemas fall back to the interpreter unless installed separately. Declaration -callbacks remain runtime code; their type parameters can be compiled. +Transformations and middleware never participate in validation replay. A single +synchronous transformation between supported leaf types can use generated +orchestration directly. Other transformations and middleware use the interpreted +orchestration, while pure child checkpoints can still use generated validators. +Suspend is resolved lazily by JIT. AOT does not evaluate Suspend thunks at build +time, so dynamically reached schemas fall back to the interpreter unless installed +separately. Declaration callbacks remain runtime code; their type parameters can +be compiled. Checks and property getters in replayable validation must be deterministic and free of side effects. Proxy inputs and modifications to built-in object behavior diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts index ac17a767004..55ab00bff08 100644 --- a/packages/effect/src/internal/schema/codegen.ts +++ b/packages/effect/src/internal/schema/codegen.ts @@ -654,7 +654,7 @@ const emitOperation = (ast: SchemaAST.AST, operation: Operation, path = "ast"): } const output = emit(ast, "i", emitter.statements, emitter, operation, path) const bindings = { - K: "failsChecks", + K: "getCheckIssues", T: "matchesTemplateLiteral", U: "getCandidates", G: "getIndexSignatureKeys", @@ -687,7 +687,7 @@ export function generate(ast: SchemaAST.AST, operation: DecoderOperation): strin "return function(i,o){if(i===R.missing)return R.missing;if(!Array.isArray(i))return R.invalid;" + "const out=new Array(i.length);for(let x=0;x 0 && ast.indexSignatures.length === 0 && ast.propertySignatures.length <= maxGeneratedNodes ? emitObject(ast) @@ -721,8 +725,7 @@ const emitArray = (): string => } }}` -const inlineIdentityPredicate = (ast: SchemaAST.AST, input: string, path: string): string | undefined => { - if (ast.checks !== undefined || getEncodingChecks(ast) !== undefined) return undefined +const inlineTypePredicate = (ast: SchemaAST.AST, input: string, path: string): string | undefined => { switch (ast._tag) { case "Null": return `${input}===null` @@ -756,6 +759,39 @@ const inlineIdentityPredicate = (ast: SchemaAST.AST, input: string, path: string } } +const inlineIdentityPredicate = (ast: SchemaAST.AST, input: string, path: string): string | undefined => + ast.checks === undefined && getEncodingChecks(ast) === undefined + ? inlineTypePredicate(ast, input, path) + : undefined + +const emitEncoding = (ast: SchemaAST.AST): string | undefined => { + const links = ast.encoding + if (links?.length !== 1 || getEncodingChecks(ast) !== undefined) return undefined + const link = links[0] + const transformation = link.transformation + if ( + link.to.encoding !== undefined || + link.to.checks !== undefined || + getEncodingChecks(link.to) !== undefined || + transformation._tag !== "Transformation" || + transformation.decode._tag !== "Transform" + ) { + return undefined + } + const source = inlineTypePredicate(link.to, "i", "ast.encoding[0].to") + const target = inlineTypePredicate(ast, "value", "ast") + if (source === undefined || target === undefined) return undefined + const success = ast.checks === undefined ? "R.succeed(value)" : "R.check(ast,value,o)" + return `const transform=ast.encoding[0].transformation.decode.transform;return function(i,o){try{ + if(i===R.missing)return R.missingExit; + if(!(${source}))return R.invalidEncoding(ast,1,i,i,o); + const value=transform(i); + if(value===R.missing)return R.missingExit; + if(!(${target}))return R.invalidEncoding(ast,0,i,value,o); + return ${success} + }catch(e){return R.die(e)}}` +} + const canInlineEncoding = (ast: SchemaAST.AST): ast is SchemaAST.AST & { readonly encoding: SchemaAST.Encoding } => ast.encoding !== undefined && ast.checks === undefined && diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts index 9a7c56b6988..21e6cfcd32b 100644 --- a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts @@ -67,9 +67,10 @@ export interface Target { * Installation uses the same registry as `SchemaCompiler.set`; normal * `SchemaParser` functions consume those entries. Generated validators and * Struct and homogeneous Array loops are static functions. They share diagnostic and - * asynchronous continuation helpers with the interpreter. Other detailed - * traversals and transformation orchestration use the interpreter with - * registry-resolved children. Transformations and middleware are not replayed. + * asynchronous continuation helpers with the interpreter. A single synchronous + * transformation between supported leaf types can use generated orchestration. + * Other detailed traversals, transformations, and middleware use the interpreter + * with registry-resolved children. Transformations and middleware are not replayed. * Only the requested operation families and their static dependencies are * emitted. Installation materializes each requested root immediately. Its * dependency entries are installed in the same registry at the same time, but diff --git a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts index 697db32f8f7..11a856ad53d 100644 --- a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts +++ b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts @@ -195,15 +195,27 @@ const setFactory = ( context: A ) => setCompilerFactory(ast, (ast, resolve) => factory(ast, context, resolve)) -const failsChecks = ( +const getCheckIssues = ( ast: SchemaAST.AST, value: unknown, encoded: boolean, options: SchemaAST.ParseOptions -): boolean => { +): ReturnType => { const checks = encoded ? "encodingChecks" in ast ? ast.encodingChecks : undefined : ast.checks - return !options.disableChecks && checks !== undefined && - SchemaAST.collectIssues(checks, value, undefined, ast, options) !== undefined + return !options.disableChecks && checks !== undefined + ? SchemaAST.collectIssues(checks, value, undefined, ast, options) + : undefined +} + +const check = ( + ast: SchemaAST.AST, + value: unknown, + options: SchemaAST.ParseOptions +): Effect.Effect => { + const issues = getCheckIssues(ast, value, false, options) + return issues === undefined + ? InternalParser.succeed(value) + : Effect.fail(new SchemaIssue.Composite(ast, issues, value, options)) } const hasExcessProperties = ( @@ -251,7 +263,8 @@ export const runtime = { die: Effect.die, invalidType, invalidEncoding, - failsChecks, + getCheckIssues, + check, getExpectedKeys: (ast: SchemaAST.Objects) => ast.propertySignatures.map((p) => typeof p.name === "number" ? String(p.name) : p.name), hasExcessProperties, From e17ad5161b99d4586c0a21f2c3b193e61dcd23af Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Thu, 17 Sep 2026 23:04:55 +0200 Subject: [PATCH 33/33] Simplify compiled decoder initialization --- packages/effect/SCHEMA.md | 9 ++- .../effect/src/internal/schema/codegen.ts | 4 +- .../src/internal/schema/compilerRegistry.ts | 71 ++++++++++++------- .../src/unstable/schema/SchemaAOTCompiler.ts | 42 +++++------ .../unstable/schema/SchemaCompiler/runtime.ts | 13 +--- .../src/unstable/schema/SchemaJITCompiler.ts | 2 +- .../test/schema/SchemaAOTCompiler.test.ts | 52 +++++++------- .../schema/SchemaAOTCompilerBuild.test.ts | 6 +- .../schema/SchemaCompilerConstruction.test.ts | 12 +++- .../test/schema/SchemaCompilerStartup.test.ts | 4 +- .../effect/test/schema/fixtures/aot-runner.ts | 4 +- 11 files changed, 115 insertions(+), 104 deletions(-) diff --git a/packages/effect/SCHEMA.md b/packages/effect/SCHEMA.md index 7ce8262338a..0eb9f48581c 100644 --- a/packages/effect/SCHEMA.md +++ b/packages/effect/SCHEMA.md @@ -165,11 +165,10 @@ already captured an entry retain it. Late installation is allowed, but startup installation is needed to optimize every consumer. Custom decoders supplied to `set` are trusted to implement the AST's semantics. -An AOT module materializes explicitly requested roots during installation. It -also installs entries for their static dependencies immediately, but creates a -dependency's decoder source only when an operation first needs it. Both kinds -of entry live in the same `WeakMap`: there is no separate AOT cache, and a later -`SchemaCompiler.set` for the same AST replaces either kind in the same way. +An AOT module installs entries for explicitly requested roots and their static +dependencies. Each generated operation initializes on first use. All entries +live in the same `WeakMap`: there is no separate AOT cache, and a later +`SchemaCompiler.set` for the same AST replaces any compiled entry in the same way. ### What is specialized diff --git a/packages/effect/src/internal/schema/codegen.ts b/packages/effect/src/internal/schema/codegen.ts index 55ab00bff08..bb51af4c1fa 100644 --- a/packages/effect/src/internal/schema/codegen.ts +++ b/packages/effect/src/internal/schema/codegen.ts @@ -678,12 +678,12 @@ export function generate(ast: SchemaAST.AST, operation: DecoderOperation): strin if (operation === "make") { if (!shouldCompileMake(ast)) return undefined if (ast._tag === "Objects") { - return `if(ast.propertySignatures.some(p=>resolve(p.type).source!==void 0))return;return ${ + return `if(ast.propertySignatures.some(p=>resolve(p.type).compiled!==void 0))return;return ${ renderOperation(emitOperation(ast, "decode")) }` } const element = renderOperation(emitOperation(ast.rest[0], "decode", "ast.rest[0]")) - return `const e=resolve(ast.rest[0]),m=e.source===void 0?${element}:e.make;if(m===void 0)return;` + + return `const e=resolve(ast.rest[0]),m=e.compiled===void 0?${element}:e.make;if(m===void 0)return;` + "return function(i,o){if(i===R.missing)return R.missing;if(!Array.isArray(i))return R.invalid;" + "const out=new Array(i.length);for(let x=0;x Entry export type DecoderSource = Partial /** @internal */ -export type Compile = (ast: SchemaAST.AST, resolve: Resolve) => DecoderSource | undefined +export type DecoderOperation = keyof CompiledDecoder + +/** @internal */ +export type Compile = ( + ast: SchemaAST.AST, + resolve: Resolve, + operation: K +) => CompiledDecoder[K] | undefined + +/** @internal */ +export type CompileSource = (ast: SchemaAST.AST, resolve: Resolve) => DecoderSource | undefined + +/** @internal */ +export type Compiled = DecoderSource | Compile const cache = new WeakMap() -let compiler: ((ast: SchemaAST.AST, resolve: Resolve) => Entry) | undefined +let compiler: CompileSource | undefined /** @internal */ export let compilerAdaptersEnabled = false @@ -40,7 +53,7 @@ const makeField = (ast: SchemaAST.AST): Parser => Interpreter.compileField(ast, /** @internal */ export interface Entry { readonly ast: SchemaAST.AST - readonly source?: DecoderSource | undefined + readonly compiled?: Compiled | undefined readonly resolve?: Resolve | undefined readonly is?: Is | undefined readonly decode?: Decode | undefined @@ -73,13 +86,13 @@ class InterpretedEntry implements Entry { } class CompilerEntry extends InterpretedEntry { + readonly compiled: Compiled | undefined readonly resolve: Resolve - private sourceValue: DecoderSource | Compile | undefined - constructor(ast: SchemaAST.AST, source: DecoderSource | Compile | undefined, resolve: Resolve) { + constructor(ast: SchemaAST.AST, compiled: Compiled | undefined, resolve: Resolve) { super(ast) + this.compiled = compiled this.resolve = resolve - this.sourceValue = source } private save(key: K, value: Entry[K]): Entry[K] { @@ -87,28 +100,27 @@ class CompilerEntry extends InterpretedEntry { return value } - get source(): DecoderSource | undefined { - const source = this.sourceValue - if (typeof source !== "function") return source - return this.save("source", this.sourceValue = source(this.ast, this.resolve)) + private operation(key: K): CompiledDecoder[K] | undefined { + const compiled = this.compiled + return typeof compiled === "function" ? compiled(this.ast, this.resolve, key) : compiled?.[key] } get is(): Is | undefined { - return this.save("is", this.source?.is) + return this.save("is", this.operation("is")) } get decode(): Decode | undefined { - return this.save("decode", this.source?.decode) + return this.save("decode", this.operation("decode")) } get make(): Make | undefined { - return this.save("make", this.source?.make) + return this.save("make", this.operation("make")) } override get decodeEffect(): Parser { return this.save( "decodeEffect", - this.source?.decodeEffect ?? Interpreter.compile(this.ast, (ast) => lazyParser(this.resolve, ast, "parser")) + this.operation("decodeEffect") ?? Interpreter.compile(this.ast, (ast) => lazyParser(this.resolve, ast, "parser")) ) } @@ -120,7 +132,7 @@ class CompilerEntry extends InterpretedEntry { } override get makeEffect(): Parser { - const makeEffect = this.source?.makeEffect + const makeEffect = this.operation("makeEffect") if (makeEffect !== undefined) return this.save("makeEffect", makeEffect) const child = (ast: SchemaAST.AST): Parser => lazyParser(this.resolve, ast, "makeEffect") return this.save( @@ -157,7 +169,9 @@ export function lazyParser( operation: "parser" | "decodeEffect" | "makeEffect" ): Parser { const entry = resolve(ast) - if (entry.source === undefined || Object.hasOwn(entry, operation)) return entry[operation] + if (entry.compiled === undefined || Object.hasOwn(entry, operation)) { + return entry[operation] + } let parser: Parser | undefined return (input, options) => (parser ??= entry[operation])(input, options) } @@ -166,7 +180,9 @@ export function lazyParser( export function resolve(ast: SchemaAST.AST): Entry { const cached = cache.get(ast) if (cached !== undefined) return cached - const entry = compiler === undefined ? new InterpretedEntry(ast) : compiler(ast, resolve) + const entry = compiler === undefined + ? new InterpretedEntry(ast) + : new CompilerEntry(ast, compiler(ast, resolve), resolve) cache.set(ast, entry) return entry } @@ -180,9 +196,11 @@ export function set(ast: SchemaAST.AST, decoder: DecoderSource | undefined, reso } /** @internal */ -export function setFactory(ast: SchemaAST.AST, compile: Compile, resolveChild: Resolve = resolve): Entry { - // Install the entry immediately so that replacement order remains identical - // to `set`; only materialization of its decoder source is deferred. +export function setCompiler( + ast: SchemaAST.AST, + compile: Compile, + resolveChild: Resolve = resolve +): Entry { activateCompilerAdapters() const entry = new CompilerEntry(ast, compile, resolveChild) cache.set(ast, entry) @@ -190,20 +208,23 @@ export function setFactory(ast: SchemaAST.AST, compile: Compile, resolveChild: R } /** @internal */ -export function install(compile: Compile): void { +export function install(compile: CompileSource): void { activateCompilerAdapters() - compiler = (ast, resolve) => new CompilerEntry(ast, compile(ast, resolve), resolve) + compiler = compile } /** @internal */ -export function enable(ast: SchemaAST.AST, compile: Compile): void { +export function enable(ast: SchemaAST.AST, compile: CompileSource): void { activateCompilerAdapters() const scoped: Resolve = (child) => { const cached = cache.get(child) - return cached !== undefined && (cached.source !== undefined || cached.resolve === scoped) + return cached !== undefined && (cached.compiled !== undefined || cached.resolve === scoped) ? cached : set(child, compile(child, scoped), scoped) } const decoder = compile(ast, scoped) - if (decoder !== undefined || cache.get(ast)?.source === undefined) set(ast, decoder, scoped) + const cached = cache.get(ast) + if (decoder !== undefined || cached?.compiled === undefined) { + set(ast, decoder, scoped) + } } diff --git a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts index 21e6cfcd32b..2aeadca37a8 100644 --- a/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaAOTCompiler.ts @@ -9,7 +9,7 @@ import * as Codegen from "../../internal/schema/codegen.ts" import * as SchemaAST from "../../SchemaAST.ts" import type { runtime } from "./SchemaCompiler/runtime.ts" -const helper = (name: keyof typeof runtime): string => `R.${name}` +const helper = (name: keyof typeof runtime): string => `runtime.${name}` const decoderOperationOrder: ReadonlyArray = [ "is", "decode", @@ -19,12 +19,12 @@ const decoderOperationOrder: ReadonlyArray = [ ] const operationOrder: ReadonlyArray = ["decode", "is", "make"] -const decoder = (sources: ReadonlyMap): string | undefined => { - const members = decoderOperationOrder.flatMap((key) => { +const compiler = (sources: ReadonlyMap): string | undefined => { + const cases = decoderOperationOrder.flatMap((key) => { const source = sources.get(key) - return source === undefined ? [] : [`get ${key}(){${source}}`] - }).join(",") - return members.length === 0 ? undefined : `{${members}}` + return source === undefined ? [] : [`case ${JSON.stringify(key)}:{${source}}`] + }).join("") + return cases.length === 0 ? undefined : `switch(operation){${cases}}` } /** @@ -72,13 +72,12 @@ export interface Target { * Other detailed traversals, transformations, and middleware use the interpreter * with registry-resolved children. Transformations and middleware are not replayed. * Only the requested operation families and their static dependencies are - * emitted. Installation materializes each requested root immediately. Its - * dependency entries are installed in the same registry at the same time, but - * their decoder sources are materialized only on first use. Missing operations - * retain the lazy interpreter fallback. Repeated ASTs and shared dependencies - * are installed once by identity. Fast paths can still inline dependency code - * into multiple parent decoders. An empty array generates a module whose - * installation does nothing. + * emitted. Installation registers roots and dependencies in the same registry; + * each generated operation initializes on first use. Missing operations retain + * the lazy interpreter fallback. Repeated ASTs and shared dependencies are + * installed once by identity. Fast paths can still inline dependency code into + * multiple parent decoders. An empty array generates a module whose installation + * does nothing. * Construction uses independently lazy `make` and `makeEffect` operations. * Pure fixed Struct and homogeneous Array constructors can use `make` for a * synchronous fast path; failures delegate to `makeEffect` for detailed issues. @@ -117,10 +116,8 @@ export const compile = (targets: ReadonlyArray): string => { readonly sources: Map readonly attempted: Set readonly compilable: boolean - readonly targeted: boolean } - const targetsByAst = new Set(targets.map((target) => target.ast)) const seen = new Map() const bindings: Array = [] const factories: Array = [] @@ -186,8 +183,7 @@ export const compile = (targets: ReadonlyArray): string => { requested: new Set(), sources: new Map(), attempted: new Set(), - compilable: Codegen.shouldCompileParser(node), - targeted: targetsByAst.has(node) + compilable: Codegen.shouldCompileParser(node) } seen.set(node, plan) } @@ -221,24 +217,20 @@ export const compile = (targets: ReadonlyArray): string => { }) for (const plan of seen.values()) { bindings.push(`const ${plan.name}=${plan.reference};`) - const source = decoder(plan.sources) + const source = compiler(plan.sources) if (source !== undefined) { let factory = factoryNames.get(source) if (factory === undefined) { factory = `d${factoryNames.size}` factoryNames.set(source, factory) - factories.push(`function ${factory}(ast,R,resolve){return ${source}}`) + factories.push(`function ${factory}(ast,resolve,operation){const R=runtime;${source}}`) } - installations.push( - plan.targeted - ? `${helper("set")}(${plan.name},${factory}(${plan.name},R,R.resolve));` - : `${helper("setFactory")}(${plan.name},${factory},R);` - ) + installations.push(`${helper("setCompiler")}(${plan.name},${factory});`) } } return [ "// Generated by SchemaAOTCompiler. Regenerate after schema or Effect changes.", - "import { runtime as R } from \"effect/unstable/schema/SchemaCompiler/runtime\";", + "import { runtime } from \"effect/unstable/schema/SchemaCompiler/runtime\";", ...factories, "/** @param {ReadonlyArray} asts */", "export function install(asts){", diff --git a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts index 11a856ad53d..7ade48b8904 100644 --- a/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts +++ b/packages/effect/src/unstable/schema/SchemaCompiler/runtime.ts @@ -8,12 +8,10 @@ import * as Effect from "../../../Effect.ts" import { effectIsExit, resolveConcurrency } from "../../../internal/effect.ts" import { - type DecoderSource, lazyParser, type Resolve, resolve, - set, - setFactory as setCompilerFactory, + setCompiler, withDecode } from "../../../internal/schema/compilerRegistry.ts" import * as Interpreter from "../../../internal/schema/interpreter.ts" @@ -189,12 +187,6 @@ const make = ( return Interpreter.compile(ast, child, field, base) } -const setFactory = ( - ast: SchemaAST.AST, - factory: (ast: SchemaAST.AST, context: A, resolve: Resolve) => DecoderSource | undefined, - context: A -) => setCompilerFactory(ast, (ast, resolve) => factory(ast, context, resolve)) - const getCheckIssues = ( ast: SchemaAST.AST, value: unknown, @@ -251,8 +243,7 @@ export const runtime = { decode, make, resolve, - set, - setFactory, + setCompiler, invalid, missing: InternalParser.missing, missingExit: InternalParser.missingExit, diff --git a/packages/effect/src/unstable/schema/SchemaJITCompiler.ts b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts index dd681df7327..efb0bb30b4b 100644 --- a/packages/effect/src/unstable/schema/SchemaJITCompiler.ts +++ b/packages/effect/src/unstable/schema/SchemaJITCompiler.ts @@ -15,7 +15,7 @@ let checked: FunctionConstructor | undefined let supported = false /** @internal */ -export const compiler: Registry.Compile = (ast, resolve) => { +export const compiler: Registry.CompileSource = (ast, resolve) => { if (!shouldCompileParser(ast)) return undefined if (checked !== globalThis.Function) { checked = globalThis.Function diff --git a/packages/effect/test/schema/SchemaAOTCompiler.test.ts b/packages/effect/test/schema/SchemaAOTCompiler.test.ts index d84c6af1af2..42fe85b697a 100644 --- a/packages/effect/test/schema/SchemaAOTCompiler.test.ts +++ b/packages/effect/test/schema/SchemaAOTCompiler.test.ts @@ -32,39 +32,38 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { it("emits only the requested operation family", () => { const schema = Schema.Struct({ value: Schema.String }) const decode = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }]) - assert.include(decode, "get decode(){") - assert.include(decode, "get decodeEffect(){") - assert.notInclude(decode, "get is(){") - assert.notInclude(decode, "get make(){") - assert.notInclude(decode, "get makeEffect(){") + assert.include(decode, "case \"decode\":{") + assert.include(decode, "case \"decodeEffect\":{") + assert.notInclude(decode, "case \"is\":{") + assert.notInclude(decode, "case \"make\":{") + assert.notInclude(decode, "case \"makeEffect\":{") const make = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["make"] }]) - assert.include(make, "get make(){") - assert.include(make, "get makeEffect(){") - assert.notInclude(make, "get is(){") - assert.notInclude(make, "get decode(){") + assert.include(make, "case \"make\":{") + assert.include(make, "case \"makeEffect\":{") + assert.notInclude(make, "case \"is\":{") + assert.notInclude(make, "case \"decode\":{") }) it("allows a target without requested operations", () => { const schema = Schema.Struct({ value: Schema.String }) const source = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: [] }]) - assert.notInclude(source, "R.set(") - assert.notInclude(source, "R.setFactory(") + assert.notInclude(source, "runtime.setCompiler(") }) it("omits fast decode operations from diagnostic-only dependencies", () => { const child = Schema.Struct({ value: Schema.String }) const schema = Schema.Array(child) const source = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }]) - assert.strictEqual(source.match(/get decode\(\)\{/g)?.length, 1) - assert.strictEqual(source.match(/get decodeEffect\(\)\{/g)?.length, 2) + assert.strictEqual(source.match(/case "decode":\{/g)?.length, 1) + assert.strictEqual(source.match(/case "decodeEffect":\{/g)?.length, 2) const targeted = SchemaAOTCompiler.compile([ { ast: schema.ast, operations: ["decode"] }, { ast: child.ast, operations: ["decode"] } ]) - assert.strictEqual(targeted.match(/get decode\(\)\{/g)?.length, 2) - assert.strictEqual(targeted.match(/get decodeEffect\(\)\{/g)?.length, 2) + assert.strictEqual(targeted.match(/case "decode":\{/g)?.length, 2) + assert.strictEqual(targeted.match(/case "decodeEffect":\{/g)?.length, 2) }) it("uses registry fallbacks for operations that were not requested", async () => { @@ -76,11 +75,11 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { const generated = await import(`${pathToFileURL(file).href}?test=${Date.now()}`) generated.install([schema.ast]) - const source = CompilerRegistry.resolve(schema.ast).source - assert.isDefined(source?.decode) - assert.isUndefined(source?.is) - assert.isUndefined(source?.make) - assert.isUndefined(source?.makeEffect) + const entry = CompilerRegistry.resolve(schema.ast) + assert.strictEqual(typeof entry.compiled, "function") + assert.isDefined(entry.decode) + assert.isUndefined(entry.is) + assert.isUndefined(entry.make) assert.strictEqual(SchemaParser.is(schema)({ value: "a" }), true) assert.deepStrictEqual(SchemaParser.make(schema)({ value: "a" }), { value: "a" }) } finally { @@ -88,7 +87,7 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { } }) - it("materializes dependency decoders on first use", async () => { + it("initializes dependency operations on first use", async () => { const child = Schema.String.pipe( Schema.decodeTo( Schema.Number, @@ -105,14 +104,14 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { generated.install([schema.ast]) const dependency = CompilerRegistry.resolve(child.ast) - assert.isFalse(Object.hasOwn(dependency, "source")) + assert.isFalse(Object.hasOwn(dependency, "decodeEffect")) const decode = SchemaParser.decodeUnknownSync(schema) assert.deepStrictEqual(decode({ child: "1" }), { child: 1 }) - assert.isFalse(Object.hasOwn(dependency, "source")) + assert.isFalse(Object.hasOwn(dependency, "decodeEffect")) assert.throws(() => decode({ child: false })) - assert.isTrue(Object.hasOwn(dependency, "source")) + assert.isTrue(Object.hasOwn(dependency, "decodeEffect")) } finally { rmSync(directory, { recursive: true, force: true }) } @@ -135,8 +134,7 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { ]), source ) - assert.strictEqual(source.match(/R\.set\(/g)?.length, 2) - assert.strictEqual(source.match(/R\.setFactory\(/g)?.length, 1) + assert.strictEqual(source.match(/runtime\.setCompiler\(/g)?.length, 3) }) it("reuses identical decoder factories", () => { @@ -144,7 +142,7 @@ describe("SchemaAOTCompiler", { concurrent: false }, () => { Array.from({ length: 8 }, (_, tag) => Schema.Struct({ tag: Schema.Literal(tag), value: Schema.Number })) ) const source = SchemaAOTCompiler.compile([{ ast: schema.ast, operations: ["decode"] }]) - assert.strictEqual(source.match(/function d\d+\(ast,R,resolve\)/g)?.length, 2) + assert.strictEqual(source.match(/function d\d+\(ast,resolve,operation\)/g)?.length, 2) }) it("runs generated decoders without dynamic code generation", () => { diff --git a/packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts b/packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts index fb1d71363d6..a6efb5977e3 100644 --- a/packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts +++ b/packages/effect/test/schema/SchemaAOTCompilerBuild.test.ts @@ -100,9 +100,9 @@ describe("SchemaAOTCompilerBuild", { concurrent: false }, () => { }) assert.notInclude(written, "import * as A from \"effect/SchemaAST\"") assert.notInclude(written, "ignored.ts") - assert.notInclude(written, "get is(){") - assert.notInclude(written, "get make(){") - assert.notInclude(written, "get makeEffect(){") + assert.notInclude(written, "case \"is\":{") + assert.notInclude(written, "case \"make\":{") + assert.notInclude(written, "case \"makeEffect\":{") assert.include(written, "install([m0[\"Port\"].ast,m0[\"User\"].ast]);") })) diff --git a/packages/effect/test/schema/SchemaCompilerConstruction.test.ts b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts index 52af3d0304a..148e4904d66 100644 --- a/packages/effect/test/schema/SchemaCompilerConstruction.test.ts +++ b/packages/effect/test/schema/SchemaCompilerConstruction.test.ts @@ -103,7 +103,7 @@ describe("Schema compiler construction", { concurrent: false }, () => { const schema = Schema.ReadonlySet(child) SchemaJITCompiler.enable(SchemaAST.toType(schema.ast)) assert.deepStrictEqual(SchemaParser.make(schema)(new Set([{ value: "a" }])), new Set([{ value: "a" }])) - assert.strictEqual(Registry.resolve(child.ast).source !== undefined, true) + assert.strictEqual(Registry.resolve(child.ast).compiled !== undefined, true) }) it("does not restart a parent if compilation fails after a default", () => { @@ -251,6 +251,16 @@ describe("Schema compiler construction", { concurrent: false }, () => { assert.deepStrictEqual(SchemaParser.make(schema)([{ a: "a" }, { a: "b" }]), [{ a: "a!" }, { a: "b!" }]) }) + it("composes a compiled synchronous child constructor in a compiled Array", () => { + const child = Schema.Struct({ a: Schema.String }) + SchemaJITCompiler.enable(child.ast) + const childEntry = Registry.resolve(child.ast) + const schema = Schema.Array(child) + SchemaJITCompiler.enable(schema.ast) + assert.deepStrictEqual(SchemaParser.make(schema)([{ a: "a" }]), [{ a: "a" }]) + assert.isTrue(Object.hasOwn(childEntry, "make")) + }) + it("falls back to detailed construction for an invalid compiled Array", () => { const schema = Schema.Array(Schema.Struct({ a: Schema.String })) SchemaJITCompiler.enable(schema.ast) diff --git a/packages/effect/test/schema/SchemaCompilerStartup.test.ts b/packages/effect/test/schema/SchemaCompilerStartup.test.ts index 8999f7892a3..bc5945d9a6b 100644 --- a/packages/effect/test/schema/SchemaCompilerStartup.test.ts +++ b/packages/effect/test/schema/SchemaCompilerStartup.test.ts @@ -7,13 +7,13 @@ describe("Schema compiler startup", () => { const schema = Schema.Struct({ a: Schema.String }) assert.deepStrictEqual(SchemaParser.make(schema)({ a: "a" }), { a: "a" }) const before = Registry.resolve(schema.ast) - assert.strictEqual(before.source, undefined) + assert.strictEqual(before.compiled, undefined) const unused = Schema.Struct({ a: Schema.Number }) const make = SchemaParser.make(unused) await import("effect/unstable/schema/SchemaJITCompiler/enable") assert.strictEqual(Registry.resolve(schema.ast), before) assert.deepStrictEqual(SchemaParser.decodeUnknownSync(schema)({ a: "a" }), { a: "a" }) assert.deepStrictEqual(make({ a: 1 }), { a: 1 }) - assert.strictEqual(Registry.resolve(unused.ast).source !== undefined, true) + assert.strictEqual(Registry.resolve(unused.ast).compiled !== undefined, true) }) }) diff --git a/packages/effect/test/schema/fixtures/aot-runner.ts b/packages/effect/test/schema/fixtures/aot-runner.ts index 3869dade495..357dcff743d 100644 --- a/packages/effect/test/schema/fixtures/aot-runner.ts +++ b/packages/effect/test/schema/fixtures/aot-runner.ts @@ -115,14 +115,14 @@ for ( "middleware" ] ) { - assert.equal(CompilerRegistry.resolve(schemas[name].ast).source !== undefined, true, name) + assert.equal(typeof CompilerRegistry.resolve(schemas[name].ast).compiled, "function", name) } assert.deepEqual(snapshot(), interpreted) assert.deepEqual(await snapshotAsync(), interpretedAsync) for (const [name, schema] of Object.entries(constructionSchemas)) { if (name === "construct-declaration") continue - assert.equal(CompilerRegistry.resolve(schema.ast).source !== undefined, true, name) + assert.equal(typeof CompilerRegistry.resolve(schema.ast).compiled, "function", name) } assert.deepEqual(await snapshotConstruction(), interpretedConstruction) const instance = Constructed.make({})