Skip to content

fix(Schema): Class rejects Struct schemas piped through encodeKeys - #8250

Closed
estilles wants to merge 3 commits into
Effect-TS:mainfrom
estilles:fix/schema-class-encodekeys
Closed

estilles wants to merge 3 commits into
Effect-TS:mainfrom
estilles:fix/schema-class-encodekeys

Conversation

@estilles

Copy link
Copy Markdown

Type

  • Refactor
  • Feature
  • Bug Fix
  • Optimization
  • Documentation Update

Description

Fixes a TypeScript overload error when a Schema.Struct transformed by Schema.encodeKeys is passed directly to Schema.Class.

class Foo extends Schema.Class<Foo>('Foo')(
  Schema.Struct({
    a: Schema.String,
  }).pipe(Schema.encodeKeys({ a: 'c' }))
) {}

fails to type-check:

No overload matches this call.
  Type 'encodeKeys<Struct<...>, ...>' is missing the following
  properties from type 'Struct<Fields>': fields, mapFields

despite working correctly at runtime — Schema.decodeUnknownSync(Foo)/Schema.is(Foo) etc. all behave as expected once the type error is silenced. The root cause is that Schema.Class's struct overload requires the literal Struct<Fields> interface, but encodeKeys's return type only extends decodeTo<To, From>, which doesn't expose fields/mapFields — even though encodeKeys's to side is structurally just the original struct.

Note: this can be worked around by applying encodeKeys after constructing the class instead of inside the field definition.

class Foo extends Schema.Class<Foo>('Foo')({
  a: Schema.String,
  b: Schema.optionalKey(Schema.Finite),
}) {}
const FooWithEncodeKeys = Foo.pipe(Schema.encodeKeys({ a: 'OriginalA' }))

That pattern is fine when a single top-level rename is needed and the caller controls the decode/encode call site directly. It doesn't work when the rename needs to live on a nested field's own struct definition (e.g. a child class embedded inside a parent, where the child's schema is what's passed to the parent's Class constructor) or when multiple nested schemas need encodeKeys. These are the use cases this PR aims to fix.

class Foo extends Schema.Class<Foo>('Foo')({
  a: Schema.String,
  b: Schema.optionalKey(Schema.Finite),
}) {}
const FooWithEncodeKeys = Foo.pipe(Schema.encodeKeys({ a: 'OriginalA' }))

class Bar extends Schema.Class<Bar>('Bar')({
  c: Schema.String,
  foo: Foo,
}) {}
const BarWithEncodedKeys = Bar.pipe(Schema.encodeKeys({ c: 'OriginalC' }))

Changes

  • encodeKeys's returned schema now carries a real .fields property (mirroring the original struct's fields) and a private type-id brand (EncodeKeysTypeId, following the existing flip/FlipTypeId pattern), so it satisfies the structural shape Class needs.
  • Class's struct overload, its interface, makeClass, and the internal isStruct guard are widened to accept Struct<Fields> | encodeKeys<Struct<Fields>, any> in the relevant positions.
  • Class.mapFields now delegates to the underlying struct (struct.to) when built from an encodeKeys schema, since encodeKeys results don't have their own runtime mapFields.
  • Class.extend explicitly throws when called on a class built from an encodeKeys schema, rather than silently reconstructing the class without the key mapping. extend merges fields via SchemaAST.struct(...) from scratch, which has no way to carry the original rename forward — attempting to support it correctly would mean re-deriving a new encodeKeys mapping that also covers newly added fields, which is a separate, larger feature. I'd rather fail loudly here than ship something that silently drops the rename.

Scope

This fixes Schema.Class only. Schema.TaggedClass, Schema.Error, and Schema.TaggedError share the same overload shape and would need the identical treatment if this pattern is wanted there too — happy to extend this PR to cover them if that's preferred, or leave it to a follow-up.

Verification

  • Schema.isSchema recognizes a class built from an encodeKeys schema.
  • Full construction / make / Equal.equals / arbitrary generation / decode success & failure / encode success & failure round-trip for a class built from an encodeKeys schema.
  • mapFields works correctly on such a class.
  • extend throws with a clear error message on such a class.
> pnpx vitest run packages/effect/test/schema/Schema.test.ts

 Test Files  1 passed (1)
      Tests  600 passed (600)

@changeset-bot

changeset-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 5723a8d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 34.58 KB 34.58 KB 0.00 KB (0.00%)
basic.ts 6.87 KB 6.87 KB 0.00 KB (0.00%)
batching.ts 9.95 KB 9.95 KB 0.00 KB (0.00%)
brand.ts 6.45 KB 6.45 KB 0.00 KB (0.00%)
cache.ts 10.77 KB 10.77 KB 0.00 KB (0.00%)
config.ts 21.51 KB 21.51 KB 0.00 KB (0.00%)
differ.ts 20.32 KB 20.32 KB 0.00 KB (0.00%)
http-client.ts 21.93 KB 21.93 KB 0.00 KB (0.00%)
http-router.ts 33.48 KB 33.48 KB 0.00 KB (0.00%)
logger.ts 10.88 KB 10.88 KB 0.00 KB (0.00%)
metric.ts 9.02 KB 9.02 KB 0.00 KB (0.00%)
optic.ts 6.70 KB 6.70 KB 0.00 KB (0.00%)
pubsub.ts 15.10 KB 15.10 KB 0.00 KB (0.00%)
queue.ts 11.85 KB 11.85 KB 0.00 KB (0.00%)
schedule.ts 10.96 KB 10.96 KB 0.00 KB (0.00%)
schema-binary.ts 39.51 KB 39.51 KB 0.00 KB (0.00%)
schema-class.ts 20.15 KB 20.06 KB +0.09 KB (+0.46%)
schema-fromJsonSchemaDocument.ts 30.93 KB 30.93 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 26.34 KB 26.34 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.63 KB 13.63 KB 0.00 KB (0.00%)
schema-string.ts 11.12 KB 11.12 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.41 KB 15.41 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 34.10 KB 34.10 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.56 KB 24.56 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.27 KB 19.27 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 19.38 KB 19.38 KB 0.00 KB (0.00%)
schema-toFormatter.ts 19.49 KB 19.49 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 23.77 KB 23.77 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.54 KB 19.54 KB 0.00 KB (0.00%)
schema.ts 19.27 KB 19.27 KB 0.00 KB (0.00%)
stm.ts 12.80 KB 12.80 KB 0.00 KB (0.00%)
stream.ts 9.83 KB 9.83 KB 0.00 KB (0.00%)

@gcanti gcanti removed the bug Something isn't working label Sep 15, 2026
@gcanti

gcanti commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

I'm not convinced there is an actual missing use case here. Can't the nested case already be handled by defining FooWithEncodeKeys = Foo.pipe(Schema.encodeKeys(...)) and using FooWithEncodeKeys as the field in Bar?

The PR's example creates FooWithEncodeKeys but then uses foo: Foo, so naturally the mapping is not applied.

@effect-janitor effect-janitor Bot added the bug Something isn't working label Sep 15, 2026
@estilles

estilles commented Sep 15, 2026

Copy link
Copy Markdown
Author

I'm not convinced there is an actual missing use case here. Can't the nested case already be handled by defining FooWithEncodeKeys = Foo.pipe(Schema.encodeKeys(...)) and using FooWithEncodeKeys as the field in Bar?

The PR's example creates FooWithEncodeKeys but then uses foo: Foo, so naturally the mapping is not applied.

I understand what you're saying. You're right. I screwed up my explanation and my example. Bar should be using FooWithEncodeKeys. Despite my error, I would argue that if

class Foo extends Schema.Class<Foo>('Foo')(
  Schema.Struct({
    a: Schema.String,
  })
) {}

is valid syntax, it would be logical to assume that

class Foo extends Schema.Class<Foo>('Foo')(
  Schema.Struct({
    a: Schema.String,
  }).pipe(Schema.encodeKeys({ a: 'c' }))
) {}

would likewise be valid syntax, if one needs different encode keys.

@estilles

Copy link
Copy Markdown
Author

I ended up encountering a few other Schema use cases, besides Schema.encodeKeys() that are not accepted by Schema.Class(). I'm abandoning this PR for now, and may revisit sometime in the future.

Thanks for the comments!

@estilles estilles closed this Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants