Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/forty-camels-smash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

Preserve field-level encoded key renames across struct field reuse, spreading, and `mapFields`.
237 changes: 203 additions & 34 deletions packages/effect/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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["Type"], this["~type.parameters"]>): this["Rebuild"]
annotateKey(annotations: Annotations.Key<this["Type"]>): this["Rebuild"]
Expand Down Expand Up @@ -408,7 +410,7 @@ export function declare<T, Iso = T>(

/**
* 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<string>`) hide most type
* parameters. `revealBottom` is useful when writing generic utilities that need
Expand All @@ -421,10 +423,11 @@ export function declare<T, Iso = T>(
*
* 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
Expand All @@ -449,7 +452,8 @@ export function revealBottom<S extends Top>(
S["~type.optionality"],
S["~type.constructor.default"],
S["~encoded.mutability"],
S["~encoded.optionality"]
S["~encoded.optionality"],
S["~encoded.key"]
> {
return bottom
}
Expand Down Expand Up @@ -550,6 +554,61 @@ export function annotateKey<S extends Top>(annotations: Annotations.Key<S["Type"
}
}

/**
* Companion type for {@link encodedKey}. Carries a field-local encoded property
* name so it can be preserved across struct field reuse.
*
* @since 4.0.0
*/
export interface encodedKey<S extends Top, Key extends PropertyKey> 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<const Key extends PropertyKey>(key: Key) {
return <S extends Top>(self: S): encodedKey<S, Key> => {
return make(AST.annotateKey(self.ast, { encodedKey: key }), { schema: self }) as any
}
}

/**
* The existential "any schema" type — all type parameters are erased to `unknown`.
*
Expand Down Expand Up @@ -581,7 +640,8 @@ export interface Top extends
Optionality,
ConstructorDefault,
Mutability,
Optionality
Optionality,
PropertyKey
>
{}

Expand Down Expand Up @@ -1594,7 +1654,8 @@ export interface optionalKey<S extends Top> extends
"optional",
S["~type.constructor.default"],
S["~encoded.mutability"],
"optional"
"optional",
S["~encoded.key"]
>
{
readonly schema: S
Expand Down Expand Up @@ -1648,8 +1709,27 @@ export const requiredKey = Struct_.lambda<requiredKeyLambda>((self) => self.sche
*
* @since 4.0.0
*/
export interface optional<S extends Top> extends optionalKey<UndefinedOr<S>> {
readonly "Rebuild": optional<S>
export interface optional<S extends Top> extends
Bottom<
UndefinedOr<S>["Type"],
UndefinedOr<S>["Encoded"],
UndefinedOr<S>["DecodingServices"],
UndefinedOr<S>["EncodingServices"],
UndefinedOr<S>["ast"],
optional<S>,
UndefinedOr<S>["~type.make.in"],
UndefinedOr<S>["Iso"],
UndefinedOr<S>["~type.parameters"],
UndefinedOr<S>["~type.make"],
UndefinedOr<S>["~type.mutability"],
"optional",
UndefinedOr<S>["~type.constructor.default"],
UndefinedOr<S>["~encoded.mutability"],
"optional",
S["~encoded.key"]
>
{
readonly schema: UndefinedOr<S>
}

interface optionalLambda extends Lambda {
Expand Down Expand Up @@ -1682,7 +1762,11 @@ interface optionalLambda extends Lambda {
*
* @since 4.0.0
*/
export const optional = Struct_.lambda<optionalLambda>((self) => optionalKey(UndefinedOr(self)))
export const optional = Struct_.lambda<optionalLambda>((self) => {
const out = optionalKey(UndefinedOr(self))
const annotations = resolveAnnotationsKey(self)
return (annotations === undefined ? out : annotateKey<typeof out>(annotations)(out)) as any
})

interface requiredLambda extends Lambda {
<S extends Top>(self: optional<S>): S
Expand Down Expand Up @@ -1720,7 +1804,8 @@ export interface mutableKey<S extends Top> extends
S["~type.optionality"],
S["~type.constructor.default"],
"mutable",
S["~encoded.optionality"]
S["~encoded.optionality"],
S["~encoded.key"]
>
{
readonly schema: S
Expand Down Expand Up @@ -2445,15 +2530,20 @@ export declare namespace Struct {
: never
}[keyof Fields]

type ResolveEncodedKey<F extends Fields, K extends keyof F> =
F[K] extends { readonly "~encoded.key": infer Key extends PropertyKey }
? Key
: K

type Encoded_<
F extends Fields,
O extends keyof F = EncodedOptionalKeys<F>,
M extends keyof F = EncodedMutableKeys<F>
> =
& { 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>]: F[K]["Encoded"] }
& { readonly [K in keyof F as K extends O ? K extends M ? never : ResolveEncodedKey<F, K> : never]?: F[K]["Encoded"] }
& { -readonly [K in keyof F as K extends M ? K extends O ? never : ResolveEncodedKey<F, K> : never]: F[K]["Encoded"] }
& { -readonly [K in keyof F as K extends M & O ? ResolveEncodedKey<F, K> : never]?: F[K]["Encoded"] }

/**
* Computes the encoded object type for a struct field map.
Expand Down Expand Up @@ -2573,8 +2663,8 @@ export interface Struct<Fields extends Struct.Fields> extends
): Struct<Simplify<Readonly<To>>>
}

function makeStruct<const Fields extends Struct.Fields>(ast: AST.Objects, fields: Fields): Struct<Fields> {
return make(ast, {
function makeStructMethods<const Fields extends Struct.Fields>(fields: Fields) {
return {
fields,
mapFields<To extends Struct.Fields>(
this: Struct<Fields>,
Expand All @@ -2583,17 +2673,98 @@ function makeStruct<const Fields extends Struct.Fields>(ast: AST.Objects, fields
readonly unsafePreserveChecks?: boolean | undefined
} | undefined
): Struct<To> {
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<const Fields extends Struct.Fields, S extends Top>(schema: S, fields: Fields): S & Struct<Fields> {
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<Fields[K]> }
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<PropertyKey, PropertyKey>()
const keyToDisplay = (key: PropertyKey) =>
typeof key === "string" ? globalThis.JSON.stringify(key) : globalThis.String(key)
for (const key of Reflect.ownKeys(fields) as Array<keyof Fields & PropertyKey>) {
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<typeof encoded>({ encodedKey: undefined })(encoded)
}
encodedFields[encodedKey] = encoded
if (encodedKey !== key) {
reverseMapping[encodedKey] = key
}
}
return { fields: encodedFields, reverseMapping } as any
}

function getFieldEncodedKeyMapping<Fields extends Struct.Fields>(fields: Fields): {
readonly [K in keyof Fields]?: PropertyKey
} {
const mapping: any = {}
for (const key of Reflect.ownKeys(fields) as Array<keyof Fields & PropertyKey>) {
const encodedKey = fields[key].ast.context?.annotations?.encodedKey
if (encodedKey !== undefined) {
mapping[key] = encodedKey
}
}
return mapping
}

function makeStruct<const Fields extends Struct.Fields>(
fields: Fields,
options?: {
readonly checks?: AST.Checks | undefined
readonly identifier?: string | undefined
}
): Struct<Fields> {
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.
Expand Down Expand Up @@ -2621,7 +2792,7 @@ function makeStruct<const Fields extends Struct.Fields>(ast: AST.Objects, fields
* @since 4.0.0
*/
export function Struct<const Fields extends Struct.Fields>(fields: Fields): Struct<Fields> {
return makeStruct(AST.struct(fields, undefined), fields)
return makeStruct(fields)
}

interface fieldsAssign<NewFields extends Struct.Fields> extends Lambda {
Expand Down Expand Up @@ -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
Expand All @@ -2706,17 +2880,7 @@ export function encodeKeys<
const M extends { readonly [K in keyof S["fields"]]?: PropertyKey }
>(mapping: M) {
return function(self: S): encodeKeys<S, M> {
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<any, any>({
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -12400,6 +12564,11 @@ export declare namespace Annotations {
* @since 4.0.0
*/
export interface Key<T> extends Documentation<T> {
/**
* 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.
*/
Expand Down
Loading