| name | domain-modeling |
| description | Create production-ready Effect domain models using Schema.TaggedStruct for ADTs, Schema.Data for automatic equality, with comprehensive predicates, orders, guards, and match functions. Use when modeling domain entities, value objects, or any discriminated union types. |
Effect Domain Modeling Skill
Use this skill when creating domain models, entities, value objects, or any types that represent core business concepts. This skill covers the complete lifecycle from type definition to runtime utilities.
Core Pattern: Schema.TaggedStruct + Schema.Data
The foundation of Effect domain modeling combines three key features:
- Schema.TaggedStruct - Automatic
_tag discriminator for union types
- Schema.Data - Automatic
Equal implementation for structural equality
- Schema.decodeSync - Type-safe constructors with validation
import { Schema, Equal } from "effect"
export const Pending = Schema.TaggedStruct("pending", {
id: Schema.String,
createdAt: Schema.DateTimeUtcFromSelf,
}).pipe(
Schema.Data,
Schema.annotations({
identifier: "Pending",
title: "Pending Task",
description: "A task that has been created but not yet started",
})
)
export const Active = Schema.TaggedStruct("active", {
id: Schema.String,
createdAt: Schema.DateTimeUtcFromSelf,
startedAt: Schema.DateTimeUtcFromSelf,
}).pipe(
Schema.Data,
Schema.annotations({
identifier: "Active",
title: "Active Task",
description: "A task that is currently being worked on",
})
)
export const Completed = Schema.TaggedStruct("completed", {
id: Schema.String,
createdAt: Schema.DateTimeUtcFromSelf,
completedAt: Schema.DateTimeUtcFromSelf,
}).pipe(
Schema.Data,
Schema.annotations({
identifier: "Completed",
title: "Completed Task",
description: "A task that has been finished",
})
)
export const Task = Schema.Union(Pending, Active, Completed).pipe(
Schema.annotations({
identifier: "Task",
title: "Task",
description: "A task can be pending, active, or completed",
})
)
export type Task = Schema.Schema.Type<typeof Task>
export type Pending = Schema.Schema.Type<typeof Pending>
export type Active = Schema.Schema.Type<typeof Active>
export type Completed = Schema.Schema.Type<typeof Completed>
Why This Pattern?
Schema.TaggedStruct Benefits:
- Automatically adds
_tag discriminator (no manual Schema.Literal)
- The
_tag is applied automatically in constructors
- Cleaner than
Schema.Struct with manual tag fields
- Enables exhaustive pattern matching
Schema.Data Benefits:
- Implements
Equal.Symbol automatically
- Enables
Equal.equals(a, b) for structural equality
- No manual equality implementation needed
- Works correctly with nested structures
Schema.annotations Benefits:
- Self-documenting schemas with identifier, title, description
- Better error messages in validation failures
- Enables schema introspection
Mandatory Module Exports
Every domain model module MUST include:
1. Type Definition with Schemas
export const Admin = Schema.TaggedStruct("Admin", {
id: Schema.String,
name: Schema.String,
permissions: Schema.Array(Schema.String),
}).pipe(Schema.Data)
export type Admin = Schema.Schema.Type<typeof Admin>
export const Customer = Schema.TaggedStruct("Customer", {
id: Schema.String,
name: Schema.String,
tier: Schema.Union(
Schema.Literal("free"),
Schema.Literal("premium")
),
}).pipe(Schema.Data)
export type Customer = Schema.Schema.Type<typeof Customer>
export const User = Schema.Union(Admin, Customer).pipe(
Schema.annotations({
identifier: "User",
title: "User",
description: "A user can be an admin or a customer",
})
)
export type User = Schema.Schema.Type<typeof User>
2. Constructors Using Schema.decodeSync
import * as DateTime from "effect/DateTime"
export const makePending = Schema.decodeSync(Pending)
export const makeActive = Schema.decodeSync(Active)
export const makeCompleted = Schema.decodeSync(Completed)
Why decodeSync?
Schema.Data returns a schema that needs decoding
decodeSync creates a validated constructor
- Automatically applies the
_tag discriminator
- Throws on invalid input (use
decodeUnknownSync for unknown data)
3. Guards and Type Predicates
export const isTask = Schema.is(Task)
export const isPending = (self: Task): self is Pending => self._tag === "pending"
export const isActive = (self: Task): self is Active => self._tag === "active"
export const isCompleted = (self: Task): self is Completed => self._tag === "completed"
4. Match Function (Pattern Matching)
import * as Match from "effect/Match"
export const match = Match.typeTags<Task>()
Match.typeTags Usage:
- Primary pattern for discriminated unions
- Type-safe and exhaustive
- Works with any
_tag discriminator
5. Equivalence (Usually Automatic via Schema.Data)
import * as Equal from "effect/Equal"
import * as Equivalence from "effect/Equivalence"
export const Equivalence = Schema.equivalence(Task)
export const EquivalenceById = Equivalence.mapInput(
Equivalence.string,
(task: Task) => task.id
)
When to Export Equivalence:
- You need multiple comparison strategies (by ID, by group, etc.)
- Field-based equality is semantically meaningful
- Business logic requires custom equality checks
When NOT to Export Equivalence:
- You only need structural equality (use
Equal.equals() directly)
- No custom comparison logic is needed
Conditional Module Exports
Include these when semantically appropriate:
Identity Values
When the type has a natural "zero" or "empty" value:
export const zero: Cents = make(0n)
export const empty: List<never> = makeEmpty()
Combinators
Functions that combine or transform values:
import { dual } from "effect/Function"
export const add: {
(that: Cents): (self: Cents) => Cents
(self: Cents, that: Cents): Cents
} = dual(2, (self: Cents, that: Cents): Cents => make(self + that))
export const min = (a: Cents, b: Cents): Cents => a < b ? a : b
export const max = (a: Cents, b: Cents): Cents => a > b ? a : b
Order Instances
Provide sorting capabilities using Order.mapInput:
import * as Order from "effect/Order"
import * as DateTime from "effect/DateTime"
export const OrderByTag: Order.Order<Task> = Order.mapInput(
Order.number,
(task) => {
const priorities = { pending: 0, active: 1, completed: 2 }
return priorities[task._tag]
}
)
export const OrderById: Order.Order<Task> =
Order.mapInput(Order.string, (task) => task.id)
export const OrderByCreatedAt: Order.Order<Task> =
Order.mapInput(DateTime.Order, (task) => task.createdAt)
export const OrderByTagThenDate: Order.Order<Task> = Order.combine(
OrderByTag,
OrderByCreatedAt
)
Key Pattern: Order.mapInput
- Compose orders from simpler base orders
- Map domain type to comparable value
- Signature:
Order.mapInput(baseOrder, (value) => extractField)
Key Pattern: Order.combine
- Combine multiple orders for multi-criteria sorting
- First order takes precedence, then second, etc.
Destructors (Getters)
Safe extraction of inner values:
export const getId = (self: Task): string => self.id
export const getCreatedAt = (self: Task): DateTime.DateTime.Utc =>
self.createdAt
Setters (Immutable Updates)
import { dual } from "effect/Function"
export const setId: {
(id: string): (self: Task) => Task
(self: Task, id: string): Task
} = dual(2, (self: Task, id: string): Task => ({ ...self, id }))
Advanced Patterns
Recursive Schemas with Schema.suspend
Use for self-referencing types (trees, graphs, nested structures):
import { Schema } from "effect"
const baseFields = {
id: Schema.String,
name: Schema.String,
}
interface Category extends Schema.Struct.Type<typeof baseFields> {
readonly subcategories: ReadonlyArray<Category>
}
export const Category = Schema.Struct({
...baseFields,
subcategories: Schema.Array(
Schema.suspend((): Schema.Schema<Category> => Category)
),
}).pipe(
Schema.Data,
Schema.annotations({
identifier: "Category",
title: "Category",
description: "A category that can contain nested subcategories",
})
)
export type Category = Schema.Schema.Type<typeof Category>
export const make = Schema.decodeSync(Category)
Key Pattern: Schema.suspend
- Use for self-referencing types
- Separate base fields for clarity
- Define interface first, then schema with
Schema.suspend
Branded Types
For types that need additional runtime guarantees:
import * as Brand from "effect/Brand"
import * as Schema from "effect/Schema"
export type Email = Brand.Branded<string, "Email">
export const Email = Brand.refined<Email>(
(s) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s),
(s) => Brand.error(`"${s}" is not a valid email`)
)
export const EmailSchema: Schema.BrandSchema<Email, string> =
Schema.String.pipe(Schema.fromBrand(Email))
Typeclass Instances
Only implement typeclasses that are semantically appropriate.
Check the project's @/typeclass/ directory for available typeclasses:
import * as Schedulable$ from "@/typeclass/Schedulable"
export const Schedulable = Schedulable$.make<Task>(
(self) => self.createdAt,
(self, date) => ({ ...self, createdAt: date })
)
export const isScheduledBefore = Schedulable$.isScheduledBefore(Schedulable)
export const OrderByScheduledDate = Schedulable$.OrderByScheduledDate(Schedulable)
Common typeclass examples:
- Schedulable: For types with date/time properties
- Durable: For types with duration properties
- Priceable: For types with price properties
- Identifiable: For types with ID properties
Import Patterns
CRITICAL: Always use namespace imports:
import * as Task from "@/schemas/Task"
import * as DateTime from "effect/DateTime"
import * as Array from "effect/Array"
import * as Order from "effect/Order"
import * as Equal from "effect/Equal"
const task = Task.makePending({
id: "123",
createdAt: DateTime.unsafeNow()
})
const isPending = Task.isPending(task)
const sorted = Array.sort(tasks, Task.OrderByTag)
const areEqual = Equal.equals(task1, task2)
NEVER do this:
import { makePending, isPending } from "@/schemas/Task"
Namespace Import Benefits:
- Clear context for all functions
- Prevents name clashes
- Enables
Task.Pending, Task.Active schema access
- Natural organization:
Task.makePending, Task.isPending
Temporal Data
Always use DateTime and Duration, never Date or number:
import * as DateTime from "effect/DateTime"
import * as Duration from "effect/Duration"
export const Task = Schema.TaggedStruct("task", {
createdAt: Schema.DateTimeUtcFromSelf,
duration: Schema.Duration,
}).pipe(Schema.Data)
export const Task = Schema.TaggedStruct("task", {
createdAt: Schema.Date,
duration: Schema.Number,
})
Immutability
Use the Data module for immutable operations:
import { Data } from "effect"
export const updateStatus = (self: Task, newTag: Task["_tag"]): Task =>
Data.struct({ ...self, _tag: newTag })
Documentation Standards
Every exported member MUST have:
- JSDoc with description
@category tag (Constructors, Guards, Pattern Matching, Orders, etc.)
@since tag (version number)
@example with fully working code including all imports
export const makePending = Schema.decodeSync(Pending)
Quality Checklist
Mandatory - Every Domain Model
Conditional - Include When Appropriate
Complete Example
import { Schema, Equal, Match, Data } from "effect"
import * as DateTime from "effect/DateTime"
import * as Order from "effect/Order"
import * as Equivalence from "effect/Equivalence"
import { dual } from "effect/Function"
export const Admin = Schema.TaggedStruct("Admin", {
id: Schema.String,
name: Schema.String,
createdAt: Schema.DateTimeUtcFromSelf,
permissions: Schema.Array(Schema.String),
}).pipe(
Schema.Data,
Schema.annotations({
identifier: "Admin",
title: "Administrator",
description: "A user with administrative privileges",
})
)
export type Admin = Schema.Schema.Type<typeof Admin>
export const Customer = Schema.TaggedStruct("Customer", {
id: Schema.String,
name: Schema.String,
createdAt: Schema.DateTimeUtcFromSelf,
tier: Schema.Union(Schema.Literal("free"), Schema.Literal("premium")),
}).pipe(
Schema.Data,
Schema.annotations({
identifier: "Customer",
title: "Customer",
description: "A customer user",
})
)
export type Customer = Schema.Schema.Type<typeof Customer>
export const User = Schema.Union(Admin, Customer).pipe(
Schema.annotations({
identifier: "User",
title: "User",
description: "A user can be an admin or a customer",
})
)
export type User = Schema.Schema.Type<typeof User>
export const makeAdmin = Schema.decodeSync(Admin)
export const makeCustomer = Schema.decodeSync(Customer)
export const isUser = Schema.is(User)
export const isAdmin = (self: User): self is Admin => self._tag === "Admin"
export const isCustomer = (self: User): self is Customer => self._tag === "Customer"
export const match = Match.typeTags<User>()
export const EquivalenceById = Equivalence.mapInput(
Equivalence.string,
(user: User) => user.id
)
export const OrderByName: Order.Order<User> =
Order.mapInput(Order.string, (user) => user.name)
export const OrderByCreatedAt: Order.Order<User> =
Order.mapInput(DateTime.Order, (user) => user.createdAt)
export const OrderByTag: Order.Order<User> = Order.mapInput(
Order.number,
(user) => (user._tag === "Admin" ? 0 : 1)
)
export const getId = (self: User): string => self.id
export const getName = (self: User): string => self.name
export const getCreatedAt = (self: User): DateTime.DateTime.Utc => self.createdAt
export const setName: {
(name: string): (self: User) => User
(self: User, name: string): User
} = dual(2, (self: User, name: string): User => ({ ...self, name }))
When to Use This Skill
- Creating domain entities (User, Product, Order)
- Modeling value objects (Email, Money, Address)
- Defining discriminated unions (states, events, commands)
- Implementing ADTs (algebraic data types)
- Building type-safe domain models with validation
- Ensuring structural equality with automatic Equal
- Creating self-documenting schemas
Key Principles Summary
- Schema.TaggedStruct - Use for all tagged union variants
- Schema.Data - Apply for automatic Equal implementation
- Schema.decodeSync - Create type-safe constructors
- Schema.annotations - Document all schemas
- Order.mapInput - Compose orders from base orders
- Match.typeTags - Pattern match on discriminated unions
- Schema.suspend - Handle recursive types
- Namespace imports - Always use
import * as
- DateTime/Duration - Never use Date/number for temporal data
- Equal.equals() - Primary equality check (from Schema.Data)
Your domain models should be production-ready, type-safe, and provide excellent developer experience.