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
7 changes: 7 additions & 0 deletions .changeset/drop-constructor-default.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 72 additions & 3 deletions packages/effect-app/src/Schema/ext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -539,13 +540,81 @@ export function makeOptional<NER extends S.Struct.Fields>(
}, {} 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<T extends S.Top> =
& Omit<
T,
"~type.constructor.default" | "Rebuild" | "annotate" | "annotateKey" | "check" | "rebuild"
>
& {
readonly "~type.constructor.default": "no-default"
readonly "Rebuild": DropConstructorDefault<T>
annotate(annotations: S.Annotations.Bottom<T["Type"], T["~type.parameters"]>): DropConstructorDefault<T>
annotateKey(annotations: S.Annotations.Key<T["Type"]>): DropConstructorDefault<T>
check(
...checks: readonly [SchemaAST.Check<T["Type"]>, ...Array<SchemaAST.Check<T["Type"]>>]
): DropConstructorDefault<T>
rebuild(ast: T["ast"]): DropConstructorDefault<T>
}

// `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 {
Comment thread
MindfulLearner marked this conversation as resolved.
readonly replaceContext: <A extends SchemaAST.AST>(ast: A, context: SchemaAST.Context | undefined) => A
}

export interface DropConstructorDefaultLambda extends Struct.Lambda {
<T extends S.Top>(self: T): DropConstructorDefault<T>
readonly "~lambda.out": DropConstructorDefault<this["~lambda.in"] & S.Top>
}

/**
* 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"
Comment thread
MindfulLearner marked this conversation as resolved.
* 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<DropConstructorDefaultLambda>(
Comment thread
MindfulLearner marked this conversation as resolved.
(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<NER extends S.Struct.Fields>(
t: NER
): {
[K in keyof NER]: NER[K] extends S.Top ? ReturnType<typeof S.optionalKey<NER[K] & S.Top>> : any
[K in keyof NER]: NER[K] extends S.Top ? DropConstructorDefault<ReturnType<typeof S.optionalKey<NER[K] & S.Top>>>
: 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)
}
Expand Down
45 changes: 45 additions & 0 deletions packages/effect-app/test/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand All @@ -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<typeof NameOnly.Type>().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
Expand Down
16 changes: 16 additions & 0 deletions packages/vue-components/stories/OmegaForm.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -365,6 +366,21 @@ export const Defaults: Story = {
})
}

export const OmitConstructorDefaults: Story = {
render: () => ({
components: { OmitConstructorDefaultsComponent },
template: "<OmitConstructorDefaultsComponent />"
}),
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 },
Expand Down
131 changes: 131 additions & 0 deletions packages/vue-components/stories/OmegaForm/OmitConstructorDefaults.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<template>
<h1>Does editing one field wipe the others?</h1>
<p>
Each column starts with a record already saved on the server: <code>name</code> and <code>age</code> both filled in.
The edit dialog below it only exposes <b>name</b>, a narrow edit action that never shows or touches <code
>age</code>. Click <b>Save</b> without touching <code>age</code> and watch what happens to it in the "Saved record"
box above.
</p>
<div style="display: flex; gap: 2rem; flex-wrap: wrap">
<section style="flex: 1; min-width: 320px">
<h2>Buggy: plain <code>Struct.omit</code></h2>

<h3>Saved record (server)</h3>
<pre>{{ buggyDb }}</pre>

<h3>Edit dialog (only "name" is editable here)</h3>
<buggyForm.Form>
<template #default>
<buggyForm.Input
label="name"
name="name"
/>
<v-btn type="submit">
Save
</v-btn>
</template>
</buggyForm.Form>

<p
v-if="buggyResult"
:style="{ color: buggyResult.ageWiped ? 'crimson' : 'seagreen', fontWeight: 'bold' }"
>
{{ buggyResult.ageWiped ? "age was wiped by an update that never touched it!" : "age survived the update" }}
</p>

<h3>Payload sent to the server</h3>
<pre>{{ buggyPayload ?? "(not submitted yet)" }}</pre>
</section>

<section style="flex: 1; min-width: 320px">
<h2>Fixed: <code>Struct.map(S.dropConstructorDefault)</code></h2>

<h3>Saved record (server)</h3>
<pre>{{ fixedDb }}</pre>

<h3>Edit dialog (only "name" is editable here)</h3>
<fixedForm.Form>
<template #default>
<fixedForm.Input
label="name"
name="name"
/>
<v-btn type="submit">
Save
</v-btn>
</template>
</fixedForm.Form>

<p
v-if="fixedResult"
:style="{ color: fixedResult.ageWiped ? 'crimson' : 'seagreen', fontWeight: 'bold' }"
>
{{ fixedResult.ageWiped ? "age was wiped by an update that never touched it!" : "age survived the update" }}
</p>

<h3>Payload sent to the server</h3>
<pre>{{ fixedPayload ?? "(not submitted yet)" }}</pre>
</section>
</div>
</template>

<script setup lang="ts">
import * as Effect from "effect-app/Effect"
import * as S from "effect-app/Schema"
import * as Struct from "effect/Struct"
import { ref } from "vue"
import { useOmegaForm } from "../../src"

// `age` carries a constructor default: the field that can leak.
// `name` is plain, no default: the field the edit dialog actually exposes.
const Person = S.Struct({
name: S.NonEmptyString.pipe(S.optionalKey),
age: S.Finite.pipe(S.optionalKey, S.withConstructorDefault(Effect.succeed(0)))
})

// The bug: fields carried over by Struct.omit keep their inherited constructor default.
const UpdatePersonBuggy = S.Struct(Struct.omit(Person.fields, [] as const))

// The fix: same fields, constructor defaults stripped.
const UpdatePersonFixed = S.Struct(Struct.map(Struct.omit(Person.fields, [] as const), S.dropConstructorDefault))

// Simulated server state: a record already saved before the edit dialog opens.
const buggyDb = ref<{ name: string; age: number }>({ name: "Acme Corp", age: 30 })
const fixedDb = ref<{ name: string; age: number }>({ name: "Acme Corp", age: 30 })

const buggyResult = ref<{ ageWiped: boolean }>()
const fixedResult = ref<{ ageWiped: boolean }>()

const buggyPayload = ref<unknown>()
const fixedPayload = ref<unknown>()

const buggyForm = useOmegaForm(UpdatePersonBuggy, {
onSubmit: async ({ value }) => {
const ageBefore = buggyDb.value.age
const payload = UpdatePersonBuggy.make(value)
buggyPayload.value = payload
// simulates the server applying the update payload as a partial merge (PATCH semantics)
buggyDb.value = { ...buggyDb.value, ...payload }
buggyResult.value = { ageWiped: buggyDb.value.age !== ageBefore }
}
})

const fixedForm = useOmegaForm(UpdatePersonFixed, {
onSubmit: async ({ value }) => {
const ageBefore = fixedDb.value.age
const payload = UpdatePersonFixed.make(value)
fixedPayload.value = payload
fixedDb.value = { ...fixedDb.value, ...payload }
fixedResult.value = { ageWiped: fixedDb.value.age !== ageBefore }
}
})
</script>

<style scoped>
h1 {
margin-bottom: 1rem;
}
h3 {
margin-top: 1rem;
}
</style>
Loading