From c2e26d749470a3710ef6dd2630fbdcdb497e97db Mon Sep 17 00:00:00 2001 From: MindfulLearner Date: Thu, 2 Jul 2026 18:23:47 +0200 Subject: [PATCH 1/8] feat(): added implementation, api test and story test, and added comment for Struct.omit jsdoc --- packages/effect-app/src/Schema/ext.ts | 44 +++++- .../effect-app/src/Schema/linttoremoveidea.md | 9 ++ packages/effect-app/test/schema.test.ts | 26 ++++ .../stories/OmegaForm.stories.ts | 16 +++ .../OmegaForm/OmitConstructorDefaults.vue | 131 ++++++++++++++++++ repos/effect/packages/effect/src/Struct.ts | 3 + 6 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 packages/effect-app/src/Schema/linttoremoveidea.md create mode 100644 packages/vue-components/stories/OmegaForm/OmitConstructorDefaults.vue diff --git a/packages/effect-app/src/Schema/ext.ts b/packages/effect-app/src/Schema/ext.ts index 7fa3a5482..71f9d0e13 100644 --- a/packages/effect-app/src/Schema/ext.ts +++ b/packages/effect-app/src/Schema/ext.ts @@ -39,9 +39,11 @@ 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 Simplify } from "effect/Types" 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" @@ -550,6 +552,46 @@ export function makeExactOptional( }, {} as any) } +type StripConstructorDefault = Omit & { + readonly "~type.constructor.default": "no-default" +} + +const SchemaASTInternal = SchemaAST as unknown as { + readonly replaceContext: (ast: A, context: SchemaAST.Context | undefined) => A +} + +/** Removes an attached `withConstructorDefault` from a field schema, if present. */ +const stripConstructorDefault = (schema: S.Top): S.Top => { + 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) + ) + ) +} + +/** + * Like `Struct.omit`, but also strips any `withConstructorDefault` carried by + * the fields that remain. Use when deriving a partial-update schema from a + * "create" schema, so an omitted field stays omitted in `.make(...)` output + * instead of being silently filled in with its inherited default. + */ +export function omitConstructorDefaults< + Fields extends S.Struct.Fields, + const Keys extends ReadonlyArray +>( + fields: Fields, + keys: Keys +): Simplify<{ [K in Exclude]: StripConstructorDefault }> { + const picked = Struct.omit(fields, keys) + return typedKeysOf(picked).reduce((acc, k) => { + acc[k] = stripConstructorDefault(picked[k] as unknown as S.Top) + return acc + }, {} as any) +} + /** A version of transform which is only a one way mapping of From->To */ export const transformTo = ( from: From, diff --git a/packages/effect-app/src/Schema/linttoremoveidea.md b/packages/effect-app/src/Schema/linttoremoveidea.md new file mode 100644 index 000000000..9d5e0800a --- /dev/null +++ b/packages/effect-app/src/Schema/linttoremoveidea.md @@ -0,0 +1,9 @@ + // or to warn the person tha using omit as anupdate. + + "name": "no-update-struct-omit-defaults", + "specifier": "./scripts/oxlint-no-update-struct-omit-defaults/index.js" + } + ], + "rules": { + "rpc-module-name/module-name": "error", + "no-update-struct-omit-defaults/no-update-struct-omit-defaults": "error", diff --git a/packages/effect-app/test/schema.test.ts b/packages/effect-app/test/schema.test.ts index 4aea149c7..db10c87bb 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,30 @@ test("literal default works", () => { expect(s2.make({}).l).toBe("b") }) +// Toy schema for `omitConstructorDefaults`: 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("omitConstructorDefaults strips the inherited constructor default (the fix)", () => { + const UpdatePersonFixed = S.Struct(S.omitConstructorDefaults(Person.fields, [] as const)) + expect(UpdatePersonFixed.make({ name: "Mario" })).toEqual({ name: "Mario" }) +}) + +test("omitConstructorDefaults still omits the requested keys, like Struct.omit", () => { + const NameOnly = S.Struct(S.omitConstructorDefaults(Person.fields, ["notes"] as const)) + expect(NameOnly.make({ name: "Mario" })).toEqual({ name: "Mario" }) + expectTypeOf().toEqualTypeOf<{ readonly name: string }>() +}) + 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..07fec109b 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 S.omitConstructorDefaults (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..16f6d4fdf --- /dev/null +++ b/packages/vue-components/stories/OmegaForm/OmitConstructorDefaults.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/repos/effect/packages/effect/src/Struct.ts b/repos/effect/packages/effect/src/Struct.ts index 9f0318f55..b3722d280 100644 --- a/repos/effect/packages/effect/src/Struct.ts +++ b/repos/effect/packages/effect/src/Struct.ts @@ -217,6 +217,9 @@ export const pick: { * * Keys not present in the struct are silently ignored. * + * On `Schema.Struct` fields, remaining fields keep their `withConstructorDefault`. + * Use `S.omitConstructorDefaults` (`effect-app/Schema`) if you need that stripped too. + * * **Example** (Removing a property) * * ```ts From 22972a1affcea4b3b6abb7b16188cd23cf89cc26 Mon Sep 17 00:00:00 2001 From: MindfulLearner Date: Thu, 2 Jul 2026 18:28:59 +0200 Subject: [PATCH 2/8] lintfix --- packages/effect-app/src/Schema/linttoremoveidea.md | 9 +++++---- .../stories/OmegaForm/OmitConstructorDefaults.vue | 14 +++++++------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/effect-app/src/Schema/linttoremoveidea.md b/packages/effect-app/src/Schema/linttoremoveidea.md index 9d5e0800a..0a81c9146 100644 --- a/packages/effect-app/src/Schema/linttoremoveidea.md +++ b/packages/effect-app/src/Schema/linttoremoveidea.md @@ -3,7 +3,8 @@ "name": "no-update-struct-omit-defaults", "specifier": "./scripts/oxlint-no-update-struct-omit-defaults/index.js" } - ], - "rules": { - "rpc-module-name/module-name": "error", - "no-update-struct-omit-defaults/no-update-struct-omit-defaults": "error", + +], +"rules": { +"rpc-module-name/module-name": "error", +"no-update-struct-omit-defaults/no-update-struct-omit-defaults": "error", diff --git a/packages/vue-components/stories/OmegaForm/OmitConstructorDefaults.vue b/packages/vue-components/stories/OmegaForm/OmitConstructorDefaults.vue index 16f6d4fdf..f8e813689 100644 --- a/packages/vue-components/stories/OmegaForm/OmitConstructorDefaults.vue +++ b/packages/vue-components/stories/OmegaForm/OmitConstructorDefaults.vue @@ -1,13 +1,13 @@