skills/effect-ts-patterns/SKILL.md
Effect-TS API patterns for Hono server (error handling, schema validation, service composition, dependency injection). Use when building API handlers, services, or working with structured error types.
npx skillsauth add bkinsey808/songshare-effect effect-ts-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Requires: file-read, terminal (linting/testing). No network access needed.
Use this skill when:
api/ that use Effect, Hono handlers, schema decoding, or typed API errors.Note: Prefer exposing functions that return an Effect instead of raw Promise results. Convert
Promise-based boundaries to Effects with Effect.tryPromise so downstream code composes and maps
errors using Effect combinators.
Execution workflow:
Data.TaggedError), decode/validate untrusted input, and keep async work in Effect.handleHttpEndpoint, errorToHttpResponse) instead of duplicating response mapping.npm run lint and targeted unit tests for changed service/handler behavior.Output requirements:
Use Effect's Data.TaggedError for discriminated error unions:
// api/src/api-errors.ts
import { Data } from "effect";
export class ValidationError extends Data.TaggedError("ValidationError") {
constructor(readonly message: string) {
super();
}
}
export class NotFoundError extends Data.TaggedError("NotFoundError") {
constructor(
readonly resource: string,
readonly id: string,
) {
super();
}
}
export class DatabaseError extends Data.TaggedError("DatabaseError") {
constructor(readonly message: string) {
super();
}
}
type ApiError = ValidationError | NotFoundError | DatabaseError;
Why: Structured errors enable type-safe error handling, better error messages, and compile-time guarantees about what can fail.
Use Effect Schema for runtime validation with compile-time type inference:
// Per-feature schema file, e.g. api/src/song/songSchemas.ts
import { Schema } from "effect";
export const CreateSongRequestSchema = Schema.Struct({
title: Schema.String.pipe(Schema.minLength(1)),
artist: Schema.String.pipe(Schema.minLength(1)),
duration: Schema.Number.pipe(Schema.positive()),
});
export type CreateSongRequest = Schema.Schema.Type<typeof CreateSongRequestSchema>;
// Usage in handler:
const validatedData =
yield *
Schema.decodeUnknown(CreateSongRequestSchema)(body).pipe(
Effect.mapError((error) => new ValidationError({ message: Schema.formatIssueSync(error) })),
);
Why: Single source of truth for validation logic; errors are detailed and actionable.
Create service interfaces and implementations using Context:
// Per-feature service file, e.g. api/src/song/songService.ts
import { Context, Effect, Layer } from "effect";
// Define service interface
export type SongService = {
readonly create: (data: CreateSongRequest) => Effect.Effect<Song, ValidationError | DatabaseError>;
readonly getById: (id: string) => Effect.Effect<Song, NotFoundError | DatabaseError>;
readonly list: () => Effect.Effect<Song[], DatabaseError>;
};
export const SongService = Context.GenericTag<SongService>(
"SongService",
);
// Implement service
export const SongServiceLive = Layer.succeed(SongService, {
create: (data) =>
Effect.gen(function* () {
// Implementation here
return songData;
}),
getById: (id) =>
Effect.gen(function* () {
const song = yield* Effect.tryPromise({
try: () => db.query("SELECT * FROM songs WHERE id = $1", [id]),
catch: () => new DatabaseError({ message: "Query failed" }),
});
if (!song) {
yield* Effect.fail(new NotFoundError({ resource: "Song", id }));
}
return song;
}),
list: () => /* ... */,
});
Why: Services become testable units; dependency injection enables swapping implementations (real DB vs mock for tests).
The project provides two utilities under api/src/http/:
handleHttpEndpoint — wraps an Effect-returning function and runs it as a Hono response handlererrorToHttpResponse — maps typed ApiError variants to HTTP Response objects with appropriate status codes// api/src/song/songHandler.ts
import { handleHttpEndpoint } from "@/api/http/handleHttpEndpoint";
app.post("/api/songs", async (c) => {
return handleHttpEndpoint(() =>
Effect.gen(function* () {
const body = yield* Effect.tryPromise({
try: () => c.req.json(),
catch: () => new ValidationError({ message: "Invalid JSON" }),
});
const service = yield* SongService;
return yield* service.create(body);
}),
)(c);
});
Why: Centralized error-to-HTTP conversion eliminates repetitive error handling in every endpoint.
Use Effect.gen for readable, sequential Effect composition:
// api/src/server.ts
app.post("/api/songs", async (c) => {
const songEffect = Effect.gen(function* () {
// Parse JSON (can fail with ValidationError)
const body = yield* Effect.tryPromise({
try: () => c.req.json(),
catch: () => new ValidationError({ message: "Invalid JSON" }),
});
// Validate against schema
const validatedData = yield* Schema.decodeUnknown(CreateSongRequestSchema)(body).pipe(
Effect.mapError((error) => new ValidationError({ message: Schema.formatIssueSync(error) })),
);
// Call service (injected via Context)
const service = yield* SongService;
const song = yield* service.create(validatedData);
return song;
});
return executeEffect(songEffect);
});
Why: Effect.gen provides do-notation style that reads like imperative code but retains functional guarantees.
// Bad: mixing Promise and Effect
const data = await somePromise;
const result = yield * service.doSomething();
✅ Better: Convert Promises to Effects:
const data =
yield *
Effect.tryPromise({
try: () => somePromise,
catch: (error) => new ApiError({ message: String(error) }),
});
const result = yield * service.doSomething();
// Bad
if (!data) {
throw new NotFoundError(...);
}
✅ Better:
if (!data) {
yield* Effect.fail(new NotFoundError(...));
}
// Bad: not mapping promise rejection to proper error type
const data = yield * Effect.tryPromise(() => dbQuery());
✅ Better: Always handle catch:
const data =
yield *
Effect.tryPromise({
try: () => dbQuery(),
catch: () => new DatabaseError({ message: "Query failed" }),
});
For detailed technical reference on Effect combinators, dependency injection, schema validation, HTTP integration, and Promise-to-Effect refactoring patterns, see Effect-TS Best Practices.
Key sections:
Run these after writing Effect code:
# Lint
npm run lint
# Unit tests (if testing service layer)
npm run test:unit
# Full build
npm run build:api
For detailed patterns and conversion workflows:
api/src/server.tsapi/src/api-errors.tsdocs/ai/rules.md.hono-best-practices.authentication-system.tools
Zustand state management patterns for this project — store creation, selectors, Immer middleware, async actions with loading states, devtools, persist, and testing. Use when authoring or editing Zustand stores (use*Store files) or components that subscribe to stores. Do NOT use for React component structure or TypeScript-only utilities.
testing
How to write, update, or split skill files in this repo. Use when creating a new SKILL.md, updating an existing one, or deciding whether to put content in a skill vs. docs/.
development
Complete guide for testing React hooks — renderHook, Documentation by Harness, installStore, fixtures, subscription patterns, lint/compiler traps, and pre-completion checklist. Read docs/testing/unit-test-hook-best-practices.md for the full reference.
development
Vitest unit test authoring for this repo — setup, mocking, API handler testing, and common pitfalls for non-hook code. Use when the user asks to add, update, fix, or review unit tests for utilities, components, API handlers, or scripts. Do NOT use for React hook tests — load unit-test-hook-best-practices instead.