Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
millionco avatar

Effect Portable Patterns

  • 46 installs
  • 3.5k repo stars
  • Updated May 6, 2026
  • millionco/expect

Helps with ai & agent building tasks during AI-assisted development.

About

effect-portable-patterns is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.

  • effect-portable-patterns
  • AI & Agent Building
  • AI-coding skill

Effect Portable Patterns by the numbers

  • 46 all-time installs (skills.sh)
  • +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
  • Ranked #7,629 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
  • Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/millionco/expect --skill effect-portable-patterns

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs46
repo stars3.5k
Last updatedMay 6, 2026
Repositorymillionco/expect

What it does

Helps with ai & agent building tasks during AI-assisted development.

Files

SKILL.mdMarkdownGitHub ↗

Effect as a Portable Promise Utility

Use Effect as a lightweight utility for running promises robustly. Every effect is self-contained (no services, no layers, no dependency injection) and resolves to a plain Promise at the boundary via Effect.runPromise.

The shape is always: Effect.fn or Effect.gen -> pipe operators -> Effect.runPromise.

Quick Reference

CapabilityAPIAvoid
Wrap promisesEffect.tryPromiseEffect.promise (swallows errors)
Define functionsEffect.fn("name")Anonymous generators
ErrorsData.TaggedError with _tagPlain Error or untagged objects
Catch by tagcatchTag / catchTagscatchAll (loses type narrowing)
FallbacksorElse, orElseSucceedNested try/catch
TimeoutsEffect.timeout / Effect.timeoutFailManual AbortController + setTimeout
RetriesEffect.retry with ScheduleManual retry loops
CachingEffect.cachedWithTTL / Effect.cachedFunctionManual Map-based caches
ConcurrencyEffect.all with { concurrency: N }Manual Promise.all chunking
Pattern matchingMatch.value / Match.type with Match.tagSwitch statements on _tag
TracingEffect.withSpan / Effect.annotateCurrentSpanManual console.time
Run at boundaryEffect.runPromiseEffect.runSync for async work

Core Pattern: Portable Effect Functions

Every effect function follows this structure - build an Effect<Success, Error, never> (no requirements), then run it as a promise at the call site.

import { Effect, Data } from "effect";

class FetchError extends Data.TaggedError("FetchError")<{
  url: string;
  status: number;
  message: string;
}> {}

const fetchUser = Effect.fn("fetchUser")(function* (userId: string) {
  const response = yield* Effect.tryPromise({
    try: () => fetch(`/api/users/${userId}`),
    catch: () =>
      new FetchError({
        url: `/api/users/${userId}`,
        status: 0,
        message: "Network error",
      }),
  });

  if (!response.ok) {
    return yield* Effect.fail(
      new FetchError({
        url: `/api/users/${userId}`,
        status: response.status,
        message: response.statusText,
      }),
    );
  }

  const user = yield* Effect.tryPromise({
    try: () => response.json() as Promise<User>,
    catch: () =>
      new FetchError({
        url: `/api/users/${userId}`,
        status: response.status,
        message: "Invalid JSON",
      }),
  });

  return user;
});

// At the call site - always resolves to a plain Promise
const user: User = await Effect.runPromise(fetchUser("123"));

Tagged Errors

Define errors with Data.TaggedError. The _tag field enables type-safe error matching without services or schemas.

import { Data } from "effect";

class TimeoutError extends Data.TaggedError("TimeoutError")<{
  operation: string;
  durationMs: number;
}> {}

class NotFoundError extends Data.TaggedError("NotFoundError")<{
  resource: string;
  id: string;
}> {}

class ValidationError extends Data.TaggedError("ValidationError")<{
  field: string;
  message: string;
}> {}

Catching Errors by Tag

Use catchTag for single tags, catchTags for multiple. Both preserve type narrowing.

const result =
  yield *
  fetchUser("123").pipe(
    Effect.catchTag("NotFoundError", (error) =>
      Effect.succeed({ id: error.id, name: "Unknown", fallback: true }),
    ),
    Effect.catchTag("TimeoutError", (error) =>
      Effect.fail(
        new ServiceUnavailableError({
          message: `${error.operation} timed out`,
        }),
      ),
    ),
  );

// Or handle multiple tags at once
const result =
  yield *
  fetchUser("123").pipe(
    Effect.catchTags({
      NotFoundError: (error) => Effect.succeed(defaultUser),
      ValidationError: (error) => Effect.fail(new BadRequestError({ message: error.message })),
    }),
  );

