This document defines the working model behind Effect-first code in this repository.
Effect-first development means domain code is written in Effect-native constructs first, and native JavaScript/TypeScript patterns only at explicit boundaries.
The goal is to make failure, absence, decoding, and dependency wiring explicit and typed.
Use three layers:
- Boundary layer:
- Parse and decode unknown input with
Schema.decodeUnknown*. - Convert nullish values to
Option. - Convert throwable/rejecting APIs to typed Effect failures.
- Parse and decode unknown input with
- Domain layer:
- Use Effect modules (
Arr,Option,R,Schema,Str,HashMap,HashSet) and typed services. - Keep business logic pure, explicit, and exhaustive.
- Use Effect modules (
- Runtime layer:
- Compose layers and run effects.
- Keep platform concerns (process, filesystem, env, network) outside core domain logic.
- If logic can fail, return
Effect.Effect<A, E, R>with a typed errorE. - Use
Schema.TaggedErrorClassfor public or cross-module failures. - Do not
throwor usenew Error(...)in production domain logic.
Example:
import { Effect } from "effect"
import * as Option from "effect/Option"
import * as Schema from "effect/Schema"
class MissingConfigError extends Schema.TaggedErrorClass<MissingConfigError>("MissingConfigError")(
"MissingConfigError",
{ key: Schema.String },
{ description: "Required configuration key is missing" }
) {}
const requireEnv = (key: string) =>
Effect.sync(() => process.env[key]).pipe(
Effect.flatMap((value) =>
Option.match(Option.fromNullishOr(value), {
onNone: () => Effect.fail(new MissingConfigError({ key })),
onSome: Effect.succeed
})
)
)- Inside domain code, avoid
| nulland| undefined. - Convert nullable values at boundaries via
Option.fromNullishOr. - Consume via
Option.map,Option.flatMap,Option.match,Option.getOrElse.
Example:
import { pipe } from "effect"
import * as Option from "effect/Option"
const toDisplayName = (rawName: string | null | undefined) =>
pipe(
Option.fromNullishOr(rawName),
Option.map((name) => name.trim()),
Option.filter((name) => name.length > 0),
Option.getOrElse(() => "anonymous")
)- Unknown or external data must be decoded at the boundary.
- Prefer
Schema.decodeUnknownEffectfor effectful paths andSchema.decodeUnknownSynconly where sync failure handling is explicit. - Never use
JSON.parse/JSON.stringify; use schema JSON codecs (Schema.UnknownFromJsonString,Schema.fromJsonString,Schema.decodeUnknown*,Schema.encode*). - Prefer
Schema.ClassoverSchema.Structfor object/domain schemas. - Do not name schemas with a
Schemasuffix; schema constants should be named after the domain type. - For non-class schemas, export type aliases with the same identifier name as the schema value.
Example:
import * as Schema from "effect/Schema"
export class CreateTaskInput extends Schema.Class<CreateTaskInput>("CreateTaskInput")({
id: Schema.String,
title: Schema.String,
priority: Schema.Int
}) {}
export const decodeCreateTaskInput = Schema.decodeUnknownEffect(CreateTaskInput)- Required aliases:
import * as Arr from "effect/Array"import * as Option from "effect/Option"import * as P from "effect/Predicate"import * as R from "effect/Record"import * as Schema from "effect/Schema"
- Prefer dedicated namespace imports for stable helper/data modules:
import * as Str from "effect/String"import * as Eq from "effect/Equal"import * as Bool from "effect/Boolean"
- Reserve root imports from
"effect"for core combinators/types such asEffect,Match,pipe, andflow. - Keep unstable imports deliberate and local.
- Use
Arr,R,Str,Eq,HashMap,HashSet,MutableHashMap,MutableHashSet. - Avoid domain usage of native
Object,Map,Set,Date, and direct native string helpers. - When behavior is unchanged, prefer the tersest helper form: direct helper refs over trivial wrapper lambdas,
flow(...)over passthroughpipe(...)callbacks, and shared thunk helpers when already in scope.
Example:
import { pipe } from "effect"
import * as Arr from "effect/Array"
import * as Option from "effect/Option"
const findActiveEmail = (users: ReadonlyArray<{ readonly active: boolean; readonly email: string }>) =>
pipe(
users,
Arr.findFirst((user) => user.active),
Option.map((user) => user.email)
)- Prefer
P.isString,P.isNumber,P.isObject, and predicate composition. - Avoid raw
typeof/ad-hoc runtime checks when a Predicate helper exists.
- Replace brittle if/else ladders with
Match. - For empty/non-empty array branching, prefer
Arr.matchover manual length checks. - Do not use native
switchstatements for domain branching. - Model domain states as schema tagged unions (see EF-13), then branch exhaustively.
Example:
import { Match } from "effect"
import * as Arr from "effect/Array"
type SyncPhase = "idle" | "running" | "failed"
const phaseLabel = (phase: SyncPhase) =>
Match.value(phase).pipe(
Match.when("idle", () => "idle"),
Match.when("running", () => "running"),
Match.when("failed", () => "failed"),
Match.exhaustive
)
const summarizeAttempts = (attempts: ReadonlyArray<number>) =>
Arr.match(attempts, {
onEmpty: () => "no-attempts",
onNonEmpty: (values) => `attempts:${Arr.length(values)}`
})- For boolean-driven branching, prefer
Bool.matchfromeffect/Booleanover ad-hocif/else. - This keeps control flow expression-oriented and consistent with Effect matching style.
- Service identity comes from a unique string key.
- Service constructors are explicit and layered.
- Dependency wiring happens in Layer composition, not hidden global state.
- Service identity must use a descriptive, unique string key.
Example:
import { ServiceMap } from "effect"
export class MyService extends ServiceMap.Service<MyService, {
readonly ping: () => string
}>()("MyService") {}- Prefer Effect runtime services such as
ClockandRandom. - Avoid direct
Date.now()andMath.random()in domain logic.
- Do not use native
fetchin runtime source. - Compose requests/responses with
HttpClientRequest,HttpClientResponse,Headers,UrlParams,HttpMethod, andHttpBody. - Provide runtime client layers explicitly (
@effect/platform-bun/BunHttpClient.layerfor Bun runtimes).
- Use
@effect/vitestandit.effect(...)for effectful tests. - Keep fixtures typed and schema-validated where useful.
- Exported APIs in package/tooling source require JSDoc.
- Examples must remain docgen-clean.
- New schemas must include meaningful annotation metadata via
.annotate({...})or the annotations parameter. - Annotation descriptions should encode intent, not repeat the symbol name.
Example:
import * as Schema from "effect/Schema"
export const Tenant = Schema.String.annotate({
title: "Tenant",
description: "Logical tenant identifier used for request and storage partitioning."
})
export type Tenant = typeof Tenant.Type- If an intermediate domain concept is named, reused, matched on, or structurally validated, model it as a schema first instead of an ad-hoc boolean helper.
- Prefer built-in schema constructors/checks such as
Schema.NonEmptyString,Schema.NonEmptyArray,Schema.TupleWithRest,Schema.Union,Schema.isPattern, andSchema.isIncludesbefore reaching forSchema.makeFilter. - Derive domain guards with
Schema.is(SomeSchema). - If an internal literal domain needs type guards, use
Schema.is(Schema.Literal(...)). For exhaustive matching over literals, useMatch. For annotation-bearing schema values, useSchema.Literal(...).annotate({...}). - Prefer named intermediate schemas; export and document them when reusable or when they materially clarify the module’s domain model, otherwise keep them module-local.
- Reusable
Schema.makeFilter,Schema.makeFilterGroup, and reusable built-in check blocks must includeidentifier,title, anddescription. - Keep
messagefocused on the user-facing decode failure. - Tiny one-off test checks may stay lighter when the schema itself is not reusable.
- If schema properties are a union of literal strings (for example
kind,state,category), compose class variants into aSchema.Unionand finalize withSchema.toTaggedUnion("<field>"). - Prefer
Schema.Classfor tagged union member schemas. - Use
Schema.TaggedUniononly for canonical_tagobject-union construction. - Reference: Effect schema docs and toTaggedUnion notes.
Example:
import * as Schema from "effect/Schema"
export class ExternalJobCreated extends Schema.Class<ExternalJobCreated>("ExternalJobCreated")(
{
kind: Schema.tag("created"),
id: Schema.String
},
{ description: "Created event from external job source." }
) {}
export class ExternalJobCompleted extends Schema.Class<ExternalJobCompleted>("ExternalJobCompleted")(
{
kind: Schema.tag("completed"),
id: Schema.String,
at: Schema.String
},
{ description: "Completed event from external job source." }
) {}
export const ExternalJobEvent = Schema.Union(ExternalJobCreated, ExternalJobCompleted).pipe(
Schema.toTaggedUnion("kind")
).annotate({
title: "ExternalJobEvent",
description: "External job event union discriminated by `kind`."
})
export type ExternalJobEvent = typeof ExternalJobEvent.Type
export const InternalJobEvent = Schema.TaggedUnion({
Created: { id: Schema.String },
Completed: { id: Schema.String, at: Schema.String }
}).annotate({
title: "InternalJobEvent",
description: "Canonical internal union discriminated by `_tag`."
})- Prefer
Effect.fn("Name")(...)for reusable/public effectful functions. - Use
Effect.fnUntraced(...)for internal hot paths where tracing overhead is unnecessary. - Reference: Effect.fn docs and Effect.fnUntraced docs.
Example:
import { Effect } from "effect"
import * as Schema from "effect/Schema"
export const loadUser = Effect.fn("User.load")(function* (userId: string) {
yield* Effect.logDebug("loading user", userId)
return { userId }
})
const parseInternal = Effect.fnUntraced(function* (input: string) {
return yield* Schema.decodeUnknownEffect(Schema.UnknownFromJsonString)(input)
})- Instrument key workflows with logs, log annotations, spans, and metrics.
- Prefer built-in helpers:
- Logging:
Effect.logWithLevel,Effect.log,Effect.logFatal,Effect.logWarning,Effect.logError,Effect.logInfo,Effect.logDebug,Effect.logTrace - Logger/context:
Effect.withLogger,Effect.annotateLogs,Effect.annotateLogsScoped,Effect.withLogSpan - Metrics/tracking:
Effect.track,Effect.trackSuccesses,Effect.trackErrors,Effect.trackDefects,Effect.trackDuration - Tracing:
Effect.annotateSpans,Effect.annotateCurrentSpan
- Logging:
Example:
import { Effect } from "effect"
import * as Metric from "effect/Metric"
const durationMs = Metric.histogram("workflow_duration_ms", {
boundaries: Metric.boundariesFromIterable([10, 50, 100, 250, 500, 1000])
})
const failures = Metric.counter("workflow_failures_total")
const workflow = Effect.fn("Workflow.run")(function* (requestId: string) {
yield* Effect.annotateCurrentSpan("requestId", requestId)
yield* Effect.logInfo("workflow started")
return "ok"
}).pipe(
Effect.withLogSpan("workflow.run"),
Effect.annotateLogs({ service: "curationspace" }),
Effect.trackDuration(durationMs),
Effect.trackErrors(failures)
)- Model timeouts, intervals, and windows with
Duration. - Avoid magic number time values in domain logic.
Example:
import { Duration, Effect } from "effect"
const timeout = Duration.seconds(30)
const pollInterval = Duration.millis(250)
const program = Effect.sleep(pollInterval).pipe(Effect.timeout(timeout))- Use dedicated schema helpers for optional/null conversions:
Schema.OptionFromNullOrSchema.OptionFromNullishOrSchema.OptionFromOptionalKeySchema.OptionFromOptional
- Reference: Schema Option helpers and Schema optional field docs.
Example:
import * as Schema from "effect/Schema"
export class AccountInput extends Schema.Class<AccountInput>("AccountInput")({
nickname: Schema.OptionFromNullishOr(Schema.String),
bio: Schema.OptionFromNullOr(Schema.String),
phone: Schema.OptionFromOptionalKey(Schema.String),
timezone: Schema.OptionFromOptional(Schema.String)
}) {}- For reusable helper combinators, support both styles:
- Data-first:
fn(self, arg) - Data-last:
pipe(self, fn(arg))
- Data-first:
- Build these helpers with
dualfromeffect/Function. - Reference: dual API.
Example:
import { dual } from "effect/Function"
import { pipe } from "effect"
export const addPrefix: {
(prefix: string): (self: string) => string
(self: string, prefix: string): string
} = dual(2, (self: string, prefix: string) => `${prefix}${self}`)
const a = addPrefix("value", "p:")
const b = pipe("value", addPrefix("p:"))- Use
Schema.UnknownFromJsonStringfor unknown JSON payloads. - Use
Schema.fromJsonString(MySchema)for typed JSON string boundaries. - Avoid direct
JSON.parse/JSON.stringifyin Effect-first code. - Reference: UnknownFromJsonString and fromJsonString.
Example:
import * as Schema from "effect/Schema"
export class User extends Schema.Class<User>("User")({
id: Schema.String,
name: Schema.String
}) {}
const UserJson = Schema.fromJsonString(User)
const decodeUserJson = Schema.decodeUnknownEffect(UserJson)
const encodeUserJson = Schema.encodeUnknownEffect(UserJson)You are not done if these fail:
bun run checkbun run lintbun run test
- Application entrypoints and tests may execute effects with
Effect.run*. - Library and domain exports should return
Effectvalues. - Keep runtime execution in one place so wiring, logging, and lifecycle behavior stay auditable.
- Reference: runPromise, runSync, and runFork.
Example:
import { Effect } from "effect"
export const runJob = Effect.fn("Job.run")(function* (id: string) {
return { id }
})
// Runtime boundary only (for example, in main.ts):
// Effect.runPromise(runJob("job-1"))- Use
Effect.tryPromisefor Promise APIs that may reject. - Keep Promise rejection details in typed failure values.
- Domain APIs should return
Effect, not rawPromise.
Example:
import { Effect } from "effect"
const fetchText = (url: string) =>
Effect.tryPromise({
try: () => fetch(url).then((response) => response.text()),
catch: (cause) => new HttpRequestError({ url, message: String(cause) })
})- Use
Effect.acquireUseReleasefor acquisition/use/release flows. - Prefer
Effect.scopedfor helper composition that allocates resources. - Do not manually open resources without an explicit finalization strategy.
- Reference: acquireUseRelease and scoped.
Example:
import { Effect } from "effect"
const withConnection = <A, E, R>(
use: (conn: Connection) => Effect.Effect<A, E, R>
) =>
Effect.acquireUseRelease(
openConnection,
use,
closeConnection
)- Encode retries with
Effect.retryandSchedule. - Avoid manual retry loops and ad-hoc mutable counters.
- Keep retry policy close to the failing effect.
- Reference: retry.
Example:
import { Effect, Schedule } from "effect"
const resilientFetch = fetchRemote.pipe(
Effect.retry(Schedule.recurs(3))
)- Use
Effect.timeoutOptionwhen timeout should becomeOption.None. - Use
Effect.timeoutOrElsewhen timeout should produce a typed fallback effect. - Avoid manually racing ad-hoc timers for business logic timeouts.
- Reference: timeoutOption and timeoutOrElse.
Example:
import { Duration, Effect } from "effect"
const lookupCachedOnTimeout = slowLookup.pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(2),
onTimeout: () => Effect.succeed("cached-value")
})
)- Prefer
Effect.forkChildso lifecycle is supervised by parent scope. - Use
Effect.forkDetachonly for explicit daemon semantics. - Make fork intent explicit in code review and comments for detached work.
- Reference: forkChild and forkDetach.
Example:
import { Effect, Fiber } from "effect"
const runWithHeartbeat = Effect.fn("Worker.run")(function* () {
const heartbeat = yield* Effect.forkChild(heartbeatLoop)
const result = yield* doWork
yield* Fiber.interrupt(heartbeat)
return result
})- For non-trivial fan-out, set concurrency in
Effect.forEach,Effect.all, orEffect.validate. - Avoid implicit unbounded parallelism on large collections.
- Concurrency should be part of API intent for throughput-sensitive paths.
- Reference: forEach concurrency, all concurrency, withConcurrency.
Example:
import { Effect } from "effect"
const hydrateUsers = (ids: ReadonlyArray<string>) =>
Effect.forEach(ids, fetchUser, { concurrency: 8 })- Use
ConfigandConfigProviderfor configuration loading and parsing. - Keep direct
process.envaccess out of domain code. - Layer/provide config sources explicitly for tests and non-default environments.
- Reference: Config and ConfigProvider.
Example:
import { Config, Effect } from "effect"
const loadPort = Effect.fn("Config.loadPort")(function* () {
return yield* Config.int("PORT")
})- Use
Config.redactedfor secret config values. - Use
Redacted.makefor sensitive values coming from non-config sources. - Never log secret values after unwrapping.
- Reference: Config.redacted and Redacted.
Example:
import { Config, Effect } from "effect"
const loadApiKey = Effect.fn("Config.loadApiKey")(function* () {
const apiKey = yield* Config.redacted("API_KEY")
yield* Effect.logDebug(`apiKey=${String(apiKey)}`)
return apiKey
})- Prefer
Effect.catchTagandEffect.catchFilterfor targeted recovery. - Do not hide unrelated failures behind broad fallback handlers.
- Keep recoverable error cases explicit in code.
Example:
import { Effect } from "effect"
import * as Option from "effect/Option"
const findUserOptional = (id: string) =>
findUser(id).pipe(
Effect.map(Option.some),
Effect.catchTag("UserNotFoundError", () => Effect.succeed(Option.none()))
)- Use
Effect.failfor expected business/domain failures. - Reserve
Effect.die/Effect.orDiefor invariant violations and impossible states. - Do not model normal user-facing errors as defects.
- Reference: die and orDie.
Example:
import { Effect } from "effect"
const validateInput = Effect.fn("Input.validate")(function* (value: string) {
if (value.length === 0) {
return yield* Effect.fail(new ValidationError({ message: "value must be non-empty" }))
}
if (value === "__unreachable__") {
return yield* Effect.die("unreachable state")
}
return value
})- Understand that layer provisioning is shared by default.
- When isolation is required, use
Effect.provide(..., { local: true })orLayer.fresh. - Document why isolation is necessary for behavior-sensitive paths.
- Reference: Effect.provide local option and Layer.fresh.
Example:
import { Effect, Layer } from "effect"
const runIsolated = program.pipe(
Effect.provide(Layer.fresh(AppLayer), { local: true })
)- If a domain data model can be expressed as
Schema, define the schema first. - Prefer
Schema.Class(or another schema constructor) over plaintype/interfacefor property-based domain shapes. - Derive runtime types from schema definitions instead of duplicating parallel
type/interfacemodels. - Keep plain
type/interfacefor cases schema cannot represent cleanly (complex type-level transforms, utility types, overload-only surfaces).
Example:
import * as Schema from "effect/Schema"
// Prefer schema-first over plain interfaces for domain payloads.
export class CreateOrderInput extends Schema.Class<CreateOrderInput>("CreateOrderInput")(
{
orderId: Schema.String,
customerId: Schema.String
},
{ description: "Input payload for creating an order." }
) {}- Put defaults in schema definitions, not in handler/service fallback object literals.
- Use
Schema.withConstructorDefaultfor constructor-time defaults. - Use
Schema.withDecodingDefault/Schema.withDecodingDefaultKeyfor decode-time defaults.
Example:
import * as Option from "effect/Option"
import * as Schema from "effect/Schema"
export class VersionSyncOptions extends Schema.Class<VersionSyncOptions>("VersionSyncOptions")(
{
shouldCheck: Schema.Boolean.pipe(
Schema.withDecodingDefault(() => true),
Schema.withConstructorDefault(() => Option.some(true))
),
categories: Schema.Array(Schema.String).pipe(
Schema.withDecodingDefault(() => []),
Schema.withConstructorDefault(() => Option.some([]))
)
},
{ description: "Version sync options with schema-level defaults." }
) {}- If a guard validates domain strings/paths/tags, define a branded schema and use
Schema.is(...). - If a domain constraint is named, reused, matched on, or structurally validated, model it as a schema first rather than a forest of ad-hoc predicate helpers.
- Prefer built-in schema constructors/checks before
Schema.makeFilter. - Keep guard intent and reusable check intent in schema annotations and check metadata.
- For internal literal domains, use
Schema.is(Schema.Literal(...))for type guards,Matchfor exhaustive matching, andSchema.Literal(...).annotate({...})for annotated schema values. - Prefer named intermediate schemas; export them only when reusable or when they materially clarify the module's domain model.
Example:
import { Match, pipe } from "effect"
import * as Arr from "effect/Array"
import * as P from "effect/Predicate"
import * as Schema from "effect/Schema"
import * as Str from "effect/String"
type TopicKind = "plain" | "scoped"
const ContainsScopeSeparator = Schema.String.check(
Schema.isIncludes(":", {
identifier: "ContainsScopeSeparatorCheck",
title: "Contains Scope Separator",
description: "A string that contains `:`.",
message: "Topic text must contain :"
})
).pipe(
Schema.brand("ContainsScopeSeparator"),
Schema.annotate({
title: "ContainsScopeSeparator",
description: "A string that contains the topic scope separator `:`."
})
)
const isContainsScopeSeparator = Schema.is(ContainsScopeSeparator)
const TopicSegment = Schema.NonEmptyString.check(
Schema.makeFilter(P.not(isContainsScopeSeparator), {
identifier: "TopicSegmentNoSeparatorCheck",
title: "Topic Segment No Separator",
description: "A topic segment that does not contain `:`.",
message: "Topic segments must not contain :"
})
).pipe(
Schema.brand("TopicSegment"),
Schema.annotate({
title: "TopicSegment",
description: "A non-empty topic segment without the scope separator."
})
)
const isTopicSegment = Schema.is(TopicSegment)
const splitNonEmpty =
(separator: string | RegExp) =>
(value: string): ReadonlyArray<string> =>
pipe(Str.split(separator)(value), Arr.filter(Str.isNonEmpty))
const classifyTopicKind = Match.type<string>().pipe(
Match.when(isContainsScopeSeparator, () => "scoped" as const),
Match.orElse(() => "plain" as const)
)
const validateTopicSegments = (kind: TopicKind, value: string) =>
Match.value(kind).pipe(
Match.when("plain", () => isTopicSegment(value)),
Match.when("scoped", () => pipe(value, splitNonEmpty(":"), Arr.every(isTopicSegment))),
Match.exhaustive
)
export const TopicName = Schema.NonEmptyString.check(
Schema.makeFilterGroup(
[
Schema.makeFilter(P.not(Str.endsWith(":")), {
identifier: "TopicNameNoTrailingSeparatorCheck",
title: "Topic Name No Trailing Separator",
description: "A topic name that does not end with `:`.",
message: "Topic names must not end with :"
}),
Schema.makeFilter((value: string) =>
validateTopicSegments(classifyTopicKind(value), value), {
identifier: "TopicNameSegmentsCheck",
title: "Topic Name Segments",
description: "A topic name whose segments are valid topic segments.",
message: "Topic names must contain only valid segments"
})
],
{
identifier: "TopicNameChecks",
title: "Topic Name",
description: "Checks for a plain or scoped topic name."
}
)
).pipe(
Schema.brand("TopicName"),
Schema.annotate({
title: "TopicName",
description: "A topic name composed from valid plain or scoped segments."
})
)Avoid this:
- A forest of
const hasX = ...,const isY = /.../.test(...), and unannotated predicate helpers when the named concepts can be expressed as schemas and reused withSchema.is(...).
- For schema-modeled domain values, use
Schema.toEquivalence(schema)instead of manual===/!==. - This keeps comparison semantics aligned with schema intent and future schema changes.
Example:
import * as Schema from "effect/Schema"
const stringArrayEq = Schema.toEquivalence(Schema.Array(Schema.String))
const arraysEqual = (left: ReadonlyArray<string>, right: ReadonlyArray<string>) =>
stringArrayEq(left, right)- If conversion is deterministic and type-shaping (path normalization, filename conversion, tagged-string normalization), model it with
Schema.decodeTo(..., SchemaTransformation.transform(...)). - Prefer schema transformation helpers over ad-hoc conversion functions.
Example:
import { SchemaTransformation } from "effect"
import * as Schema from "effect/Schema"
import * as Str from "effect/String"
const NativePathToPosixPath = Schema.String.pipe(
Schema.decodeTo(
Schema.String.check(Schema.isPattern(/^[^\\]*$/)).pipe(Schema.brand("PosixPath")),
SchemaTransformation.transform({
decode: (pathString) => Str.replaceAll("\\", "/")(pathString),
encode: (pathString) => pathString
})
)
)- Use
Arr.sort(values, order)fromeffect/Array. - Define ordering with
effect/Order(Order.String,Order.Number,Order.mapInput, etc.). - Do not call native
.sort()directly on arrays.
Example:
import { Order } from "effect"
import * as Arr from "effect/Array"
const byName = Order.mapInput(Order.String, (item: { readonly name: string }) => item.name)
const sorted = Arr.sort(items, byName)- When unknown/scalar data must normalize to domain strings, model the conversion with schema transformations.
- Compare resulting values with
Schema.toEquivalence(Schema.String)(or domain schema equivalence), not raw string equality.
Example:
import { SchemaTransformation } from "effect"
import * as Schema from "effect/Schema"
const UnknownToString = Schema.Unknown.pipe(
Schema.decodeTo(
Schema.String,
SchemaTransformation.transform({
decode: (value) => `${value}`,
encode: (value) => value
})
)
)import * as Schema from "effect/Schema"
class DomainError extends Schema.TaggedErrorClass<DomainError>("DomainError")(
"DomainError",
{
message: Schema.String
},
{ description: "Domain failure" }
) {}import { pipe } from "effect"
import * as Option from "effect/Option"
const fromNullableName = (name: string | null | undefined) =>
pipe(
Option.fromNullishOr(name),
Option.filter((value) => value.length > 0)
)import * as Schema from "effect/Schema"
export class Payload extends Schema.Class<Payload>("Payload")({
query: Schema.String
}) {}
const decodePayload = Schema.decodeUnknownEffect(Payload)import * as Schema from "effect/Schema"
export const OrderId = Schema.String
export type OrderId = typeof OrderId.Typeimport * as Schema from "effect/Schema"
export class UserProfile extends Schema.Class<UserProfile>("UserProfile")(
{
id: Schema.String,
displayName: Schema.String
},
{ description: "User profile model used in domain workflows." }
) {}import { Match } from "effect"
import * as Arr from "effect/Array"
type Phase = "draft" | "running" | "done"
const phaseLabel = (phase: Phase) =>
Match.value(phase).pipe(
Match.when("draft", () => "draft"),
Match.when("running", () => "running"),
Match.when("done", () => "done"),
Match.exhaustive
)
const summarize = (items: ReadonlyArray<string>) =>
Arr.match(items, {
onEmpty: () => "none",
onNonEmpty: (values) => `count:${Arr.length(values)}`
})import { Effect } from "effect"
export const runTask = Effect.fn("Task.run")(function* (taskId: string) {
yield* Effect.logInfo("run task", taskId)
return taskId
})import * as Schema from "effect/Schema"
export class Input extends Schema.Class<Input>("Input")({
maybeName: Schema.OptionFromNullishOr(Schema.String),
maybeEmail: Schema.OptionFromOptionalKey(Schema.String)
}) {}import { dual } from "effect/Function"
export const rename: {
(to: string): (self: { readonly name: string }) => { readonly name: string }
(self: { readonly name: string }, to: string): { readonly name: string }
} = dual(2, (self, to) => ({ ...self, name: to }))import * as Schema from "effect/Schema"
export class Payload extends Schema.Class<Payload>("Payload")({
query: Schema.String
}) {}
const PayloadJson = Schema.fromJsonString(Payload)
export const decodePayloadJson = Schema.decodeUnknownEffect(PayloadJson)
export const encodePayloadJson = Schema.encodeUnknownEffect(PayloadJson)import { Effect } from "effect"
export const buildReport = Effect.fn("Report.build")(function* () {
return "ok"
})
// runtime boundary only
// Effect.runPromise(buildReport())import { Effect } from "effect"
export const withResource = <A, E, R>(
use: (resource: Resource) => Effect.Effect<A, E, R>
) =>
Effect.acquireUseRelease(
acquireResource,
use,
releaseResource
)import { Duration, Effect, Schedule } from "effect"
export const resilientTask = task.pipe(
Effect.retry(Schedule.recurs(3)),
Effect.timeoutOption(Duration.seconds(5))
)import { Config, Effect } from "effect"
export const loadConfig = Effect.fn("Config.load")(function* () {
const port = yield* Config.int("PORT")
const apiKey = yield* Config.redacted("API_KEY")
return { port, apiKey }
})import { Effect, Layer } from "effect"
export const runIsolated = program.pipe(
Effect.provide(Layer.fresh(AppLayer), { local: true })
)Use this before submitting code:
- No
any, no type assertions, no@ts-ignore, no non-null assertions. - No untyped error throwing in domain logic.
- Nullish converted to
Optionat boundaries. - Unknown input decoded with
Schema. - Canonical namespace imports (
Option,Schema,Arr,P,R, etc.) present and used. - No native
Object/Map/Set/Date/Stringhelpers in domain logic. - Branching logic is exhaustive where appropriate (
Match.exhaustive, schema.match, andArr.matchfor array emptiness). - No new schema constants end with
Schema. - For non-class schemas, new schema constants expose
export type X = typeof X.Type. - New schemas are annotated via
.annotate({...})or the annotations parameter. Effect-returning reusable functions are created withEffect.fn/Effect.fnUntraced.- Critical flows include logs/spans/metrics instrumentation.
- Durations/time windows use
Durationvalues. - Nullish schema fields use
Schema.OptionFrom*helpers when representing absence asOption. - Exported helper combinators support dual API via
dual. - No
JSON.parse/JSON.stringifyin Effect-first domain paths. - Prefer
Schema.ClassoverSchema.Structfor new object schemas. - Required verification commands are green.
Effect.run*appears only in runtime boundaries (entrypoint/test harness).- Promise-based APIs are lifted with
Effect.tryPromise. - Acquired resources use
Effect.acquireUseReleaseorEffect.scoped. - Retries are declared with
Effect.retry+Schedule. - Timeouts use
Effect.timeoutOption/Effect.timeoutOrElse. - Forking intent is explicit (
forkChilddefault;forkDetachjustified). - Large fan-out operations specify concurrency deliberately.
- Config values come from
Config/ConfigProvider, not directprocess.envin domain logic. - Secrets are
Redacted(Config.redacted/Redacted.make) and not logged raw. - Recovery uses
catchTag/catchFilterfor targeted cases. - Expected failures use
Effect.fail; defects are reserved for invariants. - Isolation-sensitive layer provisioning uses
{ local: true }orLayer.fresh. - New domain data models are schema-first; plain
type/interfaceis used only when schema is not a practical fit. - Literal-string discriminant unions use
Schema.Union+Schema.toTaggedUnion. For exhaustive matching over literals, useMatch. For type guards, useSchema.is(Schema.Literal(...)). - Schema defaults use
Schema.withConstructorDefault/Schema.withDecodingDefault*, not ad-hoc fallback objects in handlers/services. - Named or reused domain constraints are modeled as schemas first; built-in schema constructors/checks are preferred before
Schema.makeFilter. - Guard helpers for domain strings/paths/tags come from branded schemas with
Schema.is(...), not ad-hocregex.test(...)predicates. - Reusable schema checks and filter groups carry
identifier,title, anddescription. - Intermediate schemas are exported only when reusable or materially clarifying; otherwise they stay module-local.
- Schema-modeled comparisons use
Schema.toEquivalence(...)where practical. - Deterministic format conversions use
Schema.decodeTo(..., SchemaTransformation.transform(...)). - Trivial helper wrapper lambdas are collapsed to direct helper refs where safe, and passthrough
pipe(...)callbacks are expressed withflow(...). - Runtime source avoids
node:fs/node:path/node:child_process; use EffectFileSystem/Path/ process services. - Runtime source avoids native
fetch; HTTP boundaries useeffect/unstable/http+ platform layers (BunHttpClient.layer, etc.). - Runtime sorting uses
Arr.sortwith explicitOrder, not nativeArray.prototype.sort. - Boolean branching prefers
Bool.matchover ad-hocif/elsewhen branching on booleans. - HTTP request/response composition uses Effect HTTP modules (
HttpClientRequest,HttpClientResponse,Headers,UrlParams,HttpMethod,HttpBody).