From 92c590f905348a17087ab5a95a516fc036156735 Mon Sep 17 00:00:00 2001 From: Johnny Estilles Date: Tue, 15 Sep 2026 15:06:15 +0800 Subject: [PATCH 1/3] fix(Schema): Class rejects Struct schemas piped through encodeKeys --- packages/effect/src/Schema.ts | 44 ++++++++--- packages/effect/test/schema/Schema.test.ts | 85 ++++++++++++++++++++++ 2 files changed, 120 insertions(+), 9 deletions(-) diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index c6897d77b38..073d6357efc 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -3441,6 +3441,7 @@ interface fieldsAssign extends Lambda { export function fieldsAssign(fields: NewFields) { return Struct_.lambda>((struct) => struct.mapFields(Struct_.assign(fields))) } +const EncodeKeysTypeId = "~effect/Schema/encodeKeys" /** * Type-level representation returned by {@link encodeKeys}. * @@ -3461,8 +3462,13 @@ export interface encodeKeys< } > > -{} - +{ + readonly [EncodeKeysTypeId]: typeof EncodeKeysTypeId + readonly fields: S["fields"] +} +function isEncodeKeys$(schema: unknown): schema is encodeKeys, any> { + return Predicate.hasProperty(schema, EncodeKeysTypeId) && schema[EncodeKeysTypeId] === EncodeKeysTypeId +} const canonicalPropertyKey = (key: PropertyKey): string | symbol => typeof key === "symbol" ? key : globalThis.String(key) /** @@ -3515,13 +3521,19 @@ export function encodeKeys< reverseMapping[encodedKey] = k } } - return Struct(fields).pipe(decodeTo( + const schema = Struct(fields).pipe(decodeTo( self, SchemaTransformation.transform({ decode: Struct_.renameKeys(reverseMapping), encode: Struct_.renameKeys(appliedMapping) }) - )) as any + )) + return make(schema.ast, { + [EncodeKeysTypeId]: EncodeKeysTypeId, + to: schema.to, + from: schema.from, + fields: self.fields, + }) as any } } /** @@ -13681,7 +13693,9 @@ export function Result( * @category models * @since 3.10.0 */ -export interface Class +export interface Class, any>, Inherited> extends BottomLazyWithoutNew< SchemaAST.Declaration, @@ -13789,7 +13803,7 @@ const payloadToken = {} function makeClass< Self, - S extends Struct, + S extends Struct | encodeKeys, any>, Inherited extends new(...args: ReadonlyArray) => any >( Inherited: Inherited, @@ -13856,6 +13870,11 @@ function makeClass< schema: Struct.Fields | Struct, annotations?: Annotations.Declaration ) => { + if (isEncodeKeys$(struct)) { + throw new globalThis.Error( + `extend is not supported on a Class built from an encodeKeys schema (identifier: "${identifier}")` + ) + } const extension = isStruct(schema) ? schema : Struct(schema) const fields = { ...struct.fields, ...extension.fields } const ast = SchemaAST.struct(fields, struct.ast.checks, { identifier }) @@ -13874,7 +13893,7 @@ function makeClass< readonly unsafePreserveChecks?: boolean | undefined } | undefined ): Struct>> { - return struct.mapFields(f, options) + return isEncodeKeys$(struct) ? struct.to.mapFields(f, options) : struct.mapFields(f, options) } } @@ -13948,9 +13967,12 @@ function getClassSchemaFactory( } } -function isStruct(schema: Struct.Fields | Struct): schema is Struct { +function isStruct( + schema: Struct.Fields | Struct | encodeKeys, any> +): schema is Struct | encodeKeys, any> { return isSchema(schema) } + /** * Creates a schema-backed class whose constructor validates input against a * {@link Struct} schema. Construction throws an `Error` with a @@ -14030,10 +14052,14 @@ export const Class: { schema: S, annotations?: Annotations.Declaration ): [Self] extends [never] ? MissingSelfGeneric<"Schema.Class"> : Class + , any>>( + schema: S, + annotations?: Annotations.Declaration + ): [Self] extends [never] ? MissingSelfGeneric<"Schema.Class"> : Class } } = (identifier: string) => ( - schema: Struct.Fields | Struct, + schema: Struct.Fields | Struct | encodeKeys, any>, annotations?: Annotations.Declaration]> ): [Self] extends [never] ? MissingSelfGeneric<"Schema.Class"> : Class, Brand> => { const struct = isStruct(schema) ? schema : Struct(schema) diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index 0dce68d6afd..3fb981d591c 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -85,9 +85,13 @@ describe("Schema", () => { a: Schema.String })) {} class B extends Schema.Opaque()(Schema.Struct({ a: Schema.String })) {} + class C extends Schema.Class("C")(Schema.Struct({ + a: Schema.String + }).pipe(Schema.encodeKeys({"a": "A"}))) {} assertTrue(Schema.isSchema(Schema.String)) assertTrue(Schema.isSchema(A)) assertTrue(Schema.isSchema(B)) + assertTrue(Schema.isSchema(C)) assertFalse(Schema.isSchema({})) }) @@ -6861,6 +6865,14 @@ Expected a value between -2147483648 and 2147483647` deepStrictEqual(schema.fields, { a: Schema.String, b: Schema.Number }) }) + it("mapFields on a Class built from a Struct with encodeKeys", () => { + class A extends Schema.Class("A")(Schema.Struct({ + a: Schema.String + }).pipe(Schema.encodeKeys({ "a": "c" }))) {} + const schema = A.mapFields((fields) => ({ ...fields, b: Schema.Number })) + deepStrictEqual(schema.fields, { a: Schema.String, b: Schema.Number }) + }) + it("Struct with nested Class", async () => { class A extends Schema.Class("A")(Schema.Struct({ a: Schema.String @@ -7235,6 +7247,23 @@ Expected a value between -2147483648 and 2147483647` await make.fail({ a: 1, b: 0 }, `Expected positive b`) }) + it("Class built from an encodeKeys schema is not supported", async () => { + class A extends Schema.Class("A")( + Schema.Struct({ + a: Schema.Number + }).pipe(Schema.encodeKeys({ "a": "c"})) + ) {} + throws( + () => + class B extends A.extend("B")( + Schema.Struct({ + b: Schema.Number + }) + ) {}, + new Error(`extend is not supported on a Class built from an encodeKeys schema (identifier: "B")`) + ) + }) + it("static members", async () => { class A extends Schema.Class("A")({ a: Schema.String @@ -8994,6 +9023,62 @@ Expected a value between -2147483648 and 2147483647` await encoding.succeed(new A({ a: 1, b: "b" }), { c: "1", b: "b" }) }) + it("Class from Struct with encodeKeys", async () => { + class A extends Schema.Class("A")(Schema.Struct({ + a: Schema.String + }).pipe(Schema.encodeKeys({"a": "c"}))) { + readonly _a = 1 + } + const asserts = new TestSchema.Asserts(A) + + // should be a schema + assertTrue(Schema.isSchema(A)) + // should expose the fields + deepStrictEqual(A.fields, { a: Schema.String }) + // should expose the identifier + strictEqual(A.identifier, "A") + + strictEqual(A.name, "A") + + assertTrue(new A({ a: "a" }) instanceof A) + assertTrue(A.make({ a: "a" }) instanceof A) + + // test additional fields + strictEqual(new A({ a: "a" })._a, 1) + strictEqual(A.make({ a: "a" })._a, 1) + + // test Equal.equals + assertTrue(Equal.equals(new A({ a: "a" }), new A({ a: "a" }))) + assertFalse(Equal.equals(new A({ a: "a" }), new A({ a: "b" }))) + + const make = asserts.make() + await make.succeed(new A({ a: "a" })) + await make.succeed({ a: "a" }, new A({ a: "a" })) + + if (verifyGeneration) { + asserts.arbitrary().verifyGeneration() + } + + const decoding = asserts.decoding() + await decoding.succeed({ c: "a" }, new A({ a: "a" })) + await decoding.fail( + { c: 1 }, + `Expected string + at ["c"]` + ) + + const encoding = asserts.encoding() + await encoding.succeed(new A({ a: "a" }), { c: "a" }) + await encoding.fail( + null, + "Expected A" + ) + await encoding.fail( + { a: "a" }, + `Expected A` + ) + }) + it("supports symbol source keys", () => { const field = Symbol("field") const schema = Schema.Struct({ From 9fad60ed0d54e1d33e6c84b1257fc49c03f3e905 Mon Sep 17 00:00:00 2001 From: Johnny Estilles Date: Tue, 15 Sep 2026 15:33:27 +0800 Subject: [PATCH 2/3] fix(Schema): Class rejects Struct schemas piped through encodeKeys - fix lint --- packages/effect/src/Schema.ts | 33 ++++++++++++---------- packages/effect/test/schema/Schema.test.ts | 26 ++++++++++------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 073d6357efc..b6d6a20538b 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -3532,7 +3532,7 @@ export function encodeKeys< [EncodeKeysTypeId]: EncodeKeysTypeId, to: schema.to, from: schema.from, - fields: self.fields, + fields: self.fields }) as any } } @@ -13693,20 +13693,23 @@ export function Result( * @category models * @since 3.10.0 */ -export interface Class, any>, Inherited> - extends - BottomLazyWithoutNew< - SchemaAST.Declaration, - decodeTo, S>, - readonly [S], - S["~type.mutability"], - S["~type.optionality"], - S["~type.constructor.default"], - S["~encoded.mutability"], - S["~encoded.optionality"] - > +export interface Class< + Self, + S extends + | Constraint & { readonly fields: Struct.Fields } + | encodeKeys, any>, + Inherited +> extends + BottomLazyWithoutNew< + SchemaAST.Declaration, + decodeTo, S>, + readonly [S], + S["~type.mutability"], + S["~type.optionality"], + S["~type.constructor.default"], + S["~encoded.mutability"], + S["~encoded.optionality"] + > { /** * `make`, `makeOption`, and `makeEffect` preserve an existing instance of diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index 3fb981d591c..03948932869 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -85,9 +85,11 @@ describe("Schema", () => { a: Schema.String })) {} class B extends Schema.Opaque()(Schema.Struct({ a: Schema.String })) {} - class C extends Schema.Class("C")(Schema.Struct({ - a: Schema.String - }).pipe(Schema.encodeKeys({"a": "A"}))) {} + class C extends Schema.Class("C")( + Schema.Struct({ + a: Schema.String + }).pipe(Schema.encodeKeys({ "a": "A" })) + ) {} assertTrue(Schema.isSchema(Schema.String)) assertTrue(Schema.isSchema(A)) assertTrue(Schema.isSchema(B)) @@ -6866,9 +6868,11 @@ Expected a value between -2147483648 and 2147483647` }) it("mapFields on a Class built from a Struct with encodeKeys", () => { - class A extends Schema.Class("A")(Schema.Struct({ - a: Schema.String - }).pipe(Schema.encodeKeys({ "a": "c" }))) {} + class A extends Schema.Class("A")( + Schema.Struct({ + a: Schema.String + }).pipe(Schema.encodeKeys({ "a": "c" })) + ) {} const schema = A.mapFields((fields) => ({ ...fields, b: Schema.Number })) deepStrictEqual(schema.fields, { a: Schema.String, b: Schema.Number }) }) @@ -7251,7 +7255,7 @@ Expected a value between -2147483648 and 2147483647` class A extends Schema.Class("A")( Schema.Struct({ a: Schema.Number - }).pipe(Schema.encodeKeys({ "a": "c"})) + }).pipe(Schema.encodeKeys({ "a": "c" })) ) {} throws( () => @@ -9024,9 +9028,11 @@ Expected a value between -2147483648 and 2147483647` }) it("Class from Struct with encodeKeys", async () => { - class A extends Schema.Class("A")(Schema.Struct({ - a: Schema.String - }).pipe(Schema.encodeKeys({"a": "c"}))) { + class A extends Schema.Class("A")( + Schema.Struct({ + a: Schema.String + }).pipe(Schema.encodeKeys({ "a": "c" })) + ) { readonly _a = 1 } const asserts = new TestSchema.Asserts(A) From 5723a8d93a6d9a440ffff96066fb7359325d0ee8 Mon Sep 17 00:00:00 2001 From: Johnny Estilles Date: Tue, 15 Sep 2026 16:07:03 +0800 Subject: [PATCH 3/3] fix(Schema): Class rejects Struct schemas piped through encodeKeys - fix types --- packages/effect/typetest/schema/Schema.tst.ts | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/packages/effect/typetest/schema/Schema.tst.ts b/packages/effect/typetest/schema/Schema.tst.ts index 6f2c585830f..8573f705ec9 100644 --- a/packages/effect/typetest/schema/Schema.tst.ts +++ b/packages/effect/typetest/schema/Schema.tst.ts @@ -1686,32 +1686,26 @@ describe("Schema", () => { }).pipe(Schema.encodeKeys({ a: "c" })) expect(schema).type.toBe< - Schema.decodeTo< + Schema.encodeKeys< Schema.Struct<{ readonly a: Schema.FiniteFromString readonly b: Schema.String }>, - Schema.Struct<{ - readonly c: Schema.toEncoded - readonly b: Schema.toEncoded - }> + { readonly a: "c" } > >() }) - it("should ignore encoded key mappings for missing decoded fields", () => { const schema = Schema.Struct({ a: Schema.String }).pipe(Schema.encodeKeys({ a: "c", b: "d" })) expect(schema).type.toBe< - Schema.decodeTo< + Schema.encodeKeys< Schema.Struct<{ readonly a: Schema.String }>, - Schema.Struct<{ - readonly c: Schema.toEncoded - }> + { readonly a: "c"; readonly b: "d" } > >() }) @@ -1725,13 +1719,11 @@ describe("Schema", () => { }).pipe(Schema.encodeKeys({ [decoded]: "decoded" })) expect(source).type.toBe< - Schema.decodeTo< + Schema.encodeKeys< Schema.Struct<{ readonly [decoded]: Schema.String }>, - Schema.Struct<{ - readonly decoded: Schema.toEncoded - }> + { readonly [decoded]: "decoded" } > >() @@ -1740,13 +1732,11 @@ describe("Schema", () => { }).pipe(Schema.encodeKeys({ decoded: encoded })) expect(destination).type.toBe< - Schema.decodeTo< + Schema.encodeKeys< Schema.Struct<{ readonly decoded: Schema.String }>, - Schema.Struct<{ - readonly [encoded]: Schema.toEncoded - }> + { readonly decoded: typeof encoded } > >() })