🌐what-the-fetch

Getting Started

Quick start guide to install and use what-the-fetch in your JavaScript or TypeScript project

Installation

Install what-the-fetch using your preferred package manager:

bun add what-the-fetch
pnpm add what-the-fetch
yarn add what-the-fetch
npm install what-the-fetch
deno add jsr:@hckhanh/what-the-fetch

You'll also need a schema validation library that implements Standard Schema:

npm install zod
npm install valibot
npm install arktype

TypeScript Support

what-the-fetch is written in TypeScript and includes type definitions out of the box. No need to install separate @types packages!

Basic Usage

Importing

import { createFetch } from 'what-the-fetch';
import { z } from 'zod'; // or your preferred schema library

Quick Examples

Here are some common use cases to get you started:

Simple GET Request

Define a schema and make a type-safe request:

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(),
      email: z.string()
    })
  }
};

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

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

With Query Parameters

Add query parameters to your request:

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: 20, sort: 'name' }
});
// users is typed as Array<{ id: number; name: string }>

POST Request with Body

Send data in the request 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'
  }
});
// newUser is typed as { id: number; name: string; email: string }

Using HTTP Methods

what-the-fetch automatically infers HTTP methods, but you can also specify them explicitly using the @method prefix:

const api = {
  // Automatic inference - these are equivalent
  '/users/:id': {  // Uses GET (no body)
    params: z.object({ id: z.number() }),
    response: z.object({ id: z.number(), name: z.string() })
  },
  '@get/users/:id': {  // Explicit GET - same as above
    params: z.object({ id: z.number() }),
    response: z.object({ id: z.number(), name: z.string() })
  },
  
  // POST is inferred when body is present
  '/users': {  // Uses POST (has body)
    body: z.object({ name: z.string(), email: z.string().email() }),
    response: z.object({ id: z.number(), name: z.string() })
  },
  
  // Explicit methods for other HTTP verbs
  '@put/users/:id': {
    params: z.object({ id: z.number() }),
    body: z.object({ name: z.string() }),
    response: z.object({ id: z.number(), name: z.string() })
  },
  '@patch/users/:id': {
    params: z.object({ id: z.number() }),
    body: z.object({ name: z.string().optional() }),
    response: z.object({ id: z.number(), name: z.string() })
  },
  '@delete/users/:id': {
    params: z.object({ id: z.number() }),
    response: z.object({ success: z.boolean() })
  }
};

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

// These two 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 created = await apiFetch('/users', { body: { name: 'John', email: 'john@example.com' } });

// Explicit methods
await apiFetch('@put/users/:id', { params: { id: 123 }, body: { name: 'Jane' } });
await apiFetch('@patch/users/:id', { params: { id: 123 }, body: { name: 'Jane Doe' } });
await apiFetch('@delete/users/:id', { params: { id: 123 } });

Method Inference

The @method prefix is optional. Without it, what-the-fetch automatically uses POST for requests with a body and GET for requests without a body. Both /users/:id and @get/users/:id are equivalent and result in the same GET request.

Step-by-Step Tutorial

Let's build a complete type-safe API client using what-the-fetch:

Define Your API Schema

Start by defining schemas for all your API endpoints:

import { z } from 'zod';

const api = {
  '/posts': {
    // GET /posts - List posts with filters
    query: z.object({
      page: z.number().optional(),
      limit: z.number().optional(),
      tag: z.string().optional()
    }),
    response: z.array(z.object({
      id: z.number(),
      title: z.string(),
      excerpt: z.string()
    }))
  },
  '/posts/:id': {
    // GET /posts/:id - Get post by ID
    params: z.object({ id: z.number() }),
    response: z.object({
      id: z.number(),
      title: z.string(),
      content: z.string(),
      author: z.string()
    })
  },
  '/posts/create': {
    // POST /posts/create - Create new post
    body: z.object({
      title: z.string(),
      content: z.string()
    }),
    response: z.object({
      id: z.number(),
      title: z.string(),
      content: z.string()
    })
  }
} as const;

