Step 2 (code quality): dedup + stop FE recomputing a server rule

- H1: tasksFromProfile takes the server's eligibleForHerregistratie decision
  instead of recomputing isHerregistratieEligible — the FE renders the rule,
  doesn't own it (ADR-0001). Policy reference impl kept for tests.
- M1: one shared runSubmit(fn, fallback) wrapper; the 4 submit-* commands keep
  only their payload mapping. +spec.
- M2: whenTag() kernel helper removes 10 repeated `as Extract<U,{tag}>` casts
  across the wizard/form components.

M4 (shared JA_NEE) folded into the upcoming i18n pass (clean dedup needs
$localize labels to sit in shared without breaking the English-shared-UI rule).
L1 already resolved by the restyle commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 13:48:35 +02:00
parent 94ffcf3d41
commit 1c65025fef
14 changed files with 118 additions and 68 deletions

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { runSubmit } from './submit';
describe('runSubmit', () => {
it('folds a resolved call into ok(value)', async () => {
const r = await runSubmit(async () => 'BIG-123', 'fallback');
expect(r).toEqual({ ok: true, value: 'BIG-123' });
});
it('maps a ProblemDetails rejection to err(detail)', async () => {
const r = await runSubmit(async () => {
throw { detail: 'Aanvraag afgewezen.' };
}, 'fallback');
expect(r).toEqual({ ok: false, error: 'Aanvraag afgewezen.' });
});
it('falls back when the rejection has no detail', async () => {
const r = await runSubmit(async () => {
throw new Error('network');
}, 'fallback');
expect(r).toEqual({ ok: false, error: 'fallback' });
});
});

View File

@@ -0,0 +1,20 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { problemDetail } from '@shared/infrastructure/api-error';
/**
* Run a mutating API call and fold it into a `Result` — the one place the
* try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just
* its own payload mapping. The backend re-validates and returns a 422
* ProblemDetails on rejection, surfaced here as the error string.
*/
export async function runSubmit<T>(fn: () => Promise<T>, fallback: string): Promise<Result<string, T>> {
try {
return ok(await fn());
} catch (e) {
return err(problemDetail(e, fallback));
}
}
// ponytail: i18n in Step 3/M3 wraps this in $localize with a stable @@id, which
// dedupes it at the translation layer; for now it's the single shared default.
export const SUBMIT_FAILED = 'Het indienen is niet gelukt. Probeer het later opnieuw.';

View File

@@ -21,3 +21,13 @@ export const err = <E>(error: E): Result<E, never> => ({ ok: false, error });
/** Nominal typing: Brand<string, 'Postcode'> is assignable from a plain string
only through an explicit cast — so a smart constructor is the only minter. */
export type Brand<T, B extends string> = T & { readonly __brand: B };
/** Narrow a tagged union to one variant by its `tag`, or null. The single place
the cast lives — TS can't narrow through a runtime tag argument, so callers get
`whenTag(state, 'Editing')?.foo` instead of repeating `as Extract<…>`. */
export function whenTag<U extends { tag: string }, K extends U['tag']>(
u: U,
tag: K,
): Extract<U, { tag: K }> | null {
return u.tag === tag ? (u as Extract<U, { tag: K }>) : null;
}