A suite of type utilities for building strongly-typed APIs
🚧 Under active development
- 💪 Strongly-typed query, body, headers, response.
- 🗺️ Static path segments, as well as dynamic and wildcard parameters.
- 📦 Exposes core utilities for building typed fetch functions.
Install package:
# npm
npm install fetchdts
# pnpm
pnpm install fetchdts
Define your API schema and create a strongly-typed fetch function:
import type { DynamicParam, Endpoint, TypedFetchInput, TypedFetchRequestInit, TypedFetchResponseBody, TypedResponse } from 'fetchdts'
// Define your API schema
interface APISchema {
'/users': {
[Endpoint]: {
GET: {
response: { id: number, name: string }[]
}
POST: {
body: { name: string, email: string }
response: { id: number, name: string, email: string }
}
}
[DynamicParam]: { // /users/:id
[Endpoint]: {
GET: {
response: { id: number, name: string, email: string }
}
DELETE: {
response: { success: boolean }
}
}
}
}
}
// Create your typed fetch function
async function api<T extends TypedFetchInput<APISchema>>(
input: T,
init?: TypedFetchRequestInit<APISchema, T>,
) {
return fetch(input, init as RequestInit) as unknown as Promise<TypedResponse<TypedFetchResponseBody<APISchema, T>>>
}
// Use with full type safety
const users = await api('/users').then(r => r.json()) // Type: { id: number; name: string }[]
const user = await api('/users/123').then(r => r.json()) // Type: { id: number; name: string; email: string }
Your API schema describes the structure of your endpoints using TypeScript interfaces:
interface Schema {
'/path': {
[Endpoint]: {
[HTTPMethod]: {
query?: { param: string } // Query parameters
body?: { data: any } // Request body
headers?: { auth: string } // Required headers
response: { result: any } // Response data
responseHeaders?: { 'x-rate-limit': string } // Response headers
}
}
}
}
Static Paths: Exact string matches
interface Schema {
'/api/users': {
[Endpoint]: {
GET: { response: User[] }
}
}
}
Dynamic Parameters: Single path segments
interface Schema {
'/api/users': {
[DynamicParam]: { // matches /api/users/123, /api/users/abc, etc.
[Endpoint]: {
GET: { response: User }
}
}
}
}
Wildcard Parameters: Multiple path segments
interface Schema {
'/api': {
[WildcardParam]: { // matches /api/anything/nested/deep
[Endpoint]: {
GET: { response: any }
}
}
}
}
fetchdts uses special symbols to define different types of route matching:
import { DynamicParam, Endpoint, WildcardParam } from 'fetchdts'
interface Schema {
// Endpoint: Marks where HTTP methods are defined
[Endpoint]: {
GET: { response: Data }
POST: { body: Input, response: Data }
}
// DynamicParam: Matches single path segments (e.g., /users/:id)
[DynamicParam]: {
[Endpoint]: {
GET: { response: User }
}
}
// WildcardParam: Matches multiple path segments (e.g., /files/*)
[WildcardParam]: {
[Endpoint]: {
GET: { response: File }
}
}
}
All standard HTTP methods are supported:
GET
,POST
,PUT
,DELETE
,PATCH
OPTIONS
,HEAD
,CONNECT
,TRACE
interface RESTSchema {
'/api/users': {
[Endpoint]: {
GET: { response: User[] }
POST: { body: CreateUser, response: User }
}
[DynamicParam]: {
[Endpoint]: {
GET: { response: User }
PUT: { body: UpdateUser, response: User }
PATCH: { body: Partial<UpdateUser>, response: User }
DELETE: { response: { success: boolean } }
}
}
}
}
Extracts valid URL paths from your schema:
type ValidPaths = TypedFetchInput<APISchema>
// Result: '/users' | '/users/${string}'
Provides typed request options for a specific path:
// For paths requiring body/headers/query parameters
await api('/users', {
method: 'POST',
body: { name: 'John' }, // ✅ Typed based on schema
headers: { authorization: 'Bearer token' }
})
Returns the typed response body for a given path:
const response = await api('/users/123')
// Type automatically inferred from schema
Provides typed header access:
const contentType = response.headers.get('content-type') // string | null
const customHeader = response.headers.get('x-custom') // Typed based on schema
Generate TypeScript schema from route definitions:
import { serializeRoutes } from 'fetchdts'
const schema = serializeRoutes('APISchema', [
{
path: '/users',
metadata: {
GET: {
responseType: 'User[]'
},
POST: {
bodyType: '{ name: string }',
responseType: 'User'
}
}
},
{
path: '/users/:id',
type: 'dynamic',
metadata: {
GET: {
responseType: 'User'
}
}
}
])
console.log(schema)
// Outputs TypeScript interface definition
interface APISchema {
'/search': {
[Endpoint]: {
GET: {
query: { q: string, limit?: number }
headers: { 'x-api-key': string }
response: { results: string[], total: number }
responseHeaders: { 'x-rate-limit-remaining': string }
}
}
}
}
// Usage with required query and headers
const results = await api('/search', {
query: { q: 'typescript', limit: 10 },
headers: { 'x-api-key': 'your-key' }
})
// Access typed response headers
const rateLimit = results.headers.get('x-rate-limit-remaining') // string | null
interface APISchema {
'/api': {
'/health': {
[Endpoint]: {
GET: { response: { status: 'ok' | 'error' } }
}
}
'/users': {
[Endpoint]: {
GET: { response: User[] }
POST: { body: CreateUser, response: User }
}
[DynamicParam]: {
[Endpoint]: {
GET: { response: User }
PUT: { body: UpdateUser, response: User }
DELETE: { response: { deleted: boolean } }
}
'/posts': {
[Endpoint]: {
GET: { response: Post[] }
}
[DynamicParam]: {
[Endpoint]: {
GET: { response: Post }
}
}
}
}
}
}
}
// All of these are now typed:
await api('/api/health') // { status: 'ok' | 'error' }
await api('/api/users') // User[]
await api('/api/users/123') // User
await api('/api/users/123/posts') // Post[]
await api('/api/users/123/posts/456') // Post
interface Schema {
'https://api.github.com': {
'/users': {
[DynamicParam]: {
[Endpoint]: {
GET: { response: GitHubUser }
}
'/repos': {
[Endpoint]: {
GET: { response: Repository[] }
}
}
}
}
}
}
// Works with full URLs
const user = await api('https://api.github.com/users/octocat')
const repos = await api('https://api.github.com/users/octocat/repos')
interface APISchema {
'/api/users': {
[DynamicParam]: {
[Endpoint]: {
GET: {
response: User | { error: string, code: number }
}
}
}
}
}
const result = await api('/api/users/123')
// result is typed as: User | { error: string; code: number }
if ('error' in result) {
console.error(`Error ${result.code}: ${result.error}`)
}
else {
console.log(`User: ${result.name}`)
}
For large APIs, consider organizing your schemas into modules:
// types/api.ts
interface UserAPI {
'/api/users': {
[Endpoint]: {
GET: { response: User[] }
POST: { body: CreateUser, response: User }
}
[DynamicParam]: {
[Endpoint]: {
GET: { response: User }
PUT: { body: UpdateUser, response: User }
DELETE: { response: { success: boolean } }
}
}
}
}
interface PostAPI {
'/api/posts': {
[Endpoint]: {
GET: { query?: { limit?: number }, response: Post[] }
POST: { body: CreatePost, response: Post }
}
[DynamicParam]: {
[Endpoint]: {
GET: { response: Post }
PUT: { body: UpdatePost, response: Post }
DELETE: { response: { success: boolean } }
}
}
}
}
// Combine them
type APISchema = UserAPI & PostAPI
While fetchdts provides compile-time type safety, consider adding runtime validation:
import { z } from 'zod'
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email()
})
async function api<T extends TypedFetchInput<APISchema>>(
input: T,
init?: TypedFetchRequestInit<APISchema, T>
): Promise<TypedFetchResponseBody<APISchema, T>> {
const response = await fetch(input, init as RequestInit)
const data = await response.json()
// Runtime validation for critical endpoints
if (input.startsWith('/api/users/') && init?.method !== 'DELETE') {
return UserSchema.parse(data) // Throws if invalid
}
return data
}
Design your schemas with error handling in mind:
interface APISchema {
'/api/users': {
[DynamicParam]: {
[Endpoint]: {
GET: {
response:
| { success: true, data: User }
| { success: false, error: string, code: number }
}
}
}
}
}
// Usage
const result = await api('/api/users/123')
if (result.success) {
console.log(result.data.name) // ✅ Type-safe access
}
else {
console.error(`Error ${result.code}: ${result.error}`)
}
For the best experience, ensure your tsconfig.json
includes:
{
"compilerOptions": {
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true
}
}
I would welcome contributions! Please see the Code of Conduct.
- Clone this repository
- Enable Corepack using
corepack enable
- Install dependencies using
pnpm install
- Run interactive tests using
pnpm dev
and type tests withpnpm test:types
Made with ❤️
Published under MIT License.