Skip to content

refactor(payouts): type the ?error= codes so a missing entry fails typecheck - #85

Merged
guarzo merged 1 commit into
mainfrom
worktree-typed-error-codes
Aug 4, 2026
Merged

refactor(payouts): type the ?error= codes so a missing entry fails typecheck#85
guarzo merged 1 commit into
mainfrom
worktree-typed-error-codes

Conversation

@guarzo

@guarzo guarzo commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Closes #79.

What changed

Both payout pages render an operator-facing notice keyed off ?error=. A code with no entry in the destination page's map rendered nothing at all — an unchanged form with no explanation, which #74 named as the one failure these pages cannot show an operator. operationFailed and createFailed took code: string, so a typo compiled, passed lint, and deployed; the per-code e2e cases were the only defence.

Both maps move to src/app/payouts/errors.ts as as const objects with a union derived per map. Record<string, string> was the thing defeating the type system — it accepts any key, so keyof typeof yielded string and derived nothing. Each helper now takes its own destination's union.

Two smaller things fall out:

  • The appraisal catch block built ?error=appraisal_failed by hand, which would have been the one code still outside the guard. It goes through operationFailed now — safe in a catch block, where a redirect's throw propagates past its own try rather than into it.
  • lookupErrorMessage replaces the bare index. Codes off a query string are string, not a code; an unrecognized one still degrades to no notice, as e2e/payouts.spec.ts already asserts.

Non-goal honoured: codes are not globally unique

share_format and share_range stay in both maps with different copy. The detail page's "The old value is unchanged." is true there and false on the create form. Per-page maps are the namespace; typing them is what makes the namespace enforceable.

The guard, demonstrated

Three deliberate breaks, all caught by npm run typecheck, all reverted before commit:

Break Error
operationFailed(..., "note_requird") TS2345: Argument of type '"note_requird"' is not assignable…
createFailed(..., "share_formatt") TS2345: Argument of type '"share_formatt"' is not assignable…
operationFailed(..., "name_required") — valid code, wrong page TS2345: Argument of type '"name_required"' is not assignable…

The third is the one a single shared map would have missed. The open_info_* indirection maps are covered too — mistyping a value there fails at the operationFailed call site with Did you mean "open_info_offline"?.

No live bug surfaced

Every code both helpers can emit already had an entry in its destination map, including all seven reachable through OPEN_INFO_ERROR_BY_REASON / OPEN_INFO_ERROR_BY_FAILURE. Nothing needed inventing.

Message text moved byte-for-byte — verified by diffing the extracted map bodies against main, both IDENTICAL. No copy was reworded.

Scope

The 28 per-code e2e cases stay exactly as they are; thinning them is the issue's follow-up, not this change. /login, /account and /admin/accounts still carry untyped Record<string, string> maps — the issue names the first two as worth a look, and that stays a separate question.

Verification

  • npm run typecheck → clean (tsc --noEmit, no output)
  • npm testTest Files 71 passed (71), Tests 869 passed (869)
  • npm run test:e2e -- payouts.spec.ts -g "error"32 passed (2.7m)
  • npm run format:checkAll matched files use Prettier code style!
  • npm run lint0 errors, 3 warnings (pre-existing <img> warnings, untouched files)
  • code-reviewer → no findings

No migration.

…pecheck

Closes #79.

Both payout pages render an operator-facing notice keyed off `?error=`, and
a code with no entry in the destination page's map rendered nothing at all —
an unchanged form with no explanation, the one failure these pages cannot
show an operator. `operationFailed` and `createFailed` took `code: string`,
so a typo compiled, passed lint, and deployed; the only defence was the
per-code e2e cases.

Lift both maps into src/app/payouts/errors.ts as `as const` objects and
derive a union per map. `Record<string, string>` was what defeated the type
system: it accepts any key, so `keyof typeof` yielded `string`. The helpers
now take their own destination's union, so a bad code is a tsc error.

The codes stay per-page and deliberately not globally unique. `share_format`
and `share_range` exist in both maps with different copy, because the detail
page's "The old value is unchanged." is true there and false on the create
form. Each page's map is its namespace; the types make it enforceable.

Two smaller things fall out. The appraisal catch block built its
`?error=appraisal_failed` URL by hand, which would have been the one code
still outside the guard, so it goes through `operationFailed` — safe in a
catch block, where a redirect's throw propagates past its own try.
`lookupErrorMessage` replaces the bare index: codes off a query string are
`string`, not a code, and an unrecognized one still degrades to no notice.

Message text moved byte-for-byte; no copy was reworded. The per-code e2e
cases stay as they are — thinning them is the issue's follow-up, not this.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9fd2a307-331e-4a37-bf63-1ac2fb498824

📥 Commits

Reviewing files that changed from the base of the PR and between f79479a and a9caaed.

📒 Files selected for processing (4)
  • src/app/payouts/[id]/page.tsx
  • src/app/payouts/actions.ts
  • src/app/payouts/errors.ts
  • src/app/payouts/new/page.tsx

Comment @coderabbitai help to get the list of available commands.

@guarzo
guarzo enabled auto-merge (squash) August 4, 2026 21:46
@guarzo
guarzo merged commit 2bb611f into main Aug 4, 2026
6 checks passed
guarzo pushed a commit that referenced this pull request Aug 4, 2026
…in/accounts

Follow-up to #79. #85 did this for the two payout pages and named what it
skipped: these three still carried untyped `Record<string, string>` maps, which
is the thing that defeats the type system — it accepts any key, so
`keyof typeof` yields `string` and derives nothing. A code with no entry in the
destination page's map renders nothing at all.

Unlike payouts there was no chokepoint to type: six producers wrote the URL by
hand, and two of them pick between two DIFFERENT destination pages in a single
ternary. So the helpers had to be introduced before anything could be typed.

Shape: typed URL BUILDERS returning a string, not payouts' `: never`
redirectors. Two incompatible mechanisms consume these — server actions and
page guards call `redirect()` (returns `never`, which `denyAdmin` and
`redirectOnMutationError` both depend on for their exhaustiveness over a
service error union), while the OAuth callbacks are route handlers that must
return a Response. A `: never` helper cannot be returned from a route handler,
and a Response-returning one would destroy the `never` the two switches rely
on. A string sits underneath both. It also lets one call site pick between two
destinations with each branch checked against its own page's union.

Home: `src/lib/error-redirects.ts`, not `src/app/`. `src/lib/admin-guard.ts`
produces one of these codes, and nothing outside `src/app/` imports `src/app/`
anywhere in this repo; colocating would have made admin-guard the first
inversion, for a UI string. admin-guard already hardcoded that URL and already
imports next/navigation. `src/app/payouts/errors.ts` stays where it is — it has
no `src/lib` producer, so colocation is still right there. `lookupErrorMessage`
moves to the shared module and payouts re-exports it.

`redirectOnMutationError` is now exhaustive on a second, independent axis and
its docblock says so: the service union says which failures exist, the code
union says which ones the page can explain. `adminAccountsErrorUrl` preserves
`?tier=pending` via URLSearchParams, the way `createFailed` does.

Non-goal honoured: codes are NOT globally unique. `not_admin` stays in both
/account and /admin/accounts with different copy — one is the de-roled admin's
destination, the other a stale-tab race — and `session_expired` reaches /login
from four unrelated producers.

No live bug surfaced: every emitted code already had an entry in its
destination map. Copy moved byte-for-byte (extracted map bodies diffed against
HEAD: all three IDENTICAL) and every generated URL matches the literal it
replaced, `?tier=pending&error=` param order included.

Also fixes two stale comment references from #85 naming an `ERRORS` symbol that
no longer exists in payouts (dropped.ts, e2e/payouts.spec.ts) — names only.

No migration. No change to OAuth state consumption, cookie handling or
consumeOauthTransaction; only which URL a failure redirects to.
guarzo pushed a commit that referenced this pull request Aug 4, 2026
…in/accounts

Follow-up to #79. #85 did this for the two payout pages and named what it
skipped: these three still carried untyped `Record<string, string>` maps, which
is the thing that defeats the type system — it accepts any key, so
`keyof typeof` yields `string` and derives nothing. A code with no entry in the
destination page's map renders nothing at all.

Unlike payouts there was no chokepoint to type: six producers wrote the URL by
hand, and two of them pick between two DIFFERENT destination pages in a single
ternary. So the helpers had to be introduced before anything could be typed.

Shape: typed URL BUILDERS returning a string, not payouts' `: never`
redirectors. Two incompatible mechanisms consume these — server actions and
page guards call `redirect()` (returns `never`, which `denyAdmin` and
`redirectOnMutationError` both depend on for their exhaustiveness over a
service error union), while the OAuth callbacks are route handlers that must
return a Response. A `: never` helper cannot be returned from a route handler,
and a Response-returning one would destroy the `never` the two switches rely
on. A string sits underneath both. It also lets one call site pick between two
destinations with each branch checked against its own page's union.

Home: `src/lib/error-redirects.ts`, not `src/app/`. `src/lib/admin-guard.ts`
produces one of these codes, and nothing outside `src/app/` imports `src/app/`
anywhere in this repo; colocating would have made admin-guard the first
inversion, for a UI string. admin-guard already hardcoded that URL and already
imports next/navigation. `src/app/payouts/errors.ts` stays where it is — it has
no `src/lib` producer, so colocation is still right there. `lookupErrorMessage`
moves to the shared module and payouts re-exports it.

`redirectOnMutationError` is now exhaustive on a second, independent axis and
its docblock says so: the service union says which failures exist, the code
union says which ones the page can explain. `adminAccountsErrorUrl` preserves
`?tier=pending` via URLSearchParams, the way `createFailed` does.

Non-goal honoured: codes are NOT globally unique. `not_admin` stays in both
/account and /admin/accounts with different copy — one is the de-roled admin's
destination, the other a stale-tab race — and `session_expired` reaches /login
from four unrelated producers.

No live bug surfaced: every emitted code already had an entry in its
destination map. Copy moved byte-for-byte (extracted map bodies diffed against
HEAD: all three IDENTICAL) and every generated URL matches the literal it
replaced, `?tier=pending&error=` param order included.

Also fixes two stale comment references from #85 naming an `ERRORS` symbol that
no longer exists in payouts (dropped.ts, e2e/payouts.spec.ts) — names only.

No migration. No change to OAuth state consumption, cookie handling or
consumeOauthTransaction; only which URL a failure redirects to.
guarzo added a commit that referenced this pull request Aug 4, 2026
…in/accounts (#89)

Follow-up to #79. #85 did this for the two payout pages and named what it
skipped: these three still carried untyped `Record<string, string>` maps, which
is the thing that defeats the type system — it accepts any key, so
`keyof typeof` yields `string` and derives nothing. A code with no entry in the
destination page's map renders nothing at all.

Unlike payouts there was no chokepoint to type: six producers wrote the URL by
hand, and two of them pick between two DIFFERENT destination pages in a single
ternary. So the helpers had to be introduced before anything could be typed.

Shape: typed URL BUILDERS returning a string, not payouts' `: never`
redirectors. Two incompatible mechanisms consume these — server actions and
page guards call `redirect()` (returns `never`, which `denyAdmin` and
`redirectOnMutationError` both depend on for their exhaustiveness over a
service error union), while the OAuth callbacks are route handlers that must
return a Response. A `: never` helper cannot be returned from a route handler,
and a Response-returning one would destroy the `never` the two switches rely
on. A string sits underneath both. It also lets one call site pick between two
destinations with each branch checked against its own page's union.

Home: `src/lib/error-redirects.ts`, not `src/app/`. `src/lib/admin-guard.ts`
produces one of these codes, and nothing outside `src/app/` imports `src/app/`
anywhere in this repo; colocating would have made admin-guard the first
inversion, for a UI string. admin-guard already hardcoded that URL and already
imports next/navigation. `src/app/payouts/errors.ts` stays where it is — it has
no `src/lib` producer, so colocation is still right there. `lookupErrorMessage`
moves to the shared module and payouts re-exports it.

`redirectOnMutationError` is now exhaustive on a second, independent axis and
its docblock says so: the service union says which failures exist, the code
union says which ones the page can explain. `adminAccountsErrorUrl` preserves
`?tier=pending` via URLSearchParams, the way `createFailed` does.

Non-goal honoured: codes are NOT globally unique. `not_admin` stays in both
/account and /admin/accounts with different copy — one is the de-roled admin's
destination, the other a stale-tab race — and `session_expired` reaches /login
from four unrelated producers.

No live bug surfaced: every emitted code already had an entry in its
destination map. Copy moved byte-for-byte (extracted map bodies diffed against
HEAD: all three IDENTICAL) and every generated URL matches the literal it
replaced, `?tier=pending&error=` param order included.

Also fixes two stale comment references from #85 naming an `ERRORS` symbol that
no longer exists in payouts (dropped.ts, e2e/payouts.spec.ts) — names only.

No migration. No change to OAuth state consumption, cookie handling or
consumeOauthTransaction; only which URL a failure redirects to.

Co-authored-by: gambtho <thomasgamble2@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Type the ?error= codes so a missing ERRORS entry fails typecheck, not silently

1 participant