Skip to content

Rule proposal: suggest Schema.withDecodingDefault over the verbose optional + decodeTo + SchemaGetter.withDefault recipe #442

Description

@mattiamanzati

Problem

Before Schema.withDecodingDefault / Schema.withDecodingDefaultKey were documented as the canonical way to apply a decoding default, the migration guide recommended a three-combinator recipe: wrap the schema in Schema.optional, then pipe it through Schema.decodeTo with a SchemaGetter.withDefault decode getter. The Effect team later replaced this recipe in the schema migration guide with the dedicated combinator (commit 6d8b4a3bf — "Add withDecodingDefault/withDecodingDefaultKey examples to schema migration guide", where the decision-table rows switch away from the decodeTo + SchemaGetter.withDefault form).

Early migrators still carry the verbose form. It type-checks with zero errors, and it is easy to get subtly wrong: the user must manually repeat the target schema as decodeTo's first argument (which can drift out of sync with the wrapped schema) and must remember to supply a correct encode getter (typically SchemaGetter.passthrough()) or encoding breaks. The dedicated combinator expresses the same intent in one call with none of those hazards.

Verified against effect@4.0.0-beta.101 with TypeScript 7.0.2 under --strict: the verbose form compiles with no diagnostics, and at runtime it decodes identically to the withDecodingDefault rewrite ({} decodes to { "a": "" } in both), confirming this is a pure verbosity/maintainability trap that only a lint rule can surface.

Reproduction

import { Effect, Schema, SchemaGetter } from "effect"

// Verbose legacy recipe: optional + decodeTo + SchemaGetter.withDefault
const schema = Schema.Struct({
  a: Schema.optional(Schema.String).pipe(
    Schema.decodeTo(Schema.String, {
      decode: SchemaGetter.withDefault(Effect.succeed("")),
      encode: SchemaGetter.passthrough()
    })
  )
})

// Dedicated combinator, per the current migration guide:
const rewritten = Schema.Struct({
  a: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("")))
})

Schema.decodeUnknownSync(schema)({})    // { a: "" }
Schema.decodeUnknownSync(rewritten)({}) // { a: "" }

tsc --strict reports nothing on the verbose form. At runtime both schemas behave identically, so nothing ever pushes the user toward the dedicated combinator — the verbose three-combinator form silently lingers in migrated codebases.

Proposed rule behavior

Report when a pipe chain matches all of the following (all membership checks via symbol declaration paths, not identifier text):

  • the receiver is a Schema.optional(X) (or Schema.optionalKey(X)) call, or X.pipe(Schema.optional, ...);
  • it is piped through Schema.decodeTo(T, transformation) where T's Type side equals X's Type side;
  • transformation.decode is a SchemaGetter.withDefault(f) call;
  • transformation.encode is absent or SchemaGetter.passthrough().

Report on the Schema.decodeTo(...) call expression.

Suggested message: This optional + decodeTo + SchemaGetter.withDefault chain can be replaced by Schema.withDecodingDefault(f) (mentioning withDecodingDefaultKey when the receiver uses optionalKey).

Code fix: rewrite to X.pipe(Schema.optional, Schema.withDecodingDefault(f)) (or the optionalKey/withDecodingDefaultKey pair). Note the optional wrapper must be kept — withDecodingDefault composes with an optional schema; the fix must not drop optional entirely.

Must NOT flag:

  • decodeTo whose target schema differs from the wrapped schema's Type side (a real transformation, not just a default);
  • decode getters other than SchemaGetter.withDefault (e.g. composed getters, custom transforms);
  • a non-passthrough encode getter — the chain then encodes differently from plain withDecodingDefault;
  • decodeTo on non-optional receivers.

This complements the existing "verbose pattern → dedicated combinator" rules such as effectMapFlatten and missedPipeableOpportunity; there is no overlap because none of the existing rules inspect Schema.decodeTo transformation objects.

Proposed rule name

  • schemaWithDecodingDefaultOpportunity — names the combinator to adopt and follows the *Opportunity convention (effectFnOpportunity, missedPipeableOpportunity).
  • preferSchemaWithDecodingDefault — matches the prefer* family (preferSchemaOverJson), slightly longer.
  • schemaDecodeToWithDefault — names the pattern being flagged, but reads less like a suggestion.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions