Skip to content

Schema.TaggedUnion: accept existing case schemas, not only field records #10337

Open

Description

@jasonkuhrt

Written by an AI agent (Claude) on behalf of @jasonkuhrt.

What is the problem this feature would solve?

Schema.TaggedUnion takes one field record per case and builds each case with TaggedStruct. It can't take cases that already exist as schemas: a TaggedStruct used elsewhere too, a TaggedClass, or a case with checks or annotations. For those, the remaining path is Schema.Union(Object.values(cases)).pipe(Schema.toTaggedUnion("_tag")). That path costs more than the record-keyed Schema.TaggedUnion<Cases> type Effect already has for the same cases:

20 unions of 5 TaggedStruct cases instantiations per union types per union
Schema.Union(Object.values(cases)).pipe(Schema.toTaggedUnion("_tag")) 3,078 473
Schema.Union([A, B, C, D, E]).pipe(Schema.toTaggedUnion("_tag")) 3,388 486
Schema.TaggedUnion<typeof cases> over the same case schemas 2,222 400

We hit this in effect-plus, a library that builds tagged unions from a record of case schemas. To avoid this cost it declares its own record-keyed result interface over Union + toTaggedUnion's runtime. Measured the same way, with its additions (is, make, a Tag enum, each case as a property), that interface is 26% cheaper per union than typing the same result through Schema.toTaggedUnion's type, and 31% cheaper with every member read. Effect's own Schema.TaggedUnion<Cases> costs the same as that interface for the core uses. A public way to build it from case schemas would let us delete that interface.

What is the feature you are proposing to solve the problem?

Let Schema.TaggedUnion take an existing case schema for a key, next to field records as today:

import { Schema } from "effect"

const Circle = Schema.TaggedStruct("Circle", { radius: Schema.Number })
class Square extends Schema.TaggedClass<Square>()("Square", { side: Schema.Number }) {}

const Shape = Schema.TaggedUnion({
  Circle,
  Square,
  Triangle: { base: Schema.Number, height: Schema.Number }
})

Shape.cases.Square // typeof Square
Shape.match({ Circle: (c) => c.radius, Square: (s) => s.side, Triangle: (t) => t.base })
  • Types: a case is the value itself when it's a schema, and TaggedStruct<K, Fields> otherwise. I checked this against main: cases gives { Circle: TaggedStruct<"Circle", …>; Square: typeof Square; Triangle: TaggedStruct<"Triangle", …> }, and match types each handler.

    type CaseOf<K extends string, V> = V extends Schema.Constraint ? V
      : V extends Schema.Struct.Fields ? Schema.TaggedStruct<K, V>
      : never
    
    declare function TaggedUnion<const C extends Record<string, Schema.Struct.Fields | Schema.Constraint>>(
      cases: C
    ): Schema.TaggedUnion<{ readonly [K in keyof C & string]: CaseOf<K, C[K]> }>
  • Runtime: each value goes through isSchema(value) ? value : TaggedStruct(key, value). A key must equal its case's _tag, because guards, match and matchOrElse look cases up by _tag. feat(Schema): add TaggedUnion.mapCases #10310's private makeTaggedUnion(cases, checks) already builds a TaggedUnion from a record of case schemas and throws Case key does not match its _tag, so the constructor would hand its resolved record to that helper.

Once #10310 lands, I can open a PR built on its helper. A PR now would duplicate that helper or conflict with it.

What alternatives have you considered?

  • A separate constructor, such as Schema.TaggedUnion.fromCases(cases): same helper, and TaggedUnion's input type stays fields-only. It's a second name for one concept, though.
  • Schema.TaggedUnion({}).mapCases(() => cases) once feat(Schema): add TaggedUnion.mapCases #10310 lands: it works, but it starts from an empty union to get the type.
  • Union + toTaggedUnion: this costs the instantiations in the table above. Callers who need members or mapMembers keep this path, since Schema.TaggedUnion isn't a Schema.Union.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions