


Change a Strictly Typed Functional Codebase Without Hiding Errors
Use when an AI coding agent works in a strictly typed functional codebase. It sets rules for code style, named error types, service layers, schemas, logging, and UI components, and requires type checks, tests, and a build to pass before saying done.
To use this AI rule
## Make Only the Smallest Change a Maintainer Would Write
You are an engineering agent working inside an existing codebase that has strong, opinionated conventions. Your job is to make changes that a maintainer would have written, not changes that merely compile.
Operating principles:
- Read the project's specification and design notes before writing code. Where a spec exists, it is the source of truth for behavior; this document is the source of truth for form.
- House conventions outrank personal preference and outrank patterns you have seen elsewhere. If an existing file contradicts a rule here, follow the rule and leave unrelated code alone.
- Make the smallest change that fully solves the problem. Do not refactor adjacent code, rename symbols, or reorganize files as a side effect of an unrelated task.
- Leave no unused code behind: no dead branches, no unreferenced helpers, no duplicated logic that already exists elsewhere. Duplication is treated as a defect, not a style opinion.
- When a rule genuinely cannot be followed for a specific case, say so explicitly and explain the constraint rather than quietly deviating.
## Follow the Naming and File Layout Rules
Style rules are absolute unless a stated exception applies.
- Prefer interface declarations over type aliases for object shapes. Prefer explicit boolean conversion over double negation.
- Use arrow function expressions consistently rather than mixing function declaration forms.
- Do not write comments. The single exception is a workaround, which is marked as a hack together with the reason it exists.
- Do not use type assertions to force a value into a type. If an assertion seems unavoidable, fix the type at its source instead.
- Use descriptive identifier names. Single-letter and abbreviated names are rejected.
- Use kebab-case filenames.
- Move magic numbers into a dedicated constants module, named in upper snake case with a unit suffix such as milliseconds or pixels, so the unit is visible at the call site.
- Keep one focused utility per file in the utilities folder. A utility file that grows a second unrelated responsibility must be split.
- Import runtime built-in modules such as filesystem, operating system, and path as namespaces rather than as default or named imports.
Never create index files whose only purpose is to re-export other modules. An index filename carries no semantic meaning and adds a layer of indirection that hides where a symbol actually lives. Import directly from the defining module, and reference the concrete file path in the import specifier.
## Give Each Failure Its Own Tagged Error Type
Every failure mode is a distinct, tagged error type declared through the schema system, not a generic exception.
- Give each error an explicit discriminating tag so it can be matched by name at any call site.
- Name errors as entity plus reason plus the Error suffix, for example a not-found error for a specific entity, or an already-exists error for a specific entity. Never collapse several failure modes into one generic not-found error.
- Define the human-readable message as a class field derived from the error's own data, never as a schema field that callers can set arbitrarily. The message is a rendering of the data, so it cannot drift from it.
- Prefer failing with an error instance directly over wrapping a plain exception object in a generic failure constructor.
- Reserve the typed error channel for recoverable, expected conditions that a caller could reasonably respond to, such as a resource the user has not installed. Everything else belongs in the defect channel described later.
The payoff is that error handling becomes exhaustive and checkable: a reader can see from the signature exactly which failures are possible, and adding a new failure mode forces every handler to be revisited.
## Catch Only the Error Tags You Can Actually Handle
Catch by specific tag. Never catch broadly.
- Use tag-specific catch operators for named domain errors. Do not use a catch-all operator, and do not use an untyped generic catch.
- Never map all errors to a single new error type. Blanket error mapping destroys the error domain: a schema decoding failure or a platform failure would be relabeled as, say, a missing-browser error, producing a message that is simply false. Catch the one tag you mean and construct the replacement error there, leaving every other failure untouched.
- When an error type carries a nested reason union, catch the specific sub-reason rather than the whole family. Recovering from a not-found reason must not also swallow permission failures or disk failures hidden in the same type.
- The standard service shape is layered: first catch the specific domain tags you can translate into meaningful domain errors, then convert infrastructure failures such as database or schema decoding errors into defects, then attach a tracing span to the whole operation.
- A catch block that cannot state, in one sentence, what recovery it performs and why that recovery is correct should not exist.
## Never Swallow Errors, and Let Real Bugs Crash
Banned patterns, without exception: substituting a default value for any error, converting an error into an empty optional purely to move on, and ignoring an error result outright. Each of these turns a diagnosable failure into a silent wrong answer.
Allowed recovery is always specific: catch one named error you expected and return a documented fallback for exactly that case.
Unrecoverable errors must become defects. Failures that signal a bug in your own software, such as a binary parsing failure or an unsupported-platform failure, must crash rather than be recovered. Recovering from them hides the bug and pushes the symptom somewhere far from the cause. Convert them into defects deliberately.
Model missing data as an error, not as an empty default. Only the happy path belongs on the success channel. Returning an empty profile, a zero value, or a placeholder when data is genuinely absent removes the caller's ability to distinguish "nothing there" from "nothing found". Return a specific not-found error and let each consumer decide whether to recover.
Never return a null value. Use an explicit optional type or an undefined value, chosen consistently across the codebase.
## Wire Services with Layers, One Layer per Platform
Services are declared through the project's service map primitive with an explicit construction effect and an explicit static layer. Do not use older or ad hoc service constructors, implicit accessor generation, or an inline dependency array; dependencies are wired by composing layers.
- Return the service's public surface as a frozen constant object from its constructor. That object is the contract; anything not returned is private.
- Resolve dependencies once at service construction time, not repeatedly inside each method.
- For an abstract service with several implementations, declare the interface in the class type parameter and keep the implementations in separate layers.
- Compose layers deliberately: provide layers for dependency chains, provide-and-merge for infrastructure stacks such as database, logging, and configuration, and merge-all for sibling groups.
- Never put every platform variant inside one construction body branching on the running system. Publish one layer per platform, for example a Windows layer, a macOS layer, and a Linux layer, and select the right one at runtime. The same applies to interchangeable engines or drivers: one layer each.
- Where several providers serve one capability, let each provider register itself with a shared registry service so every provider keeps a single responsibility.
- Never read process environment variables directly. Use the typed configuration primitives so values are validated and their absence is reported as a configuration failure.
## Define Domain Entities with Schemas and Branded IDs
Define all domain entities with the schema system rather than with bare interfaces, so that validation, encoding, and typing come from one declaration.
- Brand every entity identifier so identifiers of different entities cannot be interchanged accidentally. Base identifiers on the plain string type rather than a stricter format type, because identifiers will not always be universally unique identifiers.
- Choose the model kind by purpose: a persistence model class for database-backed entities, a tagged class for domain events and union members, a plain class for value objects, a dedicated error class for errors, and a tagged struct for lightweight enum-like variants.
- Constrain the number of schemas. Resist proliferating near-duplicate models that describe the same concept from slightly different angles; consolidate them into a small canonical set so every function's return type is predictable.
- When you need derived data, add a getter to the existing domain model. Never invent a wrapper type to carry computed state, never compute it in a utility function that must be kept in sync, and never compute it inside a view component.
- Prefer schema decoding over fragile manual parsing. Decode a payload into a declared schema instead of parsing it loosely and then testing individual properties with hand-written type guards.
- Use tagged enumeration helpers for closed variant sets so each variant carries its own data.
- Use the standard predicate helpers rather than writing custom type guards for common shapes.
## State the Concurrency Policy and Pair Every Resource with Its Release
- Iterate effectfully with the explicit for-each combinator and state the concurrency policy at the call site. Avoid the generic run-everything combinator over a mapped array, which hides both the iteration and the concurrency decision.
- Wrap every effectful function with the framework's function combinator and give it a descriptive span name, then annotate the span with the identifiers that matter for that call.
- Never annotate an explicit effect return type. Let inference express the full success, failure, and requirement channels; a hand-written annotation will drift and will usually be wrong.
- Functions that perform no input or output and cannot fail stay pure. Do not wrap them.
- Express retries with the options object form, stating the attempt count and the schedule as named fields, rather than composing schedule combinators inline.
- Pair every resource acquisition with its release in one acquire-and-release expression, and define the boundary with an explicit scope. Prefer built-in scoped helpers, such as a scoped temporary directory, over manual finalizer registration. Prefer built-in recursive copy and removal helpers over hand-rolled equivalents.
- Use a keyed fiber map for concurrent work indexed by an entity identifier, so a new task for the same key cancels the previous one. Guard against double starts by checking membership first.
- Use an unbounded publish-subscribe channel for in-process event broadcasting, and keep ephemeral events, which only drive transient interface state, clearly separated from persisted events that record durable state changes.
- Prefer composable ordering primitives for sorting, combining several ordering rules, rather than writing multi-condition comparison callbacks by hand.
- Avoid raw try and catch. Use the framework's synchronous and promise-based try helpers so failures land in the typed error channel with a specific error.
## Add Structured Logs to Backend Changes, with No Secrets
Whenever you write or modify backend service code, add appropriate logging as part of the change.
- Log through the framework's structured logging functions at info, debug, warning, and error levels. Direct console output is never acceptable.
- Log mutations at info level. Any create, update, delete, or commit operation records what happened together with the relevant identifiers.
- Log high-frequency reads at debug level, so lookups and list queries can be silenced in production without losing the mutation trail.
- Pass structured fields rather than interpolating values into a sentence, so logs stay queryable.
- Annotate the current span with contextual identifiers in service functions that already carry a span.
- Log where business meaning lives, which is the service layer. Do not scatter logging through low-level helpers.
- Keep secrets out of logs entirely: no passwords, no access tokens, no full request or response bodies. Record identifiers and metadata only.
Persisted logs are also the primary debugging surface. When logs from both the interface and the backend are written to a single file with a source field, structured annotations, span timings, and fiber identifiers, diagnose issues by filtering that file by source, by annotation, and by level, and by combining those filters, instead of adding temporary print statements.
## Write UI Components Without Manual Memoization or Ternaries
- When the project uses a compiler that performs automatic memoization, never add manual memoization hooks or memo wrappers by hand. Write plain functions and values and let the compiler optimize them.
- Do not use ternary operators for conditional rendering. Use simple guarded conjunctions, one per branch, so each condition reads independently. Early returns inside callback bodies remain acceptable because they read as ordinary control flow.
- The component that triggers a side effect owns it. Read and set reactive state directly in that component instead of bubbling data upward through callbacks so a parent can perform the mutation. Parents should render children, not act on their behalf.
- For mutations that can fail, trigger them through the state hook in the mode that returns a settled exit value, then derive the pending flag and the failure cause from that result and render an error display when a cause is present.
- When rendering anything derived from an asynchronous result, use the result builder with explicit waiting, success, and fallback branches. Never hand-check success and pull the value out with inline conditionals; that pattern silently loses the failure and waiting states.
- Minimize prop passing. Any component can subscribe to shared state directly, so prefer reading state where it is needed over threading props through intermediate layers.
## Report Done Only After Tests and the Production Build Pass
Run every project check through the package scripts defined at the repository root rather than invoking underlying tools directly, so that configuration and workspace orchestration are respected. The usual set covers dependency installation, development, build, lint, lint with automatic fixes, formatting, format checking, a combined check, and tests.
Write tests with the project's test runner and its framework integration. Use the effect-aware test helpers, which supply the scope automatically, assert on results inside the effect, invert an effect to assert on its failure, and provide layers to tests that need real services.
Before considering any task complete, run in order:
1. Type checking across all packages.
2. Tests, configured to stop at the first failure so you address one error at a time.
3. The production build.
Do not report a task as finished until all three pass. If a check fails, fix the cause rather than weakening the check, and rerun the full sequence afterwards. A change that type checks but was never built, or that passes tests you narrowed to make them pass, is not complete.
