Type system guide

Contents

PostHog has two type generation systems that keep frontend and backend in sync. This guide covers both directions and best practices for each.

Overview

FlowSource of truthGenerated outputUsed for
Backend → FrontendDjango serializersTypeScript + Zod (Orval)API types, client functions, and validation schemas
Frontend → BackendTypeScript schema.tsPydantic schema.pyQuery types (HogQL, filters, insights), some legacy types

These are independent systems. Don't conflate them.

Backend → Frontend (API responses)

We use Orval to generate TypeScript types and API client functions from our OpenAPI schema.

Ownership

  • Backend owns response types – Generated from Django serializers via OpenAPI
  • Frontend owns request/query types – Handwritten types for queries, filters, UI state
  • Do not manually redefine backend response types in frontend code

Where types live

TypeFileLocationEditable?
Generated API typesapi.schemas.tsproducts/<product>/frontend/generated/No
Generated API clientapi.tsproducts/<product>/frontend/generated/No
Generated Zod schemasapi.zod.tsproducts/<product>/frontend/generated/No
Core API typesapi.schemas.tsfrontend/src/generated/core/No
Core Zod schemasapi.zod.tsfrontend/src/generated/core/No
Handwritten typesfrontend/src/types/Yes

Never edit files in generated/ – they're overwritten on regeneration.

Naming conventions

  • Generated schema types end with Api suffix: TaskApi, SurveyApi, DashboardApi
  • Operation response types follow Orval naming: tasksListResponse200, tasksCreateResponse201
  • Handwritten types never use the Api suffix

This prevents name collisions between generated and manual types.

Zod validation schemas

Each generated directory also contains api.zod.ts with Zod validation schemas derived from the same OpenAPI spec. Only Body schemas are generated — response schemas, path params, query params, and headers are excluded since the primary use case is validating user input before API calls.

Schemas are named <Operation>Body:

TypeScript
import { VisualReviewReposCreateBody } from '../generated/api.zod'

All exports are annotated with /* @__PURE__ */ so unused schemas are tree-shaken from the bundle.

Regenerating types

Run after changing serializers, viewsets, or @extend_schema decorators:

Terminal
hogli build # auto-detects what changed and rebuilds
hogli build:openapi # or run this pipeline explicitly

CI will fail if generated types are stale.

Adding a new product's API

  1. Ensure products/your_product/frontend/ directory exists
  2. Put your ViewSet in products/your_product/backend/
  3. Run hogli build:openapi
  4. Types appear in products/your_product/frontend/generated/

ViewSets in products/*/backend/ are automatically tagged based on their module path. Manual @extend_schema(tags=[...]) is not needed for products.

Serializers are the source of truth for response types. Use explicit field types and help_text where helpful.

Some response contracts already exist in the backend as Pydantic models, for example a discriminated union that sources build at runtime. Declare those models directly as the response in @extend_schema(responses=...). drf-spectacular converts them to OpenAPI, so do not copy them into a serializer. Keep ordinary resource endpoints on serializers. See products/warehouse_sources/backend/facade/source_config.py for an example.

For detailed guidance on serializer patterns, field typing, and annotations that improve OpenAPI generation, see the improving-drf-endpoints skill.

Documenting query parameters

For endpoints with query parameters, use @validated_request (WIP pattern):

Python
from posthog.api.utils import validated_request
class MyQuerySerializer(serializers.Serializer):
status = serializers.ChoiceField(choices=["active", "archived"], required=False)
limit = serializers.IntegerField(default=100, min_value=1)
@validated_request(
query_serializer=MyQuerySerializer,
responses={200: MyResponseSerializer(many=True)},
)
@action(methods=["GET"], detail=False)
def my_action(self, request, **kwargs):
status = request.validated_query_data.get("status") # Use validated data
...

This validates inputs AND documents the endpoint for OpenAPI. Use request.validated_query_data, not manual request.query_params parsing.

A list view that keeps its django-filter FilterSet but filters in a service, without DjangoFilterBackend in filter_backends, loses those query params from the schema. Do not declare them again by hand with OpenApiParameter. Use the helpers in posthog/api/filterset_helpers.py:

  • @extend_schema(parameters=filterset_openapi_parameters(MyFilterSet)) emits the same params that the backend would emit. Use overrides to describe method= filters.
  • validate_filterset(MyFilterSet, request.query_params, queryset, request=request) returns the bound FilterSet, or raises the same 400 body as the backend.

Side generators

A side generator is a script that reads a Python module and writes TypeScript next to the OpenAPI flow, usually as a *.generated.ts file outside a generated/ directory. Each one needs its own hogli step, CI path filters, formatter exclusions and drift check. New ones tend to copy the nearest existing one, so the count grows.

When the frontend or the MCP server needs the values that an API accepts or returns, use the OpenAPI flow instead:

  1. Type the serializer field that carries the values, for example as a ChoiceField.
  2. Run hogli build:openapi. Orval emits the values as a *EnumApi const next to the other generated types.
  3. Read the list at runtime with Object.values(SomethingEnumApi) and use SomethingEnumApi as the type.

The field must be on an endpoint that is in the schema. A viewset action marked @extend_schema(exclude=True) does not reach the generated types.

Data rows that no endpoint serves, such as the task model catalog, go through the projection registry instead of a script of their own:

  1. Write a renderer module next to the data. Its render() function returns the full text of each output, keyed by repo-relative path. The text only has to be valid: the runner formats each output with oxfmt, or with Biome under products/desktop and packages/agent, so do not add a formatter exclusion for it.
  2. Add an entry to PROJECTIONS in tools/hogli-commands/hogli_commands/projections.py with the renderer, its inputs and its outputs.
  3. Run hogli build:projections and commit the outputs. CI runs hogli build:projections --check and fails when one is out of date.

The test_generated_files_are_registered.py repo invariant fails a PR that adds a *.generated.* file, or a new generated/ directory, that no registered projection and no known pipeline produces. Ask #team-devex before you add a projection.

Troubleshooting

Types not generating? Ensure your ViewSet is in products/your_product/backend/ and the products/your_product/frontend/ directory exists. Auto-tagging happens based on module path.

Wrong type shapes? The serializer is the source of truth. Use @extend_schema_field for custom SerializerMethodField types.

CI failing? Run hogli build:openapi locally and commit the regenerated files. For a drifted projection output, run hogli build:projections.

Design decisions

Why commit generated files? Makes type changes visible in PRs, lets CI catch drift, avoids needing Django running for frontend builds.

Why Api suffix? Prevents collisions with handwritten types. If you see Api, it came from the backend.

Why no deduplication across products? Keeps products isolated—changing one serializer won't affect another's types.

Frontend → Backend (query types)

Query types like TrendsQuery, FunnelsQuery, and HogQL filters are defined in TypeScript and generated to Python.

How it works

  1. Source: frontend/src/queries/schema.ts (TypeScript interfaces)
  2. Intermediate: frontend/src/queries/schema.json (JSON Schema)
  3. Output: posthog/schema.py (Pydantic models)

Regenerating

Terminal
hogli build # auto-detects what changed and rebuilds
hogli build:schema # or run this pipeline explicitly

This runs:

  1. build:schema-json – TS → JSON Schema
  2. build:schema-python – JSON Schema → Pydantic

When to add types here

Add to schema.ts when you need a type that:

  • Is sent from frontend to backend as a query/filter
  • Needs validation on the backend
  • Is part of HogQL or insight definitions
  • Do not add types that are only used in the frontend UI
  • Do not add types just because you need a type in Python – use handwritten types for backend-only logic

If you need a type from the backend in the frontend, define it in a serializer or a Pydantic response model and use the backend → frontend generation system.

For backend-only types, define Pydantic models directly in your own product e.g. in a domain_types.py file.

Help clean up

We're migrating from manually-defined API types to generated ones and also clean up ownership. You can help:

Quick wins

  1. Find manual types that duplicate generated ones – Search for interfaces like Dashboard, Survey, FeatureFlag in frontend/src/types/ that now have generated DashboardApi, SurveyApi equivalents
  2. Replace API call return types – If you see api.get<ManualType>(...), switch to using the generated functions and types

When touching existing code

  • If a file imports manual types for API responses, consider migrating to generated types
  • Add adapter functions where the UI needs a different shape than the API provides
  • Don't mix manual and generated types for the same entity in the same file

Was this page useful?