Repository navigation
Description
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 |
- The record-keyed type is 28% cheaper than the
Object.valuespath, and 34% cheaper than a literal tuple. - Each union is read for
Type,match,guards,cases,decodeUnknownSync,isAnyOfandmatchOrElse. - Each probe file was compiled alone against
main7a87ff9 with Fix Schema.toTaggedUnion types for array-typed member lists #10336 applied, usingtsc7.0.2+effect-tsgo.0.63.0 with--singleThreaded --extendedDiagnostics. - Per union = (probe − the same cases without a union) / 20. A second run gave the same counts.
- No public constructor builds the record row from case schemas, so the probe types it through
declare const fromCases: <const C extends Record<string, Schema.Constraint>>(cases: C) => Schema.TaggedUnion<C>. - Without Fix Schema.toTaggedUnion types for array-typed member lists #10336 the
Object.valuespath doesn't type-check at all:casesis{}(Schema.toTaggedUnion drops the members of an array-typed member list (casesis{},discriminantsisreadonly []) #10335).
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 againstmain:casesgives{ Circle: TaggedStruct<"Circle", …>; Square: typeof Square; Triangle: TaggedStruct<"Triangle", …> }, andmatchtypes 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, becauseguards,matchandmatchOrElselook cases up by_tag. feat(Schema): add TaggedUnion.mapCases #10310's privatemakeTaggedUnion(cases, checks)already builds aTaggedUnionfrom a record of case schemas and throwsCase 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, andTaggedUnion'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 needmembersormapMemberskeep this path, sinceSchema.TaggedUnionisn't aSchema.Union.
Activity