Type-safe HTTP client for Effect HTTP APIs with tRPC-like ergonomics. Server-side focused, returns Promise<Result<A, E>> for clean async/await usage in React Server Components, server actions, and other async contexts.
Effect's HTTP API is powerful, but adopting Effect across your entire stack can be daunting. This library lets you incrementally adopt Effect or leverage its amazing capabilities on the backend without needing to learn the Effect runtime:
- 🎯 tRPC-like DX:
client.group.endpoint(params)- fully typed - 🔄 Promise-based: Works with
async/await- no Effect runtime needed - 🎨 Type-safe errors: Pattern match on specific error types
- 💎 Errors as values: No exceptions thrown, all errors are explicit in the type system
- ⚡ Server-first: Perfect for Next.js Server Components & Actions
- 📦 Tiny: Minimal wrapper around Effect Platform
npm install effect-http-bridgepnpm add effect-http-bridgebun add effect-http-bridgeFirst, define your Effect HTTP API using @effect/platform:
import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "@effect/platform";
import { Schema } from "effect";
class Api extends HttpApi.make("api").add(
HttpApiGroup.make("counter")
.add(HttpApiEndpoint.get("count", "/count").addSuccess(Schema.Number))
.add(HttpApiEndpoint.post("increment", "/increment"))
) {}Use HttpBridge.Tag to create a typed client with automatic setup:
import { HttpBridge } from "effect-http-bridge";
import { FetchHttpClient } from "@effect/platform";
import { Api } from "./api";
export class ApiClient extends HttpBridge.Tag<ApiClient>()("ApiClient", {
api: Api,
httpClient: FetchHttpClient.layer,
baseUrl: "http://localhost:3000",
}) {}The client works with async/await and returns Promise<Result<A, E>>:
// In a React Server Component
const result = await ApiClient.query("counter", "count", {});
// In a Server Action
const result = await ApiClient.mutation("counter", "increment")({});Use Result.builder() for type-safe pattern matching in your UI:
import { Result } from "effect-http-bridge";
import { ApiClient } from "./api";
export default async function Page() {
const result = await ApiClient.query("counter", "count", {});
return (
<div>
{Result.builder(result)
.onSuccess((value, success) => (
<div>
<p>Count: {value}</p>
<p className="text-xs text-gray-400">
Updated: {new Date(success.timestamp).toISOString()}
</p>
</div>
))
.onErrorTag("RequestError", (error) => (
<div className="text-red-600">
Network error: Could not connect to server
</div>
))
.onErrorTag("ResponseError", (error) => (
<div className="text-red-600">Server error: {error.message}</div>
))
.onDefect((defect) => (
<div className="text-red-600">Unexpected error: {String(defect)}</div>
))
.orElse(() => (
<div className="text-red-600">Unknown error</div>
))}
</div>
);
}Multiple patterns for handling results in server actions:
"use server";
import { Result } from "effect-http-bridge";
import { Cause } from "effect";
import { ApiClient } from "./api";
export async function incrementCounter() {
const result = await ApiClient.mutation("counter", "increment")({});
if (Result.isFailure(result)) {
return {
success: false,
error: Cause.pretty(result.cause),
};
}
return {
success: true,
data: result.value,
};
}"use server";
import { Result } from "effect-http-bridge";
import { Cause } from "effect";
import { ApiClient } from "./api";
export async function getCount() {
const result = await ApiClient.query("counter", "count", {});
return Result.match(result, {
onSuccess: (s) => ({
success: true as const,
data: s.value,
}),
onFailure: (f) => ({
success: false as const,
error: Cause.pretty(f.cause),
}),
});
}"use server";
import { Result } from "effect-http-bridge";
import { ApiClient } from "./api";
export async function deleteUser(userId: string) {
const result = await ApiClient.mutation(
"users",
"delete"
)({
path: { id: userId },
});
return Result.builder(result)
.onSuccess((value) => ({
success: true as const,
message: "User deleted successfully",
data: value,
}))
.onErrorTag("NotFound", (error) => ({
success: false as const,
error: "User not found",
code: "NOT_FOUND",
}))
.onErrorTag("Unauthorized", (error) => ({
success: false as const,
error: "You don't have permission to delete this user",
code: "UNAUTHORIZED",
}))
.onError((error) => ({
success: false as const,
error: "Failed to delete user",
code: "UNKNOWN_ERROR",
}))
.orElse(() => ({
success: false as const,
error: "An unexpected error occurred",
code: "UNEXPECTED",
}));
}"use server";
import { Result } from "effect-http-bridge";
import { ApiClient } from "./api";
export async function getCountOrThrow() {
const result = await ApiClient.query("counter", "count", {});
// Throws if failure, returns value if success
return Result.getOrThrow(result);
}"use server";
import { Result } from "effect-http-bridge";
import { ApiClient } from "./api";
export async function getCountWithDefault() {
const result = await ApiClient.query("counter", "count", {});
// Returns default if failure
return Result.getOrElse(result, () => 0);
}Creates a typed client from your Effect HTTP API definition.
class YourClient extends HttpBridge.Tag<YourClient>()("YourClient", {
api: YourApi, // Your HttpApi definition
httpClient: FetchHttpClient.layer, // HTTP client layer
baseUrl: "http://localhost:3000", // Base URL for requests
}) {}query(group, endpoint, params): Make a GET requestmutation(group, endpoint): Returns a function for POST/PUT/DELETE requests
type Result<A, E> = Success<A, E> | Failure<A, E>;
interface Success<A, E> {
_tag: "Success";
value: A;
timestamp: number;
}
interface Failure<A, E> {
_tag: "Failure";
cause: Cause.Cause<E>;
}Result.builder(result): Fluent API for pattern matching.onSuccess((value, success) => T): Handle success case.onErrorTag(tag, (error) => T): Handle specific error types.onErrorTag([tag1, tag2], (error) => T): Handle multiple error types.onError((cause) => T): Handle any error.onDefect((defect, failure) => T): Handle unexpected errors.orElse(() => T): Fallback handler
Result.match(result, { onSuccess, onFailure }): Simple pattern matchingResult.isSuccess(result): Type guard for successResult.isFailure(result): Type guard for failureResult.getOrThrow(result): Extract value or throwResult.getOrElse(result, fallback): Extract value or use default
Full type inference for:
- Request parameters (path, query, payload)
- Success values
- Error types (tagged unions)
- Pattern matching exhaustiveness
This library was inspired by and borrows concepts & code from effect-atom by @tim-smart. Special thanks for the excellent work on bridging Effect with React and demonstrating patterns for integrating Effect's HttpApi in a more accessible way.
MIT