Summary
prefer-schema-class is a blunt member ban — it flags every Schema.Struct and recommends switching to Schema.Class (src/rules/prefer-schema-class.ts):
const rule: CreateRule = Rule.banMember('Schema', 'Struct', {
message:
'Prefer `Schema.Class` over `Schema.Struct` for named schema types with instance methods. …',
});
The message itself scopes the intent to "named schema types with instance methods", but the rule has no method-awareness: it fires on plain, methodless wire / DTO structs too, where converting to Schema.Class is not a stylistic nicety but an active behavioural change.
Why converting is harmful (two concrete failures)
-
JSON Schema shape changes: inline → $ref/$defs. A Schema.Struct derives a fully-inlined JSON Schema; a nested Schema.Class derives as a $ref into a $defs table (and the document root becomes a top-level $ref). Structured-output providers that require a concrete root object / a single inline schema reject the referenced form. This bit a real module whose whole purpose is Schema.toJsonSchemaDocument(...) fed to an OpenAI-compatible provider — after the class conversion the derived document carried $defs/$ref and had to be dereferenced back to inline before sending. (OpenAI Structured Outputs requires the root to be type: object; a root-level $ref is rejected.)
-
Nominal identity across a version boundary. Schema.Struct is an anonymous structural shape; Schema.Class introduces a nominal class identity. When a wire type is an HttpApi payload/success schema imported by a separate frontend bundle that crosses the Effect version boundary, a nominal class identity does not unify across that boundary the way a structural shape does. Forcing Schema.Class introduces exactly the nominal identity that is unwanted for a cross-boundary wire contract.
Minimal reproduction
import * as Schema from 'effect/Schema';
// Pure wire DTO — no methods. Consumed by HttpApi/HttpApiClient derivation and
// by Schema.toJsonSchemaDocument for structured output.
const Task = Schema.Struct({
id: Schema.String,
title: Schema.String,
});
// ^ prefer-schema-class fires — but Schema.Class here changes the derived
// JSON schema to $ref/$defs and adds unwanted nominal identity.
Expected vs actual
- Expected: the rule flags
Schema.Struct only when converting to Schema.Class is appropriate — i.e. named structs that carry (or want) instance methods / branded identity. A methodless wire/DTO struct should pass.
- Actual: every
Schema.Struct member access is flagged unconditionally.
Proposed fix direction
- Exempt structs with no methods attached (the common wire/DTO case), so the rule matches its own stated intent, or
- Make the rule opt-in / configurable, or at minimum
- Document that this rule is unsafe for schemas consumed by JSON-Schema derivation or by HttpApi/client derivation across a version boundary, and recommend disabling it for
**/Contract.ts / wire-shape modules.
Filed from downstream adoption in a CosmOS/Effect-4 codebase (vendored pristine fork of upstream 0.3.0).
Summary
prefer-schema-classis a blunt member ban — it flags everySchema.Structand recommends switching toSchema.Class(src/rules/prefer-schema-class.ts):The message itself scopes the intent to "named schema types with instance methods", but the rule has no method-awareness: it fires on plain, methodless wire / DTO structs too, where converting to
Schema.Classis not a stylistic nicety but an active behavioural change.Why converting is harmful (two concrete failures)
JSON Schema shape changes: inline →
$ref/$defs. ASchema.Structderives a fully-inlined JSON Schema; a nestedSchema.Classderives as a$refinto a$defstable (and the document root becomes a top-level$ref). Structured-output providers that require a concrete root object / a single inline schema reject the referenced form. This bit a real module whose whole purpose isSchema.toJsonSchemaDocument(...)fed to an OpenAI-compatible provider — after the class conversion the derived document carried$defs/$refand had to be dereferenced back to inline before sending. (OpenAI Structured Outputs requires the root to betype: object; a root-level$refis rejected.)Nominal identity across a version boundary.
Schema.Structis an anonymous structural shape;Schema.Classintroduces a nominal class identity. When a wire type is anHttpApipayload/success schema imported by a separate frontend bundle that crosses the Effect version boundary, a nominal class identity does not unify across that boundary the way a structural shape does. ForcingSchema.Classintroduces exactly the nominal identity that is unwanted for a cross-boundary wire contract.Minimal reproduction
Expected vs actual
Schema.Structonly when converting toSchema.Classis appropriate — i.e. named structs that carry (or want) instance methods / branded identity. A methodless wire/DTO struct should pass.Schema.Structmember access is flagged unconditionally.Proposed fix direction
**/Contract.ts/ wire-shape modules.Filed from downstream adoption in a CosmOS/Effect-4 codebase (vendored pristine fork of upstream 0.3.0).