Effektive: Effect-TS Contracts Without the Ceremony
I love Effect-TS. I wrote about
why it makes TypeScript read like business logic,
and I still think the core idea is one of the best things that happened to
TypeScript. One type, Effect<A, E, R>, tells you what a function returns, how
it can fail, and what it needs to run. Brilliant.
But. Read any Effect function and watch what your eyes do. They skip
Effect.flatMap, skip .pipe(, skip yield*, skip Effect.succeed, and hunt
for the three words that actually matter. The contract is great. The ceremony
around it is heavy.
So I built Effektive. It is a small functional language that transpiles to
Effect-TS 4. You write direct code that looks like a strict TypeScript subset.
Each exported function states its contract in plain words: what it returns, what
it fails with, what it uses. Local helpers let the compiler infer it. The
compiler checks every contract and writes the Effect code for you.
Website and mini book: effektive.timok.deno.net
One Function, Two Languages
Look at this. A lookup that turns a missing user into a typed domain error:
export const getUser = (id: UserId): User
fails UserMissing | StoreUnavailable
uses Users.find =>
match Users.find(id) {
Some(user) => user,
None => fail(UserMissing({ id }))
}That is Effektive. The signature reads like a sentence: getUser takes an id,
returns a User, fails with UserMissing or StoreUnavailable, and uses
Users.find. The body calls the service directly, like a normal function. No
yield*, no Effect.flatMap, no wrapper on the return type.
Here is the same thing in idiomatic hand-written Effect-TS:
export const getUser = (id: UserId): Effect.Effect<
User,
UserMissing | StoreUnavailable,
Users
> =>
Users.use((users) => users.find(id)).pipe(
Effect.flatMap(Option.match({
onNone: () => Effect.fail(new UserMissing({ id })),
onSome: Effect.succeed,
})),
);Both say the same thing. Both have the same types at the end, because the
Effektive version becomes an
Effect.Effect<User, UserMissing | StoreUnavailable, Users>. The difference is
who writes the plumbing.
The Contract Is the Whole Idea
Every Effektive function has three facts:
- The result type describes normal completion.
- The
failsclause names the expected domain errors. - The
usesclause names the service methods that the body calls.
The compiler maps them one to one: a function that returns A, fails with E,
and uses R becomes Effect.Effect<A, E, R>.
Here's the cool part: the contract is closed. If you leave out fails, the
function has no expected failures. If you leave out uses, it needs no
services. The body cannot exceed what the signature declares. So if you forget
to list PaymentDeclined on a function that charges a card, the compiler stops
you:
pay.eff:45:3: error failure-contract/001: the body can fail with PaymentDeclined, which the `fails` clause does not listAnd if you forget the capability:
pay.eff:45:3: error requirement-contract/001: the body requires Payments.charge, which the `uses` clause does not listThose are real messages from the compiler, not something I typed for the post. In VS Code you also get a quick fix that adds the missing failure or capability to the signature.
The contract must be written only where it matters: on exported functions, the
public surface of a module. A local helper can skip fails and uses, and the
compiler infers them from the body. This also works for recursive helpers. The
export that calls them still has to declare everything they can do, so nothing
escapes the module unannounced. In the editor, inlay hints show the inferred
fails and uses next to each helper, so you can still read the contract
without writing it.
To me is interesting that the uses clause is finer than Effect itself. In
Effect-TS the requirement type is the whole Users service. In Effektive,
Users.find, Users.save, and Users.all are separate capabilities. A
read-only function says uses Users.find, and the checker holds it to that. The
generated TypeScript still needs the whole Users service, because that is what
Effect tracks. The finer check lives in the compiler's typed intermediate
representation. It is a static check for review, not a sandbox for adapters.
Declarations: Where the Ceremony Lives
Let's take something with a few moving parts: find an order, charge it with a retry, mark it as paid, save it. Before any logic, you need a record, two errors, and two services. Here they are in Effektive:
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
}And here is the same in idiomatic Effect-TS:
import { Context, Effect, Option, Schema } from "effect";
const Cents = Schema.Int;
type Cents = typeof Cents.Type;
type ChargeId = string;
const OrderId = Schema.String.pipe(Schema.brand("OrderId"));
type OrderId = typeof OrderId.Type;
const Order = Schema.Struct({
id: OrderId,
totalCents: Cents,
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") {}Look at the errors. In Effektive an error is one line: a name and its fields. In
Effect-TS it is a class that repeats its own name three times. Every type comes
twice, once as a schema value and once as typeof X.Type. Every service method
wraps its result in Effect.Effect<...>, even though the service is the place
where effects obviously live.
I counted non-whitespace characters, with the TypeScript formatted by
deno fmt. The Effektive declarations have 314. The Effect-TS declarations
have 840. That is about 2.7 times more text for the same information, and it is
exactly the text that nobody wants to read.
A Payment Pipeline
Now the logic. In Effektive:
const findOrder = (id: OrderId) =>
match Orders.find(id) {
Some(order) => order,
None => 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
|> saveOrderThe helpers have no contracts, because they are local. The compiler knows that
findOrder fails with OrderNotFound and uses Orders.find, and it checks
that pay declares all of it.
Look at pay. It is a pipeline of four steps, and the pipeline does not care
which steps are effectful. markPaid is a pure function. findOrder talks to a
service and can fail. They compose with the same |>. In Effect you must pick
the right combinator for each step: flatMap for the effectful ones, map for
the pure one. Pick wrong and you read a long type error.
Here is my Effect-TS version. I kept it idiomatic and let TypeScript infer the helper return types, so it is as short as I can make it honestly:
const findOrder = (id: OrderId) =>
Orders.use((orders) => orders.find(id)).pipe(
Effect.flatMap(Option.match({
onNone: () => Effect.fail(new OrderNotFound({ id })),
onSome: Effect.succeed,
})),
);
const chargeOrder = (order: Order) =>
Payments.use((payments) => payments.charge(order.totalCents)).pipe(
Effect.retry({ times: 2 }),
Effect.as(order),
);
const markPaid = (order: Order): Order => ({ ...order, status: "paid" });
const saveOrder = (order: Order) =>
Orders.use((orders) => orders.save(order)).pipe(Effect.as(order));
export const pay = (id: OrderId): Effect.Effect<
Order,
OrderNotFound | PaymentDeclined,
Orders | Payments
> =>
findOrder(id).pipe(
Effect.flatMap(chargeOrder),
Effect.map(markPaid),
Effect.flatMap(saveOrder),
);Here the gap is smaller, but it is still real. The Effektive functions have 446
characters, the Effect-TS functions have 664. Both versions let the helpers
infer their types, and both write a full contract on the exported pay. So for
the same information, the logic is about a third shorter. More important than
the number: the Effektive version is direct code. A service call is a call. A
void step is a line. You never think about Effect.as to keep a value after a
write.
Whole module: 760 characters in Effektive against 1504 in Effect-TS.
What the Compiler Writes
The generated code is plain Effect-TS 4 that a human can read. For pay, the
compiler writes this:
export const pay = (id: OrderId): Effect.Effect<
Order,
OrderNotFound | PaymentDeclined,
Orders | Payments
> =>
Effect.gen(function* () {
const $p1: OrderId = id;
const $p2: Order = yield* findOrder($p1);
const $p3: Order = yield* chargeOrder($p2);
const $p4: Order = markPaid($p3);
return yield* saveOrder($p4);
});The pipeline unrolls into a generator. Effectful steps get yield*, the pure
step does not. Records become Schema.Struct, nominal OrderId = string
becomes a branded Schema.String, errors become Schema.TaggedError classes
with module-qualified tags, and services become Context.Service classes. Each
file also gets a source map that points back to your .eff source.
This means you are not locked in. If you stop using Effektive tomorrow, you keep a folder of ordinary Effect code.
The Rest of the Language, Quickly
The language is small on purpose. Every binding is immutable. There is no
assignment, no class, no this, and no return statement: a block returns its
final expression. A few more features worth showing:
Typed recovery. try/catch matches on error names, and the handled error
leaves the contract:
export const displayName = (id: UserId): Name
fails StoreUnavailable
uses Users.find =>
try {
getUser(id).name
} catch {
UserMissing(error) => "Guest"
}UserMissing is gone from fails, StoreUnavailable stays. A typed catch
handles only declared domain failures. It does not catch defects or
interruption. In Effect terms, this is Effect.catchTag, and the compiler
removes the tag from the error type for you.
Named error sets. When the same list of errors repeats, give it a name and
use the name in fails:
type PayFailure = OrderNotFound | PaymentDeclined
export const pay = (id: OrderId): Order
fails PayFailure
uses Orders | Payments.charge => ...The compiler expands the name, so the contract and the generated
Effect.Effect<Order, OrderNotFound | PaymentDeclined, ...> stay the same.
Pipelines and collections. map, filter, and fold come from the
prelude, and they work on the right side of |>:
export const activeNames = (): Name[]
fails StoreUnavailable
uses Users.all =>
Users.all()
|> filter((user) => user.active)
|> map((user) => user.name)Nominal types. nominal OrderId = string gives an identity to a string. A
plain string cannot pass where an OrderId is expected:
pay.eff:54:40: error type-mismatch/003: argument 1 has type Raw, but OrderId is expectedStreams, resources, and schedules. The language also has streams with backpressure, concurrent mapping, sinks, scoped resources with cleanup, and retry schedules. Here is a piece of a stream processor:
const incoming = bufferStream(unfold(0, (index) => Input.next(input, index)), { capacity: 2 })
const converted = mapEffect(incoming, (item) => convert(item), { concurrency: 2 })
const batches = transduceStream(converted, takeSink(2))
runStream(batches, sinkForEach((batch) =>
if ((batch |> fold(0, (count, _item) => count + 1)) > 0) { Output.write(output, batch) } else {}))Host boundary. Real drivers live in TypeScript. A small interface file declares what the host gives:
import { Users } from "../users.eff"
export provider UsersLive provides UsersThe TypeScript module behind it exports an ordinary Layer, and Effektive code
calls provide(UsersLive, () => displayName("u1")) to run with it.
Tooling
A language without tools is a toy, so Effektive ships with them. The CLI has
check, build, and watch. The language server gives diagnostics, hover,
go-to-definition, references, rename, completion, semantic tokens, inlay hints
for inferred contracts, and the contract quick fixes from above. The VS Code
extension shows the generated TypeScript next to your source and writes it on
save. There is also a browser playground that runs the compiler core in the
browser.
Real Talk: Tradeoffs
Effektive is version 0. Let me be direct about what that means.
It is deliberately small. No user-defined generics, no effect-row
polymorphism, no macros, no early returns. You cannot import an arbitrary npm
package from .eff source. HTTP, SQL, and other drivers stay in TypeScript
behind a typed host interface. For an orchestration layer that is fine. For a
general-purpose language it is a wall.
It covers a subset of Effect. I did not chase API parity. I picked three target applications (a service workflow, a scheduled worker, and a stream processor) and stopped adding features when all three compiled with precise contracts and passed failure, cancellation, and cleanup checks. If your app needs a combinator outside that set, you write it in TypeScript.
Numbers are strict. int means a safe integer, and overflow is a defect,
not a silent wrap. Division always gives number. Good for money, a little
annoying for percent math.
Inferred contracts live in the tools. A local helper without fails and
uses is shorter, but in a plain text view you see its contract only at the
export that calls it. The editor shows it as an inlay hint. A named error set
also moves the list of errors away from the place where you use it.
Another layer to debug. Source maps help, but you now have a compiler between you and the runtime. When something looks wrong, sometimes you read the generated TypeScript.
It is one person's project. It has a real test suite and three gate applications. It does not have a community or years of production use. Effect-TS does.
Where does it fit? It depends of how much of your code is orchestration. If your service is mostly "call this, handle that failure, retry, save", Effektive removes most of the noise and keeps every guarantee. If your code is mostly clever generic library work, write Effect-TS directly.
Try It
The mini book on effektive.timok.deno.net
walks through the language in eleven short chapters, with Effektive and
Effect-TS side by side. Start with the contracts chapter. If the fails/uses
signature clicks for you, the rest of the language follows from it.
For me, Effektive is the next step after TypeLang. TypeLang tried to get there by restricting TypeScript. Effektive keeps the good parts of TypeScript syntax, adds three words to the signature, and gives the plumbing to the compiler. Kind of like a good espresso: short, strong, and the machine does the hard work.