Skip to content

Schema.omit produces wrong result on Struct with optionalWith({ default }) and index signatures #6087

@taylorOntologize

Description

@taylorOntologize

What version of Effect is running?

3.19.19

What steps can reproduce the bug?

https://effect.website/play#adf921ba43fd

import { Schema as S } from "effect"

const schema = S.Struct(
  { a: S.String, b: S.optionalWith(S.Number, { default: () => 0 }) },
  S.Record({ key: S.String, value: S.Boolean })
)

const plain = S.Struct(
  { a: S.String, b: S.Number },
  S.Record({ key: S.String, value: S.Boolean })
)

console.log("With optionalWith:", String(schema.pipe(S.omit("a")).ast))
console.log("Without optionalWith:", String(plain.pipe(S.omit("a")).ast))

Output (playground):

With optionalWith: ({ readonly b?: number | undefined } <-> { readonly b: number })
Without optionalWith: { readonly [x: string]: boolean }

What is the expected behavior?

Both cases should produce { readonly [x: string]: boolean }. When a struct has index signatures, omit returns just the index signatures (dropping all property signatures) — this is the existing behavior for plain structs and should be consistent regardless of whether the struct contains optionalWith fields.

What do you see instead?

Schema.omit on a Schema.Struct that has both index signatures (via Schema.Record) and fields using Schema.optionalWith with Transformation-producing options takes a completely different code path and produces a structurally wrong result — it returns a Transformation with only the remaining property signatures instead of a TypeLiteral with the index signatures.

import { Schema as S } from "effect"

const schema = S.Struct(
  { a: S.String, b: S.optionalWith(S.Number, { default: () => 0 }) },
  S.Record({ key: S.String, value: S.Boolean })
)

console.log(String(schema.pipe(S.omit("a")).ast))
// Actual:   ({ readonly b?: number | undefined } <-> { readonly b: number })
// Expected: { readonly [x: string]: boolean }

// Without optionalWith (no Transformation), omit works correctly:
const plain = S.Struct(
  { a: S.String, b: S.Number },
  S.Record({ key: S.String, value: S.Boolean })
)
console.log(String(plain.pipe(S.omit("a")).ast))
// { readonly [x: string]: boolean }

Additional information

Why I believe this is a bug

  1. getIndexSignatures is inconsistent with its sibling functions. getPropertyKeys and getPropertyKeyIndexedAccess (fix pending in Schema: fix getPropertySignatures crash on Struct with optionalWith({ default }) #6086) both handle Transformation by delegating to ast.to. getIndexSignatures handles TypeLiteral, Suspend, and Refinement but not Transformation — the same gap that caused the crash in SchemaAST.getPropertySignatures crashes on Struct with Schema.optionalWith({ default }) #6085, except here the fallback is return [] instead of throw, so it silently produces wrong results rather than crashing.

  2. The Transformation's .to side carries the index signatures. The TypeLiteral on ast.to has indexSignatures.length === 1 — the data is there, getIndexSignatures just never looks at it.

  3. The existing omit test suite has a single test case and does not cover structs with index signatures or Transformation ASTs.

Root cause

omit calls getIndexSignatures(ast) first. If the result is non-empty, it returns a TypeLiteral with only the index signatures. If empty, it falls through to pick.

getIndexSignatures has no case "Transformation" and returns [] by default:

// SchemaAST.ts:2222-2236
const getIndexSignatures = (ast: AST): Array<IndexSignature> => {
  const annotation = getSurrogateAnnotation(ast)
  if (Option.isSome(annotation)) {
    return getIndexSignatures(annotation.value)
  }
  switch (ast._tag) {
    case "TypeLiteral":
      return ast.indexSignatures.slice()
    case "Suspend":
      return getIndexSignatures(ast.f())
    case "Refinement":
      return getIndexSignatures(ast.from)
    // missing: case "Transformation"
  }
  return []  // <-- silently returns empty for Transformation
}

So for a Transformation struct with index signatures, omit gets back [], skips the index signature branch entirely, and falls through to the pick path. This produces a completely different result: instead of a TypeLiteral with just the index signatures (correct), it returns a Transformation with only the remaining property signatures (wrong).

Environment

  • effect: 3.19.19
  • Runtime: Bun 1.3.5, macOS arm64

Workaround

Avoid combining Schema.optionalWith (with { default }, { as: "Option" }, etc.) and Schema.Record in structs that will be passed to Schema.omit. Either replace optionalWith with optional (applying defaults manually in handler code), or restructure the schema so the index-signature and optionalWith parts are separate.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions