Mapping DB Rows to Tagged Unions with Effect Schema
Database schemas rarely match your domain types. Here's how to safely map a wide table with nullable columns to a discriminated union — with exhaustiveness enforced by the type system.
The problem
Database schemas rarely match the shape of domain types.
A common mismatch is a tagged union whose persisted form is a flat wide table with
per-variant nullable columns. Schema.decode rejects that row because
its Encoded type doesn’t match, and Schema.decodeUnknown silently
accepts drift between the table and the schema.
The wide table pattern looks like this:
notifications( id text, kind text, email_address text null, email_subject text null, sms_phone text null, push_device_token text null, push_title text null)And the domain type we want on the TypeScript side:
type Notification = | { _tag: "Email"; id: string; address: string; subject: string } | { _tag: "Sms"; id: string; phone: string } | { _tag: "Push"; id: string; deviceToken: string; title: string }The solution is a Schema.transformOrFail that maps between the two,
with exhaustiveness enforced at compile time in both directions.
Step 1 — row schema and domain schema
import { Effect, Either, Match, ParseResult, Schema, SchemaAST } from "effect"
const NotificationRow = Schema.Struct({ id: Schema.String, kind: Schema.Literal("Email", "Sms", "Push"), email_address: Schema.NullOr(Schema.String), email_subject: Schema.NullOr(Schema.String), sms_phone: Schema.NullOr(Schema.String), push_device_token: Schema.NullOr(Schema.String), push_title: Schema.NullOr(Schema.String)})type NotificationRow = typeof NotificationRow.Type
const Email = Schema.TaggedStruct("Email", { id: Schema.String, address: Schema.String, subject: Schema.String })const Sms = Schema.TaggedStruct("Sms", { id: Schema.String, phone: Schema.String })const Push = Schema.TaggedStruct("Push", { id: Schema.String, deviceToken: Schema.String, title: Schema.String })
const NotificationDomain = Schema.Union(Email, Sms, Push)type NotificationDomain = typeof NotificationDomain.TypeStep 2 — decode with an exhaustive switch
decodeRow switches on row.kind and maps each variant to its domain type.
The explicit return type annotation makes the switch exhaustive: add a new
literal to Schema.Literal(...) without a matching case and the compiler
will error because the function might not return on all paths.
The need helper lifts a nullable column into a ParseResult.ParseIssue,
using the ast and row already in scope to produce a meaningful error
message. Either.all combines two of these into a single Either, and
ParseResult.map maps over the result without leaving the Either-land.
const decodeRow = ( row: NotificationRow, ast: SchemaAST.Transformation): Effect.Effect<NotificationDomain, ParseResult.ParseIssue> => { const need = <T>(value: T | null, column: string) => value === null ? ParseResult.fail(new ParseResult.Type(ast, row, `${column} required when kind=${row.kind}`)) : ParseResult.succeed(value) switch (row.kind) { case "Email": return Either.all([need(row.email_address, "email_address"), need(row.email_subject, "email_subject")]).pipe( ParseResult.map(([address, subject]) => Email.make({ id: row.id, address, subject })) ) case "Sms": return need(row.sms_phone, "sms_phone").pipe( ParseResult.map((phone) => Sms.make({ id: row.id, phone })) ) case "Push": return Either.all([need(row.push_device_token, "push_device_token"), need(row.push_title, "push_title")]).pipe( ParseResult.map(([deviceToken, title]) => Push.make({ id: row.id, deviceToken, title })) ) }}Step 3 — encode with Match.tagsExhaustive
NotificationDomain is a tagged union — a Schema.Union of TaggedStruct
variants each carrying a distinct _tag literal. Match.tagsExhaustive
requires a handler for every tag in the union at compile time: add a fourth
variant without a matching handler and it’s a compile error.
Each handler spreads a nulls baseline and fills in only the columns that
belong to that variant.
const nulls = { email_address: null, email_subject: null, sms_phone: null, push_device_token: null, push_title: null} as const
const encodeDomain = Match.type<NotificationDomain>().pipe( Match.tagsExhaustive({ Email: (n) => ({ ...nulls, id: n.id, kind: "Email" as const, email_address: n.address, email_subject: n.subject }), Sms: (n) => ({ ...nulls, id: n.id, kind: "Sms" as const, sms_phone: n.phone }), Push: (n) => ({ ...nulls, id: n.id, kind: "Push" as const, push_device_token: n.deviceToken, push_title: n.title }) }))Step 4 — wire them with Schema.transformOrFail
const Notification = Schema.transformOrFail(NotificationRow, NotificationDomain, { decode: (row, _opts, ast) => decodeRow(row, ast), encode: (domain) => ParseResult.succeed(encodeDomain(domain))})
// Usage// Schema.decode(Notification)(rowFromDriver) -> Effect<NotificationDomain, ParseError>// Schema.encode(Notification)(domainValue) -> Effect<NotificationRow, ParseError>Why this beats decodeUnknown
- The row schema is a typed
Schema. Rename a column → DB queries and the transform both fail to type-check. - Adding a new
kindliteral without a switch case is a compile error: the explicit return type forces every path to return. - Adding a new domain variant without an encode handler is also a compile
error:
Match.tagsExhaustiverequires every_tagto be covered. - Each variant mapping is concise once null-lifting is factored out.
Escape hatches
Not every project ends up with wide tables. Two common alternatives:
- JSON payload column.
kind text+payload jsonb; decode withSchema.parseJson(Schema.Union(Email, Sms, Push)). No transform needed. - Per-variant tables + view. The view emits clean rows where each row only
carries the columns for its own variant, which a plain
Schema.Unionhandles without any transform.
Complete example
import { Effect, Either, Match, ParseResult, Schema, SchemaAST } from "effect"
const NotificationRow = Schema.Struct({ id: Schema.String, kind: Schema.Literal("Email", "Sms", "Push"), email_address: Schema.NullOr(Schema.String), email_subject: Schema.NullOr(Schema.String), sms_phone: Schema.NullOr(Schema.String), push_device_token: Schema.NullOr(Schema.String), push_title: Schema.NullOr(Schema.String)})type NotificationRow = typeof NotificationRow.Type
const Email = Schema.TaggedStruct("Email", { id: Schema.String, address: Schema.String, subject: Schema.String })const Sms = Schema.TaggedStruct("Sms", { id: Schema.String, phone: Schema.String })const Push = Schema.TaggedStruct("Push", { id: Schema.String, deviceToken: Schema.String, title: Schema.String })
const NotificationDomain = Schema.Union(Email, Sms, Push)type NotificationDomain = typeof NotificationDomain.Type
const decodeRow = ( row: NotificationRow, ast: SchemaAST.Transformation): Effect.Effect<NotificationDomain, ParseResult.ParseIssue> => { const need = <T>(value: T | null, column: string) => value === null ? ParseResult.fail(new ParseResult.Type(ast, row, `${column} required when kind=${row.kind}`)) : ParseResult.succeed(value) switch (row.kind) { case "Email": return Either.all([need(row.email_address, "email_address"), need(row.email_subject, "email_subject")]).pipe( ParseResult.map(([address, subject]) => Email.make({ id: row.id, address, subject })) ) case "Sms": return need(row.sms_phone, "sms_phone").pipe( ParseResult.map((phone) => Sms.make({ id: row.id, phone })) ) case "Push": return Either.all([need(row.push_device_token, "push_device_token"), need(row.push_title, "push_title")]).pipe( ParseResult.map(([deviceToken, title]) => Push.make({ id: row.id, deviceToken, title })) ) }}
const nulls = { email_address: null, email_subject: null, sms_phone: null, push_device_token: null, push_title: null} as const
const encodeDomain = Match.type<NotificationDomain>().pipe( Match.tagsExhaustive({ Email: (n) => ({ ...nulls, id: n.id, kind: "Email" as const, email_address: n.address, email_subject: n.subject }), Sms: (n) => ({ ...nulls, id: n.id, kind: "Sms" as const, sms_phone: n.phone }), Push: (n) => ({ ...nulls, id: n.id, kind: "Push" as const, push_device_token: n.deviceToken, push_title: n.title }) }))
const Notification = Schema.transformOrFail(NotificationRow, NotificationDomain, { decode: (row, _opts, ast) => decodeRow(row, ast), encode: (domain) => ParseResult.succeed(encodeDomain(domain))})