
TypeScript-first API client with Standard Schema support, providing excellent DX and strict type safety.
- 🎯 Type-Safe: Full TypeScript support with strict type inference
- 📊 Standard Schema: Native support for Zod, Valibot, and other Standard Schema compliant libraries
- 🔍 Request/Response Validation: Runtime validation with detailed error messages
- 🏗️ Builder Pattern: Intuitive API inspired by Hono and Octokit
-
📋 Structured Response: Rich response metadata (headers, status, URL) with
~raw
access - ⚡ Lightweight: Zero dependencies (except peer dependencies)
- 🛡️ Error Handling: Comprehensive error types for different failure scenarios
- 🎪 Flexible: Works with any Standard Schema compliant validation library
- 🚫 AbortSignal Support: Request cancellation and timeout support
npm install @tknf/typefetcher
TypeFetcher works with Standard Schema compliant validation libraries. Install one or more:
# Zod (requires v3.25.0+ for Standard Schema support)
npm install zod
# Valibot (requires v1.0.0+ for Standard Schema support)
npm install valibot
- Node.js 18+: Built-in fetch support, works out of the box
- Node.js < 18: Provide a custom fetch implementation:
# Option 1: node-fetch
npm install node-fetch
# Option 2: undici (fast HTTP client)
npm install undici
import { TypeFetcher } from "@tknf/typefetcher";
const client = new TypeFetcher({
baseURL: "https://jsonplaceholder.typicode.com",
headers: {
"Authorization": "Bearer your-token"
}
});
// Register endpoints
const api = client
.addEndpoint("GET", "/users")
.addEndpoint("GET", "/users/{id}")
.addEndpoint("POST", "/users");
// Make requests (Octokit-style)
const response = await api.request("GET /users");
// Structured response with metadata
console.log("Data:", response.data); // Response body
console.log("Status:", response.status); // HTTP status code
console.log("Headers:", response.headers); // Response headers
console.log("URL:", response.url); // Request URL
console.log("Raw:", response["~raw"]); // Raw Response object
// Access specific user
const userResponse = await api.request("GET /users/{id}", {
params: { id: "1" }
});
const user = userResponse.data; // Just the data
const status = userResponse.status; // 200, 404, etc.
import { TypeFetcher } from "@tknf/typefetcher";
import { z } from "zod";
// Define your schemas
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
const CreateUserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
const PathIdSchema = z.object({
id: z.string()
});
// Create type-safe client
const client = new TypeFetcher({
baseURL: "https://api.example.com"
});
const api = client
.addEndpoint("GET", "/users", {
response: z.array(UserSchema)
})
.addEndpoint("GET", "/users/{id}", {
params: PathIdSchema, // ✅ params is required when schema provided
response: UserSchema
})
.addEndpoint("POST", "/users", {
body: CreateUserSchema, // ✅ body is required when schema provided
response: UserSchema
});
// Fully type-safe requests with structured responses
const usersResponse = await api.request("GET /users");
// Type: StructuredResponse<User[]>
const users = usersResponse.data; // User[]
const status = usersResponse.status; // number
const headers = usersResponse.headers; // Headers
const userResponse = await api.request("GET /users/{id}", {
params: { id: "123" } // ✅ TypeScript ensures correct type
});
// Type: StructuredResponse<User>
const user = userResponse.data; // User object
if (userResponse.status === 200) {
console.log("User found:", user.name);
}
const created = await api.request("POST /users", {
body: { name: "Jane", email: "jane@example.com" } // ✅ Validated at runtime
});
// Access creation details
console.log("Created user:", created.data);
console.log("Location:", created.headers.get("location"));
console.log("Status:", created.status); // 201
Every request returns a structured response with rich metadata:
interface StructuredResponse<T> {
readonly data: T; // Parsed response data (your API data)
readonly headers: Headers; // Response headers object
readonly status: number; // HTTP status code (200, 404, etc.)
readonly url: string; // Final request URL
readonly "~raw": Response; // Raw fetch Response object
}
const response = await api.request("GET /users/{id}", {
params: { id: "123" }
});
// Access parsed data (type-safe when schema is provided)
const user = response.data;
// Check HTTP status
if (response.status === 200) {
console.log("Success!");
} else if (response.status === 404) {
console.log("User not found");
}
// Access response headers
const contentType = response.headers.get("content-type");
const rateLimit = response.headers.get("x-rate-limit-remaining");
// Get request URL (useful for debugging)
console.log("Request was made to:", response.url);
// Access raw Response for advanced use cases
const rawResponse = response["~raw"];
const responseText = await rawResponse.clone().text();
import { TypeFetcher } from "@tknf/typefetcher";
import * as v from "valibot";
// Define Valibot schemas
const UserSchema = v.object({
id: v.number(),
name: v.string(),
email: v.pipe(v.string(), v.email()),
});
const CreateUserSchema = v.object({
name: v.string(),
email: v.pipe(v.string(), v.email()),
});
// Use directly with TypeFetcher
const api = client
.addEndpoint("GET", "/users", {
response: v.array(UserSchema)
})
.addEndpoint("POST", "/users", {
body: CreateUserSchema,
response: UserSchema
});
// Same type-safe API as with Zod
const usersResponse = await api.request("GET /users");
const users = usersResponse.data; // User[]
const newUserResponse = await api.request("POST /users", {
body: { name: "John", email: "john@example.com" }
});
const newUser = newUserResponse.data; // User
new TypeFetcher(config?: TypeFetcherConfig)
Parameters:
-
config
(optional): Configuration object
TypeFetcherConfig:
interface TypeFetcherConfig {
readonly baseURL?: string;
readonly headers?: Record<string, string>;
readonly timeout?: number;
readonly fetch?: typeof globalThis.fetch; // Custom fetch implementation
}
addEndpoint<Method, Path, Schema>(
method: Method,
path: Path,
schema?: Schema
): TypeFetcher<...>
Registers a new endpoint with optional schema validation.
Parameters:
-
method
: HTTP method ("GET" | "POST" | "PUT" | "PATCH" | "DELETE"
) -
path
: URL path with optional parameters (e.g.,"/users/{id}"
) -
schema
(optional): Validation schema object
Schema Object:
interface EndpointSchema {
readonly params?: StandardSchemaV1; // Path parameters
readonly query?: StandardSchemaV1; // Query parameters
readonly body?: StandardSchemaV1; // Request body
readonly response?: StandardSchemaV1; // Response validation
}
request<K>(key: K, options?: RequestOptions): Promise<StructuredResponse<T>>
Executes a request to a registered endpoint and returns a structured response.
Parameters:
-
key
: Endpoint key in format"METHOD /path"
-
options
: Request options (automatically typed based on schema)
Request Options:
interface RequestOptions {
readonly params?: Record<string, string> | SchemaType; // Path parameters
readonly query?: Record<string, string> | SchemaType; // Query parameters
readonly body?: unknown | SchemaType; // Request body
readonly headers?: Record<string, string>; // Custom headers
readonly signal?: AbortSignal; // Abort signal
}
// When schema is provided, corresponding fields become required and strongly typed
// Request cancellation
const controller = new AbortController();
// Cancel after 5 seconds
setTimeout(() => controller.abort(), 5000);
try {
const response = await api.request("GET /users/{id}", {
params: { id: "123" },
signal: controller.signal
});
console.log("User:", response.data);
} catch (error) {
if (error.name === 'AbortError') {
console.log("Request was cancelled");
}
}
const response = await api.request("GET /users/{id}", {
params: { id: "123" },
headers: {
"Accept-Language": "en-US",
"X-Custom-Header": "value",
"Authorization": "Bearer specific-token" // Override global headers
}
});
const QuerySchema = z.object({
page: z.string(),
limit: z.string(),
search: z.string().optional()
});
const api = client.addEndpoint("GET", "/users", {
query: QuerySchema,
response: z.array(UserSchema)
});
const response = await api.request("GET /users", {
query: {
page: "1",
limit: "10",
search: "john"
}
});
console.log("Users:", response.data);
console.log("Total pages:", response.headers.get("x-total-pages"));
For advanced use cases, access the raw Response
object:
const response = await api.request("GET /download/{id}", {
params: { id: "file123" }
});
// Access raw Response
const rawResponse = response["~raw"];
// Stream response body
const reader = rawResponse.body?.getReader();
const contentLength = rawResponse.headers.get("content-length");
console.log(`Downloading ${contentLength} bytes`);
// Process stream...
while (reader) {
const { done, value } = await reader.read();
if (done) break;
// Process chunk
console.log(`Received ${value.length} bytes`);
}
For Node.js environments, you can provide a custom fetch implementation:
// Node.js 18+ (built-in fetch)
const client = new TypeFetcher({
baseURL: "https://api.example.com"
});
// Node.js < 18 with node-fetch
import fetch from "node-fetch";
const client = new TypeFetcher({
baseURL: "https://api.example.com",
fetch: fetch as unknown as typeof globalThis.fetch
});
// Using undici for better performance
import { fetch } from "undici";
const client = new TypeFetcher({
baseURL: "https://api.example.com",
fetch: fetch as unknown as typeof globalThis.fetch
});
import { TypeFetcherError, ValidationError } from "@tknf/typefetcher";
try {
const response = await api.request("GET /users/{id}", {
params: { id: "123" }
});
console.log("User:", response.data);
console.log("Status:", response.status);
} catch (error) {
if (error instanceof TypeFetcherError) {
// HTTP errors (404, 500, etc.)
console.error(`HTTP ${error.status}: ${error.statusText}`);
console.error("Response data:", error.data);
} else if (error instanceof ValidationError) {
// Schema validation errors
console.error("Validation failed:", error.message);
error.issues.forEach(issue => {
console.error(`- ${issue.message} at ${issue.path?.join('.')}`);
});
} else {
// Other errors (network, abort, etc.)
console.error("Unexpected error:", error);
}
}
Zod and Valibot schemas with transformations work seamlessly:
const TransformSchema = z.object({
id: z.string().transform(val => val.toUpperCase()),
date: z.string().transform(val => new Date(val))
});
const api = client.addEndpoint("GET", "/items/{id}", {
params: TransformSchema
});
// Input is transformed before making the request
const response = await api.request("GET /items/{id}", {
params: { id: "abc", date: "2023-01-01" }
// Becomes: id="ABC", date=Date object in the actual request
});
Unlike other API clients that require adapters or wrappers, TypeFetcher natively supports any Standard Schema compliant library:
// ❌ Other libraries require adapters
const schema = someAdapter(z.string());
// ✅ TypeFetcher uses schemas directly
const schema = z.string(); // Works with Zod 3.25.0+
const schema = v.string(); // Works with Valibot 1.0.0+
Get comprehensive response metadata without extra work:
// ❌ Traditional fetch
const rawResponse = await fetch("/api/users");
const data = await rawResponse.json();
// Lost: headers, status, url information
// ✅ TypeFetcher structured response
const response = await api.request("GET /users");
// Available: data, headers, status, url, ~raw
- Required Parameters: Schema-specified parameters become required in TypeScript
- Type Inference: Full type inference from schemas to response types
- Autocomplete: Rich IDE support with endpoint and parameter suggestions
- Structured Response: Access both data and metadata with full type safety
- Zero runtime dependencies (except peer dependencies)
- Tree-shakable exports
- Only import what you use
import { TypeFetcher } from "@tknf/typefetcher";
import { z } from "zod";
const PostSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
userId: z.number()
});
class BlogAPI {
private client = new TypeFetcher({
baseURL: "https://jsonplaceholder.typicode.com"
});
private api = this.client
.addEndpoint("GET", "/posts", {
response: z.array(PostSchema)
})
.addEndpoint("GET", "/posts/{id}", {
params: z.object({ id: z.string() }),
response: PostSchema
})
.addEndpoint("POST", "/posts", {
body: z.object({
title: z.string(),
body: z.string(),
userId: z.number()
}),
response: PostSchema
});
async getAllPosts() {
const response = await this.api.request("GET /posts");
return {
posts: response.data,
count: response.headers.get("x-total-count")
};
}
async getPost(id: string) {
const response = await this.api.request("GET /posts/{id}", {
params: { id }
});
if (response.status === 404) {
throw new Error("Post not found");
}
return response.data;
}
async createPost(post: { title: string; body: string; userId: number }) {
const response = await this.api.request("POST /posts", { body: post });
return {
post: response.data,
location: response.headers.get("location"),
status: response.status
};
}
}
const api = client.addEndpoint("POST", "/upload", {
body: z.instanceof(FormData),
response: z.object({
fileId: z.string(),
url: z.string()
})
});
async function uploadFile(file: File, onProgress?: (progress: number) => void) {
const formData = new FormData();
formData.append("file", file);
const response = await api.request("POST /upload", {
body: formData,
headers: {
// Don't set Content-Type, let browser set it with boundary
}
});
console.log("Upload completed!");
console.log("File ID:", response.data.fileId);
console.log("File URL:", response.data.url);
console.log("Server:", response.headers.get("server"));
return response.data;
}
async function getAllUsers() {
const users = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await api.request("GET /users", {
query: { page: page.toString(), limit: "50" }
});
users.push(...response.data);
// Check if there are more pages
const totalPages = parseInt(response.headers.get("x-total-pages") || "1");
hasMore = page < totalPages;
page++;
}
return users;
}
# Install dependencies
pnpm install
# Run tests
pnpm test
# Run tests with coverage
pnpm run test:coverage
# Type checking
pnpm run typecheck
# Linting
pnpm run lint
# Build
pnpm run build
- Node.js: 16.x or higher
- TypeScript: 5.x or higher
- Zod: 3.25.0+ (if using Zod)
- Valibot: 1.0.0+ (if using Valibot)
MIT License - see LICENSE for details.
- Fork the repository
- Create a feature branch
- Make your changes
- Ensure tests pass:
pnpm run test
- Ensure linting passes:
pnpm run lint
- Submit a pull request
- Standard Schema specification
- Zod and Valibot for schema validation
- Hono and Octokit for API design inspiration