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. 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
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.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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3fdbbc88615c8852066b526ee73b90da0637545ba06a2fb48af3cee82b8d87f6
|
|
| MD5 |
d714b7eda1464f14182f7ee744b43b4f
|
|
| BLAKE2b-256 |
592b0dcaa7ed08f9d001dd64f1ea8654654c92247b4cc203e768b5b63486bae6
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
51ff9d2ed327bc0203dd9cc90578d4003a80f3e73bee196c69597e15a477df40
|
|
| MD5 |
2955c0023df0c14acbe72c3dc109330f
|
|
| BLAKE2b-256 |
9c67a4747dbc17998a8389fee64cffaf8783a3cb1caf123e1113e9f20f5faf93
|