Create the Fetch Function

Use createFetch to build your typed API client:

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

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

You can also provide shared options that apply to all requests:

const apiFetch = createFetch(
  api,
  'https://api.blog.com',
  {
    headers: {
      'Authorization': 'Bearer your-api-key'
    }
  }
);

Make Type-Safe Requests

Now you can make fully typed requests with autocomplete:

// Fetch posts with query parameters
const posts = await apiFetch('/posts', {
  query: { page: 2, limit: 20, tag: 'javascript' }
});
// posts is typed as Array<{ id: number; title: string; excerpt: string }>

// Fetch a single post
const post = await apiFetch('/posts/:id', {
  params: { id: 42 }
});
// post is typed as { id: number; title: string; content: string; author: string }

Add Custom Headers (Optional)

You can pass custom headers using the third parameter:

const post = await apiFetch(
  '/posts/:id',
  { params: { id: 42 } },
  {
    headers: {
      'Authorization': 'Bearer your-api-key',
      'X-Custom-Header': 'value'
    }
  }
);

Common Patterns

Using Different Schema Libraries

what-the-fetch works with any Standard Schema implementation:

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() })
  }
};

const apiFetch = createFetch(api, 'https://api.example.com');
import { createFetch } from 'what-the-fetch';
import * as v from 'valibot';

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

const apiFetch = createFetch(api, 'https://api.example.com');
import { createFetch } from 'what-the-fetch';
import { type } from 'arktype';

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

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

Combining Path and Query Parameters

Mix path parameters with 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 }
});
// URL: https://api.example.com/users/123/posts?limit=10&published=true

Custom Request Configuration

Pass additional fetch options as the third parameter:

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

Reusing the Same Fetch Function

Create one fetch function and reuse it throughout your application:

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

// users.ts
import { apiFetch } from './api';

const user = await apiFetch('/users/:id', { params: { id: 123 } });

// posts.ts
import { apiFetch } from './api';

const posts = await apiFetch('/posts', { query: { limit: 10 } });

Shared Configuration

Use shared configuration to set common options for all requests:

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

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

// You can override or add headers per request
const post = await apiFetch(
  '/posts/:id',
  { params: { id: 42 } },
  {
    headers: {
      'X-Custom-Header': 'value' // Merged with shared headers
    }
  }
);

Schema Validation Benefits

what-the-fetch validates both requests and responses automatically:

Parameterized Paths Require Schema

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

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

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

// ✅ Valid request
const user = await apiFetch('/users', {
  body: {
    name: 'John Doe',
    email: 'john@example.com'
  }
});

// ❌ This would fail at runtime with validation error
try {
  await apiFetch('/users', {
    body: {
      name: 'Jo', // Too short
      email: 'invalid-email' // Invalid format
    }
  });
} catch (error) {
  console.error('Validation failed:', error);
}

Error Handling

what-the-fetch provides clear error messages for both validation and HTTP errors:

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');

try {
  const user = await apiFetch('/users/:id', { params: { id: 123 } });
} catch (error) {
  // Could be:
  // 1. HTTP error (status !== 2xx)
  // 2. Schema validation error
  // 3. Network error
  console.error('Request failed:', error);
}

Handling HTTP Errors

what-the-fetch automatically throws on non-2xx status codes:

try {
  const user = await apiFetch('/users/:id', { params: { id: 999 } });
} catch (error) {
  if (error.message.includes('HTTP error')) {
    console.error('Request failed with status:', error);
    // Handle 404, 500, etc.
  }
}

Handling Validation Errors

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

try {
  const user = await apiFetch('/users/:id', { params: { id: 123 } });
} catch (error) {
  // Validation error if response doesn't match schema
  console.error('Invalid response:', error);
}

Type Safety

With TypeScript, most parameter errors are caught at compile time, preventing runtime errors before they happen!