Effektive

Plain code.Typed effects.

Effektive is a small language designed to transpile to Effect-TS. You write direct code, and each contract becomes an Effect.Effect<A, E, R> type.

Read a contract.

Selected contract View Users source
export const getUser = (id: UserId): User
  fails UserMissing | StoreUnavailable
  uses Users.find =>
  match Users.find(id) {
    Some(user) => user,
    None => fail(UserMissing({ id }))
  }
const getUser = (id: UserId): Effect.Effect<
  User,
  UserMissing | StoreUnavailable,
  Users
> =>
  Effect.flatMap(
    Users.use((users) => users.find(id)),
    (found) => Option.match(found, {
      onNone: () => Effect.fail(new UserMissing({ id })),
      onSome: (user) => Effect.succeed(user)
    })
  )
Returns
User
Fails with
UserMissing | StoreUnavailable
Uses
Users.find

The mini book.

Chapters

What Effektive is

Effektive is a small functional language designed to transpile to Effect-TS.

Effektive combines immutable data, arrow functions, pattern matching, and typed service operations. A program reads like direct TypeScript, while each function contract records expected failures and required capabilities.

The transpiler turns each contract into an Effect-TS type. A function that returns A, fails with E, and uses R becomes Effect.Effect<A, E, R>.

Services become Context services, and providers become Layer values. Records, nominal types, and errors become Schema declarations. Options become Option values.

The transpiler targets the effect core modules and selected modules from the Effect ecosystem. The table names each module and its role.

Effect-TS moduleRole in transpiled code
effect: Effect, Context, Layer, Option, SchemaComputations, services, providers, options, records, nominal types, and errors. The examples use these modules now.
effect/http, FileSystem, and @effect/platform-node, -bun, or -browserHTTP and file services, and the runtime for each host.
effect/sql and a driver such as @effect/sql-pgDatabase services.
@effect/opentelemetryExport of traces, metrics, and logs.

Version 0 specifies the core mapping. Bindings for the HTTP, file, SQL, and telemetry modules need later specifications. A typed host module will expose them as Effektive services.

Version 0 has a Deno transpiler and CLI in the effektive-lang repository. The Effect-TS examples in this book are manually written for Effect 4.0.5, the Effect library for TypeScript. This site does not run Effektive code.

The transpiler boundary is a typed intermediate representation, or IR. Each expression records its type, execution mode, failures, requirements, and source position. The Effect-TS backend translates checked IR into TypeScript.

Implemented core 0Core 1 design or later
Immutable records, arrays, options, and nominal typesMacros and recursive schemas
Functions, pipelines, match, and typed catchUser generics and provider construction
Services, retry, providers, and modulesArbitrary JavaScript and Promise interoperation
Scoped operation records and bounded traversalGeneral resource scopes and fiber APIs
Incremental streams and sinksBuffered streams

Effektive separates domain failures from defects and cancellation. A missing user can be data. Invalid safe-integer arithmetic is a defect. A typed catch handles only declared domain failures.

Three facts describe each function call. The result type describes normal completion. The failure set names expected errors. The requirement set names service methods.

These facts travel through composition. A caller must account for expected failures and required capabilities. Execution mode separately records whether invocation performs effects.

This book first explains values and contracts. It then develops one Users service across lookup, update, recovery, retry, and provision. A payment pipeline joins the concepts near the end.

Values and types

Every Effektive binding and aggregate value is immutable.

This chapter uses a separate example module. Chapter 4 starts the shared Users domain with UserId, an alias of string.

A const binding cannot change. A record update creates a new record and preserves the old value. Effektive has no assignment, class inheritance, or implicit receiver.

Immutable values
nominal UserId = string
type Label = string

type User = {
  id: UserId,
  name: string,
  active: boolean,
  nickname?: string
}

const activate = (user: User): User =>
  ({ ...user, active: true })

const displayId = (user: User): Label =>
  user.id
import { Schema } from "effect"

const UserId = Schema.String.pipe(Schema.brand("UserId"))
type UserId = typeof UserId.Type

type Label = string

const User = Schema.Struct({
  id: UserId,
  name: Schema.String,
  active: Schema.Boolean,
  nickname: Schema.optionalKey(Schema.String)
})
type User = typeof User.Type

const activate = (user: User): User =>
  ({ ...user, active: true })

const displayId = (user: User): Label =>
  user.id

A nominal declaration gives identity to a base value. UserId("u1") creates the value, but it performs no validation. A UserId fits where a string is expected, as in displayId, so the function needs no conversion call.

Ordinary type aliases use structural equality. Two records with equal fields are compatible. Nominal types and errors use declaration identity, which prevents accidental substitution.

A signature names declared types. A primitive type such as string or int can occur only inside a type, nominal, or error declaration. So this module declares type Label = string and uses Label in signatures.

A nominal declaration with a record body, such as nominal Account = { name: Name }, declares a nominal record with a runtime tag. Any other body, such as string, declares a nominal type with no tag. Two values of one numeric nominal type accept comparisons, addition, and subtraction.

An optional field read returns Option<T>. Some(value) represents presence, and None represents absence. This rule prevents an absent field from becoming an unchecked undefined value.

Option matching
const nicknameLabel = (user: User): Label =>
  match user.nickname {
    Some(nickname) => nickname,
    None => user.name
  }
import { Option } from "effect"

const nicknameLabel = (user: User): Label =>
  Option.match(Option.fromUndefinedOr(user.nickname), {
    onNone: () => user.name,
    onSome: (nickname) => nickname
  })

Match expressions require complete coverage. Record unions need a final wildcard if a tag is missing, optional, or open-ended. A tag arm retains every member that can match at runtime. The pattern matching reference shows an overlapping-tag example. Every arm produces a compatible result type. A never branch, such as fail(error), fits any result type.

The int type contains finite safe integers. Addition, subtraction, and multiplication can defect when the result is invalid. Division always returns number.

Records reject missing required fields and unknown fields. A record update evaluates its base once, then evaluates update fields from left to right.

Function contracts

A function contract states its success type, expected failures, and required service methods.

The examples in this chapter use the User, StoreUnavailable, and Users declarations from chapter 4. Chapter 4 defines each shared declaration.

Pure and effectful contracts
type Count = int

const add = (a: Count, b: Count): Count => a + b

export const load = (id: UserId): Option<User>
  fails StoreUnavailable
  uses Users.find => Users.find(id)
type Count = number

const add = (a: Count, b: Count): Count => a + b

export const load = (id: UserId): Effect.Effect<
  Option.Option<User>,
  StoreUnavailable,
  Users
> => Users.use((users) => users.find(id))

The result type remains Option<User>. A service call performs its operation when the running function reaches the call. The source type never becomes Effect<Option<User>, E, R>.

A declared failure is an expected outcome. A required capability records a call to one service method. A service name in uses expands to every method in that service.

Calls propagate the declared contract of the callee. The declared sets can exceed the body sets, but they cannot omit a body effect.

An exported function closes its contract. An omitted fails clause means no expected failures. An omitted uses clause means no service requirements. The body cannot exceed either set.

A local function can omit its result annotation, its fails clause, and its uses clause. The compiler infers each omitted part from the body. A written clause closes its own set, but a result annotation alone does not. Exported functions require parameter and result annotations. Parameters also require annotations without a contextual function type.

Execution mode is separate from failure and requirement sets. A provider can remove every requirement while the computation remains effectful. Empty sets can therefore describe either execution mode.

Creating an arrow function is pure. Calling that function runs its body each time. Unbodied source function types default to effect execution in version 0.

Nonempty failure or requirement sets imply effect execution. Empty sets do not imply purity. The transpiler must preserve the independently inferred execution mode.

Final-expression blocks
const activateAndCount = (user: User): Count => {
  const active = ({ ...user, active: true })
  if (active.active) { 1 } else { 0 }
}
const activateAndCount = (user: User): Count => {
  const active = { ...user, active: true }
  return active.active ? 1 : 0
}

A block returns its final expression. Effektive source has no return statement. Ordered expressions run from left to right, and only the selected branch runs.

One domain, two languages

The Users domain provides the shared declarations for the next four chapters.

Effektive service declarations contain method signatures. Service methods have no this binding. Each method remains a distinct capability after name resolution.

Shared Users declarations
type UserId = string
type Name = string

type User = {
  id: string,
  name: string,
  active: boolean
}

error UserMissing = { id: string }
error StoreUnavailable = { message: string }

service Users {
  find(id: UserId): Option<User> fails StoreUnavailable
  save(user: User): void fails StoreUnavailable
  all(): User[] fails StoreUnavailable
}
import { Context, Effect, Option, Schema } from "effect"

type UserId = string
type Name = string

const User = Schema.Struct({
  id: Schema.String,
  name: Schema.String,
  active: Schema.Boolean
})
type User = typeof User.Type

class UserMissing extends Schema.TaggedError<UserMissing>()(
  "UserMissing",
  { id: Schema.String }
) {}

class StoreUnavailable extends Schema.TaggedError<StoreUnavailable>()(
  "StoreUnavailable",
  { message: Schema.String }
) {}

class Users extends Context.Service<Users, {
  readonly find: (id: UserId) => Effect.Effect<
    Option.Option<User>,
    StoreUnavailable
  >
  readonly save: (user: User) => Effect.Effect<
    void,
    StoreUnavailable
  >
  readonly all: () => Effect.Effect<
    ReadonlyArray<User>,
    StoreUnavailable
  >
}>()("Users") {}

A service method lists only failures from that operation. Method requirement sets are empty. A host provider can have separate acquisition failures and dependencies.

Users.find, Users.save, and Users.all are separate capabilities. A contract can name one method without declaring the other methods. Static tracking does not sandbox host adapters.

The Effect-TS tab represents successful values inside Effect.Effect. Its error parameter contains tagged errors. Its requirement parameter contains the Users service when a function accesses that context.

Later TypeScript examples assume these imports, schemas, errors, and the Users class. Later Effektive examples assume the declarations above. Each Effect-TS tab shows intended translation, not generated output.

Effect-TS tracks the whole Users service in its requirement type. Effektive retains each method requirement in checked IR or backend metadata.

These isolated translations use short error tags and service keys. A transpiler backend must encode resolved identities in tags, keys, and brand tokens across modules.

Lookup and update

Small functions keep service operations, domain decisions, and record changes separate.

The first function converts None into a named domain failure. The second function changes a value. The third returns an already active user or activates and saves an inactive one.

Lookup using chapter 4 declarations
const getUser = (id: UserId) =>
  match Users.find(id) {
    Some(user) => user,
    None => fail(UserMissing({ id }))
  }
const getUser = (id: UserId): Effect.Effect<
  User,
  UserMissing | StoreUnavailable,
  Users
> =>
  Effect.flatMap(
    Users.use((users) => users.find(id)),
    (found) => Option.match(found, {
      onNone: () => Effect.fail(new UserMissing({ id })),
      onSome: (user) => Effect.succeed(user)
    })
  )

getUser is not exported, so the compiler infers its failures and requirements from the body: UserMissing, StoreUnavailable, and Users.find. fail(error) ends the normal path and contributes the error identity. Errors are nominal, so declarations with equal fields remain different error types.

The match reads the option once and selects one arm. The None arm constructs UserMissing with the requested identifier. StoreUnavailable remains unchanged.

Activate and save: direct calls and Effect sequencing View complete Effektive source
const activate = (user: User) =>
  ({ ...user, active: true })

export const saveActive = (id: UserId): User
  fails UserMissing | StoreUnavailable
  uses Users.find | Users.save => {
  const user = getUser(id)
  if (user.active) { user } else {
    const active = activate(user)
    Users.save(active)
    active
  }
}
const activate = (user: User): User =>
  ({ ...user, active: true })

export const saveActive = (id: UserId): Effect.Effect<
  User,
  UserMissing | StoreUnavailable,
  Users
> => Effect.gen(function* () {
  const user = yield* getUser(id)
  if (user.active) return user
  const active = activate(user)
  yield* Users.use((users) => users.save(active))
  return active
})

This pair uses 248 non-whitespace characters in Effektive and 304 in Effect-TS, including the pure helper and explicit exported contracts. Shared chapter 4 declarations are excluded from both counts. Effect-TS spells out the Effect.Effect result type, generator, yield*, service-context access and return. Effektive keeps direct calls and a final expression.

Effektive uses one direct call syntax for pure functions and service methods. The checked call still distinguishes an ordinary call from a service operation in typed IR.

Both versions read once and skip the write when the user is already active. Otherwise they activate and save the user. A failure stops the remaining steps; success returns the user.

Pipelines and collections

A pipeline passes its left value as the first argument of each stage.

Pipeline expansion
value |> transform          means transform(value)
value |> transform(a, b)    means transform(value, a, b)

The runtime evaluates the left side once. It then evaluates the stage callee and explicit arguments. Pipelines associate from left to right.

A stage can name a local function, imported function, builtin, or declared service method. Version 0 excludes placeholders, currying, inline lambda stages, and record-member stages.

Collection workflow using chapter 4 declarations View complete Effektive source
const activeNames = (users: User[]) =>
  users
    |> filter((user) => user.active)
    |> map((user) => user.name)

export const listActiveNames = (): Name[]
  fails StoreUnavailable
  uses Users.all =>
  Users.all() |> activeNames
const activeNames = (
  users: ReadonlyArray<User>
): ReadonlyArray<Name> =>
  users.filter((user) => user.active).map((user) => user.name)

export const listActiveNames = (): Effect.Effect<
  ReadonlyArray<Name>,
  StoreUnavailable,
  Users
> => Effect.map(
  Users.use((users) => users.all()),
  activeNames
)

The pure helper uses collection operations in both languages. The effectful wrapper uses 96 non-whitespace characters in Effektive and 144 in Effect-TS: a direct pipeline compared with Effect.map and service-context access. Including the helper and explicit exported contracts, the pair uses 187 and 266 characters respectively. Shared chapter 4 declarations are excluded from both counts.

The prelude provides map, filter, and fold. Each builtin traverses from left to right. Map and filter preserve order, while fold carries one accumulator.

Map and filter call their callback once for each visited element. Empty inputs produce empty outputs. Fold returns its initial accumulator for an empty input.

Each callback must be pure and have empty failure and requirement sets. Contextual inline arrows work because the builtin supplies their parameter types. Known pure functions also work.

A callback that provides its own service can still execute effects. Empty failures and requirements do not make that callback pure. Execution mode closes this loophole.

The checker specializes each builtin use to a closed function type before IR serialization. Version 0 does not expose the builtin type variables to users.

Fold from left to right
type Amount = int

export const total = (amounts: Amount[]): Amount =>
  amounts |> fold(0, (sum, amount) => sum + amount)
type Amount = number

export const total = (amounts: ReadonlyArray<Amount>): Amount =>
  amounts.reduce((sum, amount) => sum + amount, 0)

Recovery and provision

Effektive controls an operation through explicit syntax and deferred zero-argument functions.

A typed catch handles matching failures from its protected block. Unhandled failures continue to the caller. Catch does not intercept defects or cancellation.

Recovery using chapters 4 and 5 declarations View complete Effektive source
type Label = string

export const activationLabel = (id: UserId): Label
  fails StoreUnavailable
  uses Users.find | Users.save =>
  try {
    saveActive(id)
    "active"
  } catch {
    UserMissing(error) => "missing: " + error.id
  }
type Label = string

export const activationLabel = (id: UserId): Effect.Effect<
  Label,
  StoreUnavailable,
  Users
> => Effect.as(saveActive(id), "active").pipe(
  Effect.catchTag(
    "UserMissing",
    (error) => Effect.succeed("missing: " + error.id)
  )
)

This recovery pair uses 181 non-whitespace characters in Effektive and 216 in Effect-TS, including the Label alias and explicit exported contracts. Shared declarations and saveActive are excluded from both counts. The Effect-TS version uses Effect.as to replace a successful result and Effect.catchTag to recover a missing user. Effektive writes the sequence and typed catch directly; both versions let storage failures continue to the caller.

retry requires a deferred operation. The function below runs its first attempt and allows two more attempts. Each attempt can perform the Users.find operation again.

Retry using chapter 4 declarations View complete Effektive source
export const lookupWithRetry = (id: UserId): Option<User>
  fails StoreUnavailable
  uses Users.find =>
  retry(() => Users.find(id), { times: 2 })
export const lookupWithRetry = (id: UserId): Effect.Effect<
  Option.Option<User>,
  StoreUnavailable,
  Users
> => Effect.retry(
  Users.use((users) => users.find(id)),
  { times: 2 }
)

This retry pair uses 123 non-whitespace characters in Effektive and 156 in Effect-TS, including explicit exported contracts and excluding shared chapter 4 declarations. Effektive passes a deferred direct call. Effect-TS retries the cold effect returned by Users.use; it needs no extra Effect.suspend here. Both allow one initial attempt and two retries.

In Effektive, passing Users.find(id) directly would run the call before retry took control. Effektive never inserts suspension because a parameter expects a deferred function.

Retry preserves the callback result, failures, and requirements. Retry itself has effect execution mode. Its times value must be a finite, nonnegative safe integer literal in version 0.

Provider execution
import { UsersLive } from "host:providers"

export const runLookup = (id: UserId): Option<User>
  fails StoreUnavailable =>
  provide(UsersLive, () => lookupWithRetry(id))
import { UsersLive } from "./users-live.js"

const runLookup = (id: UserId): Effect.Effect<
  Option.Option<User>,
  StoreUnavailable
> => Effect.provide(
  Effect.suspend(() => lookupWithRetry(id)),
  UsersLive
)

The host maps its provider registration to users-live.js in the Effect-TS tab. Effektive source keeps the registered host:providers import.

These provider examples assume UsersLive is a Layer<Users> with infallible acquisition and no dependencies. A fallible provider adds its acquisition failures and requirements to the result.

Providing Users removes the Users requirement. The operation still uses effect execution mode. Version 0 receives provider values from typed host modules and defines no layer construction syntax.

A payment pipeline

The payment workflow composes effectful and pure stages through one data-first pipeline.

Payment module
type Cents = int
type ChargeId = string

nominal OrderId = string
type Order = {
  id: OrderId,
  totalCents: int,
  status: "pending" | "paid"
}

error OrderNotFound = { id: OrderId }
error PaymentDeclined = { reason: string }

service Orders {
  find(id: OrderId): Option<Order>
  save(order: Order): void
}

service Payments {
  charge(amount: Cents): ChargeId fails PaymentDeclined
}

const findOrder = (id: OrderId) =>
  Orders.find(id) ?? fail(OrderNotFound({ id }))

const chargeOrder = (order: Order) => {
  retry(() => Payments.charge(order.totalCents), { times: 2 })
  order
}

const markPaid = (order: Order) =>
  ({ ...order, status: "paid" })

const saveOrder = (order: Order) => {
  Orders.save(order)
  order
}

export const pay = (id: OrderId): Order
  fails OrderNotFound | PaymentDeclined
  uses Orders | Payments.charge =>
  id
    |> findOrder
    |> chargeOrder
    |> markPaid
    |> saveOrder
import { Context, Effect, Option, Schema } from "effect"

type Cents = number
type ChargeId = string

const OrderId = Schema.String.pipe(Schema.brand("OrderId"))
type OrderId = typeof OrderId.Type

const Int = Schema.Finite.check(
  Schema.makeFilter(
    (value) => Number.isSafeInteger(value) || "expected a safe integer"
  )
)

const Order = Schema.Struct({
  id: OrderId,
  totalCents: Int,
  status: Schema.Literals(["pending", "paid"])
})
type Order = typeof Order.Type

class OrderNotFound extends Schema.TaggedError<OrderNotFound>()(
  "OrderNotFound",
  { id: OrderId }
) {}

class PaymentDeclined extends Schema.TaggedError<PaymentDeclined>()(
  "PaymentDeclined",
  { reason: Schema.String }
) {}

class Orders extends Context.Service<Orders, {
  readonly find: (id: OrderId) => Effect.Effect<Option.Option<Order>>
  readonly save: (order: Order) => Effect.Effect<void>
}>()("Orders") {}

class Payments extends Context.Service<Payments, {
  readonly charge: (amount: Cents) => Effect.Effect<
    ChargeId,
    PaymentDeclined
  >
}>()("Payments") {}

const findOrder = (id: OrderId): Effect.Effect<
  Order,
  OrderNotFound,
  Orders
> => Effect.flatMap(
  Orders.use((orders) => orders.find(id)),
  (found) => Option.match(found, {
    onNone: () => Effect.fail(new OrderNotFound({ id })),
    onSome: (order) => Effect.succeed(order)
  })
)

const chargeOrder = (order: Order): Effect.Effect<
  Order,
  PaymentDeclined,
  Payments
> => Effect.map(
  Effect.retry(
    Effect.suspend(() =>
      Payments.use((payments) => payments.charge(order.totalCents))
    ),
    { times: 2 }
  ),
  () => order
)

const markPaid = (order: Order): Order =>
  ({ ...order, status: "paid" })

const saveOrder = (order: Order): Effect.Effect<Order, never, Orders> =>
  Effect.map(
    Orders.use((orders) => orders.save(order)),
    () => order
  )

const pay = (id: OrderId): Effect.Effect<
  Order,
  OrderNotFound | PaymentDeclined,
  Orders | Payments
> => Effect.gen(function* () {
  const order = yield* findOrder(id)
  const charged = yield* chargeOrder(order)
  const paid = markPaid(charged)
  return yield* saveOrder(paid)
})

The pipeline stops at the first expected failure. chargeOrder retries only the charge call. saveOrder cannot run after a missing order or declined payment.

The uses Orders clause expands to Orders.find and Orders.save. Payments.charge stays method-specific. The resulting contract exposes only the needed service surface.

The charge service can repeat an external action. The application author must decide whether the provider makes repeated charges safe, such as through idempotency.

The Effect-TS tab uses a schema to check safe integers at a boundary. Effektive separately requires its checker and runtime to preserve int arithmetic rules.

The Effect-TS tab uses Effect.gen for ordered operations. Effektive keeps the same order through final-expression blocks and pipeline stages. The source contract presents plain Order as its success type.

Nominal types

A nominal declaration gives a type its own identity, so two types with the same body stay different types.

Order states as nominal records
nominal OrderId = string
nominal Cents = int
type Text = string

type Receipt = { number: Text }
type Tracking = { code: Text }
type Reason = { note: Text }

nominal Pending = { id: OrderId, total: Cents }
nominal Paid = { id: OrderId, total: Cents, receipt: Receipt }
nominal Shipped = { id: OrderId, receipt: Receipt, tracking: Tracking }
nominal Cancelled = { id: OrderId, reason: Reason }
type Order = Pending | Paid | Shipped | Cancelled

const open = (id: OrderId, total: Cents): Pending =>
  Pending({ id, total })

const reprice = (order: Pending, total: Cents): Pending =>
  ({ ...order, total })
import { Schema } from "effect"

const Int = Schema.Finite.check(
  Schema.makeFilter(
    (value) => Number.isSafeInteger(value) || "expected a safe integer"
  )
)

const OrderId = Schema.String.pipe(Schema.brand("OrderId"))
type OrderId = typeof OrderId.Type

const Cents = Int.pipe(Schema.brand("Cents"))
type Cents = typeof Cents.Type

type Text = string

const Receipt = Schema.Struct({ number: Schema.String })
type Receipt = typeof Receipt.Type

const Tracking = Schema.Struct({ code: Schema.String })
type Tracking = typeof Tracking.Type

const Reason = Schema.Struct({ note: Schema.String })
type Reason = typeof Reason.Type

const Pending = Schema.TaggedStruct("Pending", { id: OrderId, total: Cents })
type Pending = typeof Pending.Type

const Paid = Schema.TaggedStruct(
  "Paid",
  { id: OrderId, total: Cents, receipt: Receipt }
)
type Paid = typeof Paid.Type

const Shipped = Schema.TaggedStruct(
  "Shipped",
  { id: OrderId, receipt: Receipt, tracking: Tracking }
)
type Shipped = typeof Shipped.Type

const Cancelled = Schema.TaggedStruct(
  "Cancelled",
  { id: OrderId, reason: Reason }
)
type Cancelled = typeof Cancelled.Type

const Order = Schema.Union([Pending, Paid, Shipped, Cancelled])
type Order = typeof Order.Type

const open = (id: OrderId, total: Cents): Pending =>
  Pending.make({ id, total })

const reprice = (order: Pending, total: Cents): Pending =>
  ({ ...order, total })

Each nominal declaration with a record body makes a nominal record type with a runtime tag. Pending and Paid share the fields id and total. The compiler still treats them as different types.

A function that takes a Pending value rejects a Paid value, even where the fields overlap. The compiler reports type-mismatch/003. A plain record type has no identity and matches by its fields only.

Pending({ id, total }) builds a value. The spread in reprice keeps the tag and gives a new Pending. A Pending value also fits a plain record type with the same fields, with no conversion call.

JSON has no tag. Decoding therefore starts at a plain record type, such as OrderInput. A function then builds the Pending value with its constructor.

The Effect-TS panels in this chapter use short tags such as "Pending". The compiler writes module-qualified tags such as "contracts#Pending", so equal names in two modules stay apart.

Match by name over Order
const describe = (order: Order): Text =>
  match order {
    Pending(p) => "pending",
    Paid(p) => p.receipt.number,
    Shipped(s) => s.tracking.code,
    Cancelled(c) => c.reason.note
  }
const describe = (order: Order): Text =>
  order._tag === "Pending"
    ? "pending"
    : order._tag === "Paid"
    ? order.receipt.number
    : order._tag === "Shipped"
    ? order.tracking.code
    : order.reason.note

The match has one arm for each state and no wildcard arm. A missing arm is a compile error (pattern/001). Adding a state to Order shows every match that needs a new arm.

The Effect-TS tab ends with an else branch. TypeScript does not report a missing state there. Effektive does.

Recursive pricing rules using Cents from the first figure
type Count = int

nominal Fixed = { amount: Cents }
nominal Times = { factor: Count }
nominal Capped = { rule: Rule, cap: Cents }
nominal Sum = { parts: Rule[] }
type Rule = Fixed | Times | Capped | Sum

const min = (left: Cents, right: Cents) =>
  if (left < right) { left } else { right }

const price = (rule: Rule, base: Cents): Cents =>
  match rule {
    Fixed(f) => f.amount,
    Times(t) => Cents(base * t.factor),
    Capped(c) => min(price(c.rule, base), c.cap),
    Sum(s) => s.parts |> fold(Cents(0), (acc, part) => acc + price(part, base))
  }
import { Schema } from "effect"

const Count = Int
type Count = typeof Count.Type

const Fixed = Schema.TaggedStruct("Fixed", { amount: Cents })
type Fixed = typeof Fixed.Type

const Times = Schema.TaggedStruct("Times", { factor: Count })
type Times = typeof Times.Type

interface Capped {
  readonly _tag: "Capped"
  readonly rule: Rule
  readonly cap: Cents
}
interface CappedEncoded {
  readonly _tag: "Capped"
  readonly rule: RuleEncoded
  readonly cap: number
}

interface Sum {
  readonly _tag: "Sum"
  readonly parts: ReadonlyArray<Rule>
}
interface SumEncoded {
  readonly _tag: "Sum"
  readonly parts: ReadonlyArray<RuleEncoded>
}

type Rule = Fixed | Times | Capped | Sum
type RuleEncoded =
  | Schema.Codec.Encoded<typeof Fixed>
  | Schema.Codec.Encoded<typeof Times>
  | CappedEncoded
  | SumEncoded

const Rule: Schema.Codec<Rule, RuleEncoded> = Schema.Union([
  Fixed,
  Times,
  Schema.suspend((): Schema.Codec<Capped, CappedEncoded> => Capped),
  Schema.suspend((): Schema.Codec<Sum, SumEncoded> => Sum)
])

const Capped = Schema.TaggedStruct("Capped", {
  rule: Schema.suspend((): Schema.Codec<Rule, RuleEncoded> => Rule),
  cap: Cents
})

const Sum = Schema.TaggedStruct("Sum", {
  parts: Schema.Array(
    Schema.suspend((): Schema.Codec<Rule, RuleEncoded> => Rule)
  )
})

const min = (left: Cents, right: Cents): Cents =>
  left < right ? left : right

const price = (rule: Rule, base: Cents): Cents =>
  rule._tag === "Fixed"
    ? rule.amount
    : rule._tag === "Times"
    ? Cents.make(base * rule.factor)
    : rule._tag === "Capped"
    ? min(price(rule.rule, base), rule.cap)
    : rule.parts.reduce<Cents>(
      (acc, part) => Cents.make(acc + price(part, base)),
      Cents.make(0)
    )

A pricing rule can hold other rules. Capped holds one rule, and Sum holds an array of rules. The compiler accepts each cycle because it passes through an array or through a union with members outside the cycle.

The Effect-TS tab spells out the types of the cycle. Each recursive type has an interface and an Encoded mirror interface, and each recursive reference uses Schema.suspend. The form typeof Rule.Type inside a cycle fails the TypeScript check.

The order lifecycle application contains these declarations and more. It has four order states across two modules, match by name, structural decoding, and nine runtime checks.

Core 1 in practice

Scopes supervise concurrent work, schedules shape streams, and Schemas control field presence and service-backed decoding.

These are implemented source forms. The compiler checks the full Effektive example; every Effect-TS tab is checked against Effect 4.0.5.

A scoped child closes with its parent View Core 1 source
type Int = int

export const supervised = (): Int =>
  scoped(defer(() => {
    forkScoped(defer(() => 3))
    3
  }))
import { Effect } from "effect"

const supervised = (): Effect.Effect<number> =>
  Effect.scoped(Effect.gen(function* () {
    yield* Effect.forkScoped(Effect.succeed(3))
    return 3
  }))

The child is attached to the scope. When the scoped job ends, Effect interrupts a child that is still running and waits for its cleanup.

Batch a stream by size or a one-second schedule View Core 1 source
type Int = int

export const timedBatches = (source: Stream<Int>): Stream<Int[]> =>
  aggregateWithinStream(source, takeSink(2), scheduleSpaced(1000))
import { Duration, Schedule, Sink, Stream } from "effect"

const timedBatches = (
  source: Stream.Stream<number>
): Stream.Stream<ReadonlyArray<number>> =>
  Stream.aggregateWithin(
    source,
    Sink.take<number>(2),
    Schedule.spaced(Duration.millis(1000))
  )

The stream stays deferred until consumed. A batch emits when the Sink fills or the schedule fires.

Optional getter and missing-key default with decode services View Core 1 source
type Int = int
type Optional = { score?: Int }
type Required = { score: Int }

service Read { next(value: Int): Int }
service Defaults { make(): Int }

const integer: Schema<Int> = schema()
const optional: SchemaOptional<Int> = schemaOptionalKey(integer)
const shiftedField: SchemaOptional<Int, Int, Read.next> =
  schemaOptionalTransform(
    optional,
    optional,
    (value: Option<Int>): Option<Int> =>
      match value {
        Some(score) => Some(Read.next(score)),
        None => None
      },
    (value: Option<Int>): Option<Int> => value
  )
const shifted: Schema<Optional, Optional, Read.next> =
  schemaStruct({ score: shiftedField })
const defaulted: Schema<Required, Optional, Defaults.make> =
  schemaStruct({
    score: schemaDefaultKey(integer, (): Int => Defaults.make())
  })

export const readShifted = (input: unknown): Optional fails SchemaError uses Read.next =>
  schemaDecode(shifted, input)
export const readDefault = (input: unknown): Required fails SchemaError uses Defaults.make =>
  schemaDecode(defaulted, input)
import { Context, Effect, Option, Schema, SchemaGetter } from "effect"

const Int = Schema.Finite.check(Schema.makeFilter(
  (value) => Number.isSafeInteger(value) || "expected a safe integer"
))
type Int = typeof Int.Type
type Optional = { readonly score?: Int }
type Required = { readonly score: Int }
class Read extends Context.Service<Read, {
  readonly next: (value: Int) => Effect.Effect<Int>
}>()("showcase#Read") {}
class Defaults extends Context.Service<Defaults, {
  readonly make: () => Effect.Effect<Int>
}>()("showcase#Defaults") {}

const optional = Schema.optionalKey(Int)
const shifted = Schema.Struct({
  score: optional.pipe(Schema.decodeTo(optional, {
    decode: SchemaGetter.transformOptionalEffect((value) =>
      Option.isSome(value)
        ? Read.use((service) =>
            Effect.map(service.next(value.value), Option.some))
        : Effect.succeed(Option.none())
    ),
    encode: SchemaGetter.transformOptional((value) => value)
  }))
})
const defaulted = Schema.Struct({
  score: Int.pipe(Schema.withDecodingDefaultTypeKey(
    Effect.suspend(() => Defaults.use((service) => service.make()))
  ))
})
const readShifted = (input: unknown): Effect.Effect<
  Optional, Schema.SchemaError, Read
> => Schema.decodeUnknownEffect(shifted)(input)
const readDefault = (input: unknown): Effect.Effect<
  Required, Schema.SchemaError, Defaults
> => Schema.decodeUnknownEffect(defaulted)(input)

The getter sees missing and present keys. The default runs only for a missing key. Their service requirements belong to decoding; encoding needs neither service in this example.

A required encoded name may decode to an absent key View Core 1 source
type Name = string
type OptionalName = { name?: Name }
type RequiredName = { name: Name }

const name: Schema<Name> = schema()
const optionalName: SchemaOptional<Name> = schemaOptionalKey(name)
const requiredNameField: SchemaDecodedOptional<Name> =
  schemaOptionalFromRequired(
    name,
    optionalName,
    (value: Name): Option<Name> => if (value === "") { None } else { Some(value) },
    (value: Option<Name>): Name => match value { Some(current) => current, None => "" }
  )
export const names: Schema<OptionalName, RequiredName> =
  schemaStruct({ name: requiredNameField })
import { Effect, Option, Schema, SchemaGetter, SchemaIssue } from "effect"

const Names = Schema.Struct({
  name: Schema.String.pipe(Schema.decodeTo(Schema.optionalKey(Schema.String), {
    decode: SchemaGetter.transformOptionalEffect((value) =>
      Option.isNone(value)
        ? Effect.fail(new SchemaIssue.InvalidValue({ message: "required name is missing" }))
        : Effect.succeed(value.value === "" ? Option.none() : value)
    ),
    encode: SchemaGetter.transformOptional((value) =>
      Option.some(Option.getOrElse(value, () => ""))
    )
  }))
})
const decoded: typeof Names.Type = {}
const encoded: typeof Names.Encoded = { name: "" }
void decoded
void encoded

The encoded record must have a name. Decoding an empty name omits it; encoding an absent name writes an empty string. The getter rejects a missing encoded key before it runs the user transformation.

Combine unlike branches or fall through after a Schema issue View Core 1 source
type Name = string
type Int = int
type Scalar = Name | Int
const name: Schema<Name> = schema()
const integer: Schema<Int> = schema()
export const scalar: Schema<Scalar> = schemaUnion([name, integer])

service Validation { accept(value: Name): Name fails SchemaIssue }

const checkedName: Schema<Name, Name, Validation.accept> =
  schemaTransform(
    name,
    name,
    (value: Name): Name => Validation.accept(value),
    (value: Name): Option<Name> => Some(value)
  )
export const fallbackName: Schema<Name, Name, Validation.accept> =
  schemaUnion([checkedName, name])
import { Context, Effect, Schema, SchemaIssue, SchemaTransformation } from "effect"

const Int = Schema.Finite.check(Schema.makeFilter(
  (value) => Number.isSafeInteger(value) || "expected a safe integer"
))
const Scalar = Schema.Union([Schema.String, Int])

class Validation extends Context.Service<Validation, {
  readonly accept: (value: string) => Effect.Effect<string, SchemaIssue.Issue>
}>()("showcase#Validation") {}

const checkedName = Schema.String.pipe(Schema.decodeTo(
  Schema.String,
  SchemaTransformation.transformEffect({
    decode: (value) => Validation.use((service) => service.accept(value)),
    encode: Effect.succeed
  })
))
const FallbackName = Schema.Union([checkedName, Schema.String])

The scalar Schema accepts a string or safe integer. The name union first tries the service-backed transformation. A Schema issue lets the plain string branch succeed; a successful first transformation wins. Decoding still requires the Validation service. Defects and interruption propagate.

Choose whether a nullable optional field preserves null View Core 1 source
type Name = string
type Nil = null
type Nullable = Name | Nil
type NullableRecord = { name?: Nullable }
type OptionalName = { name?: Name }
const name: Schema<Name> = schema()
const nil: Schema<Nil> = schema()
const nullable: Schema<Nullable> = schemaUnion([name, nil])
export const preserveNull: Schema<NullableRecord> =
  schemaStruct({ name: schemaOptionalKey(nullable) })
export const omitNull: Schema<OptionalName, NullableRecord> =
  schemaStruct({ name: schemaNullToMissingKey(name) })
export const missingName = (): NullableRecord => ({})
export const clearedName = (): NullableRecord => ({ name: null })
import { Option, Schema, SchemaGetter } from "effect"

const Nullable = Schema.Union([Schema.String, Schema.Null])
const PreserveNull = Schema.Struct({
  name: Schema.optionalKey(Nullable)
})
const OmitNull = Schema.Struct({
  name: Schema.optionalKey(Nullable).pipe(Schema.decodeTo(
    Schema.optionalKey(Schema.String),
    {
      decode: SchemaGetter.transformOptional(
        Option.filter((value) => value !== null)
      ),
      encode: SchemaGetter.transformOptional((value) => value)
    }
  ))
})
const missingName = (): typeof PreserveNull.Type => ({})
const clearedName = (): typeof PreserveNull.Type => ({ name: null })

With preserveNull, an absent key stays absent and a present null stays present. With omitNull, null decodes to an absent key. Encoding an absent key omits it in both cases. Explicit undefined fails either Schema.

Choose whether a defaulted field is written on encode View Core 1 source
type Name = string
type RequiredName = { name: Name }
type OptionalName = { name?: Name }
const name: Schema<Name> = schema()

export const omittedDefault: Schema<RequiredName, OptionalName> = schemaStruct({
  name: schemaDefaultKey(name, (): Name => "Anonymous", { encodingStrategy: "omit" })
})
export const passedDefault: Schema<RequiredName, OptionalName> = schemaStruct({
  name: schemaDefaultKey(name, (): Name => "Anonymous", { encodingStrategy: "passthrough" })
})
import { Effect, Schema } from "effect"

const OmittedDefault = Schema.Struct({
  name: Schema.String.pipe(Schema.withDecodingDefaultTypeKey(
    Effect.succeed("Anonymous"),
    { encodingStrategy: "omit" }
  ))
})
const PassedDefault = Schema.Struct({
  name: Schema.String.pipe(Schema.withDecodingDefaultTypeKey(
    Effect.succeed("Anonymous"),
    { encodingStrategy: "passthrough" }
  ))
})

Both Schemas decode an absent name to Anonymous. OmittedDefault encodes any valid name as an absent key, including names other than Anonymous. PassedDefault writes the name. Omitting the third argument also selects passthrough. Omission skips the wrapped encoder while retaining its static service requirements.

Default an absent or null field to a required value View Core 1 source
type Name = string
type Nil = null
type RequiredName = { name: Name }
type NullableRecord = { name?: Name | Nil }
const name: Schema<Name> = schema()

export const defaultedNull: Schema<RequiredName, NullableRecord> = schemaStruct({
  name: schemaDefaultKey(name, (): Name => "Anonymous", { on: "null" })
})
export const readMissingName = (): RequiredName fails SchemaError => schemaDecode(defaultedNull, {})
export const readClearedName = (): RequiredName fails SchemaError => schemaDecode(defaultedNull, { name: null })
import { Effect, Option, Schema, SchemaGetter } from "effect"

const DefaultedNull = Schema.Struct({
  name: Schema.optionalKey(Schema.NullOr(Schema.String)).pipe(
    Schema.decodeTo(Schema.String, {
      decode: SchemaGetter.transformOptionalEffect((input) => {
        const present = Option.filter(input, (value) => value !== null)
        return Option.isSome(present)
          ? Effect.succeed(present)
          : Effect.sync(() => Option.some("Anonymous"))
      }),
      encode: SchemaGetter.passthrough()
    })
  )
})
const readMissingName = () => Schema.decodeUnknownEffect(DefaultedNull)({})
const readClearedName = () => Schema.decodeUnknownEffect(DefaultedNull)({ name: null })

Both readers produce { name: "Anonymous" }. A present string skips the default, while explicit undefined fails. Encoding writes the name; the same omit option can leave it absent. Defaults may use decode services and run for each missing-or-null decode. The wrapped Schema must exclude null in both directions.

Decode a default from its wire representation View Core 1 source
type Text = string
type Int = int
type RequiredScore = { score: Int }
type WireRecord = { score?: Text }
service Wire {
  parse(value: Text): Int fails SchemaIssue
  print(value: Int): Text fails SchemaIssue
}
const wireText: Schema<Text> = schema()
const integer: Schema<Int> = schema()
const wireScore: Schema<Int, Text, Wire.parse, Wire.print> = schemaTransform(
  wireText, integer,
  (value: Text): Int => Wire.parse(value),
  (value: Int): Text => Wire.print(value)
)
export const encodedDefault: Schema<RequiredScore, WireRecord, Wire.parse, Wire.print> =
  schemaStruct({ score: schemaDefaultKey(wireScore,
    (): Text => "41", { on: "encoded", encodingStrategy: "omit" }) })
import {
  Context, Effect, Schema, SchemaIssue, SchemaTransformation
} from "effect"

class Wire extends Context.Service<Wire, {
  readonly parse: (value: string) => Effect.Effect<number, SchemaIssue.Issue>
  readonly print: (value: number) => Effect.Effect<string, SchemaIssue.Issue>
}>()("Wire") {}

const WireScore = Schema.String.pipe(Schema.decodeTo(
  Schema.Int,
  SchemaTransformation.transformEffect({
    decode: (value) => Wire.use((service) => service.parse(value)),
    encode: (value) => Wire.use((service) => service.print(value))
  })
))
const EncodedDefault = Schema.Struct({
  score: WireScore.pipe(Schema.withDecodingDefaultKey(
    Effect.sync(() => "41"), { encodingStrategy: "omit" }
  ))
})

An absent score supplies the text "41" to Wire.parse. Present text uses the same parser and skips the default. Encoding calls Wire.print and validates its output before omitting the field, so encoder failures still propagate. This differs from decoded-default omission, which skips the wrapped encoder. Without the omit option, the encoded text is written.

Fill construction defaults in a source factory View Core 1 source
type Int = int
type ConstructorInput = { score?: Int }
type ConstructorRecord = { score: Int }
const integer: Schema<Int> = schema()
const constructorRecord: Schema<ConstructorRecord> = schemaStruct({ score: integer })

export const makeScore = (input: ConstructorInput): ConstructorRecord fails SchemaIssue => {
  const score = match input.score { Some(value) => value, None => 4 }
  schemaMake(constructorRecord, { score: score })
}
import { Effect, Schema } from "effect"

const Integer = Schema.Finite.check(Schema.makeFilter(
  (value) => Number.isSafeInteger(value) || "expected a safe integer"
))
const ConstructorRecord = Schema.Struct({ score: Integer })
const makeScore = (input: { readonly score?: number }) =>
  Effect.suspend(() => ConstructorRecord.makeEffect({
    score: input.score === undefined ? 4 : input.score
  }))

makeScore accepts an absent score and fills it with 4 before schemaMake validates the decoded record. The Schema still requires score for both decoded and wire data. Type-side construction returns SchemaIssue and runs neither wire decoder nor encoder. Defaults in this example belong to the factory. The next example attaches constructor-default metadata to a reusable Schema.

Keep constructor inputs separate from decoded and wire data View Core 1 source
type Int = int
type DefaultRecord = { score: Int }
type DefaultInput = { score?: Int }
type NestedRecord = { settings: DefaultRecord }
type NestedInput = { settings?: DefaultInput }
const integer: Schema<Int> = schema()

export const constructorDefaults: Schema<DefaultRecord, DefaultRecord, never, never, DefaultInput> =
  schemaStruct({ score: schemaConstructorDefault(integer, (): Int => 4) })
export const nestedDefaults: Schema<NestedRecord, NestedRecord, never, never, NestedInput> =
  schemaStruct({ settings: schemaConstructorDefault(constructorDefaults,
    (): DefaultInput => ({})) })
export const constructorDefaultsList: Schema<DefaultRecord[], DefaultRecord[], never, never, DefaultInput[]> =
  schemaArray(constructorDefaults)
export const constructDefaults = (input: NestedInput): NestedRecord fails SchemaIssue =>
  schemaMake(nestedDefaults, input)
import { Effect, Schema } from "effect"

const Integer = Schema.Finite.check(Schema.makeFilter(
  (value) => Number.isSafeInteger(value) || "expected a safe integer"
))
const ConstructorDefaults = Schema.Struct({
  score: Integer.pipe(Schema.withConstructorDefault(Effect.succeed(4)))
})
const NestedDefaults = Schema.Struct({
  settings: ConstructorDefaults.pipe(
    Schema.withConstructorDefault(Effect.succeed({}))
  )
})
const ConstructorDefaultsList = Schema.Array(ConstructorDefaults)
const constructDefaults = (input: typeof NestedDefaults["~type.make.in"]) =>
  NestedDefaults.makeEffect(input)

constructDefaults accepts {} and constructs { settings: { score: 4 } }. The fifth Schema parameter records that make-input shape; decoded and wire records still require both fields. Arrays retain the element make input, so constructorDefaultsList accepts [{}] during construction. These defaults run only when making values. Optional field adapters retain their nested make inputs, as the next example shows. Guarded recursive constructor inputs are shown below.

Keep wire defaults separate from constructor inputs View Core 1 source
type Int = int
type DefaultRecord = { score: Int }
type DefaultInput = { score?: Int }
type SettingsRecord = { settings: DefaultRecord }
type SettingsWire = { settings?: DefaultRecord }
type SettingsInput = { settings: DefaultInput }
type OptionalSettingsInput = { settings?: DefaultInput }
type NullableSettingsWire = { settings?: DefaultRecord | null }
const integer: Schema<Int> = schema()
const constructorDefaults: Schema<DefaultRecord, DefaultRecord, never, never, DefaultInput> =
  schemaStruct({ score: schemaConstructorDefault(integer, (): Int => 4) })

export const wireDefaultSettings: Schema<SettingsRecord, SettingsWire, never, never, SettingsInput> =
  schemaStruct({ settings: schemaDefaultKey(constructorDefaults,
    (): DefaultRecord => ({ score: 8 })) })
export const optionalSettings: Schema<SettingsWire, NullableSettingsWire, never, never, OptionalSettingsInput> =
  schemaStruct({ settings: schemaNullToMissingKey(constructorDefaults) })
export const constructSettings = (input: SettingsInput): SettingsRecord fails SchemaIssue =>
  schemaMake(wireDefaultSettings, input)
import { Effect, Option, Schema, SchemaGetter } from "effect"

const Integer = Schema.Finite.check(Schema.makeFilter(
  (value) => Number.isSafeInteger(value) || "expected a safe integer"
))
const ConstructorDefaults = Schema.Struct({
  score: Integer.pipe(Schema.withConstructorDefault(Effect.succeed(4)))
})
const WireDefaultSettings = Schema.Struct({
  settings: ConstructorDefaults.pipe(
    Schema.withDecodingDefaultTypeKey(Effect.succeed({ score: 8 }))
  )
})
const OptionalSettings = Schema.Struct({
  settings: Schema.optionalKey(Schema.NullOr(ConstructorDefaults)).pipe(
    Schema.decodeTo(Schema.optionalKey(Schema.toType(ConstructorDefaults)), {
      decode: SchemaGetter.transformOptional(Option.filter((value) => value !== null)),
      encode: SchemaGetter.transformOptional((value) => value)
    })
  )
})
const constructSettings = (input: typeof WireDefaultSettings["~type.make.in"]) =>
  WireDefaultSettings.makeEffect(input)

constructSettings({ settings: {} }) constructs { settings: { score: 4 } }. Decoding {} instead uses the wire default { settings: { score: 8 } }. A wire default leaves settings required during construction. optionalSettings permits an absent settings key during construction and fills the nested score when settings is present. Null is a wire-only missing-value policy. Optional transformations use their target’s make input; Option-valued fields require None or Some containing complete decoded data.

Carry partial constructor input through a recursive tree View Core 1 source
type Int = int
type Tree = { score: Int, children: Tree[] }
type TreeInput = { score?: Int, children?: TreeInput[] }
const integer: Schema<Int> = schema()

export const treeDefaults: Schema<Tree, Tree, never, never, TreeInput> = schemaRecursive(
  (self: Schema<Tree, Tree, never, never, TreeInput>): Schema<Tree, Tree, never, never, TreeInput> =>
    schemaStruct({
      score: schemaConstructorDefault(integer, (): Int => 4),
      children: schemaConstructorDefault(schemaArray(self), (): TreeInput[] => [])
    })
)
export const constructTree = (input: TreeInput): Tree fails SchemaIssue =>
  schemaMake(treeDefaults, input)
import { Effect, Schema } from "effect"

type Tree = { readonly score: number; readonly children: ReadonlyArray<Tree> }
type TreeInput = { readonly score?: number; readonly children?: ReadonlyArray<TreeInput> }
type TreeSchema = Schema.Codec<Tree> & { readonly "~type.make.in": TreeInput }
const Integer = Schema.Finite.check(Schema.makeFilter(
  (value) => Number.isSafeInteger(value) || "expected a safe integer"
))
let body: TreeSchema
const TreeDefaults: TreeSchema = Schema.suspend(() => body)
body = Schema.make<TreeSchema>(Schema.Struct({
  score: Integer.pipe(Schema.withConstructorDefault(Effect.succeed(4))),
  children: Schema.Array(TreeDefaults).pipe(
    Schema.withConstructorDefault(Effect.succeed([]))
  )
}).ast)
const constructTree = (input: TreeInput) => TreeDefaults.makeEffect(input)

constructTree({ children: [{}] }) constructs { score: 4, children: [{ score: 4, children: [] }] }. Each self reference retains TreeInput, so defaults work at every nested node. Decoded and wire trees still require score and children. Arrays guard this recursion; optional keys and tagged unions with terminal branches are also supported. Constructor wrappers preserve a guard, and their callbacks cannot capture self. The next example gives the same recursive tree a separate wire representation.

Keep recursive wire data separate from decoded and constructor inputs View Core 1 source
type Int = int
type Text = string
type Tree = { score: Int, children: Tree[] }
type WireTree = { score: Text, children: WireTree[] }
type TreeInput = { score?: Int, children?: TreeInput[] }
service Wire {
  parse(value: Text): Int fails SchemaIssue
  print(value: Int): Text fails SchemaIssue
}
const integer: Schema<Int> = schema()
const wireText: Schema<Text> = schema()
const wireScore: Schema<Int, Text, Wire.parse, Wire.print> = schemaTransform(
  wireText, integer,
  (value: Text): Int => Wire.parse(value),
  (value: Int): Text => Wire.print(value)
)
export const wireTree: Schema<Tree, WireTree, Wire.parse, Wire.print, TreeInput> = schemaRecursive(
  (self: Schema<Tree, WireTree, Wire.parse, Wire.print, TreeInput>): Schema<Tree, WireTree, Wire.parse, Wire.print, TreeInput> =>
    schemaStruct({
      score: schemaConstructorDefault(wireScore, (): Int => 4),
      children: schemaConstructorDefault(schemaArray(self), (): TreeInput[] => [])
    })
)
export const readWireTree = (input: unknown): Tree fails SchemaError uses Wire.parse =>
  schemaDecode(wireTree, input)
export const writeWireTree = (input: Tree): WireTree fails SchemaError uses Wire.print =>
  schemaEncode(wireTree, input)
export const constructWireTree = (input: TreeInput): Tree fails SchemaIssue =>
  schemaMake(wireTree, input)
import { Context, Effect, Schema, SchemaIssue, SchemaTransformation } from "effect"

type Tree = { readonly score: number; readonly children: ReadonlyArray<Tree> }
type WireTree = { readonly score: string; readonly children: ReadonlyArray<WireTree> }
type TreeInput = { readonly score?: number; readonly children?: ReadonlyArray<TreeInput> }
class Wire extends Context.Service<Wire, {
  readonly parse: (value: string) => Effect.Effect<number, SchemaIssue.Issue>
  readonly print: (value: number) => Effect.Effect<string, SchemaIssue.Issue>
}>()("recursive-wire#Wire") {}
type TreeCodec = Schema.Codec<Tree, WireTree, Wire, Wire> & { readonly "~type.make.in": TreeInput }
const Integer = Schema.Finite.check(Schema.makeFilter(
  (value) => Number.isSafeInteger(value) || "expected a safe integer"
))
const WireScore = Schema.String.pipe(Schema.decodeTo(Integer,
  SchemaTransformation.transformEffect({
    decode: (value) => Wire.use((service) => service.parse(value)),
    encode: (value) => Wire.use((service) => service.print(value))
  })
))
let body: TreeCodec
const WireTreeSchema: TreeCodec = Schema.suspend(() => body)
body = Schema.make<TreeCodec>(Schema.Struct({
  score: WireScore.pipe(Schema.withConstructorDefault(Effect.succeed(4))),
  children: Schema.Array(WireTreeSchema).pipe(
    Schema.withConstructorDefault(Effect.succeed([]))
  )
}).ast)
const readWireTree = Schema.decodeUnknownEffect(WireTreeSchema)
const writeWireTree = Schema.encodeEffect(WireTreeSchema)
const constructWireTree = (input: TreeInput) => WireTreeSchema.makeEffect(input)

readWireTree converts text scores to integers at every node using Wire.parse. writeWireTree walks the same decoded tree and returns text scores using Wire.print. constructWireTree({ children: [{}] }) produces integer scores with defaults and needs neither wire service. Each self reference retains all three types: complete decoded Tree, complete encoded WireTree, and partial TreeInput. Optional-key and tagged recursive forms can also keep distinct wire types. The next example attaches constructor defaults to fields that already have wire-presence rules.

Give a field independent wire and constructor defaults View Core 1 source
type Int = int
type Required = { score: Int }
type Optional = { score?: Int }
const integer: Schema<Int> = schema()

export const dualDefaultField: SchemaEncodedOptionalConstructorDefault<Int> =
  schemaConstructorDefault(schemaDefaultKey(integer, (): Int => 8), (): Int => 4)
export const dualDefaultScores: Schema<Required, Optional, never, never, Optional> =
  schemaStruct({ score: dualDefaultField })
export const optionalDefaultField: SchemaOptionalConstructorDefault<Int> =
  schemaConstructorDefault(schemaOptionalKey(integer), (): Int => 4)
export const optionalDefaultScores: Schema<Optional> =
  schemaStruct({ score: optionalDefaultField })
export const makeDualDefaults = (input: Optional): Required fails SchemaIssue =>
  schemaMake(dualDefaultScores, input)
import { Effect, Schema } from "effect"

const Integer = Schema.Finite.check(Schema.makeFilter(
  (value) => Number.isSafeInteger(value) || "expected a safe integer"
))
const DualDefaultField = Integer.pipe(
  Schema.withDecodingDefaultTypeKey(Effect.succeed(8)),
  Schema.withConstructorDefault(Effect.succeed(4))
)
const DualDefaultScores = Schema.Struct({ score: DualDefaultField })
const OptionalDefaultField = Schema.optionalKey(Integer).pipe(
  Schema.withConstructorDefault(Effect.succeed(4))
)
const OptionalDefaultScores = Schema.Struct({ score: OptionalDefaultField })
const makeDualDefaults = (input: typeof DualDefaultScores["~type.make.in"]) =>
  DualDefaultScores.makeEffect(input)

makeDualDefaults({}) constructs { score: 4 }, while decoding {} through dualDefaultScores produces { score: 8 }. The field remains required in decoded data and optional in wire and make inputs. optionalDefaultScores keeps score optional in both decoded and wire data: decoding {} leaves it absent, while construction supplies 4. The combined descriptor types retain presence and constructor metadata across annotations and imports. The next example applies a field adapter after the constructor default and shows which metadata it keeps.

Track which defaults survive a later field adapter View Core 1 source
type Int = int
type Required = { score: Int }
type Optional = { score?: Int }
type OptionScores = { score: Option<Int> }
const integer: Schema<Int> = schema()
const constructorFirst: SchemaConstructorDefault<Int> =
  schemaConstructorDefault(integer, (): Int => 4)

export const adapterAfterDefault: SchemaEncodedOptionalConstructorDefault<Int> =
  schemaDefaultKey(constructorFirst, (): Int => 8)
export const adapterAfterDefaults: Schema<Required, Optional, never, never, Optional> =
  schemaStruct({ score: adapterAfterDefault })
export const optionAfterDefaults: Schema<OptionScores, Optional> =
  schemaStruct({ score: schemaOptionFromOptionalKey(constructorFirst) })
export const makeAdapterAfterDefault = (input: Optional): Required fails SchemaIssue =>
  schemaMake(adapterAfterDefaults, input)
export const makeAdaptedOption = (input: OptionScores): OptionScores fails SchemaIssue =>
  schemaMake(optionAfterDefaults, input)
import { Effect, Option, Schema } from "effect"

const Integer = Schema.Finite.check(Schema.makeFilter(
  (value) => Number.isSafeInteger(value) || "expected a safe integer"
))
const ConstructorFirst = Integer.pipe(
  Schema.withConstructorDefault(Effect.succeed(4))
)
const AdapterAfterDefault = ConstructorFirst.pipe(
  Schema.withDecodingDefaultTypeKey(Effect.succeed(8))
)
const AdapterAfterDefaults = Schema.Struct({ score: AdapterAfterDefault })
const OptionAfterDefaults = Schema.Struct({
  score: Schema.OptionFromOptionalKey(ConstructorFirst)
})
const makeAdapterAfterDefault = (input: typeof AdapterAfterDefaults["~type.make.in"]) =>
  AdapterAfterDefaults.makeEffect(input)
const makeAdaptedOption = (input: { readonly score: Option.Option<number> }) =>
  OptionAfterDefaults.makeEffect(input)

makeAdapterAfterDefault({}) still constructs { score: 4 }; decoding {} uses 8. Unary presence and wire-default adapters retain the constructor default in either order. Optional bridges take defaults only from their decoded target. The Option adapter resets the inherited default: makeAdaptedOption requires score: None or score: Some(value), and an absent score is an error. A new Option constructor default can be attached after that reset. The next example carries recursive self through wire-default adapters.

Carry recursive fields through wire defaults View Core 1 source
type Int = int
type Tree = { score: Int, children: Tree[] }
type TreeInput = { score?: Int, children?: TreeInput[] }
const integer: Schema<Int> = schema()
type DefaultWireTree = { score: Int, children?: DefaultWireTree[] }
export const defaultedTree: Schema<Tree,DefaultWireTree,never,never,TreeInput> = schemaRecursive(
  (self: Schema<Tree,DefaultWireTree,never,never,TreeInput>): Schema<Tree,DefaultWireTree,never,never,TreeInput> =>
    schemaStruct({
      score: schemaConstructorDefault(integer, (): Int => 4),
      children: schemaDefaultKey(
        schemaConstructorDefault(schemaArray(self), (): TreeInput[] => []),
        (): Tree[] => [{ score: 8, children: [] }])
    })
)
export const makeDefaultedTree = (input: TreeInput): Tree fails SchemaIssue =>
  schemaMake(defaultedTree, input)
export const readDefaultedTree = (input: unknown): Tree fails SchemaError =>
  schemaDecode(defaultedTree, input)
import { Effect, Schema } from "effect"

type Tree = { readonly score: number; readonly children: ReadonlyArray<Tree> }
type WireTree = { readonly score: number; readonly children?: ReadonlyArray<WireTree> }
type TreeInput = { readonly score?: number; readonly children?: ReadonlyArray<TreeInput> }
type View = Schema.Codec<Tree, WireTree> & { readonly "~type.make.in": TreeInput }
const Integer = Schema.Finite.check(Schema.makeFilter(
  (value) => Number.isSafeInteger(value) || "expected a safe integer"
))
let body: View
const DefaultedTree: View = Schema.suspend(() => body)
body = Schema.make<View>(Schema.Struct({
  score: Integer.pipe(Schema.withConstructorDefault(Effect.succeed(4))),
  children: Schema.Array(DefaultedTree).pipe(
    Schema.withConstructorDefault(Effect.succeed([])),
    Schema.withDecodingDefaultTypeKey(Effect.succeed([{ score: 8, children: [] }]))
  )
}).ast)
const makeDefaultedTree = (input: TreeInput) => DefaultedTree.makeEffect(input)
const readDefaultedTree = Schema.decodeUnknownEffect(DefaultedTree)

makeDefaultedTree({ children: [{}] }) fills each score with 4 and supplies empty children at the leaf. readDefaultedTree({ score: 1 }) uses the wire default and returns { score: 1, children: [{ score: 8, children: [] }] }. The array guards self through the default adapters. Null-as-missing also guards optional recursive links. Optional bridges retain guards from their Schema arguments; defaults and getters cannot capture self.

Modules and typed IR

Modules expose named declarations and keep host integration behind typed interfaces.

Version 0 imports and exports
import {
  User,
  UserId,
  UserMissing,
  StoreUnavailable,
  Users,
  getUser
} from "./users.eff"

export const load = (id: UserId): User
  fails UserMissing | StoreUnavailable
  uses Users.find => getUser(id)
import { Effect } from "effect"
import {
  User,
  UserId,
  UserMissing,
  StoreUnavailable,
  Users,
  getUser
} from "./users.js"

export const load = (id: UserId): Effect.Effect<
  User,
  UserMissing | StoreUnavailable,
  Users
> => getUser(id)

Version 0 permits named imports from relative .eff files and registered host modules. It excludes default imports, namespace imports, renaming, re-exports, and package resolution.

Module constants require pure initializers. Imports cannot run application service operations. A host starts execution through an entry function after it supplies required providers.

The transpiler parses source, resolves identities, infers effects, checks contracts, specializes builtins, and emits typed IR. Its Effect-TS backend emits TypeScript from checked IR.

Resolution assigns stable identities to types, errors, services, capabilities, values, and locals. Equal names in different modules do not identify the same declaration.

Source formMeaning
A fails E uses RA function succeeds with A, can fail with E, and requires R.
fail(ErrorName({ fields }))End the normal path with one declared error value.
match value { Some(x) => ..., None => ... }Inspect an option with complete coverage.
try { expression } catch { ErrorName(e) => ... }Handle matching typed failures from one protected expression.
value |> f(a)Evaluate value once, then call f(value, a).
retry(() => op(), { times: 2 })Run one attempt and permit two additional attempts.
provide(P, () => op())Run op with capabilities supplied by provider P.
({ ...record, field: value })Create a record with one immutable update.
nominal Name = TDeclare a nominal type with base type T. A record base gives a tagged nominal record.
map, filter, foldTraverse arrays from left to right with pure callbacks.

The IR resolves every reference to a stable identity. It separates ordinary calls from service operations. It preserves retry, provide, catch, nominal constructions, and source positions as explicit nodes.

A pipeline needs no IR node. Lowering creates fresh bindings and ordinary call or service-operation nodes. This preserves single evaluation, stage order, and useful diagnostic positions.

Acceptance fixtures must cover evaluation order, branch selection, failure propagation, contract rejection, closure deferral, retry, and provider resolution. Pipeline fixtures must also check stage identity.

The effektive-lang repository has a parser, checker, independent typed IR validator, transpiler, CLI, and runtime fixtures for the implemented core 1 subset. The specification records the supported forms and remaining boundaries.

The Effect-TS tabs use the official Effect v4 documentation. Every Effect-TS tab passes strict TypeScript checks against Effect 4.0.5, with its chapter dependencies.

Target references: https://effect.website/docs/v4/data-types/option and https://effect.website/docs/v4/schema/basic-usage.

Schema filter reference: https://effect.website/docs/v4/schema/filters. Brand reference: https://effect.website/docs/v4/schema/advanced-usage.

The application acceptance rule now guides further additions. The target covers a service workflow, a scheduled worker, and a stream processor. Each must retain precise contracts and pass failure, cancellation, and cleanup checks. All three gates now pass using existing forms. Application orchestration stays in Effektive; typed host adapters supply drivers and the runtime entry point. Broader package interoperability, gap item 9, remains deferred.

The runnable scheduled worker uses existing language forms. Its Scheduled worker source owns repetition, transient retry selection, validation and resource release. The typed driver supplies job operations and primitive waits. Ten Scheduled worker runtime checks cover timing, replay, failures, shutdown and completed cleanup. The native reference compares the selected policy with Effect repetition and retry. The accepted language stopping rule is satisfied.

The runnable service workflow also uses existing forms. Its Service workflow source validates requests, checks business rules, obtains a price and retries transient writes. The registered SQL adapter supplies one atomic save with receipt validation and connection release. Fifteen Service workflow runtime checks cover transactions, failures, retry timing and replay, and cancellation. Body interruption awaits rollback and release. After COMMIT starts, cancellation can preserve a committed row. The recording driver performs no external I/O.

The runnable stream processor keeps the complete pipeline in Stream processor source. It transforms live input with concurrency two, preserves order, applies selected fallbacks and writes batches under backpressure. Thirteen Stream processor runtime checks cover success, failures, replay and awaited shutdown. Native transduction can emit a partial batch before input failure. Typed recovery follows native mixed-cause limits. The recording driver performs no external I/O. The accepted stopping rule selects no further automatic API additions; release acceptance remains separate work. The remaining and deferred gap record lists open APIs, item 9 and runtime evidence limits.

The runnable order lifecycle is a witness for nominal types. It is not a gate application. Its source in contracts.eff and orders.eff declares four nominal states across two modules, matches them by name, and prices a recursive rule tree. It decodes JSON through a structural type. Nine Order lifecycle runtime checks cover each transition, the InvalidTransition failure, pricing, decoding and the encoded tag.

Separate release checks now cover editor references, rename, completion, semantic tokens and contract quick fixes, a bundled local CLI and VSIX, and installed editor startup, save and restart. The playground passed automated keyboard and error-recovery checks at 375, 720 and 1280 pixels. The service workflow has seven real loopback HTTP and SQLite checks for persistence, failures, rollback, cancellation and connection close. Full M1/M3/M4 release measurements meet the existing 100 ms p90 diagnostics budget on the large synthetic project and include 10,000 distinct edits across three open documents. These finite memory observations establish no bound. The user’s manual editor and playground acceptance remains pending. These additions change no source forms or package-interoperability deferral.

The earlier provider illustration uses a declared adapter interface, without a provider implementation. The Core 1 showcase source passes the compiler, independent IR validator, and strict checking of emitted TypeScript; these checks do not prove runtime equivalence.

Language core 1. Design draft 0.60. October 23, 2026.