Typed errors, end to end
Declare tagged errors once. Fail with them on the server; recover with
catchTag on the client.
SpacetimeDB that stays in Effect.
Your whole realtime backend as one Effect contract — tables, reducers, HTTP, and clients, typed from the database to the UI.
catchTag.Effect.fn, Layers, and HttpApi.import * as Effect from "effect/Effect"
import * as Schema from "effect/Schema"
import * as Stdb from "effect-spacetimedb"
const UserId = Schema.String.pipe(Schema.brand("App/UserId"))
const user = Stdb.table("user", {
public: true,
columns: {
id: Stdb.string(UserId).primaryKey(),
name: Stdb.string(),
},
})
const AppErrors = Stdb.errors.namespace("App")({
UserMissing: Stdb.error({ userId: Stdb.string(UserId) }),
})
const Users = Stdb.StdbGroup.make("Users").add(
Stdb.StdbFn.procedure("user_get", {
params: Stdb.struct({ userId: Stdb.string(UserId) }),
returns: Stdb.option(user.row),
errors: AppErrors,
}),
) Both call the same reducer and recover the same AppUserMissing
failure. Native parses it out of a string; Effect SpacetimeDB hands you a
typed error.
import { SenderError } from "spacetimedb"
import { DbConnection } from "./module_bindings"
declare const conn: DbConnection
declare const userId: string
export async function ensureUser() {
try {
await conn.reducers.userRequire({ userId })
return "ok" as const
} catch (error) {
if (error instanceof SenderError) {
// untyped string - parse and match by hand
const failure = JSON.parse(error.message) as {
tag?: string
error?: { userId?: string }
}
if (failure.tag === "AppUserMissing") {
return `missing:${failure.error?.userId}` as const
}
}
throw error
}
} import * as Effect from "effect/Effect"
import * as Stdb from "effect-spacetimedb"
import { Module } from "./module"
declare const userId: string
const Example = Stdb.project(Module.spec)
export const ensureUser = Effect.gen(function* () {
const client = yield* Example.client.http.Tag
return yield* client.reducers.userRequire({ userId }).pipe(
Effect.as("ok" as const),
// typed tagged error - error.userId is a string
Effect.catchTag("AppUserMissing", (error) =>
Effect.succeed(`missing:${error.userId}` as const),
),
)
}) The parts you get because it is built on Effect — not a list of generic SpacetimeDB features.
Declare tagged errors once. Fail with them on the server; recover with
catchTag on the client.
One branded Schema becomes your table columns, params, HTTP bodies, return values, and error fields.
Value type design →Handlers are Effects: yield Db and Tx, use
Effect.fn, and inject services through one runtime Layer.
Sessions share connection state, and tableGroup(keys).changes
coalesces update bursts into cached snapshots.
toHttpApi projects your routes into a stock Effect
HttpApi — call them from existing HttpApiClient
code.
Generated bindings stay in sync with the contract, so every reducer, route, and subscription call is typed end to end.
Generate clients →Effect Schema for data, services for dependencies, HttpApi for routes, streams for subscriptions — no new DSL to learn.
const UserId = Schema.String.pipe(Schema.brand("App/UserId"))
const UserName = Schema.String.pipe(Schema.minLength(1))
const user = Stdb.table("user", {
public: true,
columns: {
id: Stdb.string(UserId).primaryKey(),
name: Stdb.string(UserName),
},
})
const CreateUser = Stdb.struct({
userId: Stdb.string(UserId),
name: Stdb.string(UserName),
}) import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"
import * as HttpClient from "effect/unstable/http/HttpClient"
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
const Example = Stdb.project(Module.spec)
const api = Stdb.toHttpApi(Module.spec)
const httpClient = yield* HttpApiClient.make(api, {
baseUrl: Stdb.httpApiBaseUrl({ uri, databaseName }),
transformClient: HttpClient.mapRequest(
HttpClientRequest.bearerToken(token),
),
})
yield* httpClient.Users.get({ path: { userId } })
const group = session.tableGroup(["user"] as const)
yield* group.changes.pipe(Stream.runForEach(renderUsers)) Quickstart, guides, migration notes, and a generated llms.txt
— plus the full source.