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. Query functions and hooks take an AbortSignal, and the hooks pass TanStack Query's own signal through, so cancelling a query cancels the request.

The client is browser-side

configure() writes to module-level state. In the browser that's one client per page and fine. On a server it isn't: a module is shared across requests, so calling configure() per request can leak one user's headers() — an auth token — into another user's request. readCookie returns "" off the browser rather than throwing, but that is a guard, not support.

Use the generated client from browser code. Server-side rendering of a per-user API is out of scope for 0.1.x.

Custom actions

Tether can't see what a custom action's view body responds with, so an unmarked action returns unknown. Declare the response with @responds and it gets typed:

from tether import responds

class ProductViewSet(viewsets.ModelViewSet):
    @responds(ProductSerializer, many=True)
    @action(detail=False, methods=["get"])
    def featured(self, request):
        ...

    @responds(ProductSerializer, many=False)
    @action(detail=False, methods=["get"])
    def summary(self, request):
        ...
export function featuredProducts(params?: QueryParams): Promise<Product[]>;
export function summaryProducts(params?: QueryParams): Promise<Product>;

many is required. A detail=False route is free to return one object rather than a list, so Tether asks instead of guessing — a wrong guess would typecheck and then fail at runtime, which is worse than unknown. Actions left unmarked are listed when you generate, so nothing is quietly untyped.

What an action accepts is a separate question, and DRF already answers it: serializer_class. An action that takes a body and returns something else declares both.

@responds(TokenSerializer, many=False)
@action(detail=False, methods=["post"], serializer_class=CredentialsSerializer)
def login(self, request):
    ...
export function loginAuth(body: CredentialsWrite): Promise<Token>;

Without serializer_class the body stays unknown, and those actions are listed too.

An action that maps several methods gets one function per method (getStarProduct, postStarProduct). If two endpoints would end up with the same name, generation stops and tells you which ones to rename with @action(url_path=...) — rather than writing a file that cannot compile.

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.2.tar.gz (31.8 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.2-py3-none-any.whl (18.9 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: drf_tether-0.1.2.tar.gz
  • Upload date:
  • Size: 31.8 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.2.tar.gz
Algorithm Hash digest
SHA256 3fdbbc88615c8852066b526ee73b90da0637545ba06a2fb48af3cee82b8d87f6
MD5 d714b7eda1464f14182f7ee744b43b4f
BLAKE2b-256 592b0dcaa7ed08f9d001dd64f1ea8654654c92247b4cc203e768b5b63486bae6

See more details on using hashes here.

File details

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

File metadata

  • Download URL: drf_tether-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 18.9 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.2-py3-none-any.whl
Algorithm Hash digest
SHA256 51ff9d2ed327bc0203dd9cc90578d4003a80f3e73bee196c69597e15a477df40
MD5 2955c0023df0c14acbe72c3dc109330f
BLAKE2b-256 9c67a4747dbc17998a8389fee64cffaf8783a3cb1caf123e1113e9f20f5faf93

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