EFFECT_TYPESCRIPT_SCHEMA_SALAD_PLAN

Effect Schema TypeScript Backend for schema-salad-plus-pydantic

Goal

New --format effect-schema option that generates TypeScript using Effect Schema instead of plain interfaces. Provides runtime validation, encoding/decoding, and discriminated union support — analogous to what pydantic provides for Python.

Architecture

New file codegen_effect_schema.py subclassing CodeGenBase, same pattern as codegen_typescript.py. Registered in orchestrate.py and cli.py as "effect-schema" format.

Step 1: Create codegen_effect_schema.py

Subclass CodeGenBase. Key mappings:

schema-salad conceptEffect Schema output
Primitive stringSchema.String
Primitive int/long/float/doubleSchema.Number
Primitive booleanSchema.Boolean
Primitive nullSchema.Null
AnySchema.Unknown
RecordSchema.Struct({ ... }) exported as const FooSchema = ... + type Foo = typeof FooSchema.Type
InheritanceSchema.Struct({ ...ParentSchema.fields, childField: ... })
Multi-symbol enumSchema.Literal("a", "b", "c") exported as type alias
Single-symbol enumSchema.Literal("value")
Optional fieldSchema.optional(T)
ArraySchema.Array(T)
UnionSchema.Union(A, B)
Discriminated unionSchema.Union(A, B) where A/B have Schema.Literal discriminant — Effect auto-detects this
pydantic:alias / field renameProperty name uses the alias directly
pydantic:type override_python_type_to_effect() translates Python type -> Effect Schema equivalent
Abstract recordsEmit schema, type alias exported

Implementation details

Output structure:

import { Schema } from "effect"

// Enums
export const StatusEnumSchema = Schema.Literal("active", "inactive", "pending")
export type StatusEnum = typeof StatusEnumSchema.Type

// Schemas (topologically sorted)
export const BaseRecordSchema = Schema.Struct({
  id: Schema.optional(Schema.Union(Schema.Null, Schema.String)),
})
export type BaseRecord = typeof BaseRecordSchema.Type

export const ChildRecordSchema = Schema.Struct({
  ...BaseRecordSchema.fields,
  status: Schema.optional(Schema.Union(Schema.Null, StatusEnumSchema)),
  "format-version": Schema.optional(Schema.Union(Schema.Null, Schema.String)),
  items: Schema.optional(Schema.Union(Schema.Record({ key: Schema.String, value: Schema.String }), Schema.Null)),
  tags: Schema.optional(Schema.Union(Schema.Null, Schema.Array(Schema.String))),
  members: Schema.optional(Schema.Union(
    Schema.Null,
    Schema.Array(Schema.Union(PersonMemberSchema, OrgMemberSchema))
  )),
})
export type ChildRecord = typeof ChildRecordSchema.Type

// Type guards for discriminated unions
export function isPersonMember(v: PersonMember | OrgMember): v is PersonMember {
  return v?.class === "Person";
}

Step 2: Wire into orchestrator + CLI ✅

Step 3: Handle pydantic:type overrides for Effect Schema ✅

_python_type_to_effect() function (parallel to _python_type_to_ts()):

Step 4: Handle pydantic:alias

Alias used directly as the property name in the struct (same as TS backend). No fromKey needed for current use cases.

Step 5: Tests — test_effect_schema_roundtrip.py

Tests exercise generated code through real tooling (tsc + node), not string matching. Same pattern as test_typescript_roundtrip.py.

Infrastructure

Uses nodejs-wheel (already a test dependency) for node and npm binaries. A session-scoped pytest fixture:

  1. Copies scaffolding (package.json, tsconfig.json) to a temp dir
  2. Generates Effect Schema TS from the test schema into generated.ts
  3. Runs npm install (installs typescript, @types/node, and effect)

Scaffolding — tests/ts_project_effect/

Committed files:

Validation scripts (committed alongside scaffolding)

These scripts use import { Schema } from "effect" and import the generated schemas from ./generated.ts.

Test cases — test_effect_schema_roundtrip.py

Simple schema (tests/schemas/simple.yml):

  1. test_tsc_compilestsc --noEmit on all scripts + generated code passes.
  2. test_runtime_decode_goodnode validate_good.ts exits 0. Proves Schema.decodeUnknownSync accepts valid data and fields survive decoding.
  3. test_runtime_decode_bad_enumnode validate_bad_enum.ts exits 0. Proves decode rejects invalid enum values at runtime.
  4. test_runtime_decode_bad_discriminatornode validate_bad_discriminator.ts exits 0. Proves decode rejects wrong discriminator values.
  5. test_runtime_aliasnode validate_alias.ts exits 0. Proves aliased key works at decode time.
  6. test_generated_code_is_nonempty — Sanity check that generated.ts contains Effect Schema code.

gxformat2 native schema (skipped if GXFORMAT2_SCHEMA_DIR absent):

  1. test_native_tsc_compilestsc --noEmit on generated code from the real gxformat2 native schema. Compile-only, no validation scripts. This exercises circular reference handling (Schema.suspend).

Step 6: Update README

TODO — add effect-schema to the format docs.

Implementation Order (completed)

  1. ✅ Added _union_type_str, _type_ref_str to CodeGenBase, updated type_loader
  2. ✅ Created codegen_effect_schema.py with all field/enum/class methods
  3. ✅ Wired into orchestrator + CLI
  4. ✅ Created test scaffolding (tests/ts_project_effect/) with effect dependency
  5. ✅ Created validation scripts (good, bad_enum, bad_discriminator, alias)
  6. ✅ Created test_effect_schema_roundtrip.py — all 7 tests pass
  7. ✅ Topological sort for forward references
  8. Schema.suspend with typed return annotation for circular references (native schema compiles)
  9. ✅ Extracted split_top_level to shared utility, backported needs_quote fix to TS backend
  10. ✅ Code review: lint clean, mypy clean, 57 tests pass
  11. TODO: Update README

Resolved Questions

Unresolved Questions