ty-fetch
v0.3.2
Published
Automatic TypeScript types for any REST API. No codegen, no manual types — just fetch.
Maintainers
Readme
A tiny, zero-dependency HTTP client that auto-discovers your OpenAPI specs and types your API calls on-the-fly. No codegen, no build step — types generated by a TS plugin. Try it in your browser →
npm install ty-fetch// tsconfig.json
{
"compilerOptions": {
"plugins": [{
"name": "ty-fetch/plugin",
// Optional — point to specs manually. Without this, ty-fetch
// auto-discovers specs at /openapi.json, /.well-known/openapi.yaml, etc.
"specs": {
"api.mycompany.com": "https://api.mycompany.com/docs/openapi.json"
}
}]
}
}import ty from "ty-fetch";
// Fully typed — response, body, path params, query params, headers
const { data, error } = await ty.post("https://api.mycompany.com/v1/users/{team}/invite", {
params: {
path: { team: "engineering" },
query: { notify: true },
},
body: { email: "[email protected]", role: "admin" },
headers: { "x-api-key": process.env.API_KEY },
});
if (error) return console.error(error);
console.log(data.user.id); // fully typed, autocomplete worksIf your API serves an OpenAPI spec at /openapi.json (or any well-known path), ty-fetch finds it automatically. No config needed.
🤔 How does it work?
ty-fetch is a TypeScript language service plugin. When you write a ty.get("https://...") call:
- 🔍 It extracts the domain from the URL
- 📡 Auto-discovers the OpenAPI spec (checks
/openapi.json,/.well-known/openapi.yaml, etc.) - 🏗️ Generates typed overloads on-the-fly — response types, query params, headers, everything
- ✅ Validates your API paths and suggests corrections for typos
Types appear in your editor instantly. When the spec changes, types update automatically. No build step ever.
Compared to other tools
| | ty-fetch | openapi-fetch | orval |
|---|---|---|---|
| Codegen step | None | npx openapi-typescript first | npx orval |
| Generated files | None | .d.ts type files | Full client code |
| Spec changes | Auto-updates | Re-run codegen | Re-run codegen |
| Auto-discovery | Probes well-known paths | Manual | Manual |
| Editor integration | TS plugin (autocomplete, hover, path validation) | Types only | Types only |
| Path validation | Typo detection with "did you mean?" | None | None |
| Streaming | SSE, NDJSON built-in | parseAs: "stream" | Depends on client |
| Runtime | ~100 LOC, zero deps | ~6 KB | Axios/fetch client |
📦 Quick start
1. Install
npm install ty-fetch2. Add the plugin to tsconfig.json
{
"compilerOptions": {
"plugins": [{ "name": "ty-fetch/plugin" }]
}
}3. Start fetching
import ty from "ty-fetch";
// If your API has a spec at /openapi.json — types just work
const { data, error } = await ty.get("https://api.mycompany.com/v1/users");That's it. ✨
VS Code users: Make sure you're using the workspace TypeScript version, not the built-in one. Command Palette → TypeScript: Select TypeScript Version → Use Workspace Version
Want to try it without setting up a project? Open in GitHub Codespaces → — types work instantly, no local setup needed.
🔍 Spec discovery
Auto-discovery (zero config)
When you call ty.get("https://api.example.com/..."), ty-fetch automatically probes the domain for an OpenAPI spec at these well-known paths:
/.well-known/openapi.json /.well-known/openapi.yaml
/openapi.json /openapi.yaml
/api/openapi.json /docs/openapi.json
/swagger.json /api-docs/openapi.jsonIf any path returns a valid OpenAPI spec, types are generated automatically.
This means if your internal API serves a spec, ty-fetch will find it with zero configuration.
Point to specific specs
For APIs that don't serve specs at standard paths, or for local spec files:
{
"compilerOptions": {
"plugins": [
{
"name": "ty-fetch/plugin",
"specs": {
// Remote spec URL
"api.mycompany.com": "https://api.mycompany.com/docs/v2/openapi.json",
// Local file (resolved relative to tsconfig)
"payments.internal.com": "./specs/payments.yaml",
// Third-party API
"api.partner.com": "https://partner.com/openapi.json"
}
}
]
}
}Supported spec formats:
| Format | Versions | |---|---| | OpenAPI | 3.0, 3.1 | | Swagger | 2.0 | | File types | JSON, YAML | | Sources | Local files, remote URLs, auto-discovered |
Custom specs override auto-discovery for the same domain.
📖 API Reference
import ty from "ty-fetch"
The default export is a pre-configured TyFetch instance.
HTTP Methods
ty.get(url, options?) // GET
ty.post(url, options?) // POST
ty.put(url, options?) // PUT
ty.patch(url, options?) // PATCH
ty.delete(url, options?) // DELETE
ty.head(url, options?) // HEAD
ty(url, options?) // Custom method (set options.method)All methods return Promise<{ data, error, response }>.
When the plugin is active, the url parameter and all options are typed from the OpenAPI spec. Without the plugin, everything still works — just untyped.
Response Shape
Every method returns { data, error, response }:
const { data, error, response } = await ty.get("https://api.example.com/v1/users");
if (error) {
// error is the parsed error body (typed if spec defines error responses)
console.error(error.message);
console.log(response.status); // raw Response always available
return;
}
// data is the parsed response body — fully typed from the spec
console.log(data.users);data— parsed response body (undefinedif error). JSON is auto-parsed, otherwise text.error— parsed error body on non-2xx responses (undefinedif success)response— the rawResponseobject (always present)
No .json() call needed — responses are parsed automatically.
Error Handling
ty-fetch uses a discriminated union — check error to narrow the type:
const { data, error, response } = await ty.get("https://api.example.com/v1/users/123");
if (error) {
// data is undefined here, error is the parsed response body
console.log(response.status); // 404, 422, 500, etc.
console.log(error); // parsed JSON from the API
return;
}
// data is typed here, error is undefined
console.log(data.name);What error contains:
| Scenario | error value |
|---|---|
| API returns JSON error body | The parsed JSON (e.g. { message: "Not found", code: "NOT_FOUND" }) |
| API returns non-JSON error | { message: "<statusText>" } |
| Network failure / DNS error | fetch() throws — not caught by ty-fetch (see below) |
The shape of error depends on your API. Most APIs return something like { message, code } or { error: { message } }, but ty-fetch gives you whatever the server sent:
const { error, response } = await ty.post("https://api.example.com/v1/users", {
body: { email: "invalid" },
});
if (error) {
// Status-based handling
if (response.status === 422) {
// error is whatever your API returns for validation errors
console.log(error.errors); // e.g. [{ field: "email", message: "Invalid format" }]
} else if (response.status === 401) {
redirectToLogin();
}
}Network errors (DNS failure, timeout, no internet) are not API responses — fetch() itself throws. Wrap the call in try/catch if you need to handle these:
try {
const { data, error } = await ty.get("https://api.example.com/v1/users");
if (error) {
// API responded with non-2xx — server is reachable
}
} catch (e) {
// Network-level failure — server is unreachable
}Streaming errors work differently — ty.stream() throws an HTTPError on non-2xx responses:
try {
for await (const event of ty.stream("https://api.example.com/v1/events")) {
console.log(event);
}
} catch (e) {
if (e instanceof ty.HTTPError) {
console.log(e.response.status); // the raw Response is attached
}
}Options
ty.post("https://api.example.com/v1/users/{team}/invite", {
// Path params — replaces {placeholders} in the URL
params: {
path: { team: "engineering" },
query: { notify: true, role: "admin" },
},
// JSON request body (auto-serialized, Content-Type set automatically)
body: { email: "[email protected]", name: "Jane Doe" },
// Headers (typed from security schemes when plugin is active)
headers: { "x-api-key": "sk_live_..." },
// Prefix URL — prepended to the url argument
prefixUrl: "https://api.example.com",
// All standard fetch options are supported
signal: AbortSignal.timeout(5000),
cache: "no-store",
credentials: "include",
});| Option | Type | Description |
|---|---|---|
| body | object | JSON body — auto-serialized, Content-Type: application/json set |
| params.path | object | Replaces {placeholder} segments in the URL |
| params.query | object | Appended as ?key=value query string |
| headers | object | HTTP headers (typed from spec security schemes) |
| prefixUrl | string | Prepended to the URL (useful with create/extend) |
Plus all standard RequestInit options (signal, cache, credentials, mode, etc.)
Creating Instances
// Create a pre-configured instance
const api = ty.create({
prefixUrl: "https://api.mycompany.com",
headers: { "x-api-key": process.env.API_KEY },
});
// Now use short paths
const { data } = await api.get("/v1/users");
const { data: user } = await api.post("/v1/users", {
body: { name: "Jane" },
});
// Extend an existing instance (merges options)
const adminApi = api.extend({
headers: { "x-admin-token": process.env.ADMIN_TOKEN },
});Middleware
Add middleware to intercept requests and responses:
import ty from "ty-fetch";
// Add auth header to every request
ty.use({
onRequest(request) {
request.headers.set("Authorization", `Bearer ${getToken()}`);
return request;
},
});
// Log all responses
ty.use({
onResponse(response) {
console.log(`${response.status} ${response.url}`);
return response;
},
});
// Retry on 401
ty.use({
async onResponse(response) {
if (response.status === 401) {
await refreshToken();
return fetch(response.url, { headers: { Authorization: `Bearer ${getToken()}` } });
}
return response;
},
});| Hook | Signature | Description |
|---|---|---|
| onRequest | (request: Request) => Request \| RequestInit \| void | Modify the request before it's sent |
| onResponse | (response: Response) => Response \| void | Modify or replace the response |
Both hooks can be async. Middleware runs in the order it's added.
Streaming
Stream SSE (Server-Sent Events), NDJSON, or raw text responses:
// Server-Sent Events (e.g. OpenAI, Anthropic streaming APIs)
for await (const event of ty.stream("https://api.example.com/v1/chat", {
method: "POST",
body: { prompt: "Hello", stream: true },
})) {
console.log(event); // each parsed SSE event
}
// NDJSON streaming
for await (const line of ty.stream("https://api.example.com/v1/logs")) {
console.log(line); // each parsed JSON line
}Auto-detects the format from Content-Type:
text/event-stream→ SSE (parsesdata:lines, stops at[DONE])application/x-ndjson/application/jsonl→ NDJSON (parses each line as JSON)- Anything else → raw text chunks
Plugin Features (editor only)
When the TS plugin is active, you get these extras on top of the runtime API:
| Feature | What it does |
|---|---|
| Typed responses | data is the actual response type from the spec, not any |
| Typed body | body option is validated against the spec's request body schema |
| Typed query params | params.query keys and types from the spec's parameter definitions |
| Typed path params | params.path keys from {placeholder} segments |
| Typed headers | Required headers from the spec's security schemes |
| Path validation | Red squiggles on invalid API paths with "did you mean?" |
| Autocomplete | URL completions inside string literals, filtered by HTTP method |
| Hover docs | Hover over a URL to see available methods and descriptions |
| JSDoc | Property descriptions from the spec appear in hover tooltips |
| Example inference | Types inferred from response example when schema is missing |
🖥️ CI / Type checking
Why not just tsc?
tsc doesn't run TypeScript language service plugins — it only sees the base any types. Your code will compile, but you won't get type errors for wrong API paths or mismatched params.
For CI, you have two options:
Option 1: ty-fetch CLI (recommended)
Validates API paths against OpenAPI specs — catches typos and invalid endpoints:
npx ty-fetch # uses ./tsconfig.json
npx ty-fetch tsconfig.json # explicit path
npx ty-fetch --verbose # show spec fetching detailssrc/api.ts:21:11 - error TF99001: Path '/v1/uusers' does not exist.
Did you mean '/v1/users'?
1 error(s) found.Add to your CI pipeline:
# GitHub Actions
- run: npx ty-fetch tsconfig.jsonOption 2: ESLint plugin (coming soon)
If you prefer eslint over a separate CLI, you can use the ty-fetch eslint rule:
npm install -D eslint-plugin-ty-fetch// eslint.config.mjs
import tyFetch from "eslint-plugin-ty-fetch";
export default [tyFetch.configs.recommended];This runs the same validation as the CLI but inside your existing eslint pipeline.
Note: The eslint plugin is not yet published. For now, use the CLI.
🌍 Runtime compatibility
ty-fetch is a thin wrapper around the standard Fetch API. It works anywhere fetch is available:
| Runtime | Supported | |---|---| | Node.js | 18+ (native fetch) | | Bun | ✅ | | Deno | ✅ | | Browsers | ✅ (all modern browsers) | | Cloudflare Workers | ✅ |
The TS plugin (type generation) runs in your editor's TypeScript server — it doesn't affect runtime behavior.
❓ FAQ
The TS plugin needs to be active. Check:
- Plugin is in
tsconfig.jsonundercompilerOptions.plugins - In VS Code, you're using the workspace TypeScript version (not the built-in one)
- Restart the TS server after config changes (Command Palette → "TypeScript: Restart TS Server")
- The API's OpenAPI spec is reachable (try
curl https://your-api.com/openapi.json)
tsc doesn't run language service plugins — it only sees the base any types. Use the ty-fetch CLI for CI validation: npx ty-fetch tsconfig.json
Yes. The runtime client works independently — you get { data, error, response } back from every call. You just won't get typed responses or path validation. It's a perfectly usable HTTP client on its own.
You can point to a local spec file in your tsconfig:
"specs": { "api.mycompany.com": "./specs/my-api.yaml" }The runtime client (index.js) is ~100 lines. The plugin code ships in dist/ but only runs inside the TS server, not in your bundle.
🧪 Development
npm run build # compile TypeScript
npm run watch # compile in watch mode
npm test # run unit tests (132 tests)
npm run check # lint with eslintLicense
MIT
