Skip to content

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.ts and 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 usedos.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.

python
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

python
from pydantic import BaseModel, EmailStr, Field

class ContactRequest(BaseModel):
    model_config = ConfigDict(strict=True)

    name: str = Field(min_length=2, max_length=100)
    email: EmailStr
    message: str = Field(min_length=10, max_length=5000)
    company: Optional[str] = Field(default=None, max_length=200)

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:

python
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-level Config inner class).
  • Use @field_validator for cross-field logic (e.g. stripping whitespace from name before length check).
  • Use @model_validator(mode='after') only if cross-field validation is needed; avoid for single-field rules.
  • Prefer Field(...) constraints over @field_validator where 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.

bash
npx openapi-typescript http://localhost:8000/openapi.json -o shared/api.types.ts

Add to package.json:

json
"scripts": {
  "types:generate": "openapi-typescript http://localhost:8000/openapi.json -o shared/api.types.ts"
}

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

typescript
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):

yaml
- 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:

bash
npm install --save-dev eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react-hooks

Minimal eslint.config.ts:

typescript
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:

json
"lint": "eslint client/src/"

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:

typescript
// useContactForm.ts
export function useContactForm(): {
  formData: ContactFormData;
  handleInputChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
  handleSubmit: (e: React.FormEvent) => void;
  isPending: boolean;
} { ... }

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:

  1. The Postgres phase updates Python Pydantic models (adds id, created_at, updated_at; renames fields to snake_case consistently).
  2. npm run types:generate is run once.
  3. shared/api.types.ts is regenerated automatically.
  4. 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 Settings class using pydantic-settings; replace all os.getenv() calls in main.py
  • [ ] Add model_config = ConfigDict(strict=True) and Field(...) constraints to ContactRequest
  • [ ] Add @field_validator for whitespace-stripping on name field
  • [ ] Add response_class=FileResponse + responses= documentation to /api/download-cv
  • [ ] Verify startup validation: remove a required env var locally and confirm ValidationError at import time
  • [ ] Update tests/test_cv_download.py if any response shape changes
  • [ ] Ensure pytest passes with the new Settings class (set env vars in test fixtures or .env.test)

Phase 2 — OpenAPI Type Generation

  • [ ] Install openapi-typescript as a dev dependency
  • [ ] Add "types:generate" script to package.json
  • [ ] Run npm run types:generate against a local backend; commit shared/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.md or a runbook section

Phase 3 — Frontend Type Wiring

  • [ ] Install eslint, @typescript-eslint/eslint-plugin, @typescript-eslint/parser, eslint-plugin-react-hooks
  • [ ] Create eslint.config.ts with no-explicit-any: error and hooks rules
  • [ ] Add "lint" script to package.json
  • [ ] Replace interface ProjectDetail in client/src/data/projects.ts with import from @shared/api.types
  • [ ] Update components consuming projects, experience, skills to use generated types
  • [ ] Add explicit return type annotation to useContactForm
  • [ ] Verify tsc --noEmit and npm run lint both pass cleanly

Open Questions

  1. 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 if eslint.config.js is safer.

  2. openapi-typescript version: v7.x changed the output format from paths/components namespace imports. Pin to a specific major version and document in package.json.

  3. 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.yml job (which already starts the backend) and download it in the type-check job. Worth evaluating for CI speed.

  4. shared/schema.ts overlap: The existing shared/schema.ts is Drizzle schema for a users + contactSubmissions table 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.

  5. strict=True on data models: Applying ConfigDict(strict=True) to ProjectCard, 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).

  6. Lint in CI: Once ESLint is added, it should run in CI. The cleanest home is a step in test-frontend.yml alongside tsc. Confirm this workflow is the right place or whether a dedicated lint.yml is preferred.