Best practices
Best practices
A short list of patterns that come up repeatedly in production
svelte-effect-runtime apps. The runnable companion is
examples/sveltekit/
in the repo.
Tagged errors via Data.TaggedError
Use Data.TaggedError for every domain failure your remote functions
emit. The tag survives the wire, so the client can recover with
Effect.catchTag("Tag", err => ...) and read the typed payload fields
directly.
You don't have to colocate error classes — but a
src/lib/errors.tsmodule keeps tags consistent across remotes. The client's type inference works either way.
import { Data } from "effect";
export class PostNotFound extends Data.TaggedError("PostNotFound")<{
readonly slug: string;
}> {}yield*, not await
Inside <script effect>, all remote calls return Effects. Use
yield* to resolve them; await will not work — Effects don't have a
.then.
<script lang="ts" effect>
import { get_posts } from "$lib/posts.remote";
// ✅ correct — top-level yield* is rewritten by the preprocessor
const posts = yield* get_posts();
// ❌ wrong — Effects are not awaitable
// const posts = await get_posts();
</script>The yield* rewrite only fires in three places: top-level statements
of <script effect>, bodies of Effect.gen(function* () { ... }), and
inline-arrow event handlers. Wrapping it in
function foo() { yield* ... } will hit a JS parser error before the
preprocessor even sees it.
<!-- ✅ inline arrow — preprocessor lowers the yield* -->
<button onclick={() => { yield* increment(1); }}>+1</button>
<!-- ✅ explicit Effect.gen — works because gen accepts yield* -->
<script lang="ts" effect>
const increment_twice = Effect.gen(function* () {
yield* increment(1);
yield* increment(1);
});
</script>Compose with Effect operators on submit(data)
form.submit(data) returns Effect<Output, RemoteFailure<Error>, never>
— pipe it through any Effect combinator you'd use elsewhere:
const slug = yield* create_post.submit({ title, body }).pipe(
Effect.tap((post) => Effect.log(`created ${post.slug}`)),
Effect.catchTag("TitleTooShort", () => Effect.succeed("draft"))
);The same goes for Query, Command, and the new (1.6.0)
form.validate() and form.enhance(callback) callback.
Keep request-scoped data out of ServerRuntime
ServerRuntime.make(...) is for long-lived services: database pools,
loggers, configuration. Read per-request values (cookies, headers,
the route, the current user) from RequestEvent inside the Effect
instead:
export const get_session = Query(() =>
Effect.gen(function* () {
const event = yield* RequestEvent;
const users = yield* UserRepository; // from ServerRuntime
const session_id = event.cookies.get("session_id"); // from request
return yield* users.find_by_session(session_id);
})
);<form {...form}> for everything that can submit
The 1.5.0+ wrapper makes <form {...form}> bit-identical to spreading
SvelteKit's native form(). Prefer it over Command: forms degrade
gracefully without JavaScript, and the spread wires up
progressive-enhancement plus the attachment that intercepts submission
via fetch.
Use form.submit(data) when there's genuinely no <form> (wizard
modals, keyboard shortcuts) — same Effect-shape either way.