Skip to content

prefer-schema-class mis-fires on methodless wire/DTO Schema.Struct (breaks inline JSON-schema + cross-boundary HttpApi derivation) #2

Description

@artile

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)

  1. 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.)

  2. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions