What version of Effect is running?
4.0.0-beta.99
What steps can reproduce the bug?
On effect@4.0.0-beta.99, piping a Schema.Class field through Schema.withConstructorDefault makes X.make reject a plain-object literal for that field at runtime, while the type-level make input still admits it. Passing a constructed instance works, and the same field without the default accepts the literal — so the default is what breaks nested construction.
The apparent cause is in SchemaParser.ts (~line 42): recurDefaults replaces the field's encoding with the default link rather than appending to it, so the field loses its own class constructor step during make processing.
Reproduction
import { Effect, Schema } from "effect";
class Point extends Schema.Class<Point>("Point")({
x: Schema.Number,
y: Schema.Number,
}) {}
const origin = Point.make({ x: 0, y: 0 });
class WithDefault extends Schema.Class<WithDefault>("WithDefault")({
label: Schema.String,
point: Point.pipe(Schema.withConstructorDefault(Effect.succeed(origin))),
}) {}
// 1. Default applies — works:
WithDefault.make({ label: "a" });
// 2. Explicit instance — works:
WithDefault.make({ label: "b", point: Point.make({ x: 1, y: 2 }) });
// 3. Explicit plain literal — THROWS at runtime,
// but the type of `make`'s input admits it:
WithDefault.make({ label: "c", point: { x: 1, y: 2 } });
// Control: without withConstructorDefault, the plain literal is accepted:
class NoDefault extends Schema.Class<NoDefault>("NoDefault")({
label: Schema.String,
point: Point,
}) {}
NoDefault.make({ label: "d", point: { x: 1, y: 2 } }); // works
What is the expected behavior?
Case 3 constructs a WithDefault whose point is a Point instance — the same behavior as the control class. The constructor default should apply only when the key is absent and otherwise leave the field's own construction semantics intact.
What do you see instead?
Case 3 throws at runtime. ~type.make.in still admits the plain literal, so the type level and runtime disagree (unsoundness): code that typechecks crashes
Additional information
Any class that wants make-time defaults on a class-typed field (e.g. defaulting a position/metadata value object) forces its callers to construct nested instances explicitly, and the type system doesn't warn when they don't.
Decode/encode are unaffected — this is make-path only.
Source
Found while implementing @effected/markdown (a make-only constructor default for node positions); we've pinned the current behavior with a tripwire test that fires when this is fixed, so downstream detection is in place.
What version of Effect is running?
4.0.0-beta.99
What steps can reproduce the bug?
On
effect@4.0.0-beta.99, piping aSchema.Classfield throughSchema.withConstructorDefaultmakesX.makereject a plain-object literal for that field at runtime, while the type-levelmakeinput still admits it. Passing a constructed instance works, and the same field without the default accepts the literal — so the default is what breaks nested construction.The apparent cause is in
SchemaParser.ts(~line 42):recurDefaultsreplaces the field's encoding with the default link rather than appending to it, so the field loses its own class constructor step duringmakeprocessing.Reproduction
What is the expected behavior?
Case 3 constructs a
WithDefaultwhosepointis aPointinstance — the same behavior as the control class. The constructor default should apply only when the key is absent and otherwise leave the field's own construction semantics intact.What do you see instead?
Case 3 throws at runtime.
~type.make.instill admits the plain literal, so the type level and runtime disagree (unsoundness): code that typechecks crashesAdditional information
Any class that wants make-time defaults on a class-typed field (e.g. defaulting a
position/metadata value object) forces its callers to construct nested instances explicitly, and the type system doesn't warn when they don't.Decode/encode are unaffected — this is
make-path only.Source
Found while implementing
@effected/markdown(a make-only constructor default for node positions); we've pinned the current behavior with a tripwire test that fires when this is fixed, so downstream detection is in place.