From b619232c105b219461301585e091a217d357de6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 06:52:33 +0000 Subject: [PATCH 1/7] feat: preserve field-local encoded key renames Agent-Logs-Url: https://github.com/patroza/effect-smol/sessions/32848271-8151-4553-9929-2451da3e02ef Co-authored-by: patroza <42661+patroza@users.noreply.github.com> --- .changeset/forty-camels-smash.md | 5 + packages/effect/src/Schema.ts | 208 +++++++++++++++--- packages/effect/test/schema/Schema.test.ts | 76 +++++++ packages/effect/typetest/schema/Schema.tst.ts | 12 + packages/effect/typetest/schema/Struct.tst.ts | 18 ++ 5 files changed, 290 insertions(+), 29 deletions(-) create mode 100644 .changeset/forty-camels-smash.md diff --git a/.changeset/forty-camels-smash.md b/.changeset/forty-camels-smash.md new file mode 100644 index 0000000000..80ecc68d52 --- /dev/null +++ b/.changeset/forty-camels-smash.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Preserve field-level encoded key renames across struct field reuse, spreading, and `mapFields`. diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index b34a5537d1..4623afe3f3 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -223,7 +223,8 @@ export interface Bottom< out TypeOptionality extends Optionality = "required", out TypeConstructorDefault extends ConstructorDefault = "no-default", out EncodedMutability extends Mutability = "readonly", - out EncodedOptionality extends Optionality = "required" + out EncodedOptionality extends Optionality = "required", + out EncodedKey extends PropertyKey = never > extends Pipeable.Pipeable { readonly [TypeId]: typeof TypeId @@ -245,6 +246,7 @@ export interface Bottom< readonly "~type.optionality": TypeOptionality readonly "~encoded.mutability": EncodedMutability readonly "~encoded.optionality": EncodedOptionality + readonly "~encoded.key": EncodedKey annotate(annotations: Annotations.Bottom): this["Rebuild"] annotateKey(annotations: Annotations.Key): this["Rebuild"] @@ -548,6 +550,61 @@ export function annotateKey(annotations: Annotations.Key extends + Bottom< + S["Type"], + S["Encoded"], + S["DecodingServices"], + S["EncodingServices"], + S["ast"], + encodedKey, + S["~type.make.in"], + S["Iso"], + S["~type.parameters"], + S["~type.make"], + S["~type.mutability"], + S["~type.optionality"], + S["~type.constructor.default"], + S["~encoded.mutability"], + S["~encoded.optionality"], + Key + > +{ + readonly schema: S +} + +/** + * Sets the encoded property name for a field schema. The mapping is attached to + * the field itself, so it is preserved when the field is reused in other + * structs, spread into field objects, or carried through `mapFields`. + * + * **Example** (Reusable renamed field) + * + * ```ts + * import { Schema } from "effect" + * + * const fullName = Schema.String.pipe(Schema.encodedKey("full_name")) + * + * const Person = Schema.Struct({ + * name: fullName + * }) + * ``` + * + * @category Struct transformations + * @since 4.0.0 + */ +export function encodedKey(key: Key) { + return (self: S): encodedKey => { + return make(AST.annotateKey(self.ast, { encodedKey: key }), { schema: self }) as any + } +} + /** * The existential "any schema" type — all type parameters are erased to `unknown`. * @@ -1585,7 +1642,8 @@ export interface optionalKey extends "optional", S["~type.constructor.default"], S["~encoded.mutability"], - "optional" + "optional", + S["~encoded.key"] > { readonly schema: S @@ -1639,8 +1697,27 @@ export const requiredKey = Struct_.lambda((self) => self.sche * * @since 4.0.0 */ -export interface optional extends optionalKey> { - readonly "Rebuild": optional +export interface optional extends + Bottom< + UndefinedOr["Type"], + UndefinedOr["Encoded"], + UndefinedOr["DecodingServices"], + UndefinedOr["EncodingServices"], + UndefinedOr["ast"], + optional, + UndefinedOr["~type.make.in"], + UndefinedOr["Iso"], + UndefinedOr["~type.parameters"], + UndefinedOr["~type.make"], + UndefinedOr["~type.mutability"], + "optional", + UndefinedOr["~type.constructor.default"], + UndefinedOr["~encoded.mutability"], + "optional", + S["~encoded.key"] + > +{ + readonly schema: UndefinedOr } interface optionalLambda extends Lambda { @@ -1673,7 +1750,11 @@ interface optionalLambda extends Lambda { * * @since 4.0.0 */ -export const optional = Struct_.lambda((self) => optionalKey(UndefinedOr(self))) +export const optional = Struct_.lambda((self) => { + const out = optionalKey(UndefinedOr(self)) + const annotations = resolveAnnotationsKey(self) + return (annotations === undefined ? out : annotateKey(annotations)(out)) as any +}) interface requiredLambda extends Lambda { (self: optional): S @@ -1711,7 +1792,8 @@ export interface mutableKey extends S["~type.optionality"], S["~type.constructor.default"], "mutable", - S["~encoded.optionality"] + S["~encoded.optionality"], + S["~encoded.key"] > { readonly schema: S @@ -2401,15 +2483,19 @@ export declare namespace Struct { : never }[keyof Fields] + type EncodedName = F[K] extends { readonly "~encoded.key": infer Key extends PropertyKey } + ? Key + : K + type Encoded_< F extends Fields, O extends keyof F = EncodedOptionalKeys, M extends keyof F = EncodedMutableKeys > = - & { readonly [K in keyof F as K extends M | O ? never : K]: F[K]["Encoded"] } - & { readonly [K in keyof F as K extends O ? K extends M ? never : K : never]?: F[K]["Encoded"] } - & { -readonly [K in keyof F as K extends M ? K extends O ? never : K : never]: F[K]["Encoded"] } - & { -readonly [K in keyof F as K extends M & O ? K : never]?: F[K]["Encoded"] } + & { readonly [K in keyof F as K extends M | O ? never : EncodedName]: F[K]["Encoded"] } + & { readonly [K in keyof F as K extends O ? K extends M ? never : EncodedName : never]?: F[K]["Encoded"] } + & { -readonly [K in keyof F as K extends M ? K extends O ? never : EncodedName : never]: F[K]["Encoded"] } + & { -readonly [K in keyof F as K extends M & O ? EncodedName : never]?: F[K]["Encoded"] } /** * @since 4.0.0 @@ -2503,8 +2589,8 @@ export interface Struct extends ): Struct>> } -function makeStruct(ast: AST.Objects, fields: Fields): Struct { - return make(ast, { +function makeStructMethods(fields: Fields) { + return { fields, mapFields( this: Struct, @@ -2513,17 +2599,83 @@ function makeStruct(ast: AST.Objects, fields readonly unsafePreserveChecks?: boolean | undefined } | undefined ): Struct { - const fields = f(this.fields) - return makeStruct(AST.struct(fields, options?.unsafePreserveChecks ? this.ast.checks : undefined), fields) + return makeStruct(f(this.fields), { + checks: options?.unsafePreserveChecks ? this.ast.checks : undefined + }) } - }) + } +} + +function makeEncodedFields< + Fields extends Struct.Fields, + M extends { readonly [K in keyof Fields]?: PropertyKey } +>( + fields: Fields, + mapping: M +): { + readonly fields: { readonly [K in keyof Fields as K extends keyof M ? M[K] extends PropertyKey ? M[K] : K : K]: toEncoded } + readonly reverseMapping: { readonly [K in keyof Fields as K extends keyof M ? M[K] extends PropertyKey ? M[K] : never : never]: K } +} { + const encodedFields: any = {} + const reverseMapping: any = {} + const seen = new Map() + for (const key of Reflect.ownKeys(fields) as Array) { + const encodedKey = Object.hasOwn(mapping, key) ? mapping[key]! : key + const previous = seen.get(encodedKey) + if (previous !== undefined && previous !== key) { + throw new Error( + `Duplicate encoded key ${JSON.stringify(String(encodedKey))} for fields ${JSON.stringify(String(previous))} and ${ + JSON.stringify(String(key)) + }` + ) + } + seen.set(encodedKey, key) + let encoded = toEncoded(fields[key]) + const annotations = encoded.ast.context?.annotations + if (annotations?.encodedKey !== undefined) { + encoded = annotateKey({ encodedKey: undefined })(encoded) + } + encodedFields[encodedKey] = encoded + if (encodedKey !== key) { + reverseMapping[encodedKey] = key + } + } + return { fields: encodedFields, reverseMapping } as any +} + +function getFieldEncodedKeyMapping(fields: Fields): { + readonly [K in keyof Fields]?: PropertyKey +} { + const mapping: any = {} + for (const key of Reflect.ownKeys(fields) as Array) { + const encodedKey = fields[key].ast.context?.annotations?.encodedKey + if (encodedKey !== undefined) { + mapping[key] = encodedKey + } + } + return mapping +} + +function makeStruct( + fields: Fields, + options?: { + readonly checks?: AST.Checks | undefined + readonly identifier?: string | undefined + } +): Struct { + const out = make(AST.struct(fields, options?.checks, options?.identifier ? { identifier: options.identifier } : undefined), ( + makeStructMethods(fields) + )) as Struct + const mapping = getFieldEncodedKeyMapping(fields) + return Reflect.ownKeys(mapping).length === 0 ? out : Object.assign(encodeKeys(mapping)(out), makeStructMethods(fields)) } /** * Defines a struct schema from a map of field schemas. * * Each field value is a schema. Use {@link optionalKey} or {@link optional} to - * mark fields as optional, and {@link mutableKey} to mark them as mutable. + * mark fields as optional, {@link mutableKey} to mark them as mutable, and + * {@link encodedKey} to rename a field in the encoded form. * * The resulting schema's `Type` is a readonly object type with the fields' * decoded types. The `Encoded` form mirrors the field schemas' encoded types. @@ -2551,7 +2703,7 @@ function makeStruct(ast: AST.Objects, fields * @since 4.0.0 */ export function Struct(fields: Fields): Struct { - return makeStruct(AST.struct(fields, undefined), fields) + return makeStruct(fields) } interface fieldsAssign extends Lambda { @@ -2626,6 +2778,9 @@ export interface encodeKeys< * const alice = Schema.decodeUnknownSync(Encoded)({ full_name: "Alice", age: 30 }) * console.log(alice) * // { name: 'Alice', age: 30 } + * + * Field-local key renames created with {@link encodedKey} are applied first. + * Calling `encodeKeys` lets you override or add an explicit mapping on top. * ``` * * @category Struct transformations @@ -2636,17 +2791,7 @@ export function encodeKeys< const M extends { readonly [K in keyof S["fields"]]?: PropertyKey } >(mapping: M) { return function(self: S): encodeKeys { - const fields: any = {} - const reverseMapping: any = {} - for (const k in self.fields) { - const encoded = toEncoded(self.fields[k]) - if (Object.hasOwn(mapping, k)) { - fields[mapping[k]!] = encoded - reverseMapping[mapping[k]!] = k - } else { - fields[k] = encoded - } - } + const { fields, reverseMapping } = makeEncodedFields(self.fields, mapping) return Struct(fields).pipe(decodeTo( self, Transformation.transform({ @@ -10616,7 +10761,7 @@ function makeClass< return makeClass( this, identifier, - makeStruct(AST.struct(fields, struct.ast.checks, { identifier }), fields), + makeStruct(fields, { checks: struct.ast.checks, identifier }), annotations, proto ) @@ -12062,6 +12207,11 @@ export declare namespace Annotations { * @since 4.0.0 */ export interface Key extends Documentation { + /** + * The property name to use for this field in the encoded representation of + * a struct. + */ + readonly encodedKey?: PropertyKey | undefined /** * The message to use when a key is missing. */ diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index a7212032fe..69243c0a10 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -7216,6 +7216,82 @@ Expected a value with a size of at most 2, got Map([["a",1],["b",NaN],["c",3]])` }) describe("encodeKeys", () => { + it("field-local encodedKey", async () => { + const myMappedField = Schema.FiniteFromString.pipe(Schema.encodedKey("mapped_name")) + const schema = Schema.Struct({ + a: myMappedField + }) + const asserts = new TestSchema.Asserts(schema) + + const decoding = asserts.decoding() + await decoding.succeed({ mapped_name: "1" }, { a: 1 }) + + const encoding = asserts.encoding() + await encoding.succeed({ a: 1 }, { mapped_name: "1" }) + }) + + it("preserves field-local encoded keys when spreading fields", async () => { + const sharedFields = { + a: Schema.FiniteFromString.pipe(Schema.encodedKey("mapped_name")) + } + const schema = Schema.Struct({ + ...sharedFields, + b: Schema.String + }) + const asserts = new TestSchema.Asserts(schema) + + const decoding = asserts.decoding() + await decoding.succeed({ mapped_name: "1", b: "b" }, { a: 1, b: "b" }) + + const encoding = asserts.encoding() + await encoding.succeed({ a: 1, b: "b" }, { mapped_name: "1", b: "b" }) + }) + + it("preserves field-local encoded keys through mapFields", async () => { + const schema = Schema.Struct({ + a: Schema.FiniteFromString.pipe(Schema.encodedKey("mapped_a")), + b: Schema.String + }).mapFields((fields) => ({ + ...fields, + c: Schema.Boolean.pipe(Schema.encodedKey("mapped_c")) + })) + const asserts = new TestSchema.Asserts(schema) + + const decoding = asserts.decoding() + await decoding.succeed({ mapped_a: "1", b: "b", mapped_c: true }, { a: 1, b: "b", c: true }) + + const encoding = asserts.encoding() + await encoding.succeed({ a: 1, b: "b", c: true }, { mapped_a: "1", b: "b", mapped_c: true }) + }) + + it("Class.mapFields preserves field-local encoded keys", async () => { + class A extends Schema.Class("A")({ + a: Schema.FiniteFromString.pipe(Schema.encodedKey("mapped_a")) + }) {} + const schema = A.mapFields((fields) => ({ + ...fields, + b: Schema.String.pipe(Schema.encodedKey("mapped_b")) + })) + const asserts = new TestSchema.Asserts(schema) + + const decoding = asserts.decoding() + await decoding.succeed({ mapped_a: "1", mapped_b: "b" }, { a: 1, b: "b" }) + + const encoding = asserts.encoding() + await encoding.succeed({ a: 1, b: "b" }, { mapped_a: "1", mapped_b: "b" }) + }) + + it("throws on duplicate encoded keys", () => { + throws( + () => + Schema.Struct({ + a: Schema.String.pipe(Schema.encodedKey("dup")), + b: Schema.Number.pipe(Schema.encodedKey("dup")) + }), + /Duplicate encoded key "dup"/ + ) + }) + it("Struct", async () => { const schema = Schema.Struct({ a: Schema.FiniteFromString, diff --git a/packages/effect/typetest/schema/Schema.tst.ts b/packages/effect/typetest/schema/Schema.tst.ts index f6464178f4..d46adeef5c 100644 --- a/packages/effect/typetest/schema/Schema.tst.ts +++ b/packages/effect/typetest/schema/Schema.tst.ts @@ -523,6 +523,18 @@ describe("Schema", () => { ) }) + it("encodedKey", () => { + const schema = Schema.Struct({ + a: Schema.String.pipe(Schema.encodedKey("c")) + }) + expect(Schema.revealCodec(schema)).type.toBe< + Schema.Codec<{ readonly a: string }, { readonly c: string }, never, never> + >() + expect(schema).type.toBe< + Schema.Struct<{ readonly a: Schema.encodedKey }> + >() + }) + describe("Never", () => { const schema = Schema.Never diff --git a/packages/effect/typetest/schema/Struct.tst.ts b/packages/effect/typetest/schema/Struct.tst.ts index 111b5eec94..4138b43fb8 100644 --- a/packages/effect/typetest/schema/Struct.tst.ts +++ b/packages/effect/typetest/schema/Struct.tst.ts @@ -151,6 +151,24 @@ describe("Struct", () => { }) describe("mapFields", () => { + it("preserves encoded keys", () => { + const schema = Schema.Struct({ + a: Schema.FiniteFromString.pipe(Schema.encodedKey("c")) + }).mapFields(Struct.assign({ + b: Schema.String.pipe(Schema.encodedKey("d")) + })) + + expect(Schema.revealCodec(schema)).type.toBe< + Schema.Codec<{ readonly a: number; readonly b: string }, { readonly c: string; readonly d: string }, never, never> + >() + expect(schema).type.toBe< + Schema.Struct<{ + readonly a: Schema.encodedKey + readonly b: Schema.encodedKey + }> + >() + }) + describe("assign", () => { it("non-overlapping fields", () => { const schema = Schema.Struct({ a: Schema.String }).mapFields(Struct.assign({ b: Schema.String })) From 53bc5656787f9f4891ede96cf50ad201f4e2c0a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 06:58:33 +0000 Subject: [PATCH 2/7] fix: finalize encoded key field reuse support Agent-Logs-Url: https://github.com/patroza/effect-smol/sessions/32848271-8151-4553-9929-2451da3e02ef Co-authored-by: patroza <42661+patroza@users.noreply.github.com> --- packages/effect/src/Schema.ts | 22 +++++++++++++--------- packages/effect/test/schema/Schema.test.ts | 4 +++- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 4623afe3f3..3534fb588e 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -408,7 +408,7 @@ export function declare( /** * Widens a schema's type to the fully-parameterized {@link Bottom} interface, - * making all 14 type parameters visible to TypeScript. + * making all type parameters visible to TypeScript. * * Normally, concrete schema interfaces (e.g. `Schema`) hide most type * parameters. `revealBottom` is useful when writing generic utilities that need @@ -421,7 +421,7 @@ export function declare( * * const schema = Schema.String * - * // Widen to Bottom to access all 14 type parameters + * // Widen to Bottom to access all type parameters * const bottom = Schema.revealBottom(schema) * * // `bottom` now exposes Type, Encoded, DecodingServices, EncodingServices, @@ -449,7 +449,8 @@ export function revealBottom( S["~type.optionality"], S["~type.constructor.default"], S["~encoded.mutability"], - S["~encoded.optionality"] + S["~encoded.optionality"], + S["~encoded.key"] > { return bottom } @@ -563,7 +564,7 @@ export interface encodedKey extends S["DecodingServices"], S["EncodingServices"], S["ast"], - encodedKey, + S["Rebuild"], S["~type.make.in"], S["Iso"], S["~type.parameters"], @@ -636,7 +637,8 @@ export interface Top extends Optionality, ConstructorDefault, Mutability, - Optionality + Optionality, + PropertyKey > {} @@ -2623,9 +2625,11 @@ function makeEncodedFields< const encodedKey = Object.hasOwn(mapping, key) ? mapping[key]! : key const previous = seen.get(encodedKey) if (previous !== undefined && previous !== key) { - throw new Error( - `Duplicate encoded key ${JSON.stringify(String(encodedKey))} for fields ${JSON.stringify(String(previous))} and ${ - JSON.stringify(String(key)) + throw new globalThis.Error( + `Duplicate encoded key ${globalThis.JSON.stringify(globalThis.String(encodedKey))} for fields ${ + globalThis.JSON.stringify(globalThis.String(previous)) + } and ${ + globalThis.JSON.stringify(globalThis.String(key)) }` ) } @@ -2667,7 +2671,7 @@ function makeStruct( makeStructMethods(fields) )) as Struct const mapping = getFieldEncodedKeyMapping(fields) - return Reflect.ownKeys(mapping).length === 0 ? out : Object.assign(encodeKeys(mapping)(out), makeStructMethods(fields)) + return Reflect.ownKeys(mapping).length === 0 ? out : Object.assign(encodeKeys(mapping)(out), makeStructMethods(fields)) as any } /** diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index 69243c0a10..cb5483935c 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -7288,7 +7288,9 @@ Expected a value with a size of at most 2, got Map([["a",1],["b",NaN],["c",3]])` a: Schema.String.pipe(Schema.encodedKey("dup")), b: Schema.Number.pipe(Schema.encodedKey("dup")) }), - /Duplicate encoded key "dup"/ + (error) => { + strictEqual((error as Error).message, `Duplicate encoded key "dup" for fields "a" and "b"`) + } ) }) From 393813157777b48057c9c91ec1620e48f1494f26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 06:59:43 +0000 Subject: [PATCH 3/7] chore: address validation feedback Agent-Logs-Url: https://github.com/patroza/effect-smol/sessions/32848271-8151-4553-9929-2451da3e02ef Co-authored-by: patroza <42661+patroza@users.noreply.github.com> --- packages/effect/src/Schema.ts | 11 ++++++----- packages/effect/test/schema/Schema.test.ts | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 3534fb588e..f8d3296fe7 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -408,7 +408,7 @@ export function declare( /** * Widens a schema's type to the fully-parameterized {@link Bottom} interface, - * making all type parameters visible to TypeScript. + * making all 15 type parameters visible to TypeScript. * * Normally, concrete schema interfaces (e.g. `Schema`) hide most type * parameters. `revealBottom` is useful when writing generic utilities that need @@ -421,7 +421,7 @@ export function declare( * * const schema = Schema.String * - * // Widen to Bottom to access all type parameters + * // Widen to Bottom to access all 15 type parameters * const bottom = Schema.revealBottom(schema) * * // `bottom` now exposes Type, Encoded, DecodingServices, EncodingServices, @@ -2621,15 +2621,16 @@ function makeEncodedFields< const encodedFields: any = {} const reverseMapping: any = {} const seen = new Map() + const formatPropertyKey = (key: PropertyKey) => typeof key === "symbol" ? globalThis.String(key) : key for (const key of Reflect.ownKeys(fields) as Array) { const encodedKey = Object.hasOwn(mapping, key) ? mapping[key]! : key const previous = seen.get(encodedKey) if (previous !== undefined && previous !== key) { throw new globalThis.Error( - `Duplicate encoded key ${globalThis.JSON.stringify(globalThis.String(encodedKey))} for fields ${ - globalThis.JSON.stringify(globalThis.String(previous)) + `Duplicate encoded key ${globalThis.JSON.stringify(formatPropertyKey(encodedKey))} for fields ${ + globalThis.JSON.stringify(formatPropertyKey(previous)) } and ${ - globalThis.JSON.stringify(globalThis.String(key)) + globalThis.JSON.stringify(formatPropertyKey(key)) }` ) } diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index cb5483935c..f4eff92155 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -7216,7 +7216,7 @@ Expected a value with a size of at most 2, got Map([["a",1],["b",NaN],["c",3]])` }) describe("encodeKeys", () => { - it("field-local encodedKey", async () => { + it("fieldLocalEncodedKey", async () => { const myMappedField = Schema.FiniteFromString.pipe(Schema.encodedKey("mapped_name")) const schema = Schema.Struct({ a: myMappedField From db4074dd9e1085afacedd510cb90804a5d1f1fe4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 07:00:41 +0000 Subject: [PATCH 4/7] docs: polish encoded key follow-up review fixes Agent-Logs-Url: https://github.com/patroza/effect-smol/sessions/32848271-8151-4553-9929-2451da3e02ef Co-authored-by: patroza <42661+patroza@users.noreply.github.com> --- packages/effect/src/Schema.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index f8d3296fe7..08a2099d10 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -424,7 +424,8 @@ export function declare( * // Widen to Bottom to access all 15 type parameters * const bottom = Schema.revealBottom(schema) * - * // `bottom` now exposes Type, Encoded, DecodingServices, EncodingServices, + * // `bottom` now exposes all 15 type parameters, including Type, Encoded, + * // DecodingServices, EncodingServices, * // ast, Rebuild, ~type.make.in, Iso, ~type.parameters, etc. * type T = typeof bottom["Type"] // string * type E = typeof bottom["Encoded"] // string @@ -2485,7 +2486,8 @@ export declare namespace Struct { : never }[keyof Fields] - type EncodedName = F[K] extends { readonly "~encoded.key": infer Key extends PropertyKey } + type ResolveEncodedKey = + F[K] extends { readonly "~encoded.key": infer Key extends PropertyKey } ? Key : K @@ -2494,10 +2496,10 @@ export declare namespace Struct { O extends keyof F = EncodedOptionalKeys, M extends keyof F = EncodedMutableKeys > = - & { readonly [K in keyof F as K extends M | O ? never : EncodedName]: F[K]["Encoded"] } - & { readonly [K in keyof F as K extends O ? K extends M ? never : EncodedName : never]?: F[K]["Encoded"] } - & { -readonly [K in keyof F as K extends M ? K extends O ? never : EncodedName : never]: F[K]["Encoded"] } - & { -readonly [K in keyof F as K extends M & O ? EncodedName : never]?: F[K]["Encoded"] } + & { readonly [K in keyof F as K extends M | O ? never : ResolveEncodedKey]: F[K]["Encoded"] } + & { readonly [K in keyof F as K extends O ? K extends M ? never : ResolveEncodedKey : never]?: F[K]["Encoded"] } + & { -readonly [K in keyof F as K extends M ? K extends O ? never : ResolveEncodedKey : never]: F[K]["Encoded"] } + & { -readonly [K in keyof F as K extends M & O ? ResolveEncodedKey : never]?: F[K]["Encoded"] } /** * @since 4.0.0 @@ -2621,16 +2623,16 @@ function makeEncodedFields< const encodedFields: any = {} const reverseMapping: any = {} const seen = new Map() - const formatPropertyKey = (key: PropertyKey) => typeof key === "symbol" ? globalThis.String(key) : key + const symbolize = (key: PropertyKey) => typeof key === "symbol" ? globalThis.String(key) : key for (const key of Reflect.ownKeys(fields) as Array) { const encodedKey = Object.hasOwn(mapping, key) ? mapping[key]! : key const previous = seen.get(encodedKey) if (previous !== undefined && previous !== key) { throw new globalThis.Error( - `Duplicate encoded key ${globalThis.JSON.stringify(formatPropertyKey(encodedKey))} for fields ${ - globalThis.JSON.stringify(formatPropertyKey(previous)) + `Duplicate encoded key ${globalThis.JSON.stringify(symbolize(encodedKey))} for fields ${ + globalThis.JSON.stringify(symbolize(previous)) } and ${ - globalThis.JSON.stringify(formatPropertyKey(key)) + globalThis.JSON.stringify(symbolize(key)) }` ) } From f12e8455922de65a1f60cb1b5075a80a342d98c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 07:01:48 +0000 Subject: [PATCH 5/7] refactor: clarify encoded key helper naming Agent-Logs-Url: https://github.com/patroza/effect-smol/sessions/32848271-8151-4553-9929-2451da3e02ef Co-authored-by: patroza <42661+patroza@users.noreply.github.com> --- packages/effect/src/Schema.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 08a2099d10..4eef7da208 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -408,7 +408,7 @@ export function declare( /** * Widens a schema's type to the fully-parameterized {@link Bottom} interface, - * making all 15 type parameters visible to TypeScript. + * making all type parameters visible to TypeScript. * * Normally, concrete schema interfaces (e.g. `Schema`) hide most type * parameters. `revealBottom` is useful when writing generic utilities that need @@ -421,10 +421,10 @@ export function declare( * * const schema = Schema.String * - * // Widen to Bottom to access all 15 type parameters + * // Widen to Bottom to access all type parameters * const bottom = Schema.revealBottom(schema) * - * // `bottom` now exposes all 15 type parameters, including Type, Encoded, + * // `bottom` now exposes all type parameters, including Type, Encoded, * // DecodingServices, EncodingServices, * // ast, Rebuild, ~type.make.in, Iso, ~type.parameters, etc. * type T = typeof bottom["Type"] // string @@ -2623,16 +2623,15 @@ function makeEncodedFields< const encodedFields: any = {} const reverseMapping: any = {} const seen = new Map() - const symbolize = (key: PropertyKey) => typeof key === "symbol" ? globalThis.String(key) : key + const keyToDisplay = (key: PropertyKey) => + typeof key === "string" ? globalThis.JSON.stringify(key) : globalThis.String(key) for (const key of Reflect.ownKeys(fields) as Array) { const encodedKey = Object.hasOwn(mapping, key) ? mapping[key]! : key const previous = seen.get(encodedKey) if (previous !== undefined && previous !== key) { throw new globalThis.Error( - `Duplicate encoded key ${globalThis.JSON.stringify(symbolize(encodedKey))} for fields ${ - globalThis.JSON.stringify(symbolize(previous)) - } and ${ - globalThis.JSON.stringify(symbolize(key)) + `Duplicate encoded key ${keyToDisplay(encodedKey)} for fields ${keyToDisplay(previous)} and ${ + keyToDisplay(key) }` ) } @@ -2640,6 +2639,8 @@ function makeEncodedFields< let encoded = toEncoded(fields[key]) const annotations = encoded.ast.context?.annotations if (annotations?.encodedKey !== undefined) { + // The field-local encoded key has already been applied at the struct level, + // so clear it here to avoid re-applying it when building the encoded struct. encoded = annotateKey({ encodedKey: undefined })(encoded) } encodedFields[encodedKey] = encoded From 0bfc79e4803d71c60b628a47adfe94346b8b83b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 07:12:04 +0000 Subject: [PATCH 6/7] test: cover encoded key flip laws Agent-Logs-Url: https://github.com/patroza/effect-smol/sessions/4ae338a3-dc81-4aa5-a0de-1d7b81d2919a Co-authored-by: patroza <42661+patroza@users.noreply.github.com> --- packages/effect/src/Schema.ts | 19 +++++++++++--- packages/effect/test/schema/Schema.test.ts | 25 +++++++++++++++++++ packages/effect/typetest/schema/Schema.tst.ts | 12 +++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 4eef7da208..907c0d9c74 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -2610,6 +2610,14 @@ function makeStructMethods(fields: Fields) { } } +function decorateStruct(schema: S, fields: Fields): S & Struct { + const methods = makeStructMethods(fields) + const out = Object.assign(schema, methods) + const rebuild = out.rebuild.bind(out) + out.rebuild = (ast: S["ast"]) => decorateStruct(rebuild(ast) as S, fields) as any + return out as any +} + function makeEncodedFields< Fields extends Struct.Fields, M extends { readonly [K in keyof Fields]?: PropertyKey } @@ -2671,11 +2679,14 @@ function makeStruct( readonly identifier?: string | undefined } ): Struct { - const out = make(AST.struct(fields, options?.checks, options?.identifier ? { identifier: options.identifier } : undefined), ( - makeStructMethods(fields) - )) as Struct + const out = decorateStruct( + make(AST.struct(fields, options?.checks, options?.identifier ? { identifier: options.identifier } : undefined)) as Struct< + Fields + >, + fields + ) const mapping = getFieldEncodedKeyMapping(fields) - return Reflect.ownKeys(mapping).length === 0 ? out : Object.assign(encodeKeys(mapping)(out), makeStructMethods(fields)) as any + return Reflect.ownKeys(mapping).length === 0 ? out : decorateStruct(encodeKeys(mapping)(out), fields) } /** diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index f4eff92155..ddc2631843 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -2424,6 +2424,31 @@ Expected a value with a size of at most 2, got Map([["a",1],["b",NaN],["c",3]])` await makeFlipped2.succeed({ a: 1 }) await makeFlipped2.succeed({}, { a: -1 }) }) + + it("should preserve encodedKey through flip twice", async () => { + const schema = Schema.Struct({ + a: Schema.FiniteFromString.pipe(Schema.encodedKey("mapped_a")) + }) + + const flipped = schema.pipe(Schema.flip) + const assertsFlipped = new TestSchema.Asserts(flipped) + + const decodingFlipped = assertsFlipped.decoding() + await decodingFlipped.succeed({ a: 1 }, { mapped_a: "1" }) + + const encodingFlipped = assertsFlipped.encoding() + await encodingFlipped.succeed({ mapped_a: "1" }, { a: 1 }) + + const flipped2 = flipped.pipe(Schema.flip) + const assertsFlipped2 = new TestSchema.Asserts(flipped2) + deepStrictEqual(flipped2.fields, schema.fields) + + const decodingFlipped2 = assertsFlipped2.decoding() + await decodingFlipped2.succeed({ mapped_a: "1" }, { a: 1 }) + + const encodingFlipped2 = assertsFlipped2.encoding() + await encodingFlipped2.succeed({ a: 1 }, { mapped_a: "1" }) + }) }) it("declare", async () => { diff --git a/packages/effect/typetest/schema/Schema.tst.ts b/packages/effect/typetest/schema/Schema.tst.ts index d46adeef5c..2600c3418a 100644 --- a/packages/effect/typetest/schema/Schema.tst.ts +++ b/packages/effect/typetest/schema/Schema.tst.ts @@ -845,6 +845,18 @@ describe("Schema", () => { >() }) + it("encodedKey", () => { + const schema = Schema.Struct({ + a: Schema.FiniteFromString.pipe(Schema.encodedKey("mapped_a")) + }) + const flipped = Schema.flip(schema) + + expect(Schema.flip(Schema.flip(schema))).type.toBe() + expect(Schema.revealCodec(flipped)).type.toBe< + Schema.Codec<{ readonly mapped_a: string }, { readonly a: number }> + >() + }) + it("Struct & withConstructorDefault", () => { const schema = Schema.Struct({ a: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed("c"))) From 891336b495d5793408fdf30944642cdb33949362 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 07:12:59 +0000 Subject: [PATCH 7/7] chore: polish encoded key flip proof tests Agent-Logs-Url: https://github.com/patroza/effect-smol/sessions/4ae338a3-dc81-4aa5-a0de-1d7b81d2919a Co-authored-by: patroza <42661+patroza@users.noreply.github.com> --- packages/effect/src/Schema.ts | 4 ++-- packages/effect/test/schema/Schema.test.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index 907c0d9c74..7d895a9b74 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -2613,8 +2613,8 @@ function makeStructMethods(fields: Fields) { function decorateStruct(schema: S, fields: Fields): S & Struct { const methods = makeStructMethods(fields) const out = Object.assign(schema, methods) - const rebuild = out.rebuild.bind(out) - out.rebuild = (ast: S["ast"]) => decorateStruct(rebuild(ast) as S, fields) as any + const originalRebuild = out.rebuild.bind(out) + out.rebuild = (ast: S["ast"]) => decorateStruct(originalRebuild(ast) as S, fields) as any return out as any } diff --git a/packages/effect/test/schema/Schema.test.ts b/packages/effect/test/schema/Schema.test.ts index ddc2631843..166de95d9c 100644 --- a/packages/effect/test/schema/Schema.test.ts +++ b/packages/effect/test/schema/Schema.test.ts @@ -2431,22 +2431,22 @@ Expected a value with a size of at most 2, got Map([["a",1],["b",NaN],["c",3]])` }) const flipped = schema.pipe(Schema.flip) - const assertsFlipped = new TestSchema.Asserts(flipped) + const flippedAsserts = new TestSchema.Asserts(flipped) - const decodingFlipped = assertsFlipped.decoding() + const decodingFlipped = flippedAsserts.decoding() await decodingFlipped.succeed({ a: 1 }, { mapped_a: "1" }) - const encodingFlipped = assertsFlipped.encoding() + const encodingFlipped = flippedAsserts.encoding() await encodingFlipped.succeed({ mapped_a: "1" }, { a: 1 }) const flipped2 = flipped.pipe(Schema.flip) - const assertsFlipped2 = new TestSchema.Asserts(flipped2) + const doubleFlippedAsserts = new TestSchema.Asserts(flipped2) deepStrictEqual(flipped2.fields, schema.fields) - const decodingFlipped2 = assertsFlipped2.decoding() + const decodingFlipped2 = doubleFlippedAsserts.decoding() await decodingFlipped2.succeed({ mapped_a: "1" }, { a: 1 }) - const encodingFlipped2 = assertsFlipped2.encoding() + const encodingFlipped2 = doubleFlippedAsserts.encoding() await encodingFlipped2.succeed({ a: 1 }, { mapped_a: "1" }) }) })