From dabf1fee03bcaf80e3005ebbdd9541485a16738e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 12:08:00 +0000 Subject: [PATCH 01/11] Integrate Anti-IF principles: go-ldd R11 conditional-dispatch rule + TS dispatch patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go plugin (v2.2.0): - rules/R11-conditional-dispatch.md β€” a conditional that asks what a value IS may exist once; the second copy of a kind/type discriminator is a missing polymorphic type. Owns Interface Dispatch, Strategy Map, Null Object, Split Flag Argument, and the sanctioned Keep the Single Exhaustive Switch, with the inverse over-abstraction trap. - examples/anti-if-dispatch.md β€” case law: three-site channel switch to interface dispatch, strategy-map variant, and the worked rejection where the switch stays and goes exhaustive. - Wiring: refactoring routes `exhaustive` and discriminator-shaped `dupl` to R11; lint-fixer table kept consistent; code-designing dispatches R11 at design time; pre-commit-review hunts R11 and sends dispatch proposals to the skeptic with the new case file as payload. TS/React plugin (v1.1.0): - refactoring Pattern 8: lookup/component maps for duplicated discriminators, discriminated unions with never-exhaustiveness for the switch that stays; decision-tree and principles entries. - component-designing: one dispatch owner per variant family in principles and pre-code review questions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LeLyATJFYTLwWRrB5Vatfu --- .claude-plugin/marketplace.json | 2 +- README.md | 6 +- .../.claude-plugin/plugin.json | 4 +- go-linter-driven-development/CHANGELOG.md | 26 +- go-linter-driven-development/README.md | 12 +- .../agents/lint-fixer.md | 3 +- .../commands/go-ldd-analyze.md | 4 +- .../examples/anti-if-dispatch.md | 258 ++++++++++++++++++ .../rules/R11-conditional-dispatch.md | 208 ++++++++++++++ .../skills/code-designing/SKILL.md | 4 +- .../skills/pre-commit-review/SKILL.md | 27 +- .../skills/refactoring/SKILL.md | 7 +- .../.claude-plugin/plugin.json | 2 +- .../skills/component-designing/SKILL.md | 2 + .../skills/refactoring/SKILL.md | 101 +++++++ 15 files changed, 636 insertions(+), 30 deletions(-) create mode 100644 go-linter-driven-development/examples/anti-if-dispatch.md create mode 100644 go-linter-driven-development/rules/R11-conditional-dispatch.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a528213..0dd82ea 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,7 +7,7 @@ { "name": "go-linter-driven-development", "source": "./go-linter-driven-development", - "description": "Rules-as-data linter-driven development workflow for Go: 10 single-source rule files, thin directional skills (design, TDD implementation, refactoring, testing, review, documentation), and a hunter/skeptic review architecture with parallel single-rule reviewers and an over-abstraction skeptic" + "description": "Rules-as-data linter-driven development workflow for Go: 11 single-source rule files, thin directional skills (design, TDD implementation, refactoring, testing, review, documentation), and a hunter/skeptic review architecture with parallel single-rule reviewers and an over-abstraction skeptic" }, { "name": "ts-react-linter-driven-development", diff --git a/README.md b/README.md index 550e8db..0dfc1bf 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ A [Claude Code](https://claude.ai/code) plugin marketplace for **linter-driven d | | Plugin | Version | For | |---|--------|---------|-----| -| 🐹 | [`go-linter-driven-development`](go-linter-driven-development/README.md) | 2.0.0 | Go | -| βš›οΈ | [`ts-react-linter-driven-development`](ts-react-linter-driven-development/README.md) | 1.0.0 | TypeScript + React | +| 🐹 | [`go-linter-driven-development`](go-linter-driven-development/README.md) | 2.2.0 | Go | +| βš›οΈ | [`ts-react-linter-driven-development`](ts-react-linter-driven-development/README.md) | 1.1.0 | TypeScript + React | Plus the standalone rule documents the plugins grew out of: @@ -18,7 +18,7 @@ Plus the standalone rule documents the plugins grew out of: The organising idea: **the rule is the unit, not the phase.** Each design principle lives exactly once, as data: -- **`rules/` R1–R10** β€” single source of truth: primitive obsession, self-validating types, storifying, helper placement, vertical slices, test-only interfaces, test placement, no globals, repo-brain documentation, concurrency safety. +- **`rules/` R1–R11** β€” single source of truth: primitive obsession, self-validating types, storifying, helper placement, vertical slices, test-only interfaces, test placement, no globals, repo-brain documentation, concurrency safety, conditional dispatch (Anti-IF). - **`skills/`** β€” six thin directional views that sequence and route into the rules (orchestrator, design, testing, refactoring, review, documentation). - **`agents/`** β€” payload-fed isolated workers: parallel single-obsession `rule-hunter`s, an `overabstraction-skeptic` that tries to kill proposed extractions, and a `lint-fixer` that keeps the lint loop out of your conversation. - **`commands/`** β€” `/go-ldd-autopilot`, `/go-ldd-quickfix`, `/go-ldd-analyze`, `/go-ldd-review`, `/go-ldd-status`, `/wire-repo-brain`. diff --git a/go-linter-driven-development/.claude-plugin/plugin.json b/go-linter-driven-development/.claude-plugin/plugin.json index 03979df..0c52ab6 100644 --- a/go-linter-driven-development/.claude-plugin/plugin.json +++ b/go-linter-driven-development/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "go-linter-driven-development", - "version": "2.1.0", - "description": "Rules-as-data linter-driven development workflow for Go: 10 single-source rule files, thin directional skills (design, TDD implementation, refactoring, testing, review, documentation), and a hunter/skeptic review architecture with parallel single-rule reviewers and an over-abstraction skeptic", + "version": "2.2.0", + "description": "Rules-as-data linter-driven development workflow for Go: 11 single-source rule files, thin directional skills (design, TDD implementation, refactoring, testing, review, documentation), and a hunter/skeptic review architecture with parallel single-rule reviewers and an over-abstraction skeptic", "author": { "name": "Dan Mordechay" }, diff --git a/go-linter-driven-development/CHANGELOG.md b/go-linter-driven-development/CHANGELOG.md index 937397d..4c2b6e4 100644 --- a/go-linter-driven-development/CHANGELOG.md +++ b/go-linter-driven-development/CHANGELOG.md @@ -3,7 +3,30 @@ All notable changes to the `go-linter-driven-development` plugin are documented here. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/). -## [2.1.0] - 2026-07-07 +## [2.2.0] - 2026-07-10 + +### Added + +- **`rules/R11-conditional-dispatch.md`** β€” the Anti-IF rule, adapting the Anti-IF + movement's core insight (Cirillo, 2007) to the rules-as-data architecture: a + conditional that asks what a value *is* may exist once; the second copy of a + kind/type discriminator is a missing polymorphic type. R11 owns: + - Replace Duplicated Switch with Interface Dispatch β€” variants become leaf types, the decision moves to a `ParseX` boundary constructor (R2's behavioral twin: *decide* once at the edge) + - Replace If-Chain with Strategy Map β€” single-behavior variance dispatches through a map, comma-ok at the boundary only + - Introduce Null Object and Split Flag Argument + - the sanctioned form: Keep the Single Exhaustive Switch β€” one site over a closed enum stays, named per R1 and proven complete by the `exhaustive` linter + - the inverse trap: dispatch abstractions that delete no duplication are ceremony β€” one switching site with trivial variance keeps its switch (mirroring R1's over-abstraction symmetry and R6's earned-interface test) +- **`examples/anti-if-dispatch.md`** β€” case law: a three-site channel switch (already + drifted) collapsed to interface dispatch, the strategy-map variant, and the worked + rejection where the skeptic kills the extraction and the switch goes exhaustive + instead. Pasted to the skeptic alongside R11 dispatch proposals. +- Wiring: pre-commit-review hunts R11 (πŸ”΄ Design Debt), its dispatch proposals face + the over-abstraction skeptic with the new case file as payload, code-designing + dispatches R11 at design time (one dispatch owner per variant family in the + checklist), refactoring + lint-fixer route `exhaustive` failures and + discriminator-shaped `dupl` hits to it. + + ### Added @@ -58,6 +81,7 @@ Initial release as a Claude Code plugin: five-phase linter-driven workflow (desi Notable unversioned improvements between 1.0.0 and 2.0.0: auto-pilot mode and review agent commands, evidence-based review with test-only interface detection, self-validation ownership rule, improved lint-failure flow, and making the package-size hook opt-in. +[2.2.0]: https://github.com/buzzdan/ai-coding-rules/releases/tag/go-ldd-v2.2.0 [2.1.0]: https://github.com/buzzdan/ai-coding-rules/releases/tag/go-ldd-v2.1.0 [2.0.0]: https://github.com/buzzdan/ai-coding-rules/releases/tag/go-ldd-v2.0.0 [1.0.0]: https://github.com/buzzdan/ai-coding-rules/commit/746ae7d diff --git a/go-linter-driven-development/README.md b/go-linter-driven-development/README.md index 3fb8947..c72ccc6 100644 --- a/go-linter-driven-development/README.md +++ b/go-linter-driven-development/README.md @@ -16,8 +16,8 @@ The organising idea of v2: **the rule is the unit, not the phase.** Each design ``` go-linter-driven-development/ -β”œβ”€β”€ rules/ R1-primitive-obsession … R10-concurrency-safety (single source of truth) -β”œβ”€β”€ examples/ storify-leaf-type Β· overabstraction-cidr Β· dependency-rejection (case law) +β”œβ”€β”€ rules/ R1-primitive-obsession … R11-conditional-dispatch (single source of truth) +β”œβ”€β”€ examples/ storify-leaf-type Β· overabstraction-cidr Β· dependency-rejection Β· anti-if-dispatch (case law) β”œβ”€β”€ skills/ linter-driven-development Β· code-designing Β· refactoring Β· β”‚ pre-commit-review Β· testing Β· documentation (thin directional views) β”œβ”€β”€ agents/ rule-hunter Β· overabstraction-skeptic Β· lint-fixer (isolated workers) @@ -27,7 +27,7 @@ go-linter-driven-development/ **Four layers, one fact per fact:** -- **[`rules/`](rules/)** β€” R1–R10, each a self-contained hunter payload. A rule file states its Principle, Why, a real-world canonical before/after, Design guidance (forward), a Fix pattern (backward), and Falsifying questions (each phrased to *disprove* compliance, with a grep/count detection command). A rule's content is normative in its file and nowhere else β€” everything else points at it. +- **[`rules/`](rules/)** β€” R1–R11, each a self-contained hunter payload. A rule file states its Principle, Why, a real-world canonical before/after, Design guidance (forward), a Fix pattern (backward), and Falsifying questions (each phrased to *disprove* compliance, with a grep/count detection command). A rule's content is normative in its file and nowhere else β€” everything else points at it. - **[`examples/`](examples/)** β€” deep worked case studies (full before/after code + the reasoning). Rules cite them by relative path instead of inlining long studies. - **[`skills/`](skills/)** β€” thin directional views (~100–150 lines) that *sequence* and *route* into the rules. They never restate rule content. - **[`agents/`](agents/)** β€” read-only or mechanical workers spawned in isolated contexts. **Agents get knowledge as spawn-time payload β€” the relevant rule file's content is pasted into the prompt. Agents do NOT invoke skills.** @@ -78,6 +78,7 @@ Isolated contexts matter: the `lint-fixer` loop's token noise stays out of your | R8 | [`rules/R8-no-globals.md`](rules/R8-no-globals.md) | No package-level state; no `context.Background()` in library code | | R9 | [`rules/R9-repo-brain.md`](rules/R9-repo-brain.md) | Documentation network: fact at its lowest rung, reachable from the root, edges both directions; index wired into CLAUDE.md | | R10 | [`rules/R10-concurrency-safety.md`](rules/R10-concurrency-safety.md) | Goroutines with owners and exit paths; shared state guarded where it lives; no production sleeps | +| R11 | [`rules/R11-conditional-dispatch.md`](rules/R11-conditional-dispatch.md) | One dispatch owner per kind/variant family (Anti-IF): duplicated kind-switches become interface/map dispatch chosen once at the boundary; a single switch stays and goes exhaustive | **Examples β†’ rules demonstrated** (case law): @@ -86,6 +87,7 @@ Isolated contexts matter: the `lint-fixer` loop's token noise stays out of your | [`examples/storify-leaf-type.md`](examples/storify-leaf-type.md) | R3, R1, R2 β€” storifying a fat function; extracting a self-validating leaf type | | [`examples/overabstraction-cidr.md`](examples/overabstraction-cidr.md) | R1 β€” when an extraction is over-abstraction (the skeptic's payload) | | [`examples/dependency-rejection.md`](examples/dependency-rejection.md) | R8 β€” dependency rejection: eliminating globals by threading dependencies | +| [`examples/anti-if-dispatch.md`](examples/anti-if-dispatch.md) | R11 β€” duplicated kind-switch β†’ interface dispatch / strategy map, plus the kept-switch rejection (the skeptic's dispatch payload) | **Skills β†’ role** (thin views): @@ -251,7 +253,7 @@ Skills are expert consultants you can call on demand: The [`@pre-commit-review`](skills/pre-commit-review/SKILL.md) report groups findings by urgency β€” all advisory, none block a commit: - πŸ› **Bugs** β€” fail at runtime regardless of rule (fix immediately) -- πŸ”΄ **Design Debt** β€” R1, R2, R4, R5, R6, R7, R8 (fix before commit recommended) +- πŸ”΄ **Design Debt** β€” R1, R2, R4, R5, R6, R7, R8, R11 (fix before commit recommended) - 🟑 **Readability Debt** β€” R3, R9, unclear naming (improves maintainability) - 🟒 **Polish** β€” minor idiomatic improvements, the skeptic's cheaper alternatives @@ -261,7 +263,7 @@ Every finding carries evidence (`file:line` + the falsifying-question answer or The plugin follows opinionated Go best practices, each with an owning rule: -**Design:** no primitive obsession (R1), self-validating types (R2), vertical slices (R5), no globals (R8), owned goroutines and guarded shared state (R10). +**Design:** no primitive obsession (R1), self-validating types (R2), vertical slices (R5), no globals (R8), owned goroutines and guarded shared state (R10), one dispatch owner per variant family β€” the Anti-IF rule (R11). **Testing:** test the public API via `pkg_test` (R7), the composition ladder over the pyramid, real in-memory dependencies over mocks, no test-only interfaces (R6). **Refactoring:** storify top-level functions (R3), helpers on the placement ladder (R4), let the linter say WHAT and the rules say HOW. **Documentation:** a networked repo brain β€” each fact at its lowest rung, reachable from the root, edges pointing both ways (R9). diff --git a/go-linter-driven-development/agents/lint-fixer.md b/go-linter-driven-development/agents/lint-fixer.md index 5e33055..04130b9 100644 --- a/go-linter-driven-development/agents/lint-fixer.md +++ b/go-linter-driven-development/agents/lint-fixer.md @@ -40,7 +40,8 @@ this table: | `funlen` | rules/R3-storifying.md (via @refactoring) | | `nestif` | rules/R3-storifying.md (via @refactoring) | | `maintidx` | rules/R3-storifying.md + rules/R1-primitive-obsession.md | -| `dupl` | rules/R1-primitive-obsession.md (extract shared type/logic) | +| `dupl` | rules/R1-primitive-obsession.md (extract shared type/logic); duplicated switches on one discriminator β†’ rules/R11-conditional-dispatch.md | +| `exhaustive` (missing enum cases) | rules/R11-conditional-dispatch.md (via @refactoring) | | revive `file-length-limit`; package-size hook failures (`hooks/check-package-sizes.sh`) | rules/R5-vertical-slice.md | | `gochecknoglobals` / `gochecknoinits` | rules/R8-no-globals.md | | `ireturn` / interface lint on single-impl interfaces | rules/R6-test-only-interfaces.md | diff --git a/go-linter-driven-development/commands/go-ldd-analyze.md b/go-linter-driven-development/commands/go-ldd-analyze.md index b343f74..b0fd94a 100644 --- a/go-linter-driven-development/commands/go-ldd-analyze.md +++ b/go-linter-driven-development/commands/go-ldd-analyze.md @@ -63,7 +63,7 @@ Search project documentation to find test and lint commands: what needs refactoring without changing anything. (The `lint-fixer` agent, which auto-fixes, is intentionally NOT used here β€” this command never edits.) 3. **Design review**: invoke `Skill(go-linter-driven-development:pre-commit-review)` - in FULL mode over the file scope. It grep-prefilters the diff against rules R1–R10, + in FULL mode over the file scope. It grep-prefilters the diff against rules R1–R11, spawns one parallel `rule-hunter` per rule with hits, runs the `overabstraction-skeptic` over every type/package-extraction proposal, and returns evidence-backed findings. It reports β€” it never edits. @@ -76,7 +76,7 @@ Merge the three gates into one report: - βœ…/❌ **Linter**: clean / error count (with file:line and the failing linter) - βœ…/⚠️ **Review**: clean / findings, categorized as the pre-commit-review report returns them: - πŸ› **Bugs** β€” fail at runtime regardless of rule (incl. R10 goroutine leaks and unguarded concurrent writes) - - πŸ”΄ **Design Debt** β€” R1, R2, R4, R5, R6, R7, R8, R10's non-crash findings (advisory) + - πŸ”΄ **Design Debt** β€” R1, R2, R4, R5, R6, R7, R8, R10's non-crash findings, R11 (advisory) - 🟑 **Readability Debt** β€” R3, R9, unclear naming - 🟒 **Polish** β€” minor idiomatic improvements, the skeptic's cheaper alternatives - 🎯 **Clustered issues**: where a linter failure and a review finding land at the same diff --git a/go-linter-driven-development/examples/anti-if-dispatch.md b/go-linter-driven-development/examples/anti-if-dispatch.md new file mode 100644 index 0000000..3a56e1e --- /dev/null +++ b/go-linter-driven-development/examples/anti-if-dispatch.md @@ -0,0 +1,258 @@ +# Anti-IF Dispatch Case: One Decision, One Owner + +Demonstrates: R11 (edges into R1, R2, R6) + +A worked study of the R11 moves on a realistic alert-notification slice: a string +discriminator inspected in three files becomes an interface chosen once at the +boundary; a single-function variance becomes a strategy map instead; and the inverse +case β€” where the skeptic kills the extraction and the switch *stays* β€” is worked to +its cheaper alternative. The compact excerpt lives in +`../rules/R11-conditional-dispatch.md`; this file is the full case law. + +## The disease: a decision with three owners + +The alert feature delivers over email, Slack, or PagerDuty. `Alert.Channel` is a raw +string (already an R1 smell), and three sites ask what it is: + +```go +// ❌ alert/send.go +func Send(a Alert) error { + switch a.Channel { + case "email": + return smtpSend(a.Recipient, renderEmail(a)) + case "slack": + return slackPost(a.Recipient, renderSlack(a)) + case "pagerduty": + return pdCreateIncident(a.Recipient, a.Summary) + default: + return fmt.Errorf("unknown channel %q", a.Channel) + } +} + +// ❌ alert/validate.go β€” drifted: pagerduty was never added here +func validRecipient(a Alert) bool { + switch a.Channel { + case "email": + return strings.Contains(a.Recipient, "@") + case "slack": + return strings.HasPrefix(a.Recipient, "#") + } + return false +} + +// ❌ alert/retry.go β€” the same decision wearing an if-chain +func retryDelay(a Alert) time.Duration { + if a.Channel == "pagerduty" { + return 0 + } + if a.Channel == "slack" { + return 5 * time.Second + } + return time.Minute +} +``` + +Why this is a defect and not a style choice: + +- **The drift already happened.** `validRecipient` returns `false` for `"pagerduty"` + β€” not by decision, but because the second copy of the switch was not in view when + the third channel landed. Duplicated discriminators drift the same way duplicated + validation predicates drift (R1's Q2). +- **Adding SMS is a scavenger hunt.** Three known sites, plus whatever a grep misses + (test helpers, a metrics label formatter). The compiler flags none of them: an + if-chain has no completeness, and a switch with a `default` swallows the new case + silently. +- **"Unknown channel" leaks everywhere.** Every switching site carries the + `default:`/fall-through arm, so every function's signature and tests carry the + maybe-unknown concept β€” the behavioral twin of R1's maybe-invalid port. +- **Nothing unit-tests in isolation.** Slack's recipient rule is only reachable by + driving `validRecipient` with a fully built `Alert`. + +## Move 1 β€” Replace Duplicated Switch with Interface Dispatch + +Define the interface from the union of what the copies do: `Send` switches on +delivery, `validRecipient` on addressing, `retryDelay` on retry policy β€” three +methods, one type per variant: + +```go +// alert/channel.go +type Channel interface { + Send(a Alert) error + ValidRecipient(recipient string) bool + RetryDelay() time.Duration +} + +// ParseChannel is the single decision point. This switch is ALLOWED β€” +// it is the one place the raw string may be inspected (R11), exactly as +// a ParseX constructor is the one place a raw value is validated (R2). +func ParseChannel(name string) (Channel, error) { + switch name { + case "email": + return Email{}, nil + case "slack": + return Slack{}, nil + case "pagerduty": + return PagerDuty{}, nil + default: + return nil, fmt.Errorf("unknown channel %q", name) + } +} +``` + +```go +// alert/slack.go β€” each variant is a leaf type owning ALL its behavior +type Slack struct{} + +func (Slack) Send(a Alert) error { return slackPost(a.Recipient, renderSlack(a)) } +func (Slack) ValidRecipient(r string) bool { return strings.HasPrefix(r, "#") } +func (Slack) RetryDelay() time.Duration { return 5 * time.Second } +``` + +`Alert` now holds a `Channel`, constructed at the boundary (the HTTP handler or +config loader calls `ParseChannel` and fails fast there). The three switching sites +collapse to method calls: + +```go +func Send(a Alert) error { return a.Channel.Send(a) } +func validRecipient(a Alert) bool { return a.Channel.ValidRecipient(a.Recipient) } +func retryDelay(a Alert) time.Duration { return a.Channel.RetryDelay() } +``` + +(And once they are one-liners, the wrappers themselves usually dissolve into their +callers β€” the story functions call the methods directly, R3.) + +What was deleted, not relocated: every `default:` arm downstream. An `Alert` that +exists holds a `Channel` that exists; "unknown channel" is unrepresentable past +`ParseChannel`. Adding SMS is now one new file (`sms.go`) plus one `case` in +`ParseChannel` β€” no existing file changes. + +**R6 check, answered explicitly:** this interface is *earned* β€” it has three +production implementations on day one. R6 forbids interfaces whose only second +implementer is a test double; a dispatch interface born with one variant "for the +future" fails that test and should stay a switch until the second variant is real. + +### The testing payoff + +Each variant unit-tests as a leaf with literals β€” no `Alert` construction, no +switch-driving: + +```go +func TestSlack_ValidRecipient(t *testing.T) { + assert.True(t, alert.Slack{}.ValidRecipient("#oncall")) + assert.False(t, alert.Slack{}.ValidRecipient("oncall")) +} + +func TestParseChannel_Unknown(t *testing.T) { + _, err := alert.ParseChannel("carrier-pigeon") + require.Error(t, err) +} +``` + +The drift bug (`pagerduty` missing from `validRecipient`) can no longer be written: +there is no second place to forget. + +## Move 2 β€” Replace If-Chain with Strategy Map + +Not every variance deserves an interface. Suppose only *rendering* varies by an +output format, in one behavioral dimension: + +```go +// ❌ before: if-chain in the middle of business logic +func render(a Alert, format string) string { + if format == "json" { + return renderJSON(a) + } + if format == "text" { + return renderText(a) + } + return renderMarkdown(a) // silent default β€” is "yaml" markdown? nobody decided +} +``` + +One behavior β†’ a map, not three types: + +```go +type Format string + +const ( + FormatJSON Format = "json" + FormatText Format = "text" + FormatMarkdown Format = "markdown" +) + +var renderers = map[Format]func(Alert) string{ + FormatJSON: renderJSON, + FormatText: renderText, + FormatMarkdown: renderMarkdown, +} + +func ParseFormat(raw string) (Format, error) { + if _, ok := renderers[Format(raw)]; !ok { + return "", fmt.Errorf("unknown format %q", raw) + } + return Format(raw), nil +} + +func render(a Alert, f Format) string { return renderers[f](a) } +``` + +The lookup *is* the dispatch; the comma-ok check lives once, in `ParseFormat`, at the +boundary. The silent markdown default β€” an undecided decision β€” became an explicit +error. (The map is package-level immutable data, the sanctioned shape under R8; +naming the enum is R1's "Name enum strings" move.) + +## Move 3 β€” the rejection: when the switch stays + +The skeptic's side of R11, worked honestly. The same codebase has this: + +```go +// alert/severity.go β€” the ONLY site that inspects Severity +func (s Severity) Color() string { + switch s { + case SeverityInfo: + return "blue" + case SeverityWarning: + return "yellow" + case SeverityCritical: + return "red" + } + return "" +} +``` + +A dispatch-happy reading says: three variants, extract `type Severity interface` +with `Info`, `Warning`, `Critical` types. Score it before moving (R1 scorecard, via +the over-abstraction skeptic): + +- Duplication of the discriminator: **1 site** (grep `switch .*Severity` β†’ one hit) β€” +0 +- Behavioral variance: one method, returns a constant string β€” trivial β€” +0 +- Would the interface be earned (R6)? Three implementations, but each is an empty + struct wrapping one literal β€” ceremony + +Verdict: **REFUTED.** The extraction would turn 12 readable lines into three files +and an interface for zero deletion β€” no duplicated switch exists to delete. The +cheaper alternative is R11's sanctioned form, **Keep the Single Exhaustive Switch**: + +```go +func (s Severity) Color() string { + switch s { // exhaustive: linter fails the build when a Severity is added unhandled + case SeverityInfo: + return "blue" + case SeverityWarning: + return "yellow" + case SeverityCritical: + return "red" + } + return "" +} +``` + +with `exhaustive` enabled in `.golangci.yaml`. Now the linter provides what dispatch +would have: adding `SeverityFatal` fails the build at this switch instead of falling +through to `""`. That is the whole benefit, at none of the cost. + +**The dividing line, restated:** dispatch is bought with the *deletion of duplicated +decisions*. Three sites collapsed to one boundary β€” clear win (Move 1). One +single-dimension variance β€” a map (Move 2). One site, trivial variance β€” the switch +stays, made exhaustive (Move 3). If nothing gets deleted, the abstraction is +ceremony. diff --git a/go-linter-driven-development/rules/R11-conditional-dispatch.md b/go-linter-driven-development/rules/R11-conditional-dispatch.md new file mode 100644 index 0000000..2bf2c5c --- /dev/null +++ b/go-linter-driven-development/rules/R11-conditional-dispatch.md @@ -0,0 +1,208 @@ +# R11 β€” Conditional Dispatch (Anti-IF) + +## Principle + +A conditional that asks what a value *is* β€” a type switch, or a switch/if-chain on a +kind/status/mode discriminator β€” may exist **once**. The second copy of that +discriminator is a missing polymorphic type: the variants want to be implementations +of an interface (or entries in a dispatch map), chosen once at the boundary, so +downstream code *tells* the value what to do instead of asking what it is. One +well-placed, exhaustive switch is not a defect; a duplicated one always is. + +## Why + +Every `if (new kind) { new code }` doubles the execution paths through the function β€” +five conditionals means 32 paths to reason about and test. Worse, kind-switches +replicate: the same `switch msg.Channel` appears in send, validate, format, and retry +code, and adding a variant means finding and editing every copy β€” the one you miss is +the bug that ships. The compiler cannot help: an if-chain has no notion of +completeness, so a forgotten variant falls through silently. Dispatching once β€” +constructing the right implementation at the boundary (`R2-self-validating-types.md` +owns "validate once at the edge"; this rule is its behavioral twin: *decide* once at +the edge) β€” collapses N switches into one construction site, makes each variant a +leaf that unit-tests in isolation, and turns "add a variant" into "add a type" with +zero edits to existing code. This idea comes from the Anti-IF movement (Cirillo, +2007): the enemy is not `if`, it is the duplicated kind-conditional. + +## Canonical example + +A notifier must deliver alerts over email, Slack, or PagerDuty. The channel is decided +by a string field, and three parts of the codebase ask which one it is. + +### Before + +```go +// ❌ alert/send.go β€” first copy of the discriminator +func Send(a Alert) error { + switch a.Channel { + case "email": + return smtpSend(a.Recipient, renderEmail(a)) + case "slack": + return slackPost(a.Recipient, renderSlack(a)) + case "pagerduty": + return pdCreateIncident(a.Recipient, a.Summary) + default: + return fmt.Errorf("unknown channel %q", a.Channel) + } +} + +// ❌ alert/validate.go β€” second copy, drifting already: nobody added pagerduty here +func validRecipient(a Alert) bool { + switch a.Channel { + case "email": + return strings.Contains(a.Recipient, "@") + case "slack": + return strings.HasPrefix(a.Recipient, "#") + } + return false +} + +// ❌ alert/retry.go β€” third copy, as an if-chain this time +func retryDelay(a Alert) time.Duration { + if a.Channel == "pagerduty" { + return 0 + } + if a.Channel == "slack" { + return 5 * time.Second + } + return time.Minute +} +``` + +Three owners of one decision, already inconsistent: `validRecipient` silently returns +`false` for PagerDuty because the second copy was never updated. Adding SMS means +finding all three (and the fourth one hiding in a test helper). Every function also +carries the `default:` error path β€” the "maybe-unknown channel" concept leaks into +each call site, the behavioral twin of R1's maybe-invalid port. + +### After + +```go +// Channel is the behavior, not a string. Each variant is a leaf type. +type Channel interface { + Send(a Alert) error + ValidRecipient(recipient string) bool + RetryDelay() time.Duration +} + +// ParseChannel is the ONLY place the raw string is inspected β€” +// the decision is made once, at the boundary, like R2's ParsePort. +func ParseChannel(name string) (Channel, error) { + switch name { + case "email": + return Email{}, nil + case "slack": + return Slack{}, nil + case "pagerduty": + return PagerDuty{}, nil + default: + return nil, fmt.Errorf("unknown channel %q", name) + } +} + +type Slack struct{} + +func (Slack) Send(a Alert) error { return slackPost(a.Recipient, renderSlack(a)) } +func (Slack) ValidRecipient(r string) bool { return strings.HasPrefix(r, "#") } +func (Slack) RetryDelay() time.Duration { return 5 * time.Second } +``` + +The three switches are gone β€” call sites read `a.Channel.Send(a)`, +`a.Channel.RetryDelay()`. There is no `default:` anywhere downstream: an `Alert` that +exists holds a `Channel` that exists, so "unknown channel" is unrepresentable past +the boundary. Adding SMS is one new type plus one `case` in `ParseChannel` β€” existing +files untouched, and each channel's behavior unit-tests as a leaf with literals. +Full worked study including the strategy-map variant and the rejection counter-case: +`../examples/anti-if-dispatch.md`. + +## Design guidance + +- **The trigger is duplication, not existence.** Count the sites that inspect the same + discriminator. One site β€” keep the switch (make it exhaustive). Two or more β€” + the variants are a type family; dispatch. +- **Decide once, at the edge.** The one legitimate inspection of the raw discriminator + is the constructor/parser that picks the implementation + (`R2-self-validating-types.md` for the constructor discipline). Downstream code + holds the chosen behavior and never re-asks. +- **Interface vs strategy map.** Variants with several behaviors or state β†’ interface + with one type per variant. Variants that differ by a single function β†’ a map + (`var renderers = map[Format]func(Alert) string{...}`) β€” a map lookup with a + comma-ok check is a dispatch, not a conditional. Either way the decision has one + owner. +- **Null object over nil-checks.** A scattered `if x != nil { x.Log(...) }` is the + same disease with two variants. Construct a do-nothing implementation + (`type NopLogger struct{}`) once; delete every guard. (R2's "nil is not a value" + covers the constructor side.) +- **Flag arguments are two functions.** `func Render(a Alert, short bool)` forces + every caller through a conditional the callee then unpicks. Split into `Render` and + `RenderShort`, or make the variant a type. +- **A kept switch must be exhaustive.** When one switch over a closed enum stays + (single site, trivial variance), name the enum (`R1-primitive-obsession.md`, + "Name enum strings"), drop the `default`, and let the `exhaustive` linter prove + completeness β€” the linter then does what the if-chain never could: fail the build + when a variant is added but not handled. +- **The over-abstraction trap, dispatch edition.** An interface with one production + implementation is R6's territory; two trivial implementations behind one switch at + one site score LOW on R1's juiciness scorecard β€” keep the conditional. Conditionals + on *state/values* (`if n > threshold`, `if err != nil`, guard clauses per + `R3-storifying.md`) are healthy control flow, not dispatch β€” this rule never + touches them. + +## Fix pattern + +- **Replace Duplicated Switch with Interface Dispatch**: define the interface from the + union of what all copies of the switch do (one method per switching site is a + starting point, then collapse); one type per variant; move each `case` body into + its variant; introduce `ParseX(raw) (X, error)` as the single decision point and + migrate call sites to method calls. +- **Replace If-Chain with Strategy Map**: single-behavior variance β†’ package-level + `map[Kind]func(...)` (or a field), comma-ok on lookup at the boundary only. +- **Introduce Null Object**: absent-collaborator nil-checks β†’ a no-op implementation + constructed by default; delete the guards. +- **Split Flag Argument**: boolean/enum parameter that selects behavior β†’ two named + functions, or a variant type chosen by the caller's constructor. +- **Keep the Single Exhaustive Switch**: one site, closed enum β†’ named enum type (R1), + no `default`, `exhaustive` linter enforcing completeness. This is the rule's + sanctioned form β€” record it as the decision, not a TODO. +- New types this creates must pass R1's juiciness scorecard, land per + `R4-helper-placement.md`, and never become test-only interfaces + (`R6-test-only-interfaces.md`). Rejection case law: + `../examples/anti-if-dispatch.md`. + +## Falsifying questions + +Answer each with evidence (`file:line`, command output) β€” never a bare verdict. + +1. **Is the same discriminator inspected in more than one place?** + Detection: list discriminators in the diff β€” + `grep -nE 'switch [a-zA-Z_.]+\.(Type|Kind|Status|Mode|Channel|Format|Level)\b' $(git diff --name-only -- '*.go')` + and if-chain forms `grep -nE 'if [a-zA-Z_.]+\.(Type|Kind|Status|Mode|Channel|Format|Level) ==' ...`; + then count each across the package: `grep -rn 'switch .*\.' --include='*.go' . | wc -l` (plus `== ` comparisons on the same field). + Violation: β‰₯2 sites inspecting one discriminator β€” the decision has no single + owner; route to Interface Dispatch or Strategy Map. + +2. **Does a type switch dispatch on concrete types outside a boundary?** + Detection: `grep -rn 'switch .* := .*\.(type)' --include='*.go' .` β€” for each hit, + is it in a `ParseX`/decoder/boundary adapter, or in business logic? + Violation: a type switch in domain logic whose cases call variant-specific + behavior β€” the behavior belongs on the variants. + +3. **Does a `default:` (or trailing `else`) handle "unknown kind" away from the boundary?** + Detection: for each switch found in Q1, check the `default` arm for + `errors.New`/`fmt.Errorf`/panic on an unknown-kind message. + Violation: unknown-kind errors deep in the call graph β€” the maybe-unknown concept + leaked past construction; dispatch should have been chosen at `ParseX`. + +4. **Does a boolean parameter select between behaviors?** + Detection: `grep -nE 'func .*\(.*\b(is|use|with|enable|skip)[A-Za-z]* bool' $(git diff --name-only -- '*.go')`; + check whether the function branches on it near the top. + Violation: a flag argument whose branches share little code β€” Split Flag Argument. + +5. **Inverse β€” is a NEW dispatch abstraction in the diff unearned?** + Detection: for each new interface/strategy map in the diff, count production + implementations/entries and the number of sites the old conditional occupied + (`git log -p` or the pre-diff file). + Violation: one switching site with trivial variance replaced by an interface β€” + score it (R1 scorecard); if LOW, the finding is the *extraction*, and the fix is + Keep the Single Exhaustive Switch. An interface whose second implementation exists + only in tests is an R6 violation, not a dispatch win. diff --git a/go-linter-driven-development/skills/code-designing/SKILL.md b/go-linter-driven-development/skills/code-designing/SKILL.md index ca42d46..71293c3 100644 --- a/go-linter-driven-development/skills/code-designing/SKILL.md +++ b/go-linter-driven-development/skills/code-designing/SKILL.md @@ -3,7 +3,7 @@ name: code-designing description: | FORWARD view over rules/ β€” domain type design and architectural planning for Go code BEFORE it exists. Use when planning new features, designing self-validating types, preventing primitive obsession, or when refactoring reveals need for new types. - Dispatches into the Design guidance sections of rules/R1-R8 and R10. + Dispatches into the Design guidance sections of rules/R1-R8 and R10-R11. allowed-tools: - Skill(go-linter-driven-development:testing) --- @@ -73,6 +73,7 @@ For each concept in the design, open the rule that owns the question and apply i | `../../rules/R7-test-placement.md` | The test plan per type: leaf types 100% unit coverage via public constructors; orchestrators integration-tested over real collaborators. | | `../../rules/R8-no-globals.md` | Dependencies injected via constructors, `ctx` threaded from callers, globals only at entry points. | | `../../rules/R10-concurrency-safety.md` | Every planned goroutine gets an owner (stop + wait) and an exit path at construction time; shared state designed with its guard on one type β€” or designed away via handoff/confinement. | +| `../../rules/R11-conditional-dispatch.md` | How each kind/variant family dispatches: behavior-heavy or open set β†’ interface chosen once at the boundary; single-behavior variance β†’ strategy map; single-site closed enum β†’ one exhaustive switch (named enum per R1). | @@ -87,6 +88,7 @@ Before presenting the plan, verify against the rules (cite, don't restate): - [ ] Import direction strictly downward: leaf types ← sub-packages ← parent ← cmd/ (cycle-breaking move in @refactoring ``) - [ ] Dependencies constructor-injected and validated; ctx flows down; no new globals (R8, R2) - [ ] Every goroutine has an owner and exit path; shared state guarded where it lives, or confined (R10) +- [ ] Every kind/variant family has ONE dispatch owner β€” interface, strategy map, or a single exhaustive switch; no discriminator inspected in two places (R11) diff --git a/go-linter-driven-development/skills/pre-commit-review/SKILL.md b/go-linter-driven-development/skills/pre-commit-review/SKILL.md index b059cb1..8401912 100644 --- a/go-linter-driven-development/skills/pre-commit-review/SKILL.md +++ b/go-linter-driven-development/skills/pre-commit-review/SKILL.md @@ -13,7 +13,7 @@ allowed-tools: --- -Verify a finished diff against the plugin's rules (R1–R10) with evidence, by orchestrating +Verify a finished diff against the plugin's rules (R1–R11) with evidence, by orchestrating parallel single-obsession `rule-hunter` agents and one `overabstraction-skeptic`. Pure orchestration and reporting: this skill may spawn agents but never edits code, never fixes findings, and never blocks a commit. Rule knowledge lives once in `../../rules/`; @@ -54,6 +54,7 @@ A rule with zero hits is skipped β€” no hunter spawned for it. | R8 | `../../rules/R8-no-globals.md` | package-level state; `context.Background()` in library code | | R9 | `../../rules/R9-repo-brain.md` | orphan docs; broken doc edges (both directions); WHAT-comments on exported API; unwired root | | R10 | `../../rules/R10-concurrency-safety.md` | goroutines without exit paths or owners; unguarded shared-state writes; production sleeps; decorative mutexes | +| R11 | `../../rules/R11-conditional-dispatch.md` | one discriminator switched in β‰₯2 places; type switches in domain logic; unknown-kind defaults away from the boundary; flag arguments; unearned dispatch abstractions (inverse) | Also in-context: a new `//nolint` directive or `.golangci.yaml` exclusion in the diff is itself a finding β€” the change must justify, with evidence, that the rule genuinely does @@ -81,8 +82,8 @@ plus a final tally line (`R: finding(s)` or a hunted-clean line). Collect ALL type/package-extraction findings β€” every R1/R2/R4 "create a type/package" -proposal, and R10 "Extract Synchronized Owner" proposals β€” and spawn one -`overabstraction-skeptic`. Its spawn prompt MUST contain: +proposal, R10 "Extract Synchronized Owner" proposals, and R11 "Interface Dispatch" / +"Strategy Map" proposals β€” and spawn one `overabstraction-skeptic`. Its spawn prompt MUST contain: 1. The extraction findings under review β€” the hunter blocks pasted verbatim. 2. Payload: the **Juiciness scoring** and **The over-abstraction trap** sections of @@ -91,11 +92,14 @@ proposal, and R10 "Extract Synchronized Owner" proposals β€” and spawn one Verdicts per finding: `CONFIRMED (score + verified evidence)` or `REFUTED (score 0–1 + reason) β†’ cheaper alternative`. A refuted proposal does not ship; -when its cheaper alternative (better naming, private fields + accessors) is still worth -doing, report the alternative as 🟒 Polish. Only findings the skeptic cannot kill ship -as extraction findings. Non-extraction findings (R3, R5–R9, and R1/R2/R10 findings -that propose no new type) skip the skeptic and go straight to the report β€” R9 findings -(orphans, broken edges, WHAT-comments, unwired root) propose no type extractions. +when its cheaper alternative (better naming, private fields + accessors, or R11's +Keep the Single Exhaustive Switch) is still worth doing, report the alternative as +🟒 Polish. When R11 dispatch proposals are under review, additionally paste the FULL +content of `../../examples/anti-if-dispatch.md` β€” its Move 3 is the rejection case law +for dispatch extractions. Only findings the skeptic cannot kill ship as extraction +findings. Non-extraction findings (R3, R5–R9, and R1/R2/R10/R11 findings that propose +no new type) skip the skeptic and go straight to the report β€” R9 findings (orphans, +broken edges, WHAT-comments, unwired root) propose no type extractions. @@ -105,8 +109,9 @@ Merge surviving findings into one report. Category mapping: cancellation swallowed by `context.Background()`, R10 goroutine leaks and unguarded concurrent writes): fix immediately. - πŸ”΄ **Design Debt** β€” R1, R2, R4, R6, R7, R8, R10's non-crash findings (production - sleeps, fire-and-forget ownership, mutex placement), and R5 (advisory β€” never - blocks; the user may have valid reasons): fix before commit recommended. + sleeps, fire-and-forget ownership, mutex placement), R11 (duplicated discriminators, + boundary leaks), and R5 (advisory β€” never blocks; the user may have valid reasons): + fix before commit recommended. - 🟑 **Readability Debt** β€” R3, R9, unclear naming: improves maintainability. - 🟒 **Polish** β€” minor idiomatic improvements, the skeptic's cheaper alternatives. @@ -119,7 +124,7 @@ Issues noticed outside the diff scope go in a BROADER CONTEXT section, not as fi -**FULL (first run):** pre-filter all ten rules over the whole diff scope; report every +**FULL (first run):** pre-filter all eleven rules over the whole diff scope; report every surviving finding. **INCREMENTAL (re-run after fixes):** diff scope = only files changed since the last diff --git a/go-linter-driven-development/skills/refactoring/SKILL.md b/go-linter-driven-development/skills/refactoring/SKILL.md index 5bd7102..87c81aa 100644 --- a/go-linter-driven-development/skills/refactoring/SKILL.md +++ b/go-linter-driven-development/skills/refactoring/SKILL.md @@ -3,7 +3,7 @@ name: refactoring description: | BACKWARD view over rules/ β€” routes linter and review failures to the rule whose Fix pattern owns the repair. Use when linter fails with complexity issues (cyclomatic, cognitive, maintainability) or when code feels hard to read/maintain. - Applies storifying, type extraction, and function extraction patterns via rules/R1-R8 and R10. + Applies storifying, type extraction, function extraction, and conditional-dispatch patterns via rules/R1-R8 and R10-R11. allowed-tools: - Skill(go-linter-driven-development:code-designing) - Skill(go-linter-driven-development:testing) @@ -41,7 +41,8 @@ table in `../../agents/lint-fixer.md` β€” keep them consistent.) | `funlen` | `../../rules/R3-storifying.md` | | `nestif` | `../../rules/R3-storifying.md` | | `maintidx` | `../../rules/R3-storifying.md` + `../../rules/R1-primitive-obsession.md` | -| `dupl` | `../../rules/R1-primitive-obsession.md` (extract shared type/logic) | +| `dupl` | `../../rules/R1-primitive-obsession.md` (extract shared type/logic); duplicated blocks that switch on the same kind/type discriminator β†’ `../../rules/R11-conditional-dispatch.md` | +| `exhaustive` (missing enum cases) | `../../rules/R11-conditional-dispatch.md` β€” handle the case at the single dispatch site; a second switch appearing is the R11 violation itself | | revive `file-length-limit`; package-size hook failures (`hooks/check-package-sizes.sh`) | `../../rules/R5-vertical-slice.md` β€” mechanics in `` below | | `gochecknoglobals` / `gochecknoinits` | `../../rules/R8-no-globals.md` | | `ireturn` / interface lint on single-impl interfaces | `../../rules/R6-test-only-interfaces.md` | @@ -64,6 +65,7 @@ from there, never from memory: | Move test down a rung, Split `wantErr` tables, Replace sleep with synchronization | `../../rules/R7-test-placement.md` | | Extract Clean Island, Push Global Up One Level, Replace `init()` with constructor, Thread `ctx` | `../../rules/R8-no-globals.md` | | Inject the Exit Path, Make the Goroutine Joinable, Extract Synchronized Owner, Replace Sleep with Timer Select, Delete Unearned Guards | `../../rules/R10-concurrency-safety.md` | +| Replace Duplicated Switch with Interface Dispatch, Replace If-Chain with Strategy Map, Introduce Null Object, Split Flag Argument, Keep the Single Exhaustive Switch | `../../rules/R11-conditional-dispatch.md` | **Multi-rule procedures** (sequencing, god-object decomposition, package decomposition): `reference.md` in this directory. @@ -72,6 +74,7 @@ decomposition): `reference.md` in this directory. - Storify β†’ leaf type discovery: `../../examples/storify-leaf-type.md` - Over-abstraction rejection + cheaper alternatives: `../../examples/overabstraction-cidr.md` - Incremental global elimination: `../../examples/dependency-rejection.md` +- Duplicated kind-switch β†’ interface dispatch (and the kept-switch rejection): `../../examples/anti-if-dispatch.md` diff --git a/ts-react-linter-driven-development/.claude-plugin/plugin.json b/ts-react-linter-driven-development/.claude-plugin/plugin.json index c9a09ac..2dbb7a0 100644 --- a/ts-react-linter-driven-development/.claude-plugin/plugin.json +++ b/ts-react-linter-driven-development/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ts-react-linter-driven-development", - "version": "1.0.0", + "version": "1.1.0", "description": "Linter-driven development workflow for TypeScript + React with six specialized skills: component design, testing, refactoring, review, and documentation", "author": { "name": "Dan Mordechay" diff --git a/ts-react-linter-driven-development/skills/component-designing/SKILL.md b/ts-react-linter-driven-development/skills/component-designing/SKILL.md index ce62e58..f438509 100644 --- a/ts-react-linter-driven-development/skills/component-designing/SKILL.md +++ b/ts-react-linter-driven-development/skills/component-designing/SKILL.md @@ -512,6 +512,7 @@ See reference.md for detailed principles: - Custom hooks for reusable logic - Context for shared state (use sparingly) - Props interfaces and type safety +- One dispatch owner per variant family (Anti-IF): model variants as discriminated unions; render them via component/lookup maps, never the same kind-conditional in multiple components ## Pre-Code Review Questions @@ -521,6 +522,7 @@ Before writing code, ask: - Should state be local or context? - Have I avoided primitive obsession? - Is validation in the right place? +- Does each variant family (kind/status/type) have exactly one dispatch owner β€” a discriminated union rendered through a component map or one exhaustive switch? - Does this follow the existing codebase architecture? - Are components small and focused? diff --git a/ts-react-linter-driven-development/skills/refactoring/SKILL.md b/ts-react-linter-driven-development/skills/refactoring/SKILL.md index 8641d91..d2b207d 100644 --- a/ts-react-linter-driven-development/skills/refactoring/SKILL.md +++ b/ts-react-linter-driven-development/skills/refactoring/SKILL.md @@ -529,6 +529,105 @@ function LoginForm() { } ``` +### Pattern 8: Replace Tag-Based Conditionals with Dispatch (Anti-IF) + +**Signal**: The same discriminator (`kind`, `status`, `type` field) inspected in more than one place; ternary/if-else chains in JSX selecting between renderings; switch statements duplicated across files + +The Anti-IF principle: a conditional that asks what a value *is* may exist once. The second copy of the same `switch (x.kind)` is a missing abstraction β€” dispatch through a lookup map or let a discriminated union carry the decision. + +```typescript +// ❌ Before - Same discriminator inspected in three places +function StatusBadge({ status }: { status: Status }) { + return status === 'active' ? ( + Active + ) : status === 'pending' ? ( + Pending + ) : ( + Suspended + ) +} + +function statusLabel(status: Status) { + if (status === 'active') return 'Active' + if (status === 'pending') return 'Pending approval' + return 'Suspended' // drifts independently of StatusBadge +} + +function canLogin(status: Status) { + if (status === 'active') return true + if (status === 'pending') return true + return false +} + +// βœ… After - One dispatch owner: a config map keyed by the discriminator +const STATUS_CONFIG: Record = { + active: { color: 'green', label: 'Active', canLogin: true }, + pending: { color: 'yellow', label: 'Pending approval', canLogin: true }, + suspended: { color: 'red', label: 'Suspended', canLogin: false } +} + +function StatusBadge({ status }: { status: Status }) { + const { color, label } = STATUS_CONFIG[status] + return {label} +} + +// Adding a status = adding one entry; TypeScript errors until every +// Record key is present β€” the compiler enforces completeness. +``` + +For variant components, dispatch through a component map instead of ternary chains in JSX: + +```typescript +// ❌ Before - Nested ternaries selecting components +function NotificationItem({ notification }: { notification: Notification }) { + return notification.kind === 'message' ? ( + + ) : notification.kind === 'mention' ? ( + + ) : ( + + ) +} + +// βœ… After - Component map, one entry per variant +const NOTIFICATION_VIEWS: Record> = { + message: MessageNotification, + mention: MentionNotification, + system: SystemNotification +} + +function NotificationItem({ notification }: { notification: Notification }) { + const View = NOTIFICATION_VIEWS[notification.kind] + return +} +``` + +When a switch legitimately stays (single site, variant-specific payloads), make it a **discriminated union with an exhaustiveness check** β€” the compiler then does what an if-chain never could: fail the build when a variant is added but not handled: + +```typescript +type PaymentEvent = + | { kind: 'charge'; amountCents: number } + | { kind: 'refund'; amountCents: number; reason: string } + | { kind: 'dispute'; caseId: string } + +function describe(event: PaymentEvent): string { + switch (event.kind) { + case 'charge': + return `Charged ${formatCents(event.amountCents)}` + case 'refund': + return `Refunded ${formatCents(event.amountCents)}: ${event.reason}` + case 'dispute': + return `Dispute opened (${event.caseId})` + default: { + const unhandled: never = event // compile error if a variant is missed + return unhandled + } + } +} +``` + +**The dividing line**: dispatch is bought with the deletion of duplicated decisions. One switch at one site over a discriminated union is healthy β€” keep it exhaustive. The violation is the *second* copy of the discriminator, or `boolean` props that select between behaviors (`