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>
This commit is contained in:
122
Docs/TYPE_SAFETY.md
Normal file
122
Docs/TYPE_SAFETY.md
Normal file
@@ -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<string, T>` | |
|
||||
|
||||
### 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
|
||||
Reference in New Issue
Block a user