Skip to content

Fix: Effect (effect-ts) Not Working — Effect Not Running, Services Missing, or Type Errors

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Effect issues — running effects with Effect.runPromise, Layer and service dependency injection, error channel types, Fiber concurrency, Schema validation, and common TypeScript errors.

The Problem

You create an Effect but nothing happens:

import { Effect } from 'effect';

const program = Effect.log('Hello');
// Nothing prints — the effect never runs

Or providing a service fails with a type error:

Effect.runPromise(program);
// Type error: Argument of type 'Effect<void, never, UserService>'
// is not assignable to parameter of type 'Effect<void, never, never>'

Or an Effect fails at runtime with a “service not found” error:

Error: Service not found: UserService

Or error handling compiles but the error channel type is wrong:

const result = Effect.tryPromise(() => fetch('/api'));
// Type is Effect<Response, UnknownException, never>
// but you want Effect<Response, FetchError, never>

Why This Happens

Effect is a functional effect system for TypeScript. Every Effect<A, E, R> is a description of a computation, not the computation itself — it only runs when you explicitly execute it. The mental shift is similar to going from Promise (eager, runs immediately) to a lazy IO monad — and most production bugs come from treating Effect like Promise.

The three type parameters are load-bearing in a way Promise’s single T is not. A is the success type, E is the typed error channel, and R is the dependency channel. The compiler refuses to run an effect with unsatisfied dependencies, so a “type error at runPromise” almost always means a service somewhere in your call graph hasn’t been provided. This is good — it’s the compiler saving you from a Service not found exception in production — but it makes the diagnostic cycle longer because you have to walk back through the layer composition to find what was missed.

The runtime layer adds another category of issues. Effect ships an ESM-only package with optional fiber-based concurrency, and that combination interacts differently with each JavaScript runtime. Node, Bun, and Deno expose subtly different microtask schedulers, and bundler choices (Webpack vs esbuild vs tsx) can break the lazy evaluation by inlining what should stay deferred. Frontend integrations add their own complications: the browser bundle is large, React rendering doesn’t naturally compose with Effect.gen, and integrating with React’s lifecycle requires @effect-rx/rx-react or hand-rolled hooks.

  • Effects are lazy valuesEffect.log('Hello') creates an effect description. Nothing happens until you pass it to Effect.runPromise, Effect.runSync, or Effect.runFork. This is the most fundamental difference from imperative code.
  • The R type parameter tracks service dependencies — when your effect uses a service (via Effect.Service or Context.Tag), the R channel accumulates those requirements. Effect.runPromise only accepts effects where R = never (all dependencies provided). You must provide all services via Layer before running.
  • The E type parameter tracks typed errors — unlike Promise, where errors are unknown, Effect tracks exactly which errors can occur at the type level. Effect.tryPromise returns UnknownException by default because it can’t know what the promise rejects with. You must map it to a typed error.
  • Services are resolved at runtime through the Layer system — even if types check out, if you provide a Layer that doesn’t include all transitive dependencies, the runtime provide call throws.

Fix 1: Run Effects Correctly

npm install effect
import { Effect, Console } from 'effect';

// Effects are descriptions — they don't execute until you run them
const program = Effect.gen(function* () {
  yield* Console.log('Hello from Effect!');
  const value = yield* Effect.succeed(42);
  yield* Console.log(`Got value: ${value}`);
  return value;
});

// Run as a Promise
const result = await Effect.runPromise(program);
// Output: Hello from Effect!
// Output: Got value: 42
// result = 42

// Run synchronously (only for effects that don't use async operations)
const syncResult = Effect.runSync(Effect.succeed(42));

// Run and handle both success and failure
Effect.runPromiseExit(program).then(exit => {
  if (exit._tag === 'Success') {
    console.log('Success:', exit.value);
  } else {
    console.log('Failure:', exit.cause);
  }
});

// Run as a Fiber (non-blocking, cancellable)
const fiber = Effect.runFork(program);

Using Effect.gen (generator syntax — most readable):

import { Effect } from 'effect';

// Generator syntax — yield* unwraps Effect values
const fetchUser = (id: string) =>
  Effect.gen(function* () {
    const response = yield* Effect.tryPromise({
      try: () => fetch(`/api/users/${id}`),
      catch: () => new FetchError({ message: 'Network error' }),
    });

    if (!response.ok) {
      return yield* Effect.fail(new NotFoundError({ id }));
    }

    const user = yield* Effect.tryPromise({
      try: () => response.json() as Promise<User>,
      catch: () => new ParseError({ message: 'Invalid JSON' }),
    });

    return user;
  });

// Type: Effect<User, FetchError | NotFoundError | ParseError, never>

Fix 2: Define and Provide Services

Services use Context.Tag for dependency injection:

import { Effect, Context, Layer } from 'effect';

// Step 1: Define the service interface and tag
class UserRepository extends Context.Tag('UserRepository')<
  UserRepository,
  {
    readonly getById: (id: string) => Effect.Effect<User, NotFoundError>;
    readonly getAll: () => Effect.Effect<User[]>;
    readonly save: (user: User) => Effect.Effect<void>;
  }
>() {}

// Step 2: Use the service in an effect
const getUserProfile = (id: string) =>
  Effect.gen(function* () {
    const repo = yield* UserRepository;  // Request the service
    const user = yield* repo.getById(id);
    return { name: user.name, email: user.email };
  });

// Type: Effect<UserProfile, NotFoundError, UserRepository>
//                                           ^^^^^^^^^^^^^^ — requires UserRepository

// Step 3: Create a Layer that provides the service
const UserRepositoryLive = Layer.succeed(UserRepository, {
  getById: (id) =>
    Effect.tryPromise({
      try: () => fetch(`/api/users/${id}`).then(r => r.json()),
      catch: () => new NotFoundError({ id }),
    }),
  getAll: () =>
    Effect.tryPromise({
      try: () => fetch('/api/users').then(r => r.json()),
      catch: () => new Error('Failed to fetch users'),
    }),
  save: (user) =>
    Effect.tryPromise({
      try: () =>
        fetch('/api/users', {
          method: 'POST',
          body: JSON.stringify(user),
        }).then(() => undefined),
      catch: () => new Error('Failed to save'),
    }),
});

// Step 4: Provide the layer and run
const program = getUserProfile('123').pipe(
  Effect.provide(UserRepositoryLive),
);
// Type: Effect<UserProfile, NotFoundError, never>  — R is now never

const result = await Effect.runPromise(program);

Fix 3: Compose Layers for Complex Dependency Trees

Real apps have services that depend on other services:

import { Effect, Context, Layer, Config } from 'effect';

// Database service
class Database extends Context.Tag('Database')<
  Database,
  { readonly query: (sql: string) => Effect.Effect<any[]> }
>() {}

// Logger service
class Logger extends Context.Tag('Logger')<
  Logger,
  { readonly info: (msg: string) => Effect.Effect<void> }
>() {}

// UserRepository depends on Database and Logger
const UserRepositoryLive = Layer.effect(
  UserRepository,
  Effect.gen(function* () {
    const db = yield* Database;
    const logger = yield* Logger;

    return {
      getById: (id: string) =>
        Effect.gen(function* () {
          yield* logger.info(`Fetching user ${id}`);
          const rows = yield* db.query(`SELECT * FROM users WHERE id = '${id}'`);
          if (rows.length === 0) return yield* Effect.fail(new NotFoundError({ id }));
          return rows[0] as User;
        }),
      getAll: () => db.query('SELECT * FROM users') as any,
      save: (user: User) =>
        Effect.gen(function* () {
          yield* db.query(`INSERT INTO users VALUES (...)`);
          yield* logger.info(`Saved user ${user.name}`);
        }),
    };
  })
);

// Provide dependencies for the layers
const DatabaseLive = Layer.succeed(Database, {
  query: (sql) => Effect.tryPromise(() => /* pg pool query */ [] as any),
});

const LoggerLive = Layer.succeed(Logger, {
  info: (msg) => Effect.log(msg),
});

// Compose layers — UserRepositoryLive needs Database and Logger
const AppLayer = UserRepositoryLive.pipe(
  Layer.provide(Layer.merge(DatabaseLive, LoggerLive)),
);

// Run the full program
const program = getUserProfile('123').pipe(Effect.provide(AppLayer));
await Effect.runPromise(program);

Fix 4: Typed Error Handling

Effect tracks errors at the type level:

import { Effect, Data } from 'effect';

// Define typed errors using Data.TaggedError
class NotFoundError extends Data.TaggedError('NotFoundError')<{
  readonly id: string;
}> {}

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

class NetworkError extends Data.TaggedError('NetworkError')<{
  readonly url: string;
}> {}

// Effect that can fail with typed errors
const createUser = (input: unknown) =>
  Effect.gen(function* () {
    // Validate
    if (!input || typeof input !== 'object') {
      return yield* Effect.fail(new ValidationError({
        field: 'body',
        message: 'Invalid input',
      }));
    }

    // Save
    const response = yield* Effect.tryPromise({
      try: () => fetch('/api/users', { method: 'POST', body: JSON.stringify(input) }),
      catch: () => new NetworkError({ url: '/api/users' }),
    });

    if (response.status === 409) {
      return yield* Effect.fail(new ValidationError({
        field: 'email',
        message: 'Email already exists',
      }));
    }

    return yield* Effect.tryPromise({
      try: () => response.json() as Promise<User>,
      catch: () => new NetworkError({ url: '/api/users' }),
    });
  });

// Type: Effect<User, ValidationError | NetworkError, never>

// Handle specific errors
const handled = createUser(input).pipe(
  // Catch and recover from specific error types
  Effect.catchTag('ValidationError', (e) =>
    Effect.succeed({ error: `Validation failed: ${e.field} — ${e.message}` }),
  ),
  // Catch all remaining errors
  Effect.catchTag('NetworkError', (e) =>
    Effect.succeed({ error: `Network error: ${e.url}` }),
  ),
  // Or catch all errors at once
  // Effect.catchAll((e) => Effect.succeed({ error: String(e) })),
);

// Retry on transient errors
const withRetry = createUser(input).pipe(
  Effect.retry({ times: 3 }),  // Retry up to 3 times on any error
);

Fix 5: Concurrency with Fibers

Effect provides structured concurrency through Fibers:

import { Effect, Fiber, Schedule, Duration } from 'effect';

// Run effects concurrently — collect all results
const fetchAll = Effect.all(
  [fetchUser('1'), fetchUser('2'), fetchUser('3')],
  { concurrency: 3 },  // Max 3 concurrent
);

// Race — return the first to succeed
const fastest = Effect.race(
  fetchFromPrimary(),
  fetchFromFallback(),
);

// Fork a background fiber
const program = Effect.gen(function* () {
  // Start background task
  const fiber = yield* Effect.fork(
    longRunningTask().pipe(
      Effect.repeat(Schedule.spaced(Duration.seconds(30))),
    ),
  );

  // Do other work while fiber runs
  yield* doMainWork();

  // Wait for fiber or cancel it
  yield* Fiber.interrupt(fiber);
});

// Timeout
const withTimeout = fetchUser('1').pipe(
  Effect.timeout(Duration.seconds(5)),
  // Returns Option<User> — None if timed out
);

// Scoped resources — auto-cleanup
const acquireConnection = Effect.acquireRelease(
  Effect.sync(() => createDbConnection()),   // Acquire
  (conn) => Effect.sync(() => conn.close()), // Release (guaranteed)
);

Fix 6: Schema Validation

@effect/schema provides runtime validation with full type inference:

npm install @effect/schema
import { Schema } from '@effect/schema';
import { Effect } from 'effect';

// Define a schema
const UserSchema = Schema.Struct({
  id: Schema.String,
  name: Schema.String.pipe(Schema.nonEmptyString()),
  email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/)),
  age: Schema.Number.pipe(Schema.int(), Schema.between(0, 150)),
  role: Schema.Literal('admin', 'user', 'viewer'),
  createdAt: Schema.Date,
});

// Infer TypeScript type from schema
type User = Schema.Schema.Type<typeof UserSchema>;

// Decode unknown data — returns Effect with typed error
const parseUser = Schema.decodeUnknown(UserSchema);
// Type: (input: unknown) => Effect<User, ParseError, never>

const program = Effect.gen(function* () {
  const raw = yield* Effect.tryPromise(() =>
    fetch('/api/users/1').then(r => r.json())
  );

  // Validate and parse — fails with ParseError if invalid
  const user = yield* parseUser(raw);
  return user;  // Fully typed User
});

// Encode back to JSON-safe format
const encodeUser = Schema.encodeUnknown(UserSchema);

// Transform between formats
const DateFromString = Schema.transform(
  Schema.String,
  Schema.Date,
  {
    decode: (s) => new Date(s),
    encode: (d) => d.toISOString(),
  },
);

Fix 7: Runtime and Framework Integration

Effect runs on every modern JS runtime, but the integration surface differs.

Node.js (18+):

Node is the smoothest target. Use a recent LTS — Effect’s internal use of AbortSignal, structuredClone, and queueMicrotask is fine on 18+, but some experimental APIs (like the test runner with --experimental-vm-modules) interact poorly with Effect’s fiber scheduler. Stick to tsx or tsup for dev/build; ts-node is no longer recommended for Effect projects because it doesn’t preserve ESM semantics around dynamic imports.

# tsx is the recommended dev runner for Effect projects
npm install -D tsx
npx tsx src/index.ts

For TypeScript runner alternatives and edge cases, see Fix: tsx Not Working.

Bun:

Bun runs Effect natively without transpilation. Performance is generally better for fiber-heavy workloads thanks to Bun’s faster microtask queue. The one catch: Bun’s built-in fetch handles AbortSignal slightly differently than Node, so Effect.tryPromise wrapping fetch with a timeout may surface different cancellation behavior. If you need consistent behavior across Bun and Node, use Effect.timeout rather than AbortSignal directly. See Fix: Bun Not Working for Bun-specific issues.

Deno:

Effect works in Deno via npm:effect specifiers. The main issue is permission flags — Effect’s Config system reads from Deno.env, which requires --allow-env. If you forget, Config.string('DB_URL') silently fails because Deno throws a PermissionDenied error that Effect catches but doesn’t surface clearly:

deno run --allow-env --allow-net src/index.ts

ESM-only constraint:

The effect package is published as pure ESM. If your project still uses CommonJS ("type": "commonjs" in package.json or no type field), you get ERR_REQUIRE_ESM at runtime. Either set "type": "module" or use dynamic import() to load Effect inside async functions.

React integration via @effect-rx/rx-react:

Calling Effect.runPromise directly inside a useEffect works but skips Effect’s structured concurrency benefits and tends to leak fibers on unmount. The recommended pattern is @effect-rx/rx-react:

import { Result, useRxValue } from '@effect-rx/rx-react';
import { Rx } from '@effect-rx/rx';

const userRx = Rx.fn((id: string) =>
  Effect.tryPromise({
    try: () => fetch(`/api/users/${id}`).then(r => r.json()),
    catch: () => new NetworkError({ url: `/api/users/${id}` }),
  })
);

function UserProfile({ id }: { id: string }) {
  const result = useRxValue(userRx(id));
  return Result.builder(result)
    .onInitial(() => <p>Loading...</p>)
    .onFailure(e => <p>Error: {e.toString()}</p>)
    .onSuccess(user => <p>{user.name}</p>)
    .render();
}

This handles fiber cleanup on unmount, deduplicates concurrent requests, and integrates with React’s concurrent rendering. Without it, you’ll see “fiber not interrupted” warnings in dev and orphaned network requests in production.

Browser bundle size:

The full effect package weighs ~80KB gzipped. For frontend apps where every KB matters, prefer tree-shakeable imports (import { Effect } from 'effect') and avoid pulling in @effect/platform unless you genuinely need its file/HTTP abstractions. Modern bundlers tree-shake Effect well, but barrel re-exports from your own utility files can defeat that.

Service injection patterns per platform:

In a Node server, you typically build one root Layer at startup and Effect.provide it to every request handler. In serverless (Vercel, Lambda), the same approach causes cold-start latency because layers initialize per cold start. Cache the runtime with ManagedRuntime.make(AppLayer) and reuse it across invocations:

import { ManagedRuntime } from 'effect';

const runtime = ManagedRuntime.make(AppLayer);

export async function handler(event: any) {
  return runtime.runPromise(processEvent(event));
}

For TypeScript-specific issues around generic constraints inside Layer composition, see Fix: TypeScript Generic Constraint Error.

Still Not Working?

Type 'Effect<A, E, SomeService>' is not assignable to 'Effect<A, E, never>' — you have an unprovided service. Every service in the R channel must be satisfied via Effect.provide(layer) before Effect.runPromise. Use Effect.provide(Layer.merge(layerA, layerB)) to provide multiple services at once. Check that your layer composition includes all transitive dependencies.

yield* does nothing / returns the effect itself — you must be inside Effect.gen(function* () { ... }) for yield* to unwrap effects. Outside a generator, yield* is a syntax error or no-op. Also verify that every yield* targets an Effect value, not a plain promise (use Effect.tryPromise to wrap promises).

Effect runs but never completes — check for infinite loops in Effect.repeat without a Schedule termination condition. Also check for deadlocks: if two fibers wait on each other’s results via Fiber.join, neither can proceed. Use Effect.race or Effect.timeout to add upper bounds.

Config values are undefined at runtimeConfig.string('DB_URL') reads from environment variables by default. The variable must exist when the effect runs. For testing, provide a ConfigProvider: Effect.provide(Layer.setConfigProvider(ConfigProvider.fromMap(new Map([['DB_URL', 'postgres://...']])))).

ERR_REQUIRE_ESM when importing effect — your package.json doesn’t have "type": "module" and a CJS file is trying to require('effect'). Switch the project to ESM, use a dynamic import('effect'), or downgrade to a CJS-compatible Effect version if you can’t migrate. The mixed-module strategy gets messy fast — committing fully to ESM is the cleanest fix.

Schema validation passes but the parsed value is the wrong type — for related runtime validation patterns, see Fix: Zod Validation Not Working. Effect Schema and Zod share the same trap: a transformation step (Schema.transform, Zod’s .transform()) silently coerces unexpected inputs into shapes that satisfy the output schema but lose data.

React component re-renders on every fiber tick — you’re probably calling Effect.runPromise inside the render body instead of useEffect or @effect-rx/rx-react. Runs inside render trigger state updates that trigger re-renders that re-run the effect.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles