mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-26 18:45:12 +00:00
chore: publish from main
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user