| name | effect-domain-modeling |
| description | Create production-ready Effect domain models using Schema.TaggedStruct for ADTs, 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.
Effect Source Reference
The Effect v4 source is available at ~/.cache/effect-v4/.
Browse and read files there directly to look up APIs, types, and implementations.
Reference this for:
- Full Schema API:
packages/effect/SCHEMA.md
- Match source:
packages/effect/src/Match.ts
- Migration guide:
MIGRATION.md
- Effect source:
packages/effect/src/
Core Pattern: Schema.TaggedStruct
The foundation of Effect domain modeling combines two key features:
- Schema.TaggedStruct - Automatic
_tag discriminator for union types
- Schema.decodeSync - Type-safe constructors with validation
In v4, Equal.equals performs deep structural comparison by default, so no special wrapping (like the removed Schema.Data) is needed.
import { Schema, Equal } from 'effect';
export const Pending = Schema.TaggedStruct('pending', {
id: Schema.String,
createdAt: Schema.DateTimeUtc
});
export const Active = Schema.TaggedStruct('active', {
id: Schema.String,
createdAt: Schema.DateTimeUtc,
startedAt: Schema.DateTimeUtc
});
export const Completed = Schema.TaggedStruct('completed', {
id: Schema.String,
createdAt: Schema.DateTimeUtc,
completedAt: Schema.DateTimeUtc
});
export const Task = Schema.Union([Pending, Active, 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>;
Use Schema.annotate(...) selectively for public or widely reused schemas when the metadata is actually consumed. Local or still-evolving domain schemas can stay unannotated.
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
Deep Structural Equality (v4):
Equal.equals performs deep structural comparison by default in v4
- No wrapping or special setup needed — just compare any two values
- Works correctly with nested structures
When Schema.annotate Helps:
- Public or widely reused schemas that benefit from extra identifier, title, or description metadata
- Better error messages in validation failures when the metadata is actually surfaced
- Schema introspection or generated docs that read annotations
- Local or provisional schemas do not need annotations by default
Mandatory Module Exports
Every domain model module MUST include:
1. Type Definition with Schemas
import { Schema } from 'effect';
export const Admin = Schema.TaggedStruct('Admin', {
id: Schema.String,
name: Schema.String,
permissions: Schema.Array(Schema.String)
});
export type Admin = Schema.Schema.Type<typeof Admin>;
export const Customer = Schema.TaggedStruct('Customer', {
id: Schema.String,
name: Schema.String,
tier: Schema.Literals(['free', 'premium'])
});
export type Customer = Schema.Schema.Type<typeof Customer>;
export const User = Schema.Union([Admin, Customer]);
export type User = Schema.Schema.Type<typeof User>;
2. Constructors Using Schema.decodeSync
import { Schema } from 'effect';
import * as DateTime from 'effect/DateTime';
declare const Pending: Schema.Schema<any, any, never>;
declare const Active: Schema.Schema<any, any, never>;
declare const Completed: Schema.Schema<any, any, never>;
export const makePending = Schema.decodeSync(Pending);
export const makeActive = Schema.decodeSync(Active);
export const makeCompleted = Schema.decodeSync(Completed);
Why decodeSync?
decodeSync creates a validated constructor
- Automatically applies the
_tag discriminator
- Throws on invalid input (use
decodeUnknownSync for unknown data)
3. Guards and Type Predicates
import { Schema } from 'effect';
declare type Task =
| { readonly _tag: 'pending'; readonly id: string; readonly createdAt: any }
| {
readonly _tag: 'active';
readonly id: string;
readonly createdAt: any;
readonly startedAt: any;
}
| {
readonly _tag: 'completed';
readonly id: string;
readonly createdAt: any;
readonly completedAt: any;
};
declare type Pending = Extract<Task, { readonly _tag: 'pending' }>;
declare type Active = Extract<Task, { readonly _tag: 'active' }>;
declare type Completed = Extract<Task, { readonly _tag: 'completed' }>;
declare const Task: Schema.Schema<Task, any, never>;
export const isTask = Schema.is(Task);
export const isPending: (self: Task) => self is Pending = Schema.is(Pending);
export const isActive: (self: Task) => self is Active = Schema.is(Active);
export const isCompleted: (self: Task) => self is Completed =
Schema.is(Completed);
4. Match Function (Pattern Matching)
import * as Match from 'effect/Match';
declare type Task =
| { readonly _tag: 'pending'; readonly id: string; readonly createdAt: any }
| {
readonly _tag: 'active';
readonly id: string;
readonly createdAt: any;
readonly startedAt: any;
}
| {
readonly _tag: 'completed';
readonly id: string;
readonly createdAt: any;
readonly completedAt: any;
};
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
import { Schema } from 'effect';
import * as Equal from 'effect/Equal';
import * as Equivalence from 'effect/Equivalence';
declare type Task =
| { readonly _tag: 'pending'; readonly id: string; readonly createdAt: any }
| {
readonly _tag: 'active';
readonly id: string;
readonly createdAt: any;
readonly startedAt: any;
}
| {
readonly _tag: 'completed';
readonly id: string;
readonly createdAt: any;
readonly completedAt: any;
};
declare const Task: Schema.Schema<Task, any, never>;
export const EquivalenceById = Equivalence.mapInput(
Equivalence.String,
(task: Task) => task.id
);
When to Export Custom 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 Custom 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:
declare type Cents = bigint;
declare function make(value: bigint): Cents;
declare type List<T> = ReadonlyArray<T>;
declare function makeEmpty<T>(): List<T>;
export const zero: Cents = make(0n);
export const empty: List<never> = makeEmpty();
Combinators
Functions that combine or transform values:
import { dual } from 'effect/Function';
declare type Cents = bigint;
declare function make(value: bigint): Cents;
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';
declare type Task =
| {
readonly _tag: 'pending';
readonly id: string;
readonly createdAt: DateTime.DateTime.Utc;
}
| {
readonly _tag: 'active';
readonly id: string;
readonly createdAt: DateTime.DateTime.Utc;
readonly startedAt: DateTime.DateTime.Utc;
}
| {
readonly _tag: 'completed';
readonly id: string;
readonly createdAt: DateTime.DateTime.Utc;
readonly completedAt: DateTime.DateTime.Utc;
};
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:
import * as DateTime from 'effect/DateTime';
declare type Task =
| {
readonly _tag: 'pending';
readonly id: string;
readonly createdAt: DateTime.DateTime.Utc;
}
| {
readonly _tag: 'active';
readonly id: string;
readonly createdAt: DateTime.DateTime.Utc;
readonly startedAt: DateTime.DateTime.Utc;
}
| {
readonly _tag: 'completed';
readonly id: string;
readonly createdAt: DateTime.DateTime.Utc;
readonly completedAt: DateTime.DateTime.Utc;
};
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';
declare type Task =
| { readonly _tag: 'pending'; readonly id: string; readonly createdAt: any }
| {
readonly _tag: 'active';
readonly id: string;
readonly createdAt: any;
readonly startedAt: any;
}
| {
readonly _tag: 'completed';
readonly id: string;
readonly createdAt: any;
readonly completedAt: any;
};
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.Codec<Category> => Category)
)
}).pipe(
Schema.annotate({
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 { Schema } from 'effect';
export type Email = Brand.Branded<string, 'Email'>;
export const Email = Brand.make<Email>(
(s) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) || `"${s}" is not a valid email`
);
export const EmailSchema = Schema.String.pipe(Schema.fromBrand('Email', Email));
Typeclass Instances
Only implement typeclasses that are semantically appropriate.
Check the project's @/typeclass/ directory for available typeclasses:
declare namespace Schedulable$ {
function make<A>(
get: (self: A) => any,
set: (self: A, date: any) => A
): any;
function isScheduledBefore(instance: any): (a: any, b: any) => boolean;
function OrderByScheduledDate(instance: any): any;
}
declare type Task =
| { readonly _tag: 'pending'; readonly id: string; readonly createdAt: any }
| {
readonly _tag: 'active';
readonly id: string;
readonly createdAt: any;
readonly startedAt: any;
}
| {
readonly _tag: 'completed';
readonly id: string;
readonly createdAt: any;
readonly completedAt: any;
};
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';
declare const tasks: ReadonlyArray<Task.Task>;
declare const task1: Task.Task;
declare const task2: Task.Task;
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 { Schema } from 'effect';
import * as DateTime from 'effect/DateTime';
import * as Duration from 'effect/Duration';
export const Task = Schema.TaggedStruct('task', {
createdAt: Schema.DateTimeUtc,
duration: Schema.Duration
});
export const TaskBad = Schema.TaggedStruct('task', {
createdAt: Schema.Date,
duration: Schema.Number
});
Immutability
Use plain object spreads for immutable updates. In v4, Data.struct was removed — deep structural equality is the default, so no special wrapping is needed:
declare type Task =
| { readonly _tag: 'pending'; readonly id: string; readonly createdAt: any }
| {
readonly _tag: 'active';
readonly id: string;
readonly createdAt: any;
readonly startedAt: any;
}
| {
readonly _tag: 'completed';
readonly id: string;
readonly createdAt: any;
readonly completedAt: any;
};
export const updateStatus = (self: Task, newTag: Task['_tag']): Task => ({
...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
import { Schema } from 'effect';
import * as DateTime from 'effect/DateTime';
declare const Pending: Schema.Schema<any, any, never>;
export const makePending = Schema.decodeSync(Pending);
Quality Checklist
Mandatory - Every Domain Model
Conditional - Include When Appropriate
Complete Example
import { Schema, Equal, Match } 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.DateTimeUtc,
permissions: Schema.Array(Schema.String)
}).pipe(
Schema.annotate({
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.DateTimeUtc,
tier: Schema.Literals(['free', 'premium'])
}).pipe(
Schema.annotate({
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.annotate({
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 = Schema.is(Admin);
export const isCustomer: (self: User) => self is Customer = Schema.is(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) => (isAdmin(user) ? 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.decodeSync - Create type-safe constructors
- Schema.annotate - Use selectively for public or reusable 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 (deep structural comparison in v4)
Your domain models should be production-ready, type-safe, and provide excellent developer experience.