Generate a typed TypeScript client from your Django REST Framework serializers
Project description
Tether
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dff9edcdb8d2478c26208a34a9d11b332d49f5a4f2552dd3c6eebe1ed6348d12
|
|
| MD5 |
df3272ecd0f2d4897a988d2cc60a76a8
|
|
| BLAKE2b-256 |
9515c5aef930ddc7c2602a5467142026a3e2d485b774b7a411a94f7716d01f52
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5ba3e7c8dc406c0f16afce2b459949d85ca2037b76c9277fa4359ab6daf7f576
|
|
| MD5 |
414fcba135892ce22ca9a47e3a94ac50
|
|
| BLAKE2b-256 |
7346fdb4e534c181756fabb82af7b86323e72c76c047cdd49a6153911bf1a13e
|