End-to-End Typing — TypeScript + Pydantic¶
Overview¶
The goal is a pragmatic, low-churn path to end-to-end type safety: harden the Python backend with pydantic-settings and field constraints, then generate TypeScript types automatically from the FastAPI OpenAPI spec so the frontend consumes them without manual maintenance. This work comes before the Postgres migration by design — once the OpenAPI → TS generation pipeline is in place, the Postgres migration gets updated TypeScript types for free on the next npm run types:generate.
Goals¶
- Validated configuration at startup — catch missing or malformed environment variables before the server accepts requests, not at runtime inside a route handler.
- Constrained request models — field-level Pydantic constraints on
ContactRequest(length limits, email format, pattern rules) so invalid input is rejected at the framework layer with structured 422 responses. - Single source of truth for API types — TypeScript interfaces generated from the OpenAPI spec, committed to
shared/api.types.tsand imported by consumers via@shared/api.types. - CI type-drift guard — regenerating types in CI and failing on diff ensures the frontend never silently diverges from the backend schema.
- No manual TypeScript work after Postgres migration — because types regenerate from OpenAPI, the Postgres phase only needs to update the Python Pydantic models; the TS side follows automatically.
Current State¶
Backend¶
| Item | State |
|---|---|
| Pydantic version | v2.5.0 — installed, used for all models |
pydantic-settings |
In requirements.txt but not used — os.getenv() is called directly at module level (lines 26, 932–933 of main.py) |
ContactRequest |
Has EmailStr for email; name, message, company are bare str with no length or pattern constraints |
ProjectCard, ProjectDetail, WorkEntry, Skill, SkillCategory |
Minimal models, bare field types — intentionally, as they will be replaced in the Postgres phase |
response_model= on routes |
All routes set except /api/download-cv (returns FileResponse, no model declared) |
model_config / strict=True |
Not set on any model |
| Pydantic v2 validators | Not yet used (model_validator, field_validator) |
Frontend¶
| Item | State |
|---|---|
TypeScript strict: true |
Enabled in tsconfig.json |
| ESLint | Not installed — no eslint.config.* present, no @typescript-eslint packages |
@typescript-eslint/no-explicit-any |
Not enforced |
Actual TypeScript any usage |
None found in client/src/ — search across all .ts/.tsx files returns zero hits for : any, as any, Array<any>, or Promise<any>. The reported ~33 uses were false positives (substring matches on "company") |
| API response types | Manually maintained interfaces in client/src/data/projects.ts (ProjectDetail) and inlined in components — not generated from the backend schema |
shared/ directory |
Exists; contains schema.ts (Drizzle/Zod schema, unrelated to FastAPI types); @shared/* path alias already configured in tsconfig.json |
queryClient.ts |
getQueryFn returns unknown via generic <T> — callers currently infer or cast types manually |
No any elimination needed
The frontend does not currently use TypeScript any. Phase 3 reframes from "eliminate any" to "wire in generated types so manually maintained interfaces are replaced and new code has a typed source of truth from day one."
Phase 1 — Backend Pydantic Hardening¶
1a. pydantic-settings Settings class¶
Replace the three scattered os.getenv() calls with a Settings class validated at startup. pydantic-settings is already in requirements.txt.
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
email_address: str
email_password: str
base_url: str = ""
backend_url: str = ""
backend_host: str = ""
azure_client_id: str = ""
azure_client_secret: str = ""
azure_tenant_id: str = ""
settings = Settings()
Startup fails fast with a descriptive ValidationError if a required variable is absent, rather than silently substituting a placeholder. The variables email_address and email_password have no default so they are required; the remainder are optional with sane defaults.
1b. Field constraints on ContactRequest¶
strict=True on ContactRequest prevents silent coercions (e.g. integer accepted as string). The frontend useContactForm.ts already enforces the same rules client-side; the backend constraints make those rules authoritative and visible in the OpenAPI schema.
1c. Response model for /api/download-cv¶
FileResponse cannot carry a Pydantic response_model. The correct approach is to add response_class=FileResponse and document the response explicitly:
from fastapi.responses import FileResponse
@app.get(
"/api/download-cv",
response_class=FileResponse,
responses={
200: {"description": "PDF file", "content": {"application/pdf": {}}},
404: {"description": "Requested language variant not found"},
},
)
async def download_cv(request: Request, lang: str = "english"):
...
This makes the 200/404 surface visible in Swagger UI without forcing a Pydantic model onto a binary response.
1d. Models that get minimal validation (intentional)¶
The following models are ephemeral — they exist only to serve hardcoded in-process data and will be replaced entirely in the Postgres migration phase. Apply only non-breaking improvements (e.g. Optional field cleanup) and do not invest in elaborate validators:
| Model | Status after Postgres migration |
|---|---|
ProjectCard |
Replaced — DB-backed ProjectCardResponse |
ProjectDetail |
Replaced — DB-backed ProjectDetailResponse |
WorkEntry |
Replaced — DB-backed WorkEntryResponse |
Skill |
Replaced — normalized to skills table |
SkillCategory |
Replaced — DB-backed SkillCategoryResponse |
The following models are permanent and should receive full constraint treatment in Phase 1:
| Model | Notes |
|---|---|
ContactRequest |
User-facing form input — full constraints (Phase 1b above) |
ContactResponse |
Response-only, no constraints needed |
HealthResponse |
Response-only, no constraints needed |
Settings |
New — full validation via pydantic-settings |
1e. Pydantic v2 conventions to apply¶
- Use
model_config = ConfigDict(...)(not class-levelConfiginner class). - Use
@field_validatorfor cross-field logic (e.g. stripping whitespace fromnamebefore length check). - Use
@model_validator(mode='after')only if cross-field validation is needed; avoid for single-field rules. - Prefer
Field(...)constraints over@field_validatorwhere the constraint can be expressed declaratively.
Phase 2 — OpenAPI → TypeScript Type Generation¶
Tool: openapi-typescript¶
Generate TypeScript types directly from the FastAPI /openapi.json endpoint. No server-side codegen plugins needed — openapi-typescript runs as a CLI against the live spec or a downloaded JSON file.
Add to package.json:
The output file goes in shared/api.types.ts. The @shared/* path alias is already configured in tsconfig.json — no changes needed.
Consuming generated types¶
import type { components } from "@shared/api.types";
type ProjectCard = components["schemas"]["ProjectCard"];
type ProjectDetail = components["schemas"]["ProjectDetail"];
type WorkEntry = components["schemas"]["WorkEntry"];
type SkillCategory = components["schemas"]["SkillCategory"];
type ContactRequest = components["schemas"]["ContactRequest"];
Manually maintained interfaces in client/src/data/projects.ts (ProjectDetail) and inline type declarations in components can be removed and replaced with imports from @shared/api.types.
CI type-drift guard¶
Add a step to the existing test-frontend.yml (or a new typecheck.yml job that runs on PRs):
- name: Generate OpenAPI types
run: |
# Backend must be running; use the dev deployment URL or start it locally
npm run types:generate
- name: Fail on type drift
run: |
git diff --exit-code shared/api.types.ts || \
(echo "::error::api.types.ts is out of date — run npm run types:generate and commit the result" && exit 1)
The generated shared/api.types.ts is committed to the repository. Any PR that changes a Pydantic model without regenerating types fails CI, making schema drift visible at review time rather than at runtime.
Backend must be running for generation
openapi-typescript hits a live endpoint. In CI this means either starting the backend in the job (docker-compose up -d backend && sleep 5) or downloading the OpenAPI JSON as an artifact from a prior step. The simpler path is to start the backend in the CI job using docker-compose.
Phase 3 — Frontend Type Wiring¶
With generated types available from Phase 2, this phase replaces manually maintained frontend type declarations with imports from @shared/api.types and adds ESLint enforcement.
3a. Add ESLint with TypeScript plugin¶
Install:
Minimal eslint.config.ts:
import tseslint from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import reactHooks from "eslint-plugin-react-hooks";
export default [
{
files: ["client/src/**/*.{ts,tsx}"],
languageOptions: { parser: tsParser },
plugins: { "@typescript-eslint": tseslint, "react-hooks": reactHooks },
rules: {
"@typescript-eslint/no-explicit-any": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
},
},
];
Add lint script to package.json:
Add to CLAUDE.md pre-commit instructions: run npm run lint for frontend changes.
3b. Replace manually maintained interfaces¶
Files to update after Phase 2 generates shared/api.types.ts:
| File | Current type | Replace with |
|---|---|---|
client/src/data/projects.ts |
interface ProjectDetail { ... } (manually maintained) |
components['schemas']['ProjectDetail'] |
| Components consuming projects, experience, skills | Inline types or local interfaces | components['schemas']['*'] imports |
queryClient.ts getQueryFn |
Generic <T> (callers infer) |
Callers pass the generated schema type as <T> |
3c. Hook return types¶
Explicit return types on hooks make the public contract visible:
use-toast.ts is already well-typed and does not need changes.
Postgres Migration Compatibility¶
This is the core reason typing work comes first.
Model fate after Postgres migration¶
| Model | Fate |
|---|---|
ProjectCard |
Replaced — new ProjectCardResponse from SQLAlchemy mapped class |
ProjectDetail |
Replaced — merged into projects table response schema |
WorkEntry |
Replaced — new WorkEntryResponse |
Skill |
Replaced — normalized to skills table |
SkillCategory |
Replaced — new SkillCategoryResponse |
ContactRequest |
Kept — hardened in Phase 1, unchanged by Postgres work |
ContactResponse |
Kept |
HealthResponse |
Kept |
Settings |
Kept — gains database_url field in Postgres phase |
Why typing before Postgres¶
Once the OpenAPI → TS generation pipeline exists:
- The Postgres phase updates Python Pydantic models (adds
id,created_at,updated_at; renames fields to snake_case consistently). npm run types:generateis run once.shared/api.types.tsis regenerated automatically.- The CI type-drift check catches any consumer that hasn't been updated.
Without the pipeline, the Postgres migration requires manually updating TypeScript interfaces in parallel — a fragile, error-prone step that the pipeline eliminates.
Shared schema.ts
shared/schema.ts currently holds Drizzle/Zod table definitions (users, contactSubmissions). It is unrelated to the FastAPI types. The new shared/api.types.ts (generated by openapi-typescript) coexists alongside it — no conflict.
Implementation Phases — Checklist¶
Phase 1 — Backend Pydantic Hardening¶
- [ ] Add
Settingsclass usingpydantic-settings; replace allos.getenv()calls inmain.py - [ ] Add
model_config = ConfigDict(strict=True)andField(...)constraints toContactRequest - [ ] Add
@field_validatorfor whitespace-stripping onnamefield - [ ] Add
response_class=FileResponse+responses=documentation to/api/download-cv - [ ] Verify startup validation: remove a required env var locally and confirm
ValidationErrorat import time - [ ] Update
tests/test_cv_download.pyif any response shape changes - [ ] Ensure
pytestpasses with the new Settings class (set env vars in test fixtures or.env.test)
Phase 2 — OpenAPI Type Generation¶
- [ ] Install
openapi-typescriptas a dev dependency - [ ] Add
"types:generate"script topackage.json - [ ] Run
npm run types:generateagainst a local backend; commitshared/api.types.ts - [ ] Add CI step to
test-frontend.yml(or new job): start backend, regenerate,git diff --exit-code - [ ] Document the generation workflow in
CLAUDE.mdor a runbook section
Phase 3 — Frontend Type Wiring¶
- [ ] Install
eslint,@typescript-eslint/eslint-plugin,@typescript-eslint/parser,eslint-plugin-react-hooks - [ ] Create
eslint.config.tswithno-explicit-any: errorand hooks rules - [ ] Add
"lint"script topackage.json - [ ] Replace
interface ProjectDetailinclient/src/data/projects.tswith import from@shared/api.types - [ ] Update components consuming projects, experience, skills to use generated types
- [ ] Add explicit return type annotation to
useContactForm - [ ] Verify
tsc --noEmitandnpm run lintboth pass cleanly
Open Questions¶
-
ESLint config format: The repo uses Vite + ESNext.
eslint.config.ts(flat config, ESLint v9) is the modern approach. Confirm whether the current Node version supports it or ifeslint.config.jsis safer. -
openapi-typescriptversion: v7.x changed the output format frompaths/componentsnamespace imports. Pin to a specific major version and document inpackage.json. -
Backend startup in CI for type generation: Starting the backend in the type-check CI job adds ~30s per run. An alternative is to save the OpenAPI JSON as an artifact in the
test-api.ymljob (which already starts the backend) and download it in the type-check job. Worth evaluating for CI speed. -
shared/schema.tsoverlap: The existingshared/schema.tsis Drizzle schema for ausers+contactSubmissionstable that does not appear to be actively used by the current frontend or backend. Clarify whether it belongs or is leftover scaffold — it may be removable before the Postgres migration lands. -
strict=Trueon data models: ApplyingConfigDict(strict=True)toProjectCard,WorkEntry, etc. before the Postgres phase is low value since those models are seeded from controlled Python literals (not user input). Skip for now; the Postgres-phase replacements will enforce strictness where it matters (write endpoints). -
Lint in CI: Once ESLint is added, it should run in CI. The cleanest home is a step in
test-frontend.ymlalongsidetsc. Confirm this workflow is the right place or whether a dedicatedlint.ymlis preferred.