From b587c6e8506ac931f57e3e0a1a12d06ae1f47dff Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 3 May 2026 00:12:44 +0200 Subject: [PATCH] docs: add TYPE_SAFETY.md documenting frontend/backend type conventions Establishes shared type conventions to prevent runtime type mismatches between TS frontend and Python backend. Covers snake_case JSON field names, null vs empty string handling, timestamp formats, and validation patterns for country codes, bans, and jail configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Docs/TYPE_SAFETY.md | 122 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 Docs/TYPE_SAFETY.md diff --git a/Docs/TYPE_SAFETY.md b/Docs/TYPE_SAFETY.md new file mode 100644 index 0000000..340a19e --- /dev/null +++ b/Docs/TYPE_SAFETY.md @@ -0,0 +1,122 @@ +# Type Safety Between Frontend and Backend + +This document describes how BanGUI maintains type alignment between the TypeScript frontend and Python backend, and the constraints that keep runtime type mismatches from occurring. + +--- + +## 1. The Problem + +Frontend TypeScript types and backend Pydantic models are defined independently. Drift between them causes runtime errors: + +- **Empty string vs. null** — `country_code: string | null` in TypeScript but backend returns `""` for unresolved geo lookups. Frontend truthiness check `if (ban.country_code)` passes for `""` but the value is meaningless. +- **Timestamp ambiguity** — Frontend expects ISO 8601 strings; backend was passing mixed UNIX integers in some paths. +- **Silent zero values** — `0` can be indistinguishable from "not set" in weakly-typed paths. + +--- + +## 2. Shared Type Conventions + +All JSON field names use `snake_case` in both backend (Python/Pydantic) and frontend (TypeScript). No alias generators are applied. + +| Python type | TypeScript type | Notes | +|---|---|---| +| `str` | `string` | | +| `str \| None` | `string \| null` | Null-capable strings use explicit null | +| `int` | `number` | | +| `bool` | `boolean` | | +| `list[T]` | `T[]` | | +| `dict[str, T]` | `Record` | | + +### Country Code Constraint + +`country_code` is always `string | null`. An empty string `""` is **never** a valid value. The backend normalises empty strings to `None` at the Pydantic validator level so the frontend always receives either a valid 2-char uppercase code or `null`. + +--- + +## 3. Backend Validation Layer + +Every Pydantic response model that includes a country code has a `field_validator` that coerces empty strings to `None`: + +```python +@field_validator("country_code") +@classmethod +def _normalize_empty_country_code(cls, v: str | None) -> str | None: + if v == "": + return None + return v +``` + +Models affected: + +- `DashboardBanItem` — `country_code` +- `ActiveBan` — `country` +- `Ban` — `country` + +The same pattern should be applied to any new field that could arrive as `""` from the geo enrichment layer. + +### Why the Validator Lives in the Model + +Validation at the Pydantic model layer means: +- It fires on **every** API response, regardless of which router endpoint produced it. +- The mapper layer cannot accidentally skip normalisation. +- Serialisation from domain → response model is automatically safe. + +--- + +## 4. Timestamp Standard + +All ban timestamps are transmitted as **ISO 8601 UTC strings** (`"2026-04-28T07:00:00+00:00"`). UNIX integers are used internally in repositories but converted to ISO strings using `ts_to_iso()` before entering the response model: + +```python +from datetime import UTC, datetime + +def ts_to_iso(unix_ts: int) -> str: + return datetime.fromtimestamp(unix_ts, tz=UTC).isoformat() +``` + +This conversion happens once — in the service layer when building `DomainDashboardBanItem` and similar domain objects — so all response models receive pre-formatted strings. + +--- + +## 5. Frontend Type Narrowing Rules + +When consuming `country_code` in TypeScript, use explicit null checks rather than truthiness: + +```typescript +// BAD — empty string passes this check +if (ban.country_code) { ... } + +// GOOD — only null/undefined are falsy +if (ban.country_code !== null) { ... } +``` + +The backend normalisation ensures that `!ban.country_code` and `ban.country_code === null` are equivalent, but the explicit form is clearer and defensive against future changes. + +--- + +## 6. CI Type Synchronisation + +A type-generation script (planned) will emit a combined JSON schema from all Pydantic models and validate the generated TypeScript types against it on every build. Until that is in place: + +- Any change to a Pydantic model field type must be mirrored in the corresponding TypeScript interface in `frontend/src/types/ban.ts`. +- Run `pytest tests/test_models.py` to verify model-level validation after changing `ban.py`. + +--- + +## 7. Adding New Shared Types + +When adding a new response model to `backend/app/models/`: + +1. Define the Pydantic model with explicit `str | None` (not `Optional[str]`) for nullable strings. +2. Add `field_validator` stubs for any field that could receive an empty string from a database or external API. +3. Add the corresponding TypeScript interface in `frontend/src/types/`. +4. Add model-level unit tests in `tests/test_models.py`. +5. Run the full test suite before committing. + +--- + +## 8. Related Documents + +- [Architekture.md](Architekture.md) — system architecture and data flow +- [Backend-Development.md](Backend-Development.md) — Python coding conventions, Pydantic usage +- [Web-Development.md](../frontend/Docs/Web-Development.md) — TypeScript conventions \ No newline at end of file