Skip to content

SchemaAST.getPropertySignatures crashes on Struct with Schema.optionalWith({ default }) #6085

@taylorOntologize

Description

@taylorOntologize

What version of Effect is running?

3.19.19

What steps can reproduce the bug?

Playground repro

https://effect.website/play/#aeb9f2f87f47

Minimal repro

import { SchemaAST, Schema } from "effect"

// Works
const A = Schema.Struct({
  name: Schema.String,
  count: Schema.optional(Schema.Number),
})
console.log(SchemaAST.getPropertySignatures(A.ast)) // OK

// Crashes
const B = Schema.Struct({
  name: Schema.String,
  count: Schema.optionalWith(Schema.Number, { default: () => 10 }),
})
console.log(SchemaAST.getPropertySignatures(B.ast)) // throws

Output (playground):

[
  PropertySignature {
    type: StringKeyword { annotations: [Object], _tag: 'StringKeyword' },
    annotations: {},
    isOptional: false,
    name: 'name',
    isReadonly: true
  },
  PropertySignature {
    type: Union { types: [Array], annotations: {}, _tag: 'Union' },
    annotations: {},
    isOptional: true,
    name: 'count',
    isReadonly: true
  }
]
Error: Unsupported schema
schema (Transformation): (Struct (Encoded side) <-> Struct (Type side))
    at getPropertyKeyIndexedAccess (.../effect/dist/cjs/SchemaAST.js:1955:9)
    at eval (.../effect/dist/cjs/SchemaAST.js:1846:43)
    at Object.getPropertySignatures (.../effect/dist/cjs/SchemaAST.js:1846:31)

What is the expected behavior?

getPropertySignatures should return property signatures for the struct, the same way it works when fields use Schema.optional(Schema.Number) (without a default).

What do you see instead?

SchemaAST.getPropertySignatures() throws on any Schema.Struct that contains a field using Schema.optionalWith with options that produce a Transformation AST — this includes { default }, { as: "Option" }, { nullable: true }, and combinations thereof:

Error: Unsupported schema
schema (Transformation): (Struct (Encoded side) <-> Struct (Type side))

This breaks @effect/ai's Tool.getJsonSchema() and LanguageModel.streamText at runtime, since Tool.getJsonSchemaFromSchemaAst calls getPropertySignatures when building tool definitions for the API request.

Additional information

Affected packages: effect (SchemaAST), @effect/ai (Tool)

Why I believe this is a bug

  1. The fallback path in getPropertySignatures is internally inconsistent. Line 2219 calls getPropertyKeys(ast) then getPropertyKeyIndexedAccess(ast, name) on the same AST. getPropertyKeys handles Transformation (delegates to ast.to), but getPropertyKeyIndexedAccess does not — so the first half succeeds and the second half crashes. getPropertyKeys gained Transformation support in PR #2343 for the benefit of pick/omit, which handle Transformations directly and never reach the fallback. At that point no caller was passing bare Transformations to getPropertySignatures, so the asymmetry was safe. It became observable when @effect/ai's Tool.getJsonSchema started calling getPropertySignatures on tool parameter schemas that use optionalWith({ default }).

  2. Other struct operations handle Transformation — the internal pick function explicitly handles case "Transformation" for TypeLiteralTransformation. And JsonSchema.fromAST fully supports Transformation with TypeLiteralTransformation, including copying default values from the .to side.

  3. The existing test suite covers TypeLiteral, Refinement, Suspend, Union, and Class (via surrogate annotation), but has no test for a bare Transformation from optionalWith.

Root cause

Schema.optionalWith(X, { default }) inside a Schema.Struct produces a Transformation AST node (the encoded side has the field as optional; the type side has it as required with the default applied). Unlike Schema.Class or Schema.TaggedRequest, this Transformation does not carry a SurrogateAnnotation, so the surrogate check at the top of getPropertySignatures does not redirect it.

getPropertySignatures falls through its switch to line 2219:

return getPropertyKeys(ast).map((name) => getPropertyKeyIndexedAccess(ast, name))

getPropertyKeys handles Transformation (line 2352, delegates to ast.to) and returns the keys. But getPropertyKeyIndexedAccess has no case "Transformation" and throws.

Environment

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

Workaround

Replace Schema.optionalWith(X, { default: () => val }) with Schema.optional(X) in tool parameter definitions and apply defaults manually in handler code.

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