Skip to content

Design Discussion: Effect.gen with yield-style API - Type Safety and Laziness Concerns #2

@shtse8

Description

@shtse8

Problem Statement

We're considering implementing an Effect.gen API that mimics Effect-TS's yield* syntax:

Effect<int> computation() {
  return Effect.gen((yield) {
    final a = yield(Effect.sync(() => 5));    // sync yield
    final b = yield(Effect.sync(() => a + 1));
    return a + b;
  });
}

However, this design has several significant issues in Dart:

Issues Identified

1. Type Safety Problems

  • What type does yield() return? dynamic? Object??
  • No compile-time type checking
  • Requires manual type casting
  • Runtime type errors likely

2. Effect Laziness Violations

  • When does the yield function execute the Effect?
  • If immediate execution → violates Effect's lazy evaluation principle
  • If deferred execution → how to synchronously get results?

3. Implementation Complexity

Requires complex state management:

  • Track yield call order
  • Exception mechanism to pause/resume execution
  • Multiple re-executions of generator function
  • Complex error propagation

4. Conflicts with Dart Language Features

// Dart's actual yield syntax
Iterable<int> generator() sync* {
  yield 1;  // Only produces values, cannot get results
}
  • Confuses developers familiar with Dart's yield
  • Violates Dart language conventions

5. Error Handling Complexity

Effect.gen((yield) {
  final a = yield(Effect.sync(() => throw Exception()));  // How to handle?
  final b = yield(Effect.sync(() => a + 1));  // Will this execute?
  return a + b;
});

Current Solution

We've implemented type-safe flatMap chaining instead:

Effect<int> computation() {
  return Effect.sync(() => 5)
    .flatMap((int a) => Effect.sync(() => a + 1)
      .flatMap((int b) => Effect.succeed(a + b)));
}

Benefits:

  • ✅ Full type safety with compile-time checking
  • ✅ Maintains Effect laziness principles
  • ✅ Follows Dart language conventions
  • ✅ Clear error handling semantics
  • ✅ No complex implementation required

Alternative Approaches

Option 1: Async/Await Style (with laziness preserved)

Effect<int> computation() {
  return Effect.defer(() async {
    final a = await Effect.sync(() => 5).run();
    final b = await Effect.sync(() => a + 1).run();
    return a + b;
  });
}

Option 2: Record-based (Dart 3.0+)

Effect<int> computation() {
  return Effect.all([
    Effect.sync(() => 5),
    Effect.sync(() => 1),
  ]).map((results) {
    final (a, b) = results;
    return a + b;
  });
}

Conclusion

While the yield-style API looks appealing and similar to Effect-TS, it introduces significant problems in Dart:

  • Type safety issues
  • Implementation complexity
  • Language convention conflicts
  • Laziness guarantee difficulties

Recommendation: Stick with the current flatMap-based approach that respects Dart's language features while maintaining Effect's core principles.

Discussion Points

  1. Are there other approaches we haven't considered?
  2. Is the type safety trade-off worth the syntax convenience?
  3. Should we provide multiple APIs for different use cases?
  4. How important is Effect-TS syntax compatibility vs Dart idioms?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions