Skip to main content

Generate a typed TypeScript client from your Django REST Framework serializers

Project description

Tether

CI

Generate a typed TypeScript client from your Django REST Framework serializers — and keep the frontend from drifting away from the backend.

The problem

You write a serializer on the backend. Then you write it again on the frontend: the interface, the fetch call, the query hook. Two sources of truth for the same shape. The backend changes, the frontend doesn't hear about it, and something breaks in production that nothing warned you about.

The idea

Tether reads your serializers and routers directly and writes a typed client into your frontend — types, fetch functions, and query hooks — from a single command. No hand-maintained OpenAPI file in the middle. When the backend changes, you regenerate and your TypeScript compiler tells you exactly what no longer lines up.

The point isn't just generating types once. It's that the frontend can't silently drift from the API.

Install

pip install drf-tether

Add tether to your INSTALLED_APPS.

Configure

Point Tether at your serializers and (optionally) your DRF routers through a TETHER setting:

TETHER = {
    "output": "frontend/src/api",
    "serializers": [
        "shop.serializers.ProductSerializer",
    ],
    "routers": [
        "shop.urls.router",
    ],
    "hooks": True,  # emit TanStack Query hooks — optional
}
Key What it does
output Directory the client is written into (as index.ts).
serializers Import paths of the serializers to turn into interfaces.
routers Import paths of DRF routers to turn into fetch functions.
hooks When True, also emit @tanstack/react-query hooks.

Generate

python manage.py tether

Or override the output directory from the command line:

python manage.py tether --output frontend/src/api

What you get

For a ProductSerializer and a router registering a ProductViewSet, Tether writes:

// Generated by Tether. Do not edit by hand.

export interface Product {
  id: number;
  name: string;
  price: string;
}

export interface ProductWrite {
  name: string;
  price: string;
}

export function listProducts(): Promise<Product[]> {
  return request<Product[]>("GET", `/products/`);
}

export function getProduct(id: string | number): Promise<Product> {
  return request<Product>("GET", `/products/${id}/`);
}

export function createProduct(body: ProductWrite): Promise<Product> {
  return request<Product>("POST", `/products/`, body);
}

// ...update, patch, and delete too

With "hooks": True, each endpoint also gets a TanStack Query hook (useProducts, useProduct, useCreateProduct, ...). Mutation hooks invalidate the resource's queries on success.

Read and write shapes are split automatically — read_only fields drop out of the *Write interface and write_only fields out of the response. Paginated list endpoints return a Paginated<T> envelope, and custom @action routes get their own functions.

Talking to your API

Point the client at your backend and tell it how to authenticate. configure() takes a headers callback, so it runs on every request and picks up a token or CSRF cookie as it changes:

import { configure, readCookie } from "./api";

configure({
  baseUrl: "/api",
  credentials: "include",
  headers: () => ({ "X-CSRFToken": readCookie("csrftoken") }),
});

Without credentials and an X-CSRFToken header, a session-authenticated DRF backend rejects every write with a 403.

Failed requests throw an ApiError with the status and the parsed response body, which is where DRF puts its validation errors:

try {
  await createProduct({ name: "", price: "1.00" });
} catch (error) {
  if (error instanceof ApiError && error.status === 400) {
    setFieldErrors(error.body as Record<string, string[]>);
  }
}

Every GET function and hook takes query parameters:

const { data } = useProducts({ page: 2, status: "published" });

Params are part of the query key, so two different filters never share a cache entry.

Custom actions

Tether can't see what a custom action's view body responds with, so by default it returns unknown. Point the action at a serializer and it gets typed:

@action(detail=False, methods=["get"], serializer_class=ProductSerializer)
def featured(self, request):
    ...
export function featuredProducts(): Promise<Product[]>;

detail=False is typed as an array, detail=True as a single object. If your action doesn't fit that, leave serializer_class off and it stays unknown.

Field types

Each serializer field maps to the closest TypeScript type:

DRF field TypeScript
CharField, EmailField, URLField, SlugField, UUIDField, IPAddressField, RegexField string
IntegerField, FloatField number
DecimalField string (or number if COERCE_DECIMAL_TO_STRING is off)
BooleanField boolean
DateTimeField, DateField, TimeField, DurationField string
FileField, ImageField, FilePathField string
ChoiceField a union of its choices, e.g. "draft" | "published"
MultipleChoiceField an array of that union
ListField T[]
DictField, HStoreField Record<string, T>
Nested serializer the referenced interface
many=True nested or relations T[]
PrimaryKeyRelatedField number or string, taken from the related model's key
StringRelatedField, SlugRelatedField, HyperlinkedRelatedField string
SerializerMethodField inferred from the method's return annotation
JSONField, ReadOnlyField, ModelField unknown
HiddenField left out

Where a type can't be known statically — a JSONField, or a SerializerMethodField with no return annotation — the field becomes unknown instead of a guess.

Keeping the client in sync

Run the generator in CI with --check. It regenerates the client in memory, compares it against what's on disk, and exits non-zero if they differ — so a backend change that wasn't regenerated fails the build instead of drifting silently.

python manage.py tether --check

Why not drf-spectacular

drf-spectacular generates an OpenAPI schema, and something like openapi-typescript turns that into types. It covers far more than Tether does, and if anyone outside your codebase consumes your API — a mobile app, a partner, a public docs page — you want the schema and you should use it.

Tether is for the case where you own both sides and nobody else reads the API. Then the schema is a file you maintain, annotate and keep honest for no one's benefit but the generator's. Tether skips it: one command, one generated file, nothing in between.

The trade is real. No schema means no OpenAPI tooling, no mock server, no client in another language.

Status

Working today: types for the full range of DRF serializer fields, fetch functions and TanStack Query hooks from your routers, pagination, custom actions, and the --check mode. It's still pre-1.0, so configuration and output can change between versions.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

drf_tether-0.1.1.tar.gz (27.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

drf_tether-0.1.1-py3-none-any.whl (16.0 kB view details)

Uploaded Python 3

File details

Details for the file drf_tether-0.1.1.tar.gz.

File metadata

  • Download URL: drf_tether-0.1.1.tar.gz
  • Upload date:
  • Size: 27.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.4

File hashes

Hashes for drf_tether-0.1.1.tar.gz
Algorithm Hash digest
SHA256 dff9edcdb8d2478c26208a34a9d11b332d49f5a4f2552dd3c6eebe1ed6348d12
MD5 df3272ecd0f2d4897a988d2cc60a76a8
BLAKE2b-256 9515c5aef930ddc7c2602a5467142026a3e2d485b774b7a411a94f7716d01f52

See more details on using hashes here.

File details

Details for the file drf_tether-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: drf_tether-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 16.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.4

File hashes

Hashes for drf_tether-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 5ba3e7c8dc406c0f16afce2b459949d85ca2037b76c9277fa4359ab6daf7f576
MD5 414fcba135892ce22ca9a47e3a94ac50
BLAKE2b-256 7346fdb4e534c181756fabb82af7b86323e72c76c047cdd49a6153911bf1a13e

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page