diff --git a/.changeset/drop-constructor-default.md b/.changeset/drop-constructor-default.md new file mode 100644 index 000000000..41cdd1ccb --- /dev/null +++ b/.changeset/drop-constructor-default.md @@ -0,0 +1,7 @@ +--- +"effect-app": patch +--- + +Add `Schema.dropConstructorDefault`, a `Struct.Lambda` that clears a field's `withConstructorDefault`. Compose it with `Struct.omit`/`Struct.map` when deriving a partial-update schema from a "create" schema, so omitted fields' constructor defaults don't resurrect themselves in `.make(...)` output. + +Also, `makeExactOptional` now drops constructor defaults automatically, matching this behavior. diff --git a/packages/effect-app/src/Schema/ext.ts b/packages/effect-app/src/Schema/ext.ts index 7fa3a5482..94b0ba879 100644 --- a/packages/effect-app/src/Schema/ext.ts +++ b/packages/effect-app/src/Schema/ext.ts @@ -39,9 +39,10 @@ import * as S from "effect/Schema" import { isDateValid } from "effect/Schema" import * as SchemaIssue from "effect/SchemaIssue" import * as SchemaTransformation from "effect/SchemaTransformation" +import * as Struct from "effect/Struct" import { type NonEmptyReadonlyArray } from "../Array.ts" import * as Context from "../Context.ts" -import type * as SchemaAST from "../SchemaAST.ts" +import * as SchemaAST from "../SchemaAST.ts" import { extendM, typedKeysOf } from "../utils.ts" import { type AST } from "./schema.ts" @@ -539,13 +540,81 @@ export function makeOptional( }, {} as any) } +// `Omit` erases the polymorphic `this` of "this-returning" methods (`annotate`, `annotateKey`, +// `check`, `rebuild`): extracting them through a mapped type binds their `this["Rebuild"]` back to +// the original `T`, not to this intersection. So overriding just `Rebuild` above is not enough, those +// four methods have to be redeclared here with a concrete return type instead of `this["Rebuild"]`. +export type DropConstructorDefault = + & Omit< + T, + "~type.constructor.default" | "Rebuild" | "annotate" | "annotateKey" | "check" | "rebuild" + > + & { + readonly "~type.constructor.default": "no-default" + readonly "Rebuild": DropConstructorDefault + annotate(annotations: S.Annotations.Bottom): DropConstructorDefault + annotateKey(annotations: S.Annotations.Key): DropConstructorDefault + check( + ...checks: readonly [SchemaAST.Check, ...Array>] + ): DropConstructorDefault + rebuild(ast: T["ast"]): DropConstructorDefault + } + +// `replaceContext` is `@internal` in `effect/SchemaAST` (stripped from its public types, though the +// export itself still ships in the compiled JS). Effect's own `withConstructorDefault` goes through the +// same function internally, so this mirrors an existing code path rather than reaching for something +// novel, but it's still an internal API that could move or disappear without a signal. +const SchemaASTInternal = SchemaAST as unknown as { + readonly replaceContext: (ast: A, context: SchemaAST.Context | undefined) => A +} + +export interface DropConstructorDefaultLambda extends Struct.Lambda { + (self: T): DropConstructorDefault + readonly "~lambda.out": DropConstructorDefault +} + +/** + * A `Struct.Lambda` that removes a field's `withConstructorDefault`, if any. + * Compose with `Struct.pick`/`Struct.omit` via `Struct.map` to derive a + * partial-update schema from a "create" schema without inheriting defaults + * that would resurrect omitted fields in `.make(...)` output: + * + * ```ts + * import { flow } from "effect/Function" + * import * as Struct from "effect/Struct" + * import * as S from "effect-app/Schema" + * + * const Person = S.Struct({ + * id: S.String, + * name: S.String.pipe(S.optionalKey, S.withConstructorDefault(() => "")) + * }) + * + * const UpdatePerson = Person.mapFields( + * flow(Struct.omit(["id"]), Struct.map(S.dropConstructorDefault)) + * ) + * ``` + */ +export const dropConstructorDefault: DropConstructorDefaultLambda = Struct.lambda( + (schema) => { + const context = schema.ast.context + if (!context?.defaultValue) return schema + return schema.rebuild( + SchemaASTInternal.replaceContext( + schema.ast, + new SchemaAST.Context(context.isOptional, context.isMutable, undefined, context.annotations) + ) + ) + } +) + export function makeExactOptional( t: NER ): { - [K in keyof NER]: NER[K] extends S.Top ? ReturnType> : any + [K in keyof NER]: NER[K] extends S.Top ? DropConstructorDefault>> + : any } { return typedKeysOf(t).reduce((prev, cur) => { - prev[cur] = S.optionalKey(t[cur] as any) + prev[cur] = dropConstructorDefault(S.optionalKey(t[cur] as any)) return prev }, {} as any) } diff --git a/packages/effect-app/test/schema.test.ts b/packages/effect-app/test/schema.test.ts index 4aea149c7..976220a7a 100644 --- a/packages/effect-app/test/schema.test.ts +++ b/packages/effect-app/test/schema.test.ts @@ -2,6 +2,8 @@ import * as Array from "effect-app/Array" import * as S from "effect-app/Schema" import { specialJsonSchemaDocument } from "effect-app/Schema/SpecialJsonSchema" +import * as Effect from "effect/Effect" +import * as Struct from "effect/Struct" import { describe, expect, expectTypeOf, test } from "vitest" const A = S.Struct({ a: S.NonEmptyString255, email: S.NullOr(S.Email) }) @@ -27,6 +29,49 @@ test("literal default works", () => { expect(s2.make({}).l).toBe("b") }) +// Toy schema for `dropConstructorDefault`: a field with a constructor +// default that should NOT auto-populate when deriving a partial-update +// schema from a "create" schema. +const Person = S.Struct({ + name: S.String, + notes: S.NullOr(S.String).pipe(S.optionalKey, S.withConstructorDefault(Effect.succeed(null))) +}) + +test("plain Struct.omit leaks the inherited constructor default (the bug)", () => { + const UpdatePersonBuggy = S.Struct(Struct.omit(Person.fields, [] as const)) + expect(UpdatePersonBuggy.make({ name: "Mario" })).toEqual({ name: "Mario", notes: null }) +}) + +test("Struct.map(S.dropConstructorDefault) strips the inherited constructor default (the fix)", () => { + const UpdatePersonFixed = S.Struct(Struct.map(Struct.omit(Person.fields, [] as const), S.dropConstructorDefault)) + expect(UpdatePersonFixed.make({ name: "Mario" })).toEqual({ name: "Mario" }) +}) + +test("Struct.map(S.dropConstructorDefault) still omits the requested keys, like Struct.omit", () => { + const NameOnly = S.Struct(Struct.map(Struct.omit(Person.fields, ["notes"] as const), S.dropConstructorDefault)) + expect(NameOnly.make({ name: "Mario" })).toEqual({ name: "Mario" }) + expectTypeOf().toEqualTypeOf<{ readonly name: string }>() +}) + +test("makeExactOptional drops the inherited constructor default too", () => { + const UpdatePerson = S.Struct(S.makeExactOptional(Person.fields)) + expect(UpdatePerson.make({ name: "Mario" })).toEqual({ name: "Mario" }) +}) + +test("dropConstructorDefault stays 'no-default' after annotate/annotateKey (Rebuild fix)", () => { + const notesDropped = S.dropConstructorDefault(Person.fields.notes) + expectTypeOf<(typeof notesDropped)["~type.constructor.default"]>().toEqualTypeOf<"no-default">() + + const notesAnnotated = notesDropped.annotate({ description: "no longer defaulted" }) + expectTypeOf<(typeof notesAnnotated)["~type.constructor.default"]>().toEqualTypeOf<"no-default">() + + const notesKeyAnnotated = notesDropped.annotateKey({ description: "no longer defaulted (key)" }) + expectTypeOf<(typeof notesKeyAnnotated)["~type.constructor.default"]>().toEqualTypeOf<"no-default">() + + const UpdatePerson = S.Struct({ name: Person.fields.name, notes: notesAnnotated }) + expect(UpdatePerson.make({ name: "Mario" })).toEqual({ name: "Mario" }) +}) + test("NonEmptyString255.Type uses the named brand alias", () => { type A = typeof S.NonEmptyString255.Type type B = string & S.NonEmptyString255Brand diff --git a/packages/vue-components/stories/OmegaForm.stories.ts b/packages/vue-components/stories/OmegaForm.stories.ts index 894637ee5..8ebac46a0 100644 --- a/packages/vue-components/stories/OmegaForm.stories.ts +++ b/packages/vue-components/stories/OmegaForm.stories.ts @@ -26,6 +26,7 @@ import MetaFormComponent from "./OmegaForm/Meta.vue" import NullComponent from "./OmegaForm/Null.vue" import NullableComponent from "./OmegaForm/Nullable.vue" import NullableNestedStructComponent from "./OmegaForm/NullableNestedStruct.vue" +import OmitConstructorDefaultsComponent from "./OmegaForm/OmitConstructorDefaults.vue" import OptionalKeyComponent from "./OmegaForm/OptionalKey.vue" import PersistencyFormComponent from "./OmegaForm/PersistencyForm.vue" import ProgrammaticallyHandleSubmitCheckErrorsComponent from "./OmegaForm/ProgrammaticallyHandleSubmitCheckErrors.vue" @@ -365,6 +366,21 @@ export const Defaults: Story = { }) } +export const OmitConstructorDefaults: Story = { + render: () => ({ + components: { OmitConstructorDefaultsComponent }, + template: "" + }), + parameters: { + docs: { + description: { + story: + "Compares deriving an update schema with plain Struct.omit (leaks inherited withConstructorDefault) against Struct.map(S.dropConstructorDefault) (strips it). Each column starts with a saved record; the edit dialog only lets you change one field. Save without touching the other field and watch whether it survives." + } + } + } +} + export const Redacted: Story = { render: () => ({ components: { RedactedComponent }, diff --git a/packages/vue-components/stories/OmegaForm/OmitConstructorDefaults.vue b/packages/vue-components/stories/OmegaForm/OmitConstructorDefaults.vue new file mode 100644 index 000000000..5ef5da368 --- /dev/null +++ b/packages/vue-components/stories/OmegaForm/OmitConstructorDefaults.vue @@ -0,0 +1,131 @@ + + + + +