Generating Typed Clients from Spatial OpenAPI Schemas

Produce typed Python and TypeScript clients from a spatial openapi.json with openapi-python-client and openapi-generator-cli, and understand how GeoJSON discriminated unions map to client types for map frontends.

← Back to OpenAPI Schema Generation for Spatial Types

Compile a spatial openapi.json into typed Python and TypeScript SDKs, and map GeoJSON discriminated unions onto client types so map frontends narrow geometry by its type field instead of casting any.

Context & when to use

Once the API emits an accurate schema — typed geometry models, a oneOf with a discriminator, valid examples — the payoff is that clients no longer have to be written by hand. A generator reads openapi.json and produces request/response models, method stubs, and (crucially for geospatial work) a tagged union for geometry that a TypeScript map frontend can switch on. Generate clients when more than one team or language consumes the API, when you want compile-time safety against schema drift, or when a map UI needs geometry types it can narrow rather than probe at runtime.

This is the downstream end of the pipeline in OpenAPI schema generation for spatial types: the quality of the generated client is capped by the quality of the schema, so a clean discriminated union upstream is what makes a clean sum type downstream. Prefer generated clients over hand-written HTTP calls for anything beyond a throwaway script; the main cost is a regeneration step in CI whenever the schema changes.

The two generators covered here occupy different niches. openapi-python-client is Python-native, produces attrs-based models and an httpx transport, and tends to render oneOf unions faithfully as typing.Union with per-variant classes — a good fit when the consumer is another Python service. openapi-generator-cli is the polyglot workhorse (dozens of target languages) and is usually how a TypeScript map frontend gets its client; its typescript-fetch generator maps a discriminated oneOf onto a TypeScript tagged union that narrows on the discriminator. Pick per consumer; there is no need to standardise on one generator across languages.

Preconditions: a reachable openapi.json (from a running app or exported to a file), Python 3.10+ with pipx for openapi-python-client, and Node.js with npx (plus a JRE) for openapi-generator-cli.


Generator pipeline

One schema, two typed clientsA single openapi.json with a geometry oneOf and discriminator feeds two generators: openapi-python-client producing attrs/Pydantic models and a typed Python client, and openapi-generator-cli producing a TypeScript tagged union and a fetch client. Both narrow geometry on the type field.openapi.jsongeometry oneOf + discriminatoropenapi-python-clientattrs models · httpx · typedPython: Union[Point, LineString, Polygon]openapi-generator-clitypescript-fetch generatorTS: type Geometry = Point | Polygon

Runnable implementation

Export the schema, generate both clients, then narrow the geometry union in each language.

# 1. Export openapi.json from the running FastAPI app (or curl it directly)
curl -s http://localhost:8000/openapi.json -o openapi.json

# 2. Typed PYTHON client (attrs models + httpx, fully typed)
pipx run openapi-python-client generate \
  --path openapi.json \
  --meta pyproject \
  --output-path ./spatial_client_py
# Produces: spatial_client_py/spatial_features_api_client/models/{point,polygon,feature}.py
#   with a `Geometry` union and per-variant classes keyed on `type`.

# 3. Typed TYPESCRIPT client (fetch-based, tagged unions preserved)
npx @openapitools/openapi-generator-cli generate \
  -i openapi.json \
  -g typescript-fetch \
  -o ./spatial_client_ts \
  --additional-properties=supportsES6=true,withInterfaces=true,useSingleRequestParameter=true
# Produces: spatial_client_ts/models/{Point,Polygon,Feature}.ts
#   and a Geometry.ts discriminated union type.

Using the generated Python client — the discriminated geometry becomes a Union, narrowed with isinstance:

# consume_py.py
from spatial_features_api_client import Client
from spatial_features_api_client.models import Feature, Point, Polygon
from spatial_features_api_client.api.default import create_feature_features_post

client = Client(base_url="http://localhost:8000")

# Build a typed request body — mypy checks the geometry variant.
body = Feature.from_dict({
    "type": "Feature",
    "geometry": {"type": "Point", "coordinates": [-0.1278, 51.5074]},
    "properties": {"name": "Trafalgar Square"},
})

resp: Feature = create_feature_features_post.sync(client=client, body=body)

# Narrow the union on the concrete class the generator produced.
geom = resp.geometry
if isinstance(geom, Point):
    lon, lat = geom.coordinates
    print(f"point at {lon}, {lat}")
elif isinstance(geom, Polygon):
    print(f"polygon with {len(geom.coordinates[0])} vertices in outer ring")

Using the generated TypeScript client in a map frontend — switch on the discriminator, no any:

// consume.ts
import { DefaultApi, Configuration, Feature, Geometry } from "./spatial_client_ts";

const api = new DefaultApi(new Configuration({ basePath: "http://localhost:8000" }));

const feature: Feature = await api.createFeatureFeaturesPost({
  feature: {
    type: "Feature",
    geometry: { type: "Point", coordinates: [-0.1278, 51.5074] },
    properties: { name: "Trafalgar Square" },
  },
});

// The generator maps oneOf+discriminator to a tagged union: narrow on `type`.
function toLngLat(geom: Geometry): [number, number] {
  switch (geom.type) {
    case "Point":
      return [geom.coordinates[0], geom.coordinates[1]]; // [lon, lat]
    case "Polygon":
      return geom.coordinates[0][0] as [number, number];  // first vertex
    default:
      // Exhaustiveness check — a new geometry type breaks the build here.
      throw new Error(`unhandled geometry: ${(geom as { type: string }).type}`);
  }
}

Class-count in the generated client is a direct readout of how much the schema repeats itself.

Generated client size for the same spatial APIA horizontal bar chart. inline schemas is 62 model classes. shared geometry component is 18. + shared error component is 14. + pagination envelope shared is 11 — one per real resource. Each shared component removes a family of near-duplicate classes; the target is one class per resource, not one per endpoint.Generated client size for the same spatial APIinline schemas62 model classesshared geometry component18+ shared error component14+ pagination envelope shared11 — one per real resourceEach shared component removes a family of near-duplicate classes; the target is one class per resource, not one per endpoint.

Key parameters & options

GeneratorFlagEffect
openapi-python-client--meta pyprojectEmit an installable package with pyproject.toml rather than bare modules
openapi-python-client--config config.ymlOverride class names, add field aliases, post-process hooks
openapi-generator-cli-g typescript-fetchFetch-based TS client; typescript-axios if you prefer axios
openapi-generator-cliwithInterfaces=trueEmit interfaces alongside classes so unions stay structural
openapi-generator-cliuseSingleRequestParameter=trueOne request-object arg per method — friendlier for many params
openapi-generator-cli--type-mappingsRemap a schema type to a client type (rarely needed for GeoJSON)
bothpin generator versionReproducible output; unpinned generators drift model names across CI runs

For the TypeScript generator, withInterfaces=true plus a discriminated oneOf is what yields a real narrowable union; without the discriminator upstream the generator falls back to an object and the switch above will not type-check.

openapi-python-client accepts a --config YAML that is worth setting up for spatial APIs: it lets you pin generated class names (so Point does not become PointType1 when the generator disambiguates), add field-level property_overrides, and register post-generation hooks. A minimal config that stabilises geometry class names looks like:

# config.yml — passed via --config
class_overrides:
  Point:
    class_name: GeoPoint
    module_name: geo_point
use_path_prefixes_for_title_model_names: false

Regeneration is meant to be cheap and frequent, so keep both the export command and the generate command in a single make client target and run it in CI on every schema change.


Generation is easy; keeping the artefact honest is the part that needs a pipeline step.

Keeping the generated client in stepA horizontal timeline. model change: a field is added. schema export: CI dumps openapi.json. diff gate: fails if the committed schema is stale. regenerate: client rebuilt from the new schema. publish: versioned package. The diff gate is the load-bearing step: without it the committed schema silently drifts from the running API.Keeping the generated client in stepmodel changea field is addedschemaCI dumps openapi.jsondiff gatefails if the committed schema is staleregenerateclient rebuilt from the new schemapublishversioned packageThe diff gate is the load-bearing step: without it the committed schema silently drifts from the running API.

Gotchas & failure modes

  • oneOf without a discriminator generates an untagged union. If the schema has oneOf but no discriminator.mapping, openapi-generator-cli emits Point | Polygon with no tag, so switch (geom.type) does not narrow and you are back to runtime casts. Fix upstream: add Field(discriminator="type") as shown in OpenAPI schema generation for spatial types.
  • additionalProperties: true on geometry weakens the type. If a geometry model allows extra properties, some generators widen it to [key: string]: any, defeating strictness. Set model_config = {"extra": "forbid"} on the geometry models so the schema emits additionalProperties: false.
  • Schema drift silently breaks the client. Regenerating against a changed schema can rename or drop model classes; consumers fail to compile with no warning until then. Regenerate in CI and fail the build on a non-empty git diff of the generated directory so drift is caught at the source, in step with versioning geospatial APIs without breaking clients.
  • Unbounded coordinate arrays generate number[] with no shape. If the schema did not constrain coordinates length, the client types a position as number[], so const [lon, lat] gives no safety. Bound the position array upstream so minItems/maxItems reach the generator.
  • JRE missing for openapi-generator-cli. The CLI is a Java tool; npx @openapitools/openapi-generator-cli fails with Unable to locate a Java Runtime if no JDK/JRE is on PATH. Install a JRE 11+ or use the Docker image openapitools/openapi-generator-cli.
  • FastAPI operationIds produce ugly method names. Names like create_feature_features_post come from the default operationId. Set explicit operation_id= on routes (or a custom generate_unique_id_function) before exporting the schema for cleaner generated method names.

Verification

Confirm the generated client compiles and actually round-trips against the live API:

# Python client: install locally and type-check
pip install -e ./spatial_client_py
python -m mypy consume_py.py        # 0 errors => geometry union narrows cleanly
python consume_py.py                # prints "point at -0.1278, 51.5074"

# TypeScript client: type-check the union narrowing
cd spatial_client_ts && npm install && npx tsc --noEmit ../consume.ts
# A missing `case` for a geometry type fails the exhaustiveness check at build time.

A successful mypy/tsc pass proves the discriminated union survived the schema→client trip; a live python consume_py.py returning the echoed feature proves the wire contract matches the generated models.


← Back to OpenAPI Schema Generation for Spatial Types