Skip to content

Schema: recurDefaults replaces a constructor-defaulted class field's encoding #6491

Description

@spencerbeggs

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.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions