This page compares plain TypeScript, Effect, and Hem across five common service scenarios.
First, for services with retries, timeouts, fan-out, resource management, and dependency injection, Effect provides a more reliable model than plain promises and manual conventions.
Second, Hem retains that model while providing dedicated language syntax and additional compiler checks for common patterns.
Each scenario below shows three implementations: plain TypeScript, raw Effect, and Hem. The examples model a Cloudflare Workers application calling remote LLM APIs.
Hem is an application language, not a runtime replacement. Emitted code uses Effect, and complex policies use standard Effect functions.
The five sections cover typed errors, resilience policies, cancellable concurrency, automatic resource management, and swappable dependencies.
1. Errors are part of the type
To determine what a plain TypeScript function throws, you must inspect its implementation and all called dependencies.
async function getScan(domain: string): Promise<Scan> {
const record = await findScan(domain); // throws? returns null? both?
if (!record) throw new NotFoundError(domain);
const whois = await fetchWhois(domain); // throws on 4xx? 5xx? timeout?
return buildScan(record, whois);
}
try {
return await getScan(domain);
} catch (e) {
// e: unknown. An instanceof ladder, and hope you got them all.
}TypeScript does not track error types in Promise signatures. If a function deeper in the call stack adds a new failure mode, the type checker will not flag unhandled cases.
In Effect, failure types appear in the second type parameter and are inferred automatically:
class ScanNotFound extends Data.TaggedError("ScanNotFound")<{ readonly domain: string }> {}
class WhoisUnavailable extends Data.TaggedError("WhoisUnavailable")<{ readonly status: number }> {}
const getScan = (domain: string) =>
Effect.gen(function* () {
const record = yield* findScan(domain);
if (record == null) return yield* Effect.fail(new ScanNotFound({ domain }));
const whois = yield* fetchWhois(domain);
return buildScan(record, whois);
});
// inferred: Effect<Scan, DbFailed | ScanNotFound | WhoisUnavailable, never>Handling an error removes it from the error channel type. A dependency’s new failure can widen and propagate through compatible Effect error channels; TypeScript reports the incompatible boundaries that must handle it or widen their declared contract.
Hem keeps these semantics with shorter syntax:
error ScanNotFound({ domain: string })
error WhoisUnavailable({ status: number })
let getScan = (domain: string) => effect {
let Some(record) = findScan(domain)! else raise ScanNotFound({ domain })
buildScan(record, fetchWhois(domain)!)
}
let scan = getScan(domain) catch { ScanNotFound(_) => freshScan(domain) }Error types are declared in a single line. Guard clauses replace explicit null checks and failures. Pattern-matching catch blocks match against the inferred error channel; if a pattern does not match an actual error type, the compiler reports an error.
2. Resilience is a value
Consider calling an API with a 30-second timeout, retrying on HTTP 429 and 5xx responses with jittered exponential backoff for up to five attempts.
In plain TypeScript:
async function askModel(prompt: string): Promise<Answer> {
let delay = 250;
for (let attempt = 1; ; attempt++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
try {
const res = await fetch(MISTRAL_URL, { ...request(prompt), signal: controller.signal });
if (res.status === 429 || res.status >= 500) throw new Retryable(res.status);
if (!res.ok) throw new UpstreamFailed(res.status);
return parseAnswer(await res.json());
} catch (e) {
const retryable =
e instanceof Retryable || (e instanceof DOMException && e.name === "AbortError");
if (!retryable || attempt >= 5) throw e;
await new Promise((r) => setTimeout(r, delay + Math.random() * delay));
delay *= 2;
} finally {
clearTimeout(timer);
}
}
}This implementation mixes the retry policy into the request logic. Reusing this policy requires duplicating code or writing custom abstractions. Error types remain untracked, cancellation requires manual AbortController plumbing, and testing backoff requires timer mocks.
In Effect, retry and timeout policies are standalone values that can be reused and tested independently:
const backoff = Schedule.exponential("250 millis").pipe(Schedule.jittered);
const flaky = (e: AskError) =>
e._tag === "RateLimited" || (e._tag === "UpstreamFailed" && e.status >= 500);
const askModel = (prompt: string) =>
callMistral(prompt).pipe(
Effect.timeout("30 seconds"),
Effect.retry({ schedule: backoff, times: 4, while: flaky }),
);In Hem, including the underlying HTTP request:
error RateLimited
error UpstreamFailed({ status: number })
let backoff = Schedule.exponential("250 millis") |> Schedule.jittered
let flaky = (e: RateLimited | UpstreamFailed | TimeoutError) => match e {
| RateLimited => true
| UpstreamFailed({ status }) => status >= 500
| _ => false
}
let askModel = (prompt: string) =>
effect {
let res = Effect.tryPromise({
try: (signal) => fetch(mistralUrl, { ...request(prompt), signal }),
catch: (_) => new UpstreamFailed({ status: 0 }),
})!
if res.status == 429 { raise RateLimited() }
if !res.ok { raise UpstreamFailed({ status: res.status }) }
let json = await res.json() catch { _ => raise UpstreamFailed({ status: res.status }) }
match Answer.safeParse(json) {
| { success: true, data } => data
| _ => raise UpstreamFailed({ status: res.status })
}
}
|> Effect.timeout("30 seconds")
|> Effect.retry({ schedule: backoff, times: 4, while: flaky })The fetch uses Effect.tryPromise so that interruption passes an AbortSignal to fetch. When a timeout fires or a caller is interrupted, the runtime aborts the HTTP request. Plain await is used for promises where abort signals do not apply.
The flaky predicate matches specific error variants by name, with _ matching unhandled cases. The parse match includes a _ wildcard because record patterns are structurally open (see pattern matching).
The timeout error is tracked in the error channel type, requiring callers to handle potential timeouts.
Because the timeout, schedule, and predicate are values, they can be extracted into reusable helper functions.
3. Concurrency that cancels
When racing two requests with Promise.race, the promise settles as soon as the first finishes, but the losing request continues running unless manually cancelled.
Effect interrupts the losing fiber automatically.
let best = (prompt: string) => effect {
Effect.race(askMistral(prompt), askGemini(prompt))!
}Interruption propagates through retries, timeouts, and down to the abort signal of the underlying fetch. Similarly, if one member of an and group fails, the runtime interrupts the remaining operations, unlike Promise.all. Bounded concurrency is configured via options rather than external semaphore utilities.
let dashboard = (id: string) => effect {
let user = fetchUser(id)! and orgs = fetchOrgs(id)! // fail one, interrupt the other
let scans = Effect.forEach(domains, scanJob, { concurrency: 10 })!
{user, orgs, scans}
}Hem supports the same and syntax for standard JavaScript promises over Promise.all. However, cancelling plain promises still requires manual signal coordination, whereas Effect handles interruption automatically across the group.
4. Resources that always release
Managing multiple resources in plain TypeScript requires nested try/finally blocks and manual ordering:
const conn = await pool.connect();
try {
const lock = await locks.acquire(domain);
try {
return await runScan(conn, domain);
} finally {
await locks.release(lock);
}
} finally {
conn.release();
}If wrapped in a Promise.race with a timeout, the race resolves when the timeout triggers, but runScan continues running. Its finally blocks do not run until the underlying operation finishes. While AbortController can cancel individual APIs, plain promises do not tie cancellation to resource lifecycles automatically.
Effect manages resource lifetimes using scopes: acquisition is uninterruptible, release actions are registered atomically, and interruption triggers finalizers.
In Hem, the enclosing effect block serves as the scope:
let scanJob = (domain: string) =>
effect {
use conn = Pool.acquire
use _ = Locks.hold(domain)
runScan(conn, domain)!
} |> Effect.timeout("30 seconds")When the timeout fires, the fiber is interrupted, the lock releases, the connection closes, and the timeout error propagates. Resource cleanup always runs before error recovery.
Creating resources for callers to manage uses resource { } blocks, which integrate with Layer.effect. Binding a scoped resource with let instead of use is a compile error:
HEM4105 · this effect acquires a resource — bind it with `use`
scan.hem:12 let conn = Pool.acquire!This prevents unmanaged resource leaks at compile time.
5. Dependencies you can swap
In plain TypeScript, managing dependencies usually requires passing them explicitly through signatures or using module-level mocks during testing.
Effect tracks required dependencies in the third type parameter and represents implementations as values (Layer). Hem provides concise syntax for declaring and accessing services.
service LLM {
ask: (prompt: string) => Effect<Answer, LlmError>
}
// production wiring; the function from the second argument is the implementation
let LLMLive = Layer.succeed(LLM, { ask: askModel })
// a test provides a different world, with no module mocking anywhere
let LLMTest = Layer.succeed(LLM, { ask: (_) => Effect.succeed(cannedAnswer) })
it("classifies jurisdiction from the answer", () =>
scanDomain("example.eu")
|> Effect.provide(LLMTest)
|> Effect.runPromise)This also enables deterministic testing. Because the system clock is a service in Effect, retry tests run against a virtual test clock without waiting for real-world delays.
When required services are not provided, Hem reports the missing dependencies and the source lines where they were introduced:
HEM4102 · unprovided requirements
routes/scan.hem:24 |> Effect.runPromise
This effect still needs 2 services when run:
LLM — enters at llm.ask(prompt)! (line 11)
Pool — enters at Pool.acquire (line 9)
Provide them first: |> Effect.provide(AppLayer)The repeated application code, itemized
Everything above is available in standard Effect. Hem provides concise syntax for frequent application patterns and adds source-level compiler diagnostics.
| Raw Effect | Hem |
|---|---|
Effect.gen(function* () { … yield* e … }) |
effect { … e! … } |
class X extends Data.TaggedError("X")<{…}> {} |
error X({ … }) |
class S extends Context.Service<S, {…}>()("app/S") {} |
service S { … } |
yield* Effect.fail(new X({ id })) |
raise X({ id }) |
Effect.tryPromise({ try: () => p, catch: f }) |
await p, with catch arms to recover |
Effect.catchTags({ A: …, B: … }) |
catch { A(_) => … | B(e) => … } |
| Scope-wrapper placement puzzles | the block containing use |
Effect.all([a, b], { concurrency: "unbounded" }) and the sequential-default trap |
let x = a! and y = b! |
pipe(a, f, g) |
a |> f |> g |
Type 'S' is not assignable to type 'never' |
HEM4102 with per-service blame lines |
What it still costs, honestly
Hem simplifies Effect’s syntax, but requires understanding its execution model.
Working with Effect in Hem still requires understanding lazy execution, the three effect channels (success, failure, requirements), and Effect’s broader library modules. APIs such as Schedule, Layer, Stream, and Config remain standard library calls without special syntax.
Stack traces reflect the fiber runtime. Hem provides provenance diagnostics during compilation rather than runtime debugging.
Interfacing with promise-based libraries requires explicit lifting at boundary points.
For simple programs, such as a single HTTP request handler, plain async/await with ?T and match in core Hem is often sufficient. The Effect integration is optional and configured per project.
The compounding argument
Individual features like retries, concurrency limits, dependency injection, and resource disposal can be addressed with separate promise-based libraries.
Effect unifies these concerns into a single model where timeouts interrupt retries, abort HTTP requests, and release resources in order, with test clocks for simulation and compiler tracking for errors and requirements.
Hem provides direct language syntax for this model.
Related
- How to enable the Effect extension explains how to configure the integration.
- Effect boundaries lists the compiler safety rules.
- Part II of the tour explains each construct and its emitted TypeScript.