🌐what-the-fetch

API Reference

Complete API documentation for what-the-fetch with detailed examples and type signatures

Main Function

createFetch()

Creates a type-safe fetch function for your API.

function createFetch<Schema extends ApiSchema>(
  apis: Schema,
  baseUrl: string,
  sharedInit?: RequestInit
): <Path extends ApiPath<Schema>>(
  path: Path,
  options?: FetchOptions<Schema, Path>,
  init?: RequestInit
) => Promise<ApiData<Schema, Path, 'response'>>

Parameters:

  • apis - An object mapping API paths to their schema definitions
  • baseUrl - The base URL for all API requests
  • sharedInit (optional) - Shared RequestInit options that will be merged with per-request options

Returns: A typed fetch function that accepts:

  • path - The API path (must be a key from your schema)
  • options (optional) - Request options (params, query, body) based on the path's schema
  • init (optional) - Per-request RequestInit to customize the fetch request (merged with sharedInit)

Example:

import { createFetch } from 'what-the-fetch';
import { z } from 'zod';

const api = {
  '/users/:id': {
    params: z.object({ id: z.number() }),
    query: z.object({ fields: z.string().optional() }),
    response: z.object({
      id: z.number(),
      name: z.string(),
      email: z.string()
    })
  }
};

const apiFetch = createFetch(api, 'https://api.example.com');

const user = await apiFetch('/users/:id', {
  params: { id: 123 },
  query: { fields: 'name,email' }
});
// user is typed as { id: number; name: string; email: string }

Type Definitions

ApiSchema

Schema definition for an API. Maps API paths to their schema definitions.

type ApiSchema = Record<
  string,
  {
    params?: StandardSchemaV1<Record<string, unknown>>
    query?: StandardSchemaV1<Record<string, unknown>>
    body?: StandardSchemaV1<Record<string, unknown>>
    response?: StandardSchemaV1<Record<string, unknown>>
  }
>

Each path in your schema can have:

  • params - Schema for URL path parameters (e.g., :id)
  • query - Schema for query string parameters
  • body - Schema for request body (automatically sets method to POST)
  • response - Schema for response validation

Important: If your path contains parameters (e.g., /users/:id), you must define a params schema. Attempting to use a parameterized path without a params schema will throw an error at runtime.

Example:

import { z } from 'zod';

const api: ApiSchema = {
  '/users/:id': {
    params: z.object({ id: z.number() }),
    query: z.object({ fields: z.string().optional() }),
    response: z.object({ id: z.number(), name: z.string() })
  },
  '/users': {
    // GET /users - List users with pagination
    query: z.object({
      limit: z.number().optional(),
      offset: z.number().optional()
    }),
    response: z.array(z.object({ id: z.number(), name: z.string() }))
  }
};

ApiPath<T>

Extract valid API paths from an API schema.

type ApiPath<T extends ApiSchema> = keyof T & string

Example:

const api = {
  '/users/:id': { /* ... */ },
  '/posts': { /* ... */ }
};

type Path = ApiPath<typeof api>;
// Path = '/users/:id' | '/posts'

FetchOptions<T, Path>

Extract the required fetch options for a specific API path.

type FetchOptions<T extends ApiSchema, Path extends ApiPath<T>> = 
  // Infers correct types for params, query, and body

Automatically infers the correct types for params, query, and body based on the schema definition for the given path.

Example:

const api = {
  '/users/:id': {
    params: z.object({ id: z.number() }),
    query: z.object({ fields: z.string().optional() })
  }
};

type Options = FetchOptions<typeof api, '/users/:id'>;
// Options = { params: { id: number }, query?: { fields?: string } }

ApiData<Schemas, Path, Option>

Extract the inferred data type for a specific schema option from an API path.

type ApiData<
  Schemas extends ApiSchema,
  Path extends ApiPath<Schemas>,
  Option extends keyof Schemas[Path]
>

This is a flexible type that extracts the inferred TypeScript type for any schema option ('params', 'query', 'body', or 'response') from a specific API path. This replaces the older ApiResponse type and provides more flexibility for working with different parts of your API schema.

Parameters:

  • Schemas - The complete API schema type
  • Path - The specific API path to extract from
  • Option - The schema option to extract ('params', 'query', 'body', or 'response')

Examples:

const api = {
  '/users/:id': {
    params: z.object({ id: z.number() }),
    query: z.object({ fields: z.string().optional() }),
    response: z.object({ id: z.number(), name: z.string() })
  }
};

// Extract params type
type UserParams = ApiData<typeof api, '/users/:id', 'params'>;
// UserParams = { id: number }

// Extract query type
type UserQuery = ApiData<typeof api, '/users/:id', 'query'>;
// UserQuery = { fields?: string }

// Extract response type
type UserResponse = ApiData<typeof api, '/users/:id', 'response'>;
// UserResponse = { id: number; name: string }

Note: For backward compatibility, you can create a type alias:

type ApiResponse<T extends ApiSchema, Path extends ApiPath<T>> = 
  ApiData<T, Path, 'response'>;

Request Validation

what-the-fetch automatically validates all request data (params, query, and body) as well as response data using the schemas you provide. The validation happens concurrently for optimal performance.

Validation Behavior

  • Params validation: If your path contains parameters (e.g., /users/:id), you must provide a params schema. The library will throw an error if a parameterized path is missing a params schema.
  • Query validation: Query parameters are validated if a query schema is provided.
  • Body validation: Request body is validated if a body schema is provided.
  • Response validation: Response data is validated if a response schema is provided.
  • Concurrent validation: All request validations (params, query, body) run concurrently using Promise.all() for better performance.

Validation Errors

When validation fails, the library throws an error with details about the validation issues:

const api = {
  '/users/:id': {
    params: z.object({ id: z.number() }),
    response: z.object({ id: z.number(), name: z.string() })
  }
};

const apiFetch = createFetch(api, 'https://api.example.com');

// This will throw a validation error
try {
  await apiFetch('/users/:id', { params: { id: 'invalid' } });
} catch (error) {
  console.error(error.message); // "Validation failed: [...]"
}

Required Params Schema

If your path contains URL parameters, you must define a params schema:

// ❌ This will throw an error
const badApi = {
  '/users/:id': {
    response: z.object({ id: z.number(), name: z.string() })
    // Missing params schema!
  }
};

// ✅ This is correct
const goodApi = {
  '/users/:id': {
    params: z.object({ id: z.number() }),
    response: z.object({ id: z.number(), name: z.string() })
  }
};

The library detects parameterized paths using the pattern /:paramName and will throw this error if no params schema is found:

Path contains parameters but no "params" schema is defined.

Request Examples

GET Request with Path Parameters

const api = {
  '/users/:id': {
    params: z.object({ id: z.number() }),
    response: z.object({
      id: z.number(),
      name: z.string(),
      email: z.string()
    })
  }
};

const apiFetch = createFetch(api, 'https://api.example.com');

const user = await apiFetch('/users/:id', {
  params: { id: 123 }
});
// GET https://api.example.com/users/123

GET Request with Query Parameters

const api = {
  '/users': {
    query: z.object({
      limit: z.number().optional(),
      offset: z.number().optional(),
      sort: z.string().optional()
    }),
    response: z.array(z.object({
      id: z.number(),
      name: z.string()
    }))
  }
};

const apiFetch = createFetch(api, 'https://api.example.com');

const users = await apiFetch('/users', {
  query: { limit: 10, offset: 0, sort: 'name' }
});
// GET https://api.example.com/users?limit=10&offset=0&sort=name

GET Request with Both Path and Query Parameters

const api = {
  '/users/:id/posts': {
    params: z.object({ id: z.number() }),
    query: z.object({
      limit: z.number().optional(),
      published: z.boolean().optional()
    }),
    response: z.array(z.object({
      id: z.number(),
      title: z.string()
    }))
  }
};

const apiFetch = createFetch(api, 'https://api.example.com');

const posts = await apiFetch('/users/:id/posts', {
  params: { id: 123 },
  query: { limit: 10, published: true }
});
// GET https://api.example.com/users/123/posts?limit=10&published=true

POST Request with Body

const api = {
  '/users': {
    body: z.object({
      name: z.string(),
      email: z.string().email()
    }),
    response: z.object({
      id: z.number(),
      name: z.string(),
      email: z.string()
    })
  }
};

const apiFetch = createFetch(api, 'https://api.example.com');

const newUser = await apiFetch('/users', {
  body: {
    name: 'John Doe',
    email: 'john@example.com'
  }
});
// POST https://api.example.com/users
// Content-Type: application/json
// Body: {"name":"John Doe","email":"john@example.com"}

Custom Headers and Options

const api = {
  '/users/:id': {
    params: z.object({ id: z.number() }),
    response: z.object({ id: z.number(), name: z.string() })
  }
};

const apiFetch = createFetch(api, 'https://api.example.com');

const user = await apiFetch(
  '/users/:id',
  { params: { id: 123 } },
  {
    headers: {
      'Authorization': 'Bearer token',
      'X-Custom-Header': 'value'
    },
    cache: 'no-cache',
    signal: abortController.signal
  }
);

HTTP Methods

what-the-fetch automatically infers HTTP methods based on the presence of a request body. You can optionally use the @method prefix for explicit control or to specify methods other than GET and POST.

Automatic Method Inference

By default, what-the-fetch infers the HTTP method:

  • Requests with a bodyPOST
  • Requests without a bodyGET
const api = {
  '/users/:id': { /* Uses GET (no body) */ },
  '/users': { body: z.object({...}), /* Uses POST (has body) */ }
};

Method Prefix Syntax (Optional)

For explicit control or to use other HTTP methods, add the @method prefix:

const api = {
  '@get/resource': { /* Explicit GET - same as /resource */ },
  '/resource': { /* Implicit GET */ },
  '@post/resource': { /* Explicit POST */ },
  '@put/resource/:id': { /* PUT - prefix required */ },
  '@patch/resource/:id': { /* PATCH - prefix required */ },
  '@delete/resource/:id': { /* DELETE - prefix required */ }
};

Note: /users/:id and @get/users/:id are completely equivalent and both result in a GET request.

Supported HTTP Methods

All standard HTTP methods are supported:

  • @get - GET requests (retrieve data)
  • @post - POST requests (create new resources)
  • @put - PUT requests (replace existing resources)
  • @patch - PATCH requests (partially update resources)
  • @delete - DELETE requests (remove resources)
  • @head - HEAD requests (retrieve headers only)
  • @options - OPTIONS requests (check available methods)

Complete Example

import { createFetch } from 'what-the-fetch';
import { z } from 'zod';

const api = {
  // GET - Retrieve a user
  '@get/users/:id': {
    params: z.object({ id: z.number() }),
    response: z.object({
      id: z.number(),
      name: z.string(),
      email: z.string()
    })
  },
  
  // POST - Create a new user
  '@post/users': {
    body: z.object({
      name: z.string(),
      email: z.string().email()
    }),
    response: z.object({
      id: z.number(),
      name: z.string(),
      email: z.string()
    })
  },
  
  // PUT - Replace entire user
  '@put/users/:id': {
    params: z.object({ id: z.number() }),
    body: z.object({
      name: z.string(),
      email: z.string().email()
    }),
    response: z.object({
      id: z.number(),
      name: z.string(),
      email: z.string()
    })
  },
  
  // PATCH - Partially update user
  '@patch/users/:id': {
    params: z.object({ id: z.number() }),
    body: z.object({
      name: z.string().optional(),
      email: z.string().email().optional()
    }),
    response: z.object({
      id: z.number(),
      name: z.string(),
      email: z.string()
    })
  },
  
  // DELETE - Remove a user
  '@delete/users/:id': {
    params: z.object({ id: z.number() }),
    response: z.object({ success: z.boolean() })
  }
} as const;

const apiFetch = createFetch(api, 'https://api.example.com');

// Make requests with explicit methods
const user = await apiFetch('@get/users/:id', { params: { id: 123 } });
// GET https://api.example.com/users/123

const newUser = await apiFetch('@post/users', {
  body: { name: 'John Doe', email: 'john@example.com' }
});
// POST https://api.example.com/users

await apiFetch('@put/users/:id', {
  params: { id: 123 },
  body: { name: 'Jane Doe', email: 'jane@example.com' }
});
// PUT https://api.example.com/users/123

await apiFetch('@patch/users/:id', {
  params: { id: 123 },
  body: { name: 'Jane Smith' }
});
// PATCH https://api.example.com/users/123

await apiFetch('@delete/users/:id', { params: { id: 123 } });
// DELETE https://api.example.com/users/123

Equivalence Examples

The following path definitions are equivalent:

const api = {
  // These two are identical - both use GET
  '/users/:id': {
    params: z.object({ id: z.number() }),
    response: z.object({ id: z.number(), name: z.string() })
  },
  '@get/users/:id': {  // Same as above - @get is optional
    params: z.object({ id: z.number() }),
    response: z.object({ id: z.number(), name: z.string() })
  },
  
  // POST is inferred from body
  '/users': {
    body: z.object({ name: z.string(), email: z.string() }),
    response: z.object({ id: z.number(), name: z.string() })
  }
};

const apiFetch = createFetch(api, 'https://api.example.com');

// Both calls are equivalent - both use GET
const user1 = await apiFetch('/users/:id', { params: { id: 123 } });
const user2 = await apiFetch('@get/users/:id', { params: { id: 123 } });

// POST (inferred from body)
const newUser = await apiFetch('/users', {
  body: { name: 'John', email: 'john@example.com' }
});

Method inference rules:

  • Request has a bodyPOST
  • Request has no bodyGET
  • Method prefix specified → Uses that method

Method Prefix with Path Parameters

The method prefix works seamlessly with path parameters:

const api = {
  '@put/users/:userId/posts/:postId': {
    params: z.object({
      userId: z.number(),
      postId: z.number()
    }),
    body: z.object({ title: z.string(), content: z.string() }),
    response: z.object({ success: z.boolean() })
  }
};

const apiFetch = createFetch(api, 'https://api.example.com');

await apiFetch('@put/users/:userId/posts/:postId', {
  params: { userId: 123, postId: 456 },
  body: { title: 'Updated', content: 'New content' }
});
// PUT https://api.example.com/users/123/posts/456

Case Insensitive

Method prefixes are case-insensitive and converted to uppercase:

const api = {
  '@GET/users': { /* ... */ },
  '@get/posts': { /* ... */ },
  '@GeT/comments': { /* ... */ }
};

// All are treated as GET

Best Practices

  1. Be explicit with methods: Use method prefixes for clarity, especially for PUT, PATCH, and DELETE operations
  2. RESTful design: Follow REST conventions:
    • @get for retrieval
    • @post for creation
    • @put for full replacement
    • @patch for partial updates
    • @delete for removal
  3. Consistency: Choose either to always use method prefixes or rely on automatic detection - be consistent across your API schema

Advanced Usage

Shared Request Configuration

Use the third parameter of createFetch() to set shared options for all requests:

import { createFetch } from 'what-the-fetch';
import { z } from 'zod';

const api = {
  '/users/:id': {
    params: z.object({ id: z.number() }),
    response: z.object({ id: z.number(), name: z.string() })
  },
  '/posts': {
    response: z.array(z.object({ id: z.number(), title: z.string() }))
  }
};

// Create fetch with shared headers that apply to all requests
const apiFetch = createFetch(
  api,
  'https://api.example.com',
  {
    headers: {
      'Authorization': 'Bearer my-token',
      'X-API-Version': '2.0'
    },
    cache: 'no-cache'
  }
);

// All requests automatically include shared headers
const user = await apiFetch('/users/:id', { params: { id: 123 } });
const posts = await apiFetch('/posts');

// Override or add additional headers per request
const userWithCustomHeader = await apiFetch(
  '/users/:id',
  { params: { id: 456 } },
  {
    headers: {
      'X-Custom-Header': 'value' // Merged with shared headers
    }
  }
);

Multiple Endpoints with Shared Schemas

import { createFetch } from 'what-the-fetch';
import { z } from 'zod';

// Define reusable schemas
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string()
});

const PostSchema = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
  authorId: z.number()
});

// Use schemas in multiple endpoints
const api = {
  '/users/:id': {
    params: z.object({ id: z.number() }),
    response: UserSchema
  },
  '/users': {
    response: z.array(UserSchema)
  },
  '/posts/:id': {
    params: z.object({ id: z.number() }),
    response: PostSchema
  },
  '/users/:id/posts': {
    params: z.object({ id: z.number() }),
    response: z.array(PostSchema)
  }
};

const apiFetch = createFetch(api, 'https://api.example.com');

Building a Complete API Client

import { createFetch } from 'what-the-fetch';
import { z } from 'zod';

// Define comprehensive API schema
const api = {
  '/users': {
    // GET /users - List users with pagination
    query: z.object({
      page: z.number().optional(),
      limit: z.number().optional()
    }),
    response: z.object({
      users: z.array(z.object({
        id: z.number(),
        name: z.string()
      })),
      total: z.number()
    })
  },
  '/users/:id': {
    // GET /users/:id - Get user by ID
    params: z.object({ id: z.number() }),
    response: z.object({
      id: z.number(),
      name: z.string(),
      email: z.string(),
      createdAt: z.string()
    })
  },
  '/users/create': {
    // POST /users/create - Create new user
    body: z.object({
      name: z.string().min(3),
      email: z.string().email(),
      password: z.string().min(8)
    }),
    response: z.object({
      id: z.number(),
      name: z.string(),
      email: z.string()
    })
  },
  '/users/:id/update': {
    // PATCH /users/:id/update - Update user
    params: z.object({ id: z.number() }),
    body: z.object({
      name: z.string().min(3).optional(),
      email: z.string().email().optional()
    }),
    response: z.object({
      id: z.number(),
      name: z.string(),
      email: z.string()
    })
  }
} as const;

// Create client with shared configuration
class APIClient {
  private fetch;

  constructor(baseUrl: string, apiKey: string) {
    // Use shared headers for authentication
    this.fetch = createFetch(
      api,
      baseUrl,
      {
        headers: {
          'Authorization': `Bearer ${apiKey}`
        }
      }
    );
  }

  async getUsers(page = 1, limit = 10) {
    // No need to pass auth header - it's shared
    return this.fetch('/users', { query: { page, limit } });
  }

  async getUser(id: number) {
    return this.fetch('/users/:id', { params: { id } });
  }

  async createUser(data: { name: string; email: string; password: string }) {
    return this.fetch('/users/create', { body: data });
  }

  async updateUser(id: number, data: { name?: string; email?: string }) {
    return this.fetch('/users/:id/update', { params: { id }, body: data });
  }
}

// Usage
const client = new APIClient('https://api.example.com', 'your-api-key');
const users = await client.getUsers(1, 20);
const user = await client.getUser(123);

Standard Schema Support

what-the-fetch works with any schema library that implements Standard Schema:

Zod

import { z } from 'zod';

const api = {
  '/users/:id': {
    params: z.object({ id: z.number() }),
    response: z.object({ id: z.number(), name: z.string() })
  }
};

Valibot

import * as v from 'valibot';

const api = {
  '/users/:id': {
    params: v.object({ id: v.number() }),
    response: v.object({ id: v.number(), name: v.string() })
  }
};

ArkType

import { type } from 'arktype';

const api = {
  '/users/:id': {
    params: type({ id: 'number' }),
    response: type({ id: 'number', name: 'string' })
  }
};

Error Handling

HTTP Errors

what-the-fetch automatically throws an error for non-2xx status codes:

try {
  const user = await apiFetch('/users/:id', { params: { id: 999 } });
} catch (error) {
  console.error('HTTP error:', error.message);
  // → "HTTP error! status: 404"
}

Validation Errors

Schema validation errors are thrown when response doesn't match the schema:

const api = {
  '/users/:id': {
    params: z.object({ id: z.number() }),
    response: z.object({
      id: z.number(),
      name: z.string() // Required field
    })
  }
};

try {
  // If server returns { id: 123 } without name field
  const user = await apiFetch('/users/:id', { params: { id: 123 } });
} catch (error) {
  console.error('Validation error:', error);
  // Schema validation failed
}

TypeScript Features

Full Type Inference

const api = {
  '/users/:id': {
    params: z.object({ id: z.number() }),
    response: z.object({ id: z.number(), name: z.string() })
  }
} as const;

const apiFetch = createFetch(api, 'https://api.example.com');

// TypeScript infers everything:
const user = await apiFetch('/users/:id', {
  params: { id: 123 } // TypeScript knows params is required and id must be number
});
// TypeScript knows user is { id: number; name: string }

Autocomplete Support

When using what-the-fetch in TypeScript, you get full autocomplete for:

  • Available API paths
  • Required options (params, query, body)
  • Field names and types
  • Response properties

Compile-Time Validation

// ✅ This compiles
await apiFetch('/users/:id', { params: { id: 123 } });

// ❌ TypeScript error: Property 'params' is missing
await apiFetch('/users/:id', {});

// ❌ TypeScript error: Type 'string' is not assignable to type 'number'
await apiFetch('/users/:id', { params: { id: '123' } });

// ❌ TypeScript error: '/invalid' is not assignable to type '/users/:id' | ...
await apiFetch('/invalid', {});

Performance Considerations

what-the-fetch is designed to be performant:

  • Small bundle size: Minimal dependencies (only fast-url for URL building and Standard Schema spec)
  • Efficient URL building: Leverages fast-url library's optimized implementation
  • Type-level computation: Most type checking happens at compile time
  • Runtime validation: Only validates responses, not TypeScript types

Bundle Size

what-the-fetch adds minimal overhead to your bundle:

  • Core library: ~2KB minified + gzipped
  • Plus your choice of schema library (Zod, Valibot, etc.)

Browser and Runtime Compatibility

what-the-fetch works in all modern JavaScript environments:

  • ✅ Node.js 18+
  • ✅ Bun
  • ✅ Deno
  • ✅ Modern browsers (Chrome, Firefox, Safari, Edge)
  • ✅ React Native
  • ✅ Electron

Requirements:

  • fetch API support (built-in in modern environments)
  • Standard Schema-compatible validation library