Timeouts

Basic Timeout (raises TimeoutException)

const result = yield * fetchUser("123").pipe(Effect.timeout("5 seconds"));

Timeout with Custom Error

const result =
  yield *
  fetchUser("123").pipe(
    Effect.timeoutFail({
      duration: "5 seconds",
      onTimeout: () => new TimeoutError({ operation: "fetchUser", durationMs: 5000 }),
    }),
  );

Timeout with Fallback Value

const result =
  yield *
  fetchUser("123").pipe(
    Effect.timeoutTo({
      duration: "5 seconds",
      onSuccess: (user) => user,
      onTimeout: () => defaultUser,
    }),
  );

Retries

Fixed Retry Count

import { Schedule } from "effect";

const result = yield * fetchUser("123").pipe(Effect.retry({ times: 3 }));

Exponential Backoff

const result =
  yield *
  fetchUser("123").pipe(
    Effect.retry(Schedule.exponential("100 millis").pipe(Schedule.compose(Schedule.recurs(5)))),
  );

Retry Only Specific Errors

const result =
  yield *
  fetchUser("123").pipe(
    Effect.retry({
      times: 3,
      while: (error) => error._tag === "TimeoutError",
    }),
  );

Retry with Fallback on Exhaustion

const result =
  yield *
  Effect.retryOrElse(fetchUser("123"), { times: 3 }, (error, fiberId) =>
    Effect.succeed(defaultUser),
  );

Combining Timeout + Retry

const robustFetch = Effect.fn("robustFetch")(function* (userId: string) {
  const user = yield* fetchUser(userId).pipe(
    Effect.timeoutFail({
      duration: "3 seconds",
      onTimeout: () => new TimeoutError({ operation: "fetchUser", durationMs: 3000 }),
    }),
    Effect.retry(Schedule.exponential("200 millis").pipe(Schedule.compose(Schedule.recurs(3)))),
  );
  return user;
});

Fallbacks

// Try primary, fall back to secondary on any failure
const result = yield * fetchFromPrimary(id).pipe(Effect.orElse(() => fetchFromSecondary(id)));

// Fall back to a default value
const result = yield * fetchUser(id).pipe(Effect.orElseSucceed(() => defaultUser));

// Remap the error type on failure
const result =
  yield *
  fetchUser(id).pipe(
    Effect.orElseFail(() => new ServiceUnavailableError({ message: "All sources failed" })),
  );

// Try multiple sources, use the first that succeeds
const result =
  yield * Effect.firstSuccessOf([fetchFromCache(id), fetchFromPrimary(id), fetchFromSecondary(id)]);

Caching

Cache an Effect with TTL

import { Effect } from "effect";

const cachedConfig = Effect.cachedWithTTL(
  Effect.tryPromise(() => fetch("/api/config").then((r) => r.json())),
  "5 minutes",
);

// Use it - first call fetches, subsequent calls return cached value within TTL
const program = Effect.gen(function* () {
  const getConfig = yield* cachedConfig;
  const config1 = yield* getConfig;
  const config2 = yield* getConfig; // same value, no re-fetch
});

Cache with Manual Invalidation

const [getConfig, invalidate] = yield * Effect.cachedInvalidateWithTTL(fetchConfig, "10 minutes");

const config = yield * getConfig;
yield * invalidate; // force re-fetch on next call

Memoize a Function by Arguments

const memoizedFetchUser =
  yield *
  Effect.cachedFunction((userId: string) =>
    Effect.tryPromise(() => fetch(`/api/users/${userId}`).then((r) => r.json())),
  );

const user1 = yield * memoizedFetchUser("123"); // fetches
const user2 = yield * memoizedFetchUser("123"); // returns cached
const user3 = yield * memoizedFetchUser("456"); // fetches (different key)

Concurrency

Parallel Execution

// Run all effects in parallel (unbounded)
const [users, posts, comments] =
  yield *
  Effect.all([fetchUsers, fetchPosts, fetchComments], {
    concurrency: "unbounded",
  });

// Bounded concurrency (e.g., max 5 at a time)
const results = yield * Effect.all(tasks, { concurrency: 5 });

// Parallel forEach
const enrichedUsers = yield * Effect.forEach(userIds, (id) => fetchUser(id), { concurrency: 10 });

Racing (First to Succeed)

// Race two effects, take the first to complete
const result = yield * Effect.race(fetchFromEast, fetchFromWest);

// Race many effects
const result = yield * Effect.raceAll([fetchFromCache(id), fetchFromDb(id), fetchFromRemote(id)]);

Pattern Matching

Use Match for exhaustive, type-safe branching on tagged unions and error types.

Matching Tagged Errors

import { Match } from "effect";

type ApiError = NotFoundError | TimeoutError | ValidationError;

const describeError = (error: ApiError) =>
  Match.value(error).pipe(
    Match.tag("NotFoundError", (e) => `${e.resource} ${e.id} not found`),
    Match.tag("TimeoutError", (e) => `${e.operation} timed out after ${e.durationMs}ms`),
    Match.tag("ValidationError", (e) => `Invalid ${e.field}: ${e.message}`),
    Match.exhaustive,
  );

Matching by Type (Reusable Matcher)

const handleResult = Match.type<string | number | boolean>().pipe(
  Match.when(Match.string, (s) => `string: ${s}`),
  Match.when(Match.number, (n) => `number: ${n}`),
  Match.when(Match.boolean, (b) => `boolean: ${b}`),
  Match.exhaustive,
);

handleResult("hello"); // "string: hello"
handleResult(42); // "number: 42"

Matching with Predicates

const categorize = Match.type<{ status: number }>().pipe(
  Match.when({ status: (s) => s >= 500 }, () => "server_error"),
  Match.when({ status: (s) => s >= 400 }, () => "client_error"),
  Match.when({ status: (s) => s >= 200 }, () => "success"),
  Match.orElse(() => "unknown"),
);

Tracing

Adding Spans

Effect.fn automatically creates a span with the given name. For additional spans or annotations:

const fetchAndProcess = Effect.fn("fetchAndProcess")(function* (userId: string) {
  yield* Effect.annotateCurrentSpan("userId", userId);

  const user = yield* fetchUser(userId).pipe(Effect.withSpan("fetchUser"));

  const processed = yield* processUser(user).pipe(Effect.withSpan("processUser"));

  yield* Effect.annotateCurrentSpan("processed", true);
  return processed;
});

Logging within Effects

const program = Effect.fn("program")(function* () {
  yield* Effect.log("Starting operation");
  const result = yield* doWork();
  yield* Effect.log("Operation complete", { resultId: result.id });
  return result;
});

Complete Example: Robust API Call

Combining all patterns into a single portable function:

import { Effect, Data, Schedule, Match } from "effect";

class ApiError extends Data.TaggedError("ApiError")<{
  url: string;
  status: number;
  message: string;
}> {}

class ApiTimeoutError extends Data.TaggedError("ApiTimeoutError")<{
  url: string;
  durationMs: number;
}> {}

const fetchApi = Effect.fn("fetchApi")(function* <T>(url: string) {
  yield* Effect.annotateCurrentSpan("url", url);
  yield* Effect.log("Fetching", { url });

  const response = yield* Effect.tryPromise({
    try: () => fetch(url),
    catch: () => new ApiError({ url, status: 0, message: "Network error" }),
  }).pipe(
    Effect.timeoutFail({
      duration: "10 seconds",
      onTimeout: () => new ApiTimeoutError({ url, durationMs: 10_000 }),
    }),
    Effect.retry(Schedule.exponential("500 millis").pipe(Schedule.compose(Schedule.recurs(3)))),
  );

  if (!response.ok) {
    return yield* Effect.fail(
      new ApiError({
        url,
        status: response.status,
        message: response.statusText,
      }),
    );
  }

  return yield* Effect.tryPromise({
    try: () => response.json() as Promise<T>,
    catch: () => new ApiError({ url, status: response.status, message: "Invalid JSON" }),
  });
});

// Usage at the boundary - resolves to a plain Promise
const data = await Effect.runPromise(
  fetchApi<User>("/api/users/123").pipe(
    Effect.catchTag("ApiTimeoutError", () =>
      Effect.succeed({ id: "123", name: "Unknown" } as User),
    ),
  ),
);

Rules

RuleGuidance
No services or layersKeep effects self-contained with R = never. Use services only when the effect-best-practices skill applies.
Always Effect.fnName every function for automatic tracing spans.
Always Data.TaggedErrorEvery distinct failure gets its own tagged error class.
Always Effect.tryPromiseNever use Effect.promise - it swallows errors as defects.
Prefer catchTagUse catchTag / catchTags over catchAll to preserve type narrowing.
Timeout everythingExternal calls should always have Effect.timeout or Effect.timeoutFail.
Run at the boundaryCall Effect.runPromise at the outermost call site, not inside effect functions.

References

Related skills

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.