-
-
Notifications
You must be signed in to change notification settings - Fork 537
Description
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
-
getIndexSignaturesis inconsistent with its sibling functions.getPropertyKeysandgetPropertyKeyIndexedAccess(fix pending in Schema: fix getPropertySignatures crash on Struct with optionalWith({ default }) #6086) both handleTransformationby delegating toast.to.getIndexSignatureshandlesTypeLiteral,Suspend, andRefinementbut notTransformation— the same gap that caused the crash inSchemaAST.getPropertySignaturescrashes on Struct withSchema.optionalWith({ default })#6085, except here the fallback isreturn []instead ofthrow, so it silently produces wrong results rather than crashing. -
The Transformation's
.toside carries the index signatures. TheTypeLiteralonast.tohasindexSignatures.length === 1— the data is there,getIndexSignaturesjust never looks at it. -
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.