chore: publish from main

This commit is contained in:
github-actions[bot]
2026-08-25 23:57:04 +00:00
parent f362fc0534
commit 00ce5234bd
7 changed files with 1736 additions and 0 deletions
@@ -0,0 +1,416 @@
# Hazard Catalog
The recurring shapes that produce mistakes, organized by the lens that finds them. Each entry:
what to look for, why it bites, and the device that closes it with the rung it reaches.
Use this as working vocabulary, not a checklist to run top to bottom. The lens questions are
the real tool; this catalog is what the lenses usually turn up.
## Contents
- [Contact lens, can the wrong thing fit?](#contact-lens-can-the-wrong-thing-fit)
- [C1. Adjacent same-type parameters](#c1-adjacent-same-type-parameters)
- [C2. Boolean flag parameters](#c2-boolean-flag-parameters)
- [C3. Primitive obsession at boundaries](#c3-primitive-obsession-at-boundaries)
- [C4. Stringly-typed enums](#c4-stringly-typed-enums)
- [C5. Implicit units and magnitudes](#c5-implicit-units-and-magnitudes)
- [C6. Money as a float](#c6-money-as-a-float)
- [C7. Unvalidated external input](#c7-unvalidated-external-input)
- [C8. Bag-of-optionals structs](#c8-bag-of-optionals-structs)
- [C9. Naive datetimes](#c9-naive-datetimes)
- [Fixed-value lens, can an incomplete or wrong-sized set pass?](#fixed-value-lens-can-an-incomplete-or-wrong-sized-set-pass)
- [F1. Non-exhaustive branching](#f1-non-exhaustive-branching)
- [F2. Unbounded destructive operations](#f2-unbounded-destructive-operations)
- [F3. Defaults that hide a decision](#f3-defaults-that-hide-a-decision)
- [F4. Config discovered missing at runtime](#f4-config-discovered-missing-at-runtime)
- [F5. Partial writes without a transaction](#f5-partial-writes-without-a-transaction)
- [F6. Invariants enforced only in the application](#f6-invariants-enforced-only-in-the-application)
- [F7. Unbounded input](#f7-unbounded-input)
- [Motion-step lens, can the order be wrong?](#motion-step-lens-can-the-order-be-wrong)
- [M1. Temporal coupling](#m1-temporal-coupling)
- [M2. Non-idempotent retryable effects](#m2-non-idempotent-retryable-effects)
- [M3. Illegal state transitions](#m3-illegal-state-transitions)
- [M4. Resources that must be released](#m4-resources-that-must-be-released)
- [M5. Check-then-act races](#m5-check-then-act-races)
- [M6. Fire-and-forget async](#m6-fire-and-forget-async)
- [M7. Order-dependent migrations and deploys](#m7-order-dependent-migrations-and-deploys)
- [Cross-cutting, devices that were removed](#cross-cutting-devices-that-were-removed)
- [X1. Swallowed errors](#x1-swallowed-errors)
- [X2. Silent coercion and fallback](#x2-silent-coercion-and-fallback)
- [X3. Disabled tests](#x3-disabled-tests)
- [X4. Escape hatches in the type system](#x4-escape-hatches-in-the-type-system)
- [X5. Mutable shared defaults](#x5-mutable-shared-defaults)
---
## Contact lens, can the wrong thing fit?
The factory analogy: a part that only seats one way. In software, the type is the shape.
### C1. Adjacent same-type parameters
**Signal**: two or more consecutive parameters of the same primitive type, `transfer(from: string, to: string)`, `resize(w: number, h: number)`,
`slice(start: int, end: int)`.
**Why it bites**: swapping them compiles, passes review, and produces a plausible wrong
result. It is among the most common footguns in software, and one of the most cleanly
solved, once the two types differ, the wrong order will not compile.
**Device**: distinct types per concept, branded types, newtypes, value objects, so a
`SourceAccount` cannot be passed as a `DestinationAccount`. **Control.**
Fallback where types can't help: force keyword/named arguments so the caller must write the
name at the call site. **Warning**, but nearly free and it makes the swap visible in review.
### C2. Boolean flag parameters
**Signal**: `createUser(name, true, false)`, `save(data, force=True)`, any `bool` parameter
that selects behavior rather than carrying data.
**Why it bites**: the call site is unreadable, so misordered or misunderstood flags are
invisible. Adding a second boolean makes it exponentially worse.
**Device**: an enum or literal union per axis (`Visibility.Public`), an options object with
named fields, or two separate functions. **Control** for the enum, since the wrong value has
no spelling. Note the exception: a single boolean whose name reads correctly at the call site
in a keyword-argument language is fine.
### C3. Primitive obsession at boundaries
**Signal**: `string` for email, URL, path, token, tenant ID, phone; `int` for a percentage or
a duration, especially on public functions.
**Why it bites**: every downstream function must re-check or trust. Validation that returns a
boolean throws away the proof, so the check gets repeated, skipped, or done inconsistently.
**Device**: parse-don't-validate. `parseEmail(s): Email | Error` once at the boundary, then
downstream signatures demand `Email`. The type carries the guarantee permanently. **Control.**
### C4. Stringly-typed enums
**Signal**: `status: string` with a comment listing the values; string comparison against
literals; a value crossing a boundary as text with no schema.
**Why it bites**: typos compile. New variants added elsewhere never reach this code. Nothing
tells you which values are legal.
**Device**: a literal union, enum, or sealed class, with exhaustive matching (F1). **Control.**
### C5. Implicit units and magnitudes
**Signal**: `timeout: number`, `distance: float`, `retryAfter: int`: no unit anywhere except
possibly a name or a comment. Two systems in the same codebase disagreeing on seconds vs
milliseconds.
**Why it bites**: a 1000x error is silent and looks like a hang or a hot loop. This class of
mistake famously destroyed a Mars orbiter.
**Device**: unit-bearing types (`Duration`, `Milliseconds`), or at minimum encode the unit in
the parameter name (`timeoutMs`). **Control** for the type. The name is **rung 0**: it makes
a mismatch visible to a reader who is looking, and produces no diagnostic for one who is not.
Worth doing; not a device.
### C6. Money as a float
**Signal**: `price: float`, `amount: number`, arithmetic on currency in binary floating point,
`==` comparisons on money.
**Why it bites**: 0.1 + 0.2 ≠ 0.3. Errors accumulate over aggregation and reconciliation
fails in ways that take days to trace.
**Device**: integer minor units (cents) in a `Money` type carrying its currency, or a decimal
type. Mixed-currency arithmetic should not typecheck. **Control.**
### C7. Unvalidated external input
**Signal**: `JSON.parse(body)` into `any`, `request.json()` into a bare dict, a third-party
API response used field-by-field with no schema, `os.environ[...]` read deep inside logic.
**Why it bites**: the failure surfaces far from the boundary, as a confusing error about a
missing property, long after the malformed data has been partially processed or stored.
**Device**: a schema at every edge, zod/valibot, Pydantic, `encoding/json` into a typed
struct with validation, serde. Parse once, then work with parsed types. **Control.**
This applies to *your own* services' responses too; "internal" is not a guarantee.
### C8. Bag-of-optionals structs
**Signal**: a type with several optional fields where only certain combinations are
meaningful, `{ status, data?, error?, retryAt? }`, `{ isLoading, data, error }`.
**Why it bites**: N optional fields claim 2^N legal states. Every consumer must guess which
are real, and they guess differently. States like "loading and errored with data" become
reachable and get handled inconsistently.
**Device**: a discriminated union with exactly the legal variants, so impossible combinations
have no representation. **Control.** This is the canonical "make invalid states
unrepresentable" move.
### C9. Naive datetimes
**Signal**: timezone-less timestamps, `datetime.now()` / `new Date()` scattered through
business logic, dates stored as strings, DST-unaware arithmetic.
**Why it bites**: correct in the developer's timezone, wrong in production, and wrong twice a
year in the places that observe DST. Also hard to test, logic that reads the clock directly
cannot be exercised at a boundary condition without freezing or injecting time.
**Device**: timezone-aware types everywhere, UTC at rest, an injected clock so time is a
parameter rather than an ambient read. **Control** for the type, and the injected clock buys
testability, which is a Detection-rung device that finally becomes possible.
---
## Fixed-value lens, can an incomplete or wrong-sized set pass?
The factory analogy: a counter confirming all six screws were fitted.
### F1. Non-exhaustive branching
**Signal**: a `switch`/`match` over an enum with a `default` that does nothing meaningful, or
an if/else chain over a closed set of values.
**Why it bites**: adding a variant silently takes the default branch at every site that
should have been updated. The bug appears months later, in the one code path nobody tested.
**Device**: compiler-enforced exhaustiveness: an `assertNever(x: never)` arm in TypeScript,
`match` without a catch-all in Rust, `assert_never` with mypy, an exhaustive linter for Go.
**Control**, one line per switch, and among the highest-leverage devices available.
### F2. Unbounded destructive operations
**Signal**: `DELETE`/`UPDATE` built from a filter that can be empty; `rm -rf "$VAR"`;
`.deleteMany(where)`; bulk send/publish over a query result; a "cleanup" job with no cap.
**Why it bites**: irreversible, instant, and proportional to your data volume. An empty filter
frequently means "match everything."
**Device**: refuse an empty predicate; require an explicit `all=True` for the full-table case;
cap the affected row count and require confirmation above it; dry-run by default with the
count printed. Soft-delete where the domain allows. **Control.**
### F3. Defaults that hide a decision
**Signal**: a default value for something with no safe default, `retries=3`, `timeout=30`,
`currency="USD"`, `tenant=None`, `region=default`.
**Why it bites**: the caller never considers the parameter, and the default is wrong for their
case. Worse than an error, because it produces confident wrong behavior.
**Device**: make it required. Reserve defaults for parameters where one value is correct for
the overwhelming majority and wrong-but-harmless for the rest. **Control.**
### F4. Config discovered missing at runtime
**Signal**: `os.getenv("X")` inside a request handler; config read lazily on first use; a
missing key producing `None` that flows onward.
**Why it bites**: the service starts, passes health checks, and fails on the one code path
that needs the key, often the payment path, often at 3am.
**Device**: parse and validate the entire config into a typed object at startup, and exit
non-zero if anything is missing or malformed. Every consumer takes the typed object.
**Control**, and it converts a 3am page into a failed deploy.
### F5. Partial writes without a transaction
**Signal**: several writes in sequence with no transaction; a write followed by an external
call followed by another write; "create the record then send the email."
**Why it bites**: a failure in the middle leaves the system in a state your code does not
model and cannot repair.
**Device**: wrap in a transaction; move external effects outside it via an outbox; make the
sequence idempotent so replay converges. **Control** for the transaction.
### F6. Invariants enforced only in the application
**Signal**: uniqueness checked with a `SELECT` before an `INSERT`; nullability enforced in a
model class but not in the column; a foreign key relationship maintained by convention.
**Why it bites**: the check races under concurrency, and it is bypassed entirely by any other
service, migration, script, or human with `psql`.
**Device**: push it into the schema, `NOT NULL`, `UNIQUE`, `CHECK`, foreign keys, partial
unique indexes. The database is a type system shared by everything that touches the data.
**Control**, and uniquely durable.
### F7. Unbounded input
**Signal**: pagination with no maximum page size; a file upload with no size limit; a query
built from a user-supplied list with no cap; unbounded recursion or retries.
**Why it bites**: a resource exhaustion incident indistinguishable from an attack, triggered
by an ordinary user with a large account.
**Device**: explicit caps at the boundary, enforced by the parsing type where possible.
**Control.**
---
## Motion-step lens, can the order be wrong?
The factory analogy: a sensor confirming step 3 happened before step 4.
### M1. Temporal coupling
**Signal**: `init()`, `connect()`, `configure()`, `validate()` that must be called before
other methods; documentation containing the phrase "you must call X first."
**Why it bites**: nothing enforces it. The failure is a null dereference or, worse, a
silently-wrong result from a half-configured object.
**Device**: the constructor or a static factory returns a fully ready object; or typestate,
where `connect()` returns a `Connected` type and the other methods exist only on it.
**Control.**
### M2. Non-idempotent retryable effects
**Signal**: a charge, email, webhook, or external mutation reachable from a retry, a queue
consumer, or a UI button, with no idempotency key, or with an optional one.
**Why it bites**: at-least-once delivery is the norm, not the exception. Duplicate charges are
the canonical version and they are expensive and public.
**Device**: a **required** idempotency key parameter, backed by a unique constraint on
`(entity, key)`. **Control.** An optional idempotency key is rung zero wearing a costume.
The constraint is necessary and not sufficient. Rejecting the duplicate is not the same as
being idempotent: the key has to be *reserved in the same transaction as the effect*, bound
to the request payload so a different payload under a reused key is an error rather than a
silent no-op, and the stored result replayed to the second caller. A caller that retries and
gets a constraint violation has learned nothing about whether the first attempt worked.
### M3. Illegal state transitions
**Signal**: an entity with a `status` field mutated by assignment from several places; a
refund reachable before a charge; "cancelled" transitioning back to "pending".
**Why it bites**: every site that assigns the field must know the whole state machine, and one
of them doesn't.
**Device**: a single transition function that is the only path to a new state, rejecting
illegal transitions; or typestate so illegal transitions don't compile. **Control.**
A row-level `CHECK` is not defence in depth here: it constrains one row's values and cannot
see the state that row is coming from, so it can forbid `status = 'refunded' AND total < 0`
but not `shipped → pending`. Policing transitions in the database needs a trigger, or a
transition table the row must join against.
### M4. Resources that must be released
**Signal**: `open()`/`close()`, `acquire()`/`release()`, `begin()`/`commit()` as separate
statements, especially with a `return` or `throw` reachable between them.
**Why it bites**: the happy path is fine and the error path leaks. Leaks surface as connection
pool exhaustion under load, which is when you can least afford it.
**Device**: scope-bound acquisition, `with`, `defer`, RAII, `using`, try-with-resources.
**Control.**
### M5. Check-then-act races
**Signal**: `if (!exists(x)) create(x)`, read-modify-write on a shared counter, checking a
balance and then debiting it, `if (!file.exists()) write(file)`.
**Why it bites**: correct in every test and wrong under concurrency, intermittently, in
production only.
**Device**: make it atomic: a unique constraint plus `INSERT ... ON CONFLICT`, a conditional
update carrying the expected version, `SELECT FOR UPDATE`, a compare-and-swap. **Control.**
### M6. Fire-and-forget async
**Signal**: a promise not awaited, a goroutine with no error path, `asyncio.create_task` with
no reference kept, a background write nobody joins.
**Why it bites**: errors vanish. Worse, the process may exit before the work completes, so
writes are lost silently and non-deterministically.
**Device**: `no-floating-promises` as a lint error, an errgroup, structured concurrency,
holding and awaiting the task. **Warning** from the linter, which is the practical answer
in TypeScript, Python and Go. Rust is the closest thing to an exception: futures are lazy and `#[must_use]`, so a dropped
future produces a compiler warning without any linter. That is **Warning**, for free; add
`#![deny(unused_must_use)]` to make the build fail and it becomes **Control**.
### M7. Order-dependent migrations and deploys
**Signal**: a migration that drops or renames a column in the same deploy as the code change;
a migration and code that must land in a specific order with nothing enforcing it.
**Why it bites**: during the rollout window, old code runs against the new schema. This is an
outage, not a bug.
**Device**: expand/contract, add, backfill, dual-write, switch, then drop in a later deploy, with a CI gate that blocks destructive DDL from co-deploying with code changes. **Control**
via the gate; the pattern itself is the design.
---
## Cross-cutting, devices that were removed
Several of these are hazards of removal, someone installed a device and someone else took
it out. Others (X2, X5) are defaults nobody chose: the language ships them switched the wrong
way and they stay that way until someone notices.
Treat them with more suspicion than a missing device, since the code around them was written
by someone who knew the failure was possible.
### X1. Swallowed errors
**Signal**: `catch {}`, `except: pass`, `except Exception: pass`, `_ = err`, `catch (e) {
console.log(e) }` with execution continuing, `.catch(() => null)`.
**Why it bites**: converts a loud failure into a quiet wrong answer: the exact inversion of
mistake-proofing. The system continues on corrupted assumptions.
**Device**: handle it, or let it propagate. Where absorbing genuinely is correct, the comment
must name which specific failure is expected and why continuing is safe; catch that specific
type, not everything. Enforce with `no-empty` / bare-except lint rules as errors. **Warning.**
### X2. Silent coercion and fallback
**Signal**: `value || default` where `0`/`""`/`false` are legal values; `parseInt` without a
radix or a NaN check; `int(x)` in a try/except returning a default; `.unwrap_or_default()` on
a genuine error; `?.` chains ending in `undefined` that flow into logic.
**Why it bites**: produces a plausible value from bad input. The wrongness surfaces far away,
where the cause is invisible.
**Device**: `??` instead of `||` where zero is legal; explicit parse with an error branch;
fail at the boundary rather than substituting. **Control** at the parse site.
### X3. Disabled tests
**Signal**: `it.only`, `describe.skip`, `@pytest.mark.skip`, `t.Skip()`, `#[ignore]`: especially without a reason. Lint and type-checker suppressions (`eslint-disable`,
`# type: ignore`, `@ts-ignore`, `#nosec`) are X4, and the detector splits them the same way.
**Why it bites**: a Detection-rung device switched off, usually temporarily, permanently. The
suite stays green and stops meaning anything.
**Device**: fail CI on focused/skipped tests; require a justification comment and an issue
link on every suppression; count suppressions and ratchet the number downward. **Warning.**
### X4. Escape hatches in the type system
**Signal**: `any`, `as unknown as T`, `!` non-null assertion, `interface{}` with a type
switch, `# type: ignore`, `unsafe`, `cast()`, `Object` as a parameter type.
**Why it bites**: every one is a place where the type system's guarantee stops. Concentrated
in the boundary code that most needs the guarantee.
**Device**: ban them by lint at error level with a narrow, justified allowlist; replace with
parsing at the boundary. **Warning**: a required CI gate is still rung 2 by the ladder in
[method.md](../../../docs/method.md): it announces the mistake rather than removing the
ability to make it. Reach **Control** only when the unchecked value cannot be constructed.
### X5. Mutable shared defaults
**Signal**: Python's `def f(items=[])`, a module-level dict used as a cache and mutated, a
shared config object mutated after construction, class attributes used as instance state.
**Why it bites**: state leaks between calls, requests, or tests. The symptom is
order-dependent behavior that disappears when you try to reproduce it.
**Device**: `None` sentinel with in-function construction, frozen/immutable value types,
per-request construction. `B006` in ruff/flake8-bugbear enforces the argument-default case
only; the module-level cache, the shared config object and the mutable class attribute have
no lint rule and need review or a type that cannot be mutated.
**Warning**, or **Control** with frozen types.
+181
View File
@@ -0,0 +1,181 @@
# Python Devices
Python's type hints are optional and unenforced at runtime, which splits every device into two
questions: what the checker catches, and what actually holds when the code runs.
**Prerequisite**: `mypy --strict` (or `pyright` in strict mode) as a *required* CI check.
Without it, annotations are documentation, rung zero. Pair it with `ruff` at error level.
## Contact, NewType for cheap distinctness
```python
from typing import NewType
UserId = NewType("UserId", str)
OrderId = NewType("OrderId", str)
def transfer(src: UserId, dst: UserId) -> None: ...
transfer(order_id, user_id) # mypy: error: zero runtime cost
```
`NewType` is free at runtime and stops the mix-up at check time. It does not validate, use it
when the concepts differ but the shape doesn't need checking.
## Contact, parse at the boundary with Pydantic
When the value needs checking, parse into a model and let the type carry the proof:
```python
from pydantic import BaseModel, EmailStr, Field, ConfigDict
class CreateUser(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
email: EmailStr
age: int = Field(ge=0, le=150)
```
Two settings do most of the work. `extra="forbid"` turns a typo'd field into an error instead
of a silently ignored key: the difference between a 400 and a user whose preference never
saved. `frozen=True` blocks reassignment of the model's fields, so nothing downstream can
quietly replace what you verified. It is shallow, though: a `list` or `dict` field is still
mutable in place, so reach for `tuple`, `frozenset`, or a nested frozen model where that
matters.
Apply at every edge: request bodies, queue messages, third-party responses, file loads.
## Contact, keyword-only arguments
Python's answer to swapped parameters, and it costs one character:
```python
def transfer(*, source: AccountId, dest: AccountId, amount: Money) -> None: ...
transfer(source=a, dest=b, amount=m) # the only legal form
transfer(a, b, m) # TypeError
```
Force keyword-only for anything with more than two parameters, and always when two share a
type. This is Warning-rung. It makes the mistake visible rather than impossible, but it is
the highest-value one-character change in the language.
## Fixed-value, exhaustiveness
```python
from typing import assert_never, Literal
Status = Literal["pending", "active", "closed"]
def label(s: Status) -> str:
match s:
case "pending": return "Pending"
case "active": return "Active"
case "closed": return "Closed"
case _: assert_never(s) # mypy errors here if a variant is unhandled
```
`assert_never` turns "someone added a status" into a build failure at every site that must
change. Works with `Literal`, `Enum`, and tagged dataclass unions.
## Fixed-value, config validated at startup
```python
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
stripe_key: str
region: str # no default: an unset value should stop the deploy
settings = Settings() # raises at import, before the service reports healthy
```
Import this once at startup and pass the object down. Every `os.getenv` buried in a handler is
a 3am page waiting for the one request that reaches it.
## Contact, immutable value objects
```python
from dataclasses import dataclass
@dataclass(frozen=True, slots=True, kw_only=True)
class Money:
cents: int
currency: str
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError(f"cannot add {self.currency} to {other.currency}")
return Money(cents=self.cents + other.cents, currency=self.currency)
```
`frozen=True` prevents mutation after validation, `slots=True` makes a typo'd attribute
assignment an `AttributeError` rather than a silently-created new attribute, and `kw_only=True`
kills positional swaps. Three flags, three hazard classes closed.
## Motion-step, context managers
Any acquire/release pair belongs in a context manager. Never expose `open()`/`close()` as
separate public methods: the error path will leak, and only under load.
```python
from contextlib import contextmanager
@contextmanager
def transaction(conn):
tx = conn.begin()
try:
yield tx
tx.commit()
except Exception:
tx.rollback()
raise # re-raise: swallowing here would be X1
```
## Python-specific traps worth checking every time
- **Mutable default arguments**: `def f(items=[])` shares one list across every call. Use
`None` and construct inside. Caught by ruff `B006`.
- **Bare `except:`** catches `KeyboardInterrupt` and `SystemExit` too. Caught by `E722`.
- **`assert` for validation** is stripped under `python -O`. Never use it for anything
security- or correctness-critical; raise instead.
- **Naive `datetime.now()`**: use `datetime.now(timezone.utc)`, and inject a clock so time
is testable. Caught by ruff `DTZ`.
- **Float money**: use `int` cents or `decimal.Decimal`, never `float`.
- **`==` vs `is`** on strings and ints works by accident via interning and breaks in
production on longer values. Caught by `F632`.
- **`asyncio.create_task` without keeping a reference**: the task can be garbage collected
mid-flight, so the work silently doesn't happen. Caught by ruff `RUF006`.
## Ruff rule sets that are poka-yoke
Style rules aren't mistake-proofing; these are, which is why `E` appears only as its
bug-shaped subsets and not whole. Select at error level:
```toml
[tool.ruff.lint]
select = [
"F", # pyflakes: undefined names, unused imports
"E4", "E7", "E9", # pycodestyle's bug-shaped rules: bare except, `== None`, syntax errors
"B", # bugbear: mutable defaults, loop variable capture, assert-on-tuple
"S", # bandit: hardcoded secrets, unsafe subprocess, weak crypto
"DTZ", # naive datetimes
"ASYNC", # blocking calls inside async functions
"RUF006", # dangling asyncio tasks
"PLE", # pylint errors: genuine bugs only
"T20", # stray print/pprint
]
```
## Known limits
- **Annotations are not enforced at runtime.** Anything crossing a boundary, or reachable
from unchecked code, needs a real runtime parse. Pydantic is how you get Control; mypy
alone gives you Control only over code mypy actually checks.
- **`Any` is contagious** and an untyped dependency reintroduces it silently. Set
`disallow_any_unimported` and `warn_return_any`; audit `# type: ignore` comments and require
a reason on each.
- **No affine types**, so use-after-close isn't preventable; context managers are the answer.
- **Monkey-patching means no encapsulation is absolute.** Push invariants that truly must hold
into the database rather than into a class.
+213
View File
@@ -0,0 +1,213 @@
# Rust and Go Devices
Two languages at opposite ends of the expressiveness spectrum. Rust can encode almost any
invariant in types; Go deliberately cannot, so its devices lean on convention plus tooling.
Know which one you're in before proposing a device.
---
# Rust
Rust's type system reaches Control for more hazard classes than any other mainstream language.
The affine type system in particular is the only mainstream answer to use-after-move, and it
turns use-after-close into a compile error rather than a convention, where Python has context
managers, TypeScript has scope-bound callbacks, and Go has `defer`, Rust has the compiler.
## Contact, newtypes and smart constructors
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(Uuid);
#[derive(Debug, Clone)]
pub struct Email(String);
impl Email {
// The only way to build one. Private field means no bypass, even in-crate
// if you put it behind a module boundary.
pub fn parse(s: &str) -> Result<Self, InvalidEmail> {
if !s.contains('@') { return Err(InvalidEmail); }
Ok(Email(s.to_owned()))
}
pub fn as_str(&self) -> &str { &self.0 }
}
```
A private field plus a fallible constructor means possessing an `Email` *is* proof of
validation. This is the strongest form of parse-don't-validate available anywhere.
## Contact, enums make illegal states unrepresentable
```rust
// Each variant carries exactly the data that variant has. There is no
// "succeeded with an error", because it cannot be written.
pub enum JobState {
Queued { enqueued_at: DateTime<Utc> },
Running { started_at: DateTime<Utc>, worker: WorkerId },
Succeeded { output: Output },
Failed { error: JobError, retries: u32 },
}
```
`match` without a catch-all is exhaustive by default, adding a variant breaks the build
everywhere it must. Avoid `_ => {}` arms in domain logic for exactly this reason: the wildcard
is what turns a compile error into a silent fallthrough two releases later.
## Motion-step, typestate
Ownership makes typestate genuinely practical, since each transition consumes the old state:
```rust
pub struct Draft;
pub struct Validated;
pub struct Order<S> { items: Vec<Item>, _state: PhantomData<S> }
impl Order<Draft> {
pub fn validate(self) -> Result<Order<Validated>, ValidationError> { /**/ }
}
impl Order<Validated> {
// submit() does not exist on Order<Draft>. Not "returns an error", does not exist.
pub fn submit(self) -> Result<OrderId, SubmitError> { /**/ }
}
```
The consumed `self` means the draft is gone after validation, so a stale unvalidated copy
cannot be submitted later.
## Fixed-value, make errors impossible to ignore
`#[must_use]` on `Result` is built in; add it to your own types where dropping the value is a
bug. Then set the lints:
```toml
[workspace.lints.clippy]
unwrap_used = "deny"
expect_used = "warn" # allow in tests and startup with a reason
panic = "deny"
indexing_slicing = "deny" # forces .get() and a real branch
float_cmp = "deny"
arithmetic_side_effects = "warn" # forces checked_/saturating_ where overflow matters
todo = "deny"
dbg_macro = "deny"
```
`unwrap_used = "deny"` is the highest-value line in that block: it converts every "this can't
fail" assumption into an explicit decision at review time.
## Rust limits
- **`unsafe` and `unwrap` are the escape hatches.** Deny both by lint and require a
`// SAFETY:` comment for each `unsafe` block.
- **Compile-time only.** Deserialized input still needs `serde` with `deny_unknown_fields`.
- **Panics bypass the type system.** A device that panics is Warning, not Control.
- **Typestate has real ergonomic cost.** Reserve it for genuinely dangerous sequences, payments, resource lifecycles, protocol state: not for every builder.
---
# Go
Go rejects most compile-time expressiveness by design. Its devices are therefore fewer, and
tooling plus data-layer constraints carry more of the load. Say so plainly when you propose a
device, Control is often not reachable here, and pretending otherwise is worse than
acknowledging the rung.
## Contact, defined types
```go
type UserID string
type OrderID string
func Transfer(from, to UserID) error { ... }
// Transfer(orderID, userID), compile error, because these are defined types, not aliases.
```
Use `type X string` (a defined type), never `type X = string` (an alias, which gives you
nothing). This is the one genuine Control-rung contact device Go offers, and it is
underused.
## Contact, functional options instead of boolean flags
```go
type Option func(*Config)
func WithTimeout(d time.Duration) Option { return func(c *Config) { c.Timeout = d } }
func WithRetries(n int) Option { return func(c *Config) { c.Retries = n } }
func New(addr string, opts ...Option) (*Client, error) { ... }
```
Every option is named at the call site, `time.Duration` carries its unit in the type, and
adding an option later doesn't break callers. This replaces both the boolean-flag hazard and
the implicit-units hazard.
## Motion-step, constructors and defer
```go
func NewClient(addr string) (*Client, error) {
// Fully ready on return. No Connect() to forget.
}
conn, err := pool.Acquire(ctx)
if err != nil { return err }
defer conn.Release() // on the line after acquisition, always
```
Put `defer` immediately after the acquisition, before any other statement. Any code between
the two is a leak on the error path.
## Fixed-value, exhaustiveness
Go has no exhaustive switch. Use a linter:
```yaml
# .golangci.yml
version: "2"
linters:
enable:
- errcheck # unchecked errors: the single most valuable Go linter
- exhaustive # non-exhaustive switch over typed constants
- bodyclose # unclosed HTTP response bodies
- rowserrcheck # unchecked sql.Rows.Err
- sqlclosecheck
- contextcheck # context not propagated
- nilerr # returning nil after a non-nil error
- noctx # HTTP requests without a context
- gosec
settings:
exhaustive:
default-signifies-exhaustive: false
```
That is the v2 schema. golangci-lint v2 refuses to run against a v1 file rather than ignoring
the parts it no longer understands, so run `golangci-lint migrate` over an existing config
before upgrading.
`errcheck` is non-negotiable, Go's error convention is entirely opt-in without it, and
`_ = doSomething()` is how data loss enters a Go codebase.
## Go-specific traps
- **Nil maps** accept reads but panic on write. Construct with `make` in the constructor.
- **Loop variable capture** in goroutines, fixed in Go 1.22+, still present in older
codebases and vendored code.
- **`time.Duration` vs bare int**: always take a `Duration`; never an `int` seconds.
- **Zero values are valid**, so a struct with a missing field looks initialized. Use a
constructor that returns `(T, error)` and unexported fields to force it.
- **Slices share backing arrays**, `append` to a sub-slice can mutate the original. Use
three-index slicing `s[a:b:b]` when handing a slice out.
- **`context.Context` dropped** across a call boundary silently disables cancellation and
timeouts. `contextcheck` catches it.
## Go limits
Go cannot express: exhaustive matching, non-nullable references, immutability, typestate, or
generic constraints rich enough for units. Its Control-rung devices are essentially defined
types, unexported fields with constructors, and the database schema.
The practical consequence: in Go, **push more invariants into the database and into required
CI checks** than you would in Rust or TypeScript. `NOT NULL`, `CHECK`, and unique constraints
are doing work the language declines to do, and `golangci-lint` as a required check is what
makes the rest hold.
@@ -0,0 +1,147 @@
# TypeScript / JavaScript Devices
What the type system can and cannot enforce, and the constructs that get you to Control.
**Prerequisite**: none of this is load-bearing without `strict: true` in tsconfig and
`tsc --noEmit` as a *required* CI check. A branded type in a repo that doesn't typecheck in CI
is a comment. Start there.
Also enable `noUncheckedIndexedAccess` (array access returns `T | undefined`, which is the
truth) and `exactOptionalPropertyTypes`. Both catch real mistakes that `strict` alone misses.
## Contact, branded types
TypeScript is structurally typed, so `type UserId = string` gives you nothing. Branding adds a
phantom property that exists only at compile time:
```ts
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
export type UserId = Brand<string, "UserId">;
export type OrderId = Brand<string, "OrderId">;
export const UserId = (s: string): UserId => s as UserId;
// transfer(orderId, userId) is now a compile error
declare function transfer(from: UserId, to: UserId): void;
```
Zero runtime cost, no wrapper object. Pair the constructor with validation when the string has
a shape worth checking, and it becomes a parse (below) rather than a cast.
## Contact, parse, don't validate
```ts
import { z } from "zod";
const Email = z.string().email().brand<"Email">();
export type Email = z.infer<typeof Email>;
// At the boundary, and only here:
const parsed = Email.safeParse(req.body.email);
if (!parsed.success) return res.status(400).json({ error: parsed.error.format() });
sendWelcome(parsed.data); // sendWelcome(to: Email) cannot receive an unvalidated string
```
Zod's `.brand()` composes validation and branding in one step, which is the ideal shape: short
of an `as` cast, the only way to obtain an `Email` is to have parsed one, which is why the
lint against `as unknown as T` is part of the device, not a style preference.
Apply at every edge: HTTP handlers, queue consumers, `process.env`, third-party responses,
file reads. `JSON.parse` returns `any` and `any` is where guarantees go to die.
## Contact, discriminated unions over optional bags
```ts
// Permits "success with an error", "loading with data", only three combinations are real
type Result = { status: string; data?: User; error?: Error };
// Permits exactly what exists
type Result =
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };
```
The second version makes `result.data` inaccessible until you've narrowed to `"success"`,
so the check cannot be forgotten: the compiler asks for it.
## Fixed-value, exhaustiveness
```ts
function assertNever(x: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
}
switch (result.status) {
case "loading": return spinner();
case "success": return view(result.data);
case "error": return errorView(result.error);
default: return assertNever(result);
}
```
Adding a variant now breaks the build at every switch that must change. Enforce repo-wide with
`@typescript-eslint/switch-exhaustiveness-check`. This is the cheapest high-value device in
the language: one line per switch.
## Motion-step, builders and typestate
Encode required steps in the type so `.delete()` doesn't exist until they've run:
```ts
class QueryBuilder<HasFrom extends boolean = false, HasWhere extends boolean = false> {
from(t: string): QueryBuilder<true, HasWhere> { /* … */ }
where(c: Cond): QueryBuilder<HasFrom, true> { /* … */ }
// Only callable once both have been set
delete(this: QueryBuilder<true, true>): string { /* … */ }
}
```
The `this` parameter is the key trick: it constrains which instances a method exists on.
This makes "delete without a where clause" a compile error rather than an incident.
## Motion-step, required idempotency
```ts
// Optional key = suggestion. Required key = device.
function charge(account: AccountId, amount: Money, idempotencyKey: IdempotencyKey): Promise<Charge>
```
Back it with a unique index on `(account_id, idempotency_key)` so the second attempt is
rejected by the database, not by application logic that might be skipped.
## The lint rules that are actually poka-yoke
Style rules are not mistake-proofing. These are, set every one to `error`:
| Rule | Mistake prevented |
|---|---|
| `@typescript-eslint/no-floating-promises` | A write that is never awaited and silently lost |
| `@typescript-eslint/no-misused-promises` | An async function passed where sync is expected |
| `@typescript-eslint/switch-exhaustiveness-check` | New enum variant silently unhandled |
| `@typescript-eslint/no-unnecessary-condition` | A check that is always true, usually a real bug |
| `@typescript-eslint/no-explicit-any` | Type guarantees silently disabled |
| `@typescript-eslint/no-unsafe-assignment` / `-return` / `-argument` | `any` leaking from untyped libraries |
| `no-empty` (with `allowEmptyCatch: false`) | Empty catch blocks |
| `eqeqeq` | `==` coercion surprises |
| `require-atomic-updates` | Read-modify-write races across `await` |
| `no-restricted-syntax` on `it.only` / `describe.only` | A focused test disabling the rest of the suite |
`no-empty` only sees the empty block: a catch holding a comment, or one that logs and carries
on, swallows the error and passes the lint. Catching that shape is a review job.
## Known limits
- **No runtime enforcement.** Types vanish at compile time. Anything crossing a boundary needs
a runtime schema, and anything reachable from untyped JavaScript needs a runtime check.
- **Structural typing** means every distinct concept needs explicit branding; the compiler
will not distinguish them for you.
- **`as` casts are unchecked.** Confine them to the inside of parse functions, and lint
against `as unknown as T` anywhere else.
- **No affine types**, so use-after-move and use-after-close cannot be prevented; scope-bound
patterns (a `withConnection(fn)` callback rather than `open`/`close`) are the closest you
get, and they are usually enough.