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 df1aea69e4..ffbe9dfe6a 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"] @@ -408,7 +410,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,10 +423,11 @@ 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, + * // `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 * type E = typeof bottom["Encoded"] // string @@ -449,7 +452,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 } @@ -550,6 +554,61 @@ export function annotateKey(annotations: Annotations.Key extends + Bottom< + S["Type"], + S["Encoded"], + S["DecodingServices"], + S["EncodingServices"], + S["ast"], + S["Rebuild"], + 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`. * @@ -581,7 +640,8 @@ export interface Top extends Optionality, ConstructorDefault, Mutability, - Optionality + Optionality, + PropertyKey > {} @@ -1594,7 +1654,8 @@ export interface optionalKey extends "optional", S["~type.constructor.default"], S["~encoded.mutability"], - "optional" + "optional", + S["~encoded.key"] > { readonly schema: S @@ -1648,8 +1709,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 { @@ -1682,7 +1762,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 @@ -1720,7 +1804,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 @@ -2445,15 +2530,20 @@ export declare namespace Struct { : never }[keyof Fields] + type ResolveEncodedKey = + 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 : 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"] } /** * Computes the encoded object type for a struct field map. @@ -2573,8 +2663,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, @@ -2583,17 +2673,98 @@ 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 decorateStruct(schema: S, fields: Fields): S & Struct { + const methods = makeStructMethods(fields) + const out = Object.assign(schema, methods) + const originalRebuild = out.rebuild.bind(out) + out.rebuild = (ast: S["ast"]) => decorateStruct(originalRebuild(ast) as S, fields) as any + return out as any +} + +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() + 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 ${keyToDisplay(encodedKey)} for fields ${keyToDisplay(previous)} and ${ + keyToDisplay(key) + }` + ) + } + seen.set(encodedKey, key) + 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 + 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 = 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 : decorateStruct(encodeKeys(mapping)(out), 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. @@ -2621,7 +2792,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 { @@ -2696,6 +2867,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 @@ -2706,17 +2880,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({ @@ -10943,7 +11107,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 ) @@ -12400,6 +12564,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..166de95d9c 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 flippedAsserts = new TestSchema.Asserts(flipped) + + const decodingFlipped = flippedAsserts.decoding() + await decodingFlipped.succeed({ a: 1 }, { mapped_a: "1" }) + + const encodingFlipped = flippedAsserts.encoding() + await encodingFlipped.succeed({ mapped_a: "1" }, { a: 1 }) + + const flipped2 = flipped.pipe(Schema.flip) + const doubleFlippedAsserts = new TestSchema.Asserts(flipped2) + deepStrictEqual(flipped2.fields, schema.fields) + + const decodingFlipped2 = doubleFlippedAsserts.decoding() + await decodingFlipped2.succeed({ mapped_a: "1" }, { a: 1 }) + + const encodingFlipped2 = doubleFlippedAsserts.encoding() + await encodingFlipped2.succeed({ a: 1 }, { mapped_a: "1" }) + }) }) it("declare", async () => { @@ -7216,6 +7241,84 @@ Expected a value with a size of at most 2, got Map([["a",1],["b",NaN],["c",3]])` }) describe("encodeKeys", () => { + it("fieldLocalEncodedKey", 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")) + }), + (error) => { + strictEqual((error as Error).message, `Duplicate encoded key "dup" for fields "a" and "b"`) + } + ) + }) + 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..2600c3418a 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 @@ -833,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"))) 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 }))