mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-26 18:45:12 +00:00
Add poka-yoke skill: make misuse unrepresentable rather than warned against (#2792)
* Add poka-yoke skill: make misuse unrepresentable rather than warned against
Mistake-proofing, applied to code. The skill pushes toward devices that make
a wrong action impossible or self-announcing, rather than instructions asking
someone to avoid it, and refuses to accept a comment or a convention as a fix.
The gap it addresses. Given a design, models readily list what to fix and
rarely state what the fix makes impossible. Measured across 591 blind-graded
runs and six model families, responses named the foreclosed set 42% of the
time unprompted and 81% with this skill applied. Assertions were written
before the runs and graded blind to configuration.
Stated with its limits, because they matter: every run was the first turn of
a fresh session, so this measures the ceiling rather than what survives a
long working session; the comparison was against no methodology rather than a
different one, so it does not establish that this particular method caused
the gain; and the skill costs something measurable, making responses somewhat
worse at spotting the specific defect already on the page while better at
changing the shape that allowed it. All of that is in the skill body rather
than omitted.
Bundled, all self-contained, no network access and no dependencies:
scripts/detect_hazards.py standard-library scanner, 42 pattern rules
across 20 hazard shapes, five languages
references/hazard-catalog.md the taxonomy with device per shape
references/lang-*.md Python, TypeScript, Go and Rust patterns
Verified with npm run skill:validate and npm run build. Raw benchmark runs,
the harness and the assertion checklists are public at
https://github.com/rainmanjam/poka-yoke
* Rename HasTable to HasFrom in the TypeScript typestate example
codespell failed the PR: it reads HasTable as a misspelling of hashtable.
The identifier is a legitimate generic parameter on a typestate builder,
QueryBuilder<HasTable, HasWhere>, so this is a false positive, but it is our
file breaking their gate and the fix costs nothing.
HasFrom is also the better name: the flag tracks whether .from() has been
called, not whether a table exists.
Renamed in the upstream repository too, so the two copies do not diverge on
day one. Verified the whole bundle is codespell-clean locally.
* Correct the effect figures in the poka-yoke skill
The submitted numbers (42% -> 81%) could not be reproduced from the upstream
gradings. Recomputed over the six models the sentence describes: 45% -> 80%
across 132 verdicts. Adds the per-scenario breakdown, which is the more useful
claim: the gains are in tasks where nobody asked for a design review.
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