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.
Problem
Before
Schema.withDecodingDefault/Schema.withDecodingDefaultKeywere documented as the canonical way to apply a decoding default, the migration guide recommended a three-combinator recipe: wrap the schema inSchema.optional, then pipe it throughSchema.decodeTowith aSchemaGetter.withDefaultdecode getter. The Effect team later replaced this recipe in the schema migration guide with the dedicated combinator (commit6d8b4a3bf— "Add withDecodingDefault/withDecodingDefaultKey examples to schema migration guide", where the decision-table rows switch away from thedecodeTo+SchemaGetter.withDefaultform).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 correctencodegetter (typicallySchemaGetter.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.101with TypeScript 7.0.2 under--strict: the verbose form compiles with no diagnostics, and at runtime it decodes identically to thewithDecodingDefaultrewrite ({}decodes to{ "a": "" }in both), confirming this is a pure verbosity/maintainability trap that only a lint rule can surface.Reproduction
tsc --strictreports 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):
Schema.optional(X)(orSchema.optionalKey(X)) call, orX.pipe(Schema.optional, ...);Schema.decodeTo(T, transformation)whereT's Type side equalsX's Type side;transformation.decodeis aSchemaGetter.withDefault(f)call;transformation.encodeis absent orSchemaGetter.passthrough().Report on the
Schema.decodeTo(...)call expression.Suggested message:
This optional + decodeTo + SchemaGetter.withDefault chain can be replaced by Schema.withDecodingDefault(f)(mentioningwithDecodingDefaultKeywhen the receiver usesoptionalKey).Code fix: rewrite to
X.pipe(Schema.optional, Schema.withDecodingDefault(f))(or theoptionalKey/withDecodingDefaultKeypair). Note theoptionalwrapper must be kept —withDecodingDefaultcomposes with an optional schema; the fix must not dropoptionalentirely.Must NOT flag:
decodeTowhose target schema differs from the wrapped schema's Type side (a real transformation, not just a default);decodegetters other thanSchemaGetter.withDefault(e.g. composed getters, custom transforms);encodegetter — the chain then encodes differently from plainwithDecodingDefault;decodeToon non-optional receivers.This complements the existing "verbose pattern → dedicated combinator" rules such as
effectMapFlattenandmissedPipeableOpportunity; there is no overlap because none of the existing rules inspectSchema.decodeTotransformation objects.Proposed rule name
schemaWithDecodingDefaultOpportunity— names the combinator to adopt and follows the*Opportunityconvention (effectFnOpportunity,missedPipeableOpportunity).preferSchemaWithDecodingDefault— matches theprefer*family (preferSchemaOverJson), slightly longer.schemaDecodeToWithDefault— names the pattern being flagged, but reads less like a suggestion.