From b08fe50e9230490fc2addf725189bdc717088fbe Mon Sep 17 00:00:00 2001 From: Bluscream Date: Fri, 18 Sep 2026 18:22:26 +0200 Subject: [PATCH] feat(coding-dev): require domain values to be typed, not primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A value with rules of its own — a URL, a path, a closed set, an identifier — gets a type of its own. Passing it as a bare string discards those rules at the first function boundary and leaves every consumer re-deriving them. Added as a subsection of Architecture Preferences, with a table mapping the common cases to the type to reach for in any language, the reasoning, and the boundary rule: parse once at the edge, keep the type inward, serialize back only where the wire actually demands a string. Notes the counter-case, so this is not read as a mandate to wrap every string. python.md gains the concrete choices: yarl.URL (already present wherever aiohttp is), pathlib, StrEnum, and NewType for non-interchangeable identifiers. --- code-languages/python.md | 17 +++++++++++++++++ coding-dev.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/code-languages/python.md b/code-languages/python.md index 4df7d6e..a80e5b4 100644 --- a/code-languages/python.md +++ b/code-languages/python.md @@ -48,6 +48,23 @@ disallow_untyped_defs = true - Third-party library missing stubs: install `types-*` if it exists, otherwise a narrowly scoped `# type: ignore[import-untyped]` with a comment — never a blanket module-level ignore. +### Value types over primitives + +The general rule is in [`coding-dev.md` §7](skills-public/coding-dev.md); Python's concrete +choices are: + +- **`yarl.URL` for URLs**, not f-string assembly. It ships with `aiohttp`, so any async HTTP + project already has it. `URL(base) / "api" / "v4" / "users"` joins without double slashes, + and `.with_query({...})` escapes parameters that string formatting would corrupt. +- **`pathlib.Path` for paths**, never `os.path` + `str`. Ruff's `PTH` rules enforce this; + keep `os.fdopen` where a descriptor rather than a name is being adopted. +- `enum.StrEnum` (3.11+) where a closed set must also serialize as a string, so the wire + format stays a string without the code comparing bare literals. +- `typing.NewType` for identifiers that should not be interchangeable — a `UserId` and a + `RepoId` are both `int` and nothing otherwise stops them being swapped. +- Parse at the boundary: take `str` in the public signature if callers supply one, convert + immediately, and keep the real type internal. + --- ## 3. Linting diff --git a/coding-dev.md b/coding-dev.md index aaa1172..2f563a2 100644 --- a/coding-dev.md +++ b/coding-dev.md @@ -257,6 +257,36 @@ Run the **whole** gate, not the convenient part of it. A typical full gate: - **A search-and-replace that matches nothing is a silent failure.** Assert the target string exists before replacing, and verify the result afterward. +### Model domain values as types, not primitives + +**A value with rules of its own gets a type of its own.** Passing it around as a bare string, +int, or dict throws those rules away at the first function boundary and makes every consumer +re-derive them. + +| Value | Use | Not | +| :--- | :--- | :--- | +| URL | the platform's URL type (`yarl.URL`, `Uri`, `url::Url`, `URL`) | `str` + f-string assembly | +| Filesystem path | `Path` / `PathBuf` / `std::filesystem::path` | `str` + manual separators | +| Closed set of values | enum / union of literals | bare strings compared with `==` | +| Structured record | struct, dataclass, or typed record | `dict[str, Any]` / `map[string]any` | +| Quantity with a unit | a wrapper carrying the unit | a bare number and a naming convention | +| Identifier of a specific kind | a distinct newtype | a `str` that any other id also fits | + +**Why:** the type is where the rules live. A URL type knows how to join a path without +producing a double slash, escapes query parameters, and cannot be concatenated into something +malformed. A path type knows the platform separator. An enum cannot hold a typo. Each one +turns a class of runtime bug into something the compiler or checker rejects — and makes the +signature say what it accepts instead of `str`. + +- **Convert at the boundary.** Parse incoming strings into the real type once, at the edge, + and keep the type inward. Serialize back to a string only where the wire or the filesystem + actually demands one. +- **Don't reach for a wrapper where the primitive genuinely is the value** — a person's name + is a string. The test is whether the value has invariants, a format, or operations that + callers keep re-implementing. +- Retrofit opportunistically: convert what you touch rather than rewriting a stringly-typed + codebase in one pass. + --- ## 8. Scope Discipline