Skip to content

Rule proposal: Effect.partition followed by fail-if-any-errors re-implements Effect.validate #662

Description

@mattiamanzati

Problem

Effect.partition(items, f) runs f over every element and returns the two channels as a plain tuple: Effect<[excluded: Array<E>, satisfying: Array<B>], never, R>. A very common follow-up is to immediately gate on the error half — if any element failed, fail with the collected errors; otherwise continue with the successes. Users write this by hand with an Effect.flatMap (or, in gen style, a destructured yield* followed by an if), because partition is the API they know that "collects all failures instead of short-circuiting".

Effect already ships exactly this composite as Effect.validate(items, f). It always evaluates every element, and its type is more precise than what the manual gate can express: it succeeds with Array<B> and fails with NonEmptyArray<E> — the non-emptiness of the error array is carried in the error channel for free, whereas the hand-rolled version fails with a plain Array<E> (the errors.length > 0 fact is erased the moment it leaves the conditional). The rewrite deletes the entire flatMap/conditional scaffold, removes the tuple destructuring (and the chance of swapping the tuple halves — partition puts errors first, which regularly surprises people), and gives downstream catchAll handlers a NonEmptyArray<E> they can index into without a length check.

Bad — compiles cleanly, the rule should flag this

// RULE: partitionThenFailToValidate
// BAD: Effect.partition followed by "fail if any errors, otherwise succeed
// with the results" re-implements Effect.validate, but with a weaker type:
// the failure is a plain Array<E> instead of NonEmptyArray<E>, and the
// [errors, results] tuple order is an easy thing to get backwards.
import { Effect } from "effect"

declare const checkUser: (id: string) => Effect.Effect<string, Error>
declare const userIds: ReadonlyArray<string>

const validatedUsers = Effect.partition(userIds, checkUser).pipe(
  Effect.flatMap(([errors, users]) =>
    errors.length > 0 ? Effect.fail(errors) : Effect.succeed(users)
  )
)
void validatedUsers

// gen-form of the same pattern
const validatedUsersGen = Effect.gen(function*() {
  const [errors, users] = yield* Effect.partition(userIds, checkUser)
  if (errors.length > 0) {
    return yield* Effect.fail(errors)
  }
  return users
})
void validatedUsersGen

Good

// RULE: partitionThenFailToValidate
// GOOD: Effect.validate is the built-in "run everything, then fail with all
// collected errors" combinator. It succeeds with Array<B> and fails with
// NonEmptyArray<E>, so the non-emptiness the manual length check proved is
// preserved in the error channel instead of erased.
import { Effect } from "effect"

declare const checkUser: (id: string) => Effect.Effect<string, Error>
declare const userIds: ReadonlyArray<string>

// Effect<Array<string>, NonEmptyArray<Error>>
const validatedUsers = Effect.validate(userIds, checkUser)
void validatedUsers

Proposed rule behavior

  • Match an Effect.flatMap (or Effect.andThen with a callback returning an Effect) whose input resolves, via the checker, to a call of Effect.partition, where the callback destructures the tuple into two bindings [errors, results].
  • The callback body must be a conditional on the error binding's emptiness — errors.length > 0, errors.length !== 0, or Arr.isNonEmptyArray(errors) / Arr.isNonEmptyReadonlyArray(errors) — whose truthy branch is Effect.fail(errors) with the error binding passed through unchanged, and whose other branch is Effect.succeed(results) (or just the results binding for andThen).
  • Also match the gen form: a destructured const [errors, results] = yield* Effect.partition(...) followed by if (errors.length > 0) return yield* Effect.fail(errors) and a subsequent use/return of results.
  • Use the checker to confirm the destructured tuple really is partition's [excluded, satisfying] result (element order matters — the rule should key on which binding feeds the length check and which feeds Effect.fail).
  • Do NOT flag when the failing branch does anything other than fail with the errors array as-is — mapping/joining the errors into a message, throwing, logging, or otherwise aggregating (the closest real-world near-miss found, opencode's help-snapshots test, joins the failures into a thrown Error message and consumes the results between the destructure and the check — custom aggregation the rule must leave alone).
  • Do NOT flag when the results are used between the partition and the emptiness gate, or when only one side of the tuple is consumed — those are genuine partition use cases.
  • Report suggesting Effect.validate(items, f), noting the strengthened NonEmptyArray<E> error type; a concurrency option on the partition call carries over unchanged.

Where this came up

No true-positive occurrences found in Effect-TS/effect@c3c7647 or anomalyco/opencode@550d1ff — proposed from the API sweep; both reference codebases are expert-written, so absence there is weak negative signal.

Mined from a per-export sweep of the Effect module (v4): for each exported function, asking what manual pattern it replaces and whether that pattern is statically detectable; grounded against Effect-TS/effect and anomalyco/opencode; deduplicated against implemented tsgo diagnostics and prior rule-proposal issues.

Proposed rule name

partitionThenFailToValidate

Incremental true-positive recount: T3 Code

Reviewed pingdotgg/t3code at 01e05c15268d on 2026-09-14. Scope: tracked first-party TypeScript/JavaScript, including authored tests unless excluded by this proposal; vendored .repos, generated files, dependencies, build output and documentation examples excluded.

  • New T3 Code matches: 0. Counts refer to vetted diagnostic source sites, not observed production failures.
  • Previous reviewed count bucket: value:tp-0.
  • Confirmed aggregate minimum: 0; label: value:tp-0. Previously vetted sites remain included; this pass only adds T3 Code.

Review notes. No first-party Effect.partition call.

No new source location met the reviewed trigger and exclusions. Uncertain and version-inapplicable candidates were not added to the count.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    value:tp-0Rule: 0 vetted true positives in checked repositories

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions