03·01Schema Modeling

A TypeScript type is a compile-time promise. At runtime, an API response, a database row, or a user-submitted form is `unknown`.

18 - Schema: Data Modeling at the Boundary

Previous · Next

A TypeScript type is a compile-time promise. At runtime, an API response, a database row, or a user-submitted form is unknown.

Schema is a single declaration that produces a compile-time type, a runtime validator, an encoder, and, when combined with branding, nominal primitives. Define it once at the boundary; trust the result inside.

What you’ll work through

index.test.ts contains five beats covering the five faces of Schema: Schema.Class for domain models, Schema.TaggedClass for variants, Schema.brand + Schema.Literal for refined primitives, decoders for parsing untrusted input, and encode/decode round-trips.

By the end you should treat Schema as the boundary contract: one definition that produces type, validator, encoder, and runtime constructor in lockstep.


The Boundary

untyped boundary your system
─────────────────────────────────────────────────────────────────
│
unknown ─────────►│ Schema.decodeUnknownEffect(User) ──► User
(JSON, API, form) │ (typed)
│
User ◄─────────│ Schema.encode(User) ◄── User
(JSON out) │
│
─────────────────────┴────────────────────────────────────────────

Validation lives at the seam between untyped and typed worlds. Inside the system, every value satisfies its declared schema, no defensive checks, no as User casts.


Schema.Class<Self>

Creates a class that doubles as a runtime schema and a TypeScript type. The Self generic binds the class to itself so decode returns an instance, not a plain object.

import { Schema } from "effect"
class User extends Schema.Class<User>("User")({
id: Schema.String,
name: Schema.String,
email: Schema.String,
}) {
get displayName() {
return `${this.name} <${this.email}>`
}
}
// ┌─── User
// ▼
const user = new User({ id: "u1", name: "Alice", email: "alice@example.com" })
user.displayName // "Alice <alice@example.com>"

Details

The <User> generic is load-bearing. Omit it and the class loses self-reference; methods return Schema.Class.Type instead of User, and instanceof checks behave unexpectedly.

When to Use

When the model has methods, computed properties, or needs instanceof checks. For plain records without behaviour, reach for Schema.Struct.


Schema.TaggedClass<Self>

Creates a class with an automatic _tag field. The result composes with Schema.Union for discriminated unions and with Match.valueTags for exhaustive pattern matching.

import { Schema, Match } from "effect"
class Success extends Schema.TaggedClass<Success>("Success")("Success", {
value: Schema.Number,
}) {}
class Failure extends Schema.TaggedClass<Failure>("Failure")("Failure", {
error: Schema.String,
}) {}
const render = (r: Success | Failure) =>
Match.valueTags(r, {
Success: ({ value }) => `Got: ${value}`,
Failure: ({ error }) => `Error: ${error}`,
})

The first string argument identifies the schema; the second is the discriminator value written into _tag on every instance.


Schema.brand for Nominal Primitives

Branding attaches a phantom marker to a primitive so the type system can distinguish otherwise identical strings or numbers.

const UserId = Schema.String.pipe(Schema.brand("UserId"))
const PostId = Schema.String.pipe(Schema.brand("PostId"))
type UserId = typeof UserId.Type // string & Brand<"UserId">
type PostId = typeof PostId.Type // string & Brand<"PostId">
const id: UserId = UserId.make("user-123")
declare const lookup: (id: UserId) => User
lookup(id) // ok
lookup("user-123") // type error: missing brand
lookup(PostId.make("post-456")) // type error: wrong brand

The brand exists only in the type system. Runtime representation is still a plain string, so there is no overhead.


Decoders and Encoders

import { Schema } from "effect"
// Throwing, for top-level scripts and tests
// ┌─── User
// ▼
const u = Schema.decodeUnknownSync(User)(raw)
// Result, for boundaries where parse failure is a value
// ┌─── Result<User, ParseError>
// ▼
const eu = Schema.decodeUnknownExit(User)(raw)
// Effect, for boundaries inside Effect pipelines
// ┌─── Effect<User, ParseError>
// ▼
const fu = Schema.decodeUnknownEffect(User)(raw)
// Encode back to unknown for transport
// ┌─── unknown
// ▼
const json = Schema.encodeSync(User)(u)

When to Use

  • decodeUnknownSync at startup (config files, fixtures) where throwing is acceptable.
  • decodeUnknownExit when the call site needs to branch on success vs failure.
  • decodeUnknown inside Effect.gen so parse errors join the error channel.

A Full Example: Decode → Transform → Encode

import { Schema, Effect } from "effect"
class Product extends Schema.Class<Product>("Product")({
sku: Schema.String,
price: Schema.Number,
quantity: Schema.Number,
}) {
get total() {
return this.price * this.quantity
}
}
// ┌─── Effect<Product, ParseError, never>
// ▼
const parseProduct = (raw: unknown) =>
Schema.decodeUnknownEffect(Product)(raw)
const json = { sku: "WIDGET-001", price: 9.99, quantity: 3 }
const product = Effect.runSync(parseProduct(json))
product.total // 29.97

Common Pitfalls

1, Omitting the Self generic. Schema.Class("User")({...}) compiles but loses self-reference. Always write Schema.Class<User>("User")({...}).

2, decodeUnknownSync deep inside business logic. It throws, bypassing the typed error channel. Decode once at the boundary; pass typed values inward.

3, Confusing Schema.Class with Schema.Struct. Class produces a real JavaScript class (methods, instanceof, new). Struct produces a schema over a plain object.

4, Forgetting to validate on the way out. Schema.encode checks that the value still satisfies the schema before serialising. Skipping it lets a tampered field reach the wire.


matches `Data.Class` exactly. - [ ] Add a tagged variant with `Schema.TaggedClass` and confirm the `_tag` flows through. - [ ] Brand a primitive with `Schema.brand` and verify the wrapper type cannot be confused with a plain string. - [ ] Decode untrusted input and round-trip encode it; the `decode` step is the boundary between `unknown` and trusted shape.

See Also

  • Previous, 25-config: validating environment values.
  • Next, 27-effect-fn: named, traced effectful functions.
  • Related, 04-error-types for the parallel Data.TaggedError pattern.
  • Later, 16-branded-types, 19-schema-classes, 20-schema-transformations.

Run the tests: pnpm test 18-schema-modeling


The full test suite

The index.test.ts for this lesson, verbatim from steps/18-schema-modeling/.

index.test.ts
import { describe, expect, it } from "vite-plus/test"
import { Effect, Exit, Schema } from "effect"
const User = Schema.Struct({ id: Schema.String, age: Schema.Int })
describe("Schema modeling", () => {
it("decodes unknown boundary data", () => {
expect(Schema.decodeUnknownSync(User)({ id: "u1", age: 42 })).toEqual({ id: "u1", age: 42 })
})
it("reports invalid boundary data in the error channel", () => {
expect(Exit.isFailure(Effect.runSyncExit(Schema.decodeUnknownEffect(User)({ id: "u1", age: 4.2 })))).toBe(true)
})
})
← PreviousPattern MatchingNext →Schema Classes