npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tahanabavi/typefetch

v1.5.7

Published

A strongly typed TypeScript HTTP client powered by Zod contracts, middleware, retries, auth, mock data, response wrappers, and validation.

Readme

TypeFetch

TypeFetch is a strongly typed HTTP client for TypeScript projects, built around Zod contracts.

Define your API once with Zod schemas, then TypeFetch generates a fully typed client with request validation, response validation, middleware support, retries, mock data, response wrappers, token handling, and structured request support.

const user = await api.user.getUser({
  path: { id: "123" },
});

Features

  • End-to-end TypeScript inference from Zod schemas
  • Runtime request and response validation
  • Structured request model: { path, query, body, headers }
  • Automatic path parameter injection
  • Automatic query string generation
  • JSON and form-data request bodies
  • Middleware pipeline
  • Built-in retry engine with backoff strategies
  • Timeout and AbortController support
  • Static tokens and dynamic token providers
  • Mock mode for development and testing
  • Response wrapper support for API envelopes
  • Normalized error handling with RichError
  • Field-level encryption middleware
  • Backward-compatible flat request schemas

Installation

npm install @tahanabavi/typefetch zod

Or with Yarn:

yarn add @tahanabavi/typefetch zod

Or with pnpm:

pnpm add @tahanabavi/typefetch zod

Quick Start

import { z } from "zod";
import { ApiClient } from "@tahanabavi/typefetch";

const contracts = {
  user: {
    getUser: {
      method: "GET",
      path: "/users/:id",
      auth: true,
      request: z.object({
        path: z.object({
          id: z.string(),
        }),
      }),
      response: z.object({
        id: z.string(),
        name: z.string(),
      }),
    },
  },
} as const;

const client = new ApiClient(
  {
    baseUrl: "https://api.example.com",
    tokenProvider: async () => "your-token",
  },
  contracts,
);

client.init();

const api = client.modules;

const user = await api.user.getUser({
  path: { id: "123" },
});

console.log(user.name);

Defining API Contracts

A TypeFetch contract is a grouped object of modules and endpoints.

const contracts = {
  user: {
    getUser: {
      method: "GET",
      path: "/users/:id",
      request: z.object({
        path: z.object({
          id: z.string(),
        }),
      }),
      response: z.object({
        id: z.string(),
        name: z.string(),
      }),
    },

    createUser: {
      method: "POST",
      path: "/users",
      request: z.object({
        body: z.object({
          name: z.string(),
          email: z.string().email(),
        }),
      }),
      response: z.object({
        id: z.string(),
        name: z.string(),
        email: z.string(),
      }),
    },
  },
} as const;

After calling client.init(), TypeFetch generates typed methods:

await api.user.getUser({
  path: { id: "123" },
});

await api.user.createUser({
  body: {
    name: "Taha",
    email: "[email protected]",
  },
});

Structured Request Model

The recommended request shape is:

z.object({
  path: z.object({}).optional(),
  query: z.object({}).optional(),
  body: z.any().optional(),
  headers: z.record(z.string(), z.string()).optional(),
});

Each section has a specific purpose.

| Key | Purpose | | --------- | ------------------------------------------ | | path | Replaces path parameters like /users/:id | | query | Builds the query string | | body | Sent as the JSON or form-data body | | headers | Per-request headers |

Example:

const contracts = {
  user: {
    updateUser: {
      method: "PATCH",
      path: "/users/:id",
      request: z.object({
        path: z.object({
          id: z.string(),
        }),
        query: z.object({
          notify: z.boolean().optional(),
        }).optional(),
        body: z.object({
          name: z.string(),
        }),
        headers: z.record(z.string(), z.string()).optional(),
      }),
      response: z.object({
        id: z.string(),
        name: z.string(),
      }),
    },
  },
} as const;

Usage:

await api.user.updateUser({
  path: { id: "123" },
  query: { notify: true },
  headers: {
    "X-Tenant": "main",
  },
  body: {
    name: "Taha",
  },
});

TypeFetch sends:

PATCH /users/123?notify=true

With body:

{
  "name": "Taha"
}

Request Schema Helper

You can use makeRequestSchema to make structured request schemas easier to write.

import { z } from "zod";
import { makeRequestSchema } from "@tahanabavi/typefetch";

const updateUserRequest = makeRequestSchema<
  { id: z.ZodString },
  { notify: z.ZodOptional<z.ZodBoolean> },
  z.ZodObject<{
    name: z.ZodString;
  }>
>()({
  path: z.object({
    id: z.string(),
  }),
  query: z.object({
    notify: z.boolean().optional(),
  }),
  body: z.object({
    name: z.string(),
  }),
  headers: z.record(z.string(), z.string()).optional(),
});

Use it inside an endpoint:

const contracts = {
  user: {
    updateUser: {
      method: "PATCH",
      path: "/users/:id",
      request: updateUserRequest,
      response: z.object({
        id: z.string(),
        name: z.string(),
      }),
    },
  },
} as const;

Backward Compatibility

Flat request schemas are still supported.

const contracts = {
  user: {
    createUser: {
      method: "POST",
      path: "/users",
      request: z.object({
        name: z.string(),
      }),
      response: z.object({
        id: z.string(),
        name: z.string(),
      }),
    },
  },
} as const;

Usage:

await api.user.createUser({
  name: "Taha",
});

For non-GET requests, the full flat input is sent as the JSON body.

For GET requests, flat input is validated but no body is sent.


Creating the Client

import { ApiClient } from "@tahanabavi/typefetch";

const client = new ApiClient(
  {
    baseUrl: "https://api.example.com",
  },
  contracts,
);

client.init();

const api = client.modules;

Always call client.init() before using client.modules.


Client Configuration

const client = new ApiClient(
  {
    baseUrl: "https://api.example.com",
    token: "static-token",
    tokenProvider: async () => "dynamic-token",
    useMockData: false,
    mockDelay: {
      min: 200,
      max: 1000,
    },
  },
  contracts,
);

| Option | Type | Description | | --------------- | --------------------------------- | ---------------------- | | baseUrl | string | Base API URL | | token | string | Static bearer token | | tokenProvider | () => string \| Promise<string> | Dynamic token provider | | useMockData | boolean | Enables mock mode | | mockDelay | { min: number; max: number } | Simulated mock latency |

When both token and tokenProvider are provided, tokenProvider takes priority.


Authentication

Set auth: true on endpoints that require an authorization token.

const contracts = {
  user: {
    getProfile: {
      method: "GET",
      path: "/profile",
      auth: true,
      request: z.object({}),
      response: z.object({
        id: z.string(),
        name: z.string(),
      }),
    },
  },
} as const;

Use a static token:

const client = new ApiClient(
  {
    baseUrl: "https://api.example.com",
    token: "my-token",
  },
  contracts,
);

Or use a dynamic token provider:

const client = new ApiClient(
  {
    baseUrl: "https://api.example.com",
    tokenProvider: async () => {
      return localStorage.getItem("token") ?? "";
    },
  },
  contracts,
);

You can also set the token provider later:

client.setTokenProvider(async () => "new-token");

Middleware System

TypeFetch supports middleware for logging, authentication, caching, retries, encryption, and custom request behavior.

client.use(async (ctx, next) => {
  console.log("Request:", ctx.url);

  const response = await next();

  console.log("Response:", response.status);

  return response;
});

Middlewares run in registration order before the request, then unwind in reverse order after the response.

client.use(firstMiddleware);
client.use(secondMiddleware);

Execution flow:

firstMiddleware before
secondMiddleware before
fetch
secondMiddleware after
firstMiddleware after

Built-in Middlewares

Depending on how you export your middlewares, they can be registered directly or as factories.

Direct middleware example:

client.use(loggingMiddleware, {
  debug: true,
  logRequest: true,
  logResponse: true,
});

Factory middleware example:

client.use(cacheMiddleware({ ttl: 60_000 }));
client.use(retryMiddleware({ maxRetries: 3, delay: 300 }));

Retry Engine

TypeFetch includes a built-in retry engine on the client.

client.setRetryConfig({
  maxRetries: 3,
  backoff: "exponential",
  retryCondition: (error, attempt) => {
    return error.status !== undefined && error.status >= 500;
  },
});

Supported backoff strategies:

| Strategy | Behavior | | ------------- | ------------------------------ | | fixed | Same delay each retry | | linear | Delay increases linearly | | exponential | Delay doubles after each retry |

Example:

client.setRetryConfig({
  maxRetries: 3,
  backoff: "fixed",
});

Timeout and Abort Support

Each request can receive per-call options.

await api.user.getUser(
  {
    path: { id: "123" },
  },
  {
    timeout: 5000,
  },
);

You can also pass an external AbortSignal.

const controller = new AbortController();

await api.user.getUser(
  {
    path: { id: "123" },
  },
  {
    signal: controller.signal,
  },
);

controller.abort();

Mock Mode

Mock mode lets you return endpoint-level mock data instead of calling the network.

const contracts = {
  user: {
    getUser: {
      method: "GET",
      path: "/users/:id",
      request: z.object({
        path: z.object({
          id: z.string(),
        }),
      }),
      response: z.object({
        id: z.string(),
        name: z.string(),
      }),
      mockData: {
        id: "mock-1",
        name: "Mock User",
      },
    },
  },
} as const;

Enable mock mode:

client.setMockMode(true, {
  min: 200,
  max: 1000,
});

Dynamic mock data is also supported:

mockData: () => ({
  id: crypto.randomUUID(),
  name: "Dynamic Mock User",
});

Mock responses are still validated against the endpoint response schema.


Response Wrappers

Many APIs return wrapped responses.

{
  "success": true,
  "data": {
    "id": "123",
    "name": "Taha"
  },
  "timestamp": "2026-01-01T00:00:00.000Z"
}

TypeFetch can validate and unwrap these responses.

import { z } from "zod";

client.setResponseWrapper((successResponse) =>
  z.union([
    z.object({
      success: z.literal(true),
      data: successResponse,
      timestamp: z.string().optional(),
      requestId: z.string().optional(),
    }),
    z.object({
      success: z.literal(false),
      message: z.string(),
      code: z.number().optional(),
      timestamp: z.string().optional(),
      requestId: z.string().optional(),
    }),
  ]),
);

Successful responses return only data.

Failed wrapped responses throw RichError.


Error Handling

TypeFetch normalizes errors into RichError.

client.onError((error) => {
  console.error(error.message);
  console.error(error.status);
  console.error(error.code);
});

RichError may include:

{
  message: string;
  status?: number;
  code?: string;
  title?: string;
  detail?: string;
  errors?: Record<string, string[]>;
}

Handled error types include:

  • HTTP errors
  • Validation errors
  • Wrapped API errors
  • Missing token errors
  • Network errors
  • Timeout errors
  • Retry exhaustion

Example:

try {
  await api.user.getUser({
    path: { id: "missing" },
  });
} catch (error) {
  if (error instanceof RichError) {
    console.error(error.status, error.message);
  }
}

File Uploads

Set bodyType: "form-data" on an endpoint.

const contracts = {
  user: {
    uploadAvatar: {
      method: "POST",
      path: "/users/:id/avatar",
      bodyType: "form-data",
      request: z.object({
        path: z.object({
          id: z.string(),
        }),
        body: z.object({
          file: z.instanceof(File),
          alt: z.string().optional(),
        }),
      }),
      response: z.object({
        uploaded: z.boolean(),
      }),
    },
  },
} as const;

Usage:

await api.user.uploadAvatar({
  path: { id: "123" },
  body: {
    file,
    alt: "Profile avatar",
  },
});

When using form-data, TypeFetch does not force the Content-Type: application/json header.


Encryption Middleware

TypeFetch includes optional field-level encryption through encryptionMiddleware.

It can:

  • Encrypt selected request body fields
  • Decrypt selected response fields
  • Process deeply nested objects
  • Process arrays
  • Use different encryption methods per field
  • Support custom encryption and decryption handlers

Supported methods:

type EncryptionMethod = "AES" | "DES" | "RSA" | "Base64" | "Custom";

Registering the Middleware

import { encryptionMiddleware } from "@tahanabavi/typefetch/middlewares";

client.use(encryptionMiddleware, {
  keyProvider: async () => ({
    type: "symmetric",
    key: "my-secret-key",
  }),
});

Endpoint Encryption Config

const contracts = {
  secure: {
    createSecret: {
      method: "POST",
      path: "/secure",
      request: z.object({
        body: z.object({
          secret: z.string(),
          profile: z.object({
            pin: z.string(),
          }),
        }),
      }),
      response: z.object({
        id: z.string(),
        token: z.string(),
      }),
      encryption: {
        method: "AES",
        request: {
          secret: true,
          profile: {
            pin: "Base64",
          },
        },
        response: {
          token: true,
        },
      },
    },
  },
} as const;

Usage:

await api.secure.createSecret({
  body: {
    secret: "private-value",
    profile: {
      pin: "1234",
    },
  },
});

Before the request is sent:

  • secret is encrypted with AES
  • profile.pin is encoded with Base64

After the response is received:

  • token is decrypted with AES

Separate Request and Response Methods

encryption: {
  method: {
    request: "RSA",
    response: "AES",
  },
  request: {
    password: true,
  },
  response: {
    token: true,
  },
}

Custom Encryption

client.use(encryptionMiddleware, {
  keyProvider: async () => ({
    type: "symmetric",
    key: "custom-key",
  }),
  customHandlers: {
    encrypt: async (value, key) => {
      return `encrypted:${value}`;
    },
    decrypt: async (value, key) => {
      return value.replace("encrypted:", "");
    },
  },
});

Endpoint config:

encryption: {
  method: "Custom",
  request: {
    secret: true,
  },
  response: {
    token: true,
  },
}

Fail-Closed Behavior

By default, encryption should fail closed.

That means if request encryption fails, the request is not sent as plaintext.

client.use(encryptionMiddleware, {
  keyProvider: async () => ({
    type: "symmetric",
    key: "secret",
  }),
  failClosed: true,
});

For debugging, you may disable fail-closed behavior:

client.use(encryptionMiddleware, {
  keyProvider: async () => ({
    type: "symmetric",
    key: "secret",
  }),
  failClosed: false,
});

Use failClosed: false carefully.


Encryption Maps

Encryption maps describe which fields should be transformed.

encryption: {
  method: "AES",
  request: {
    password: true,
    profile: {
      ssn: true,
    },
    metadata: {
      publicValue: false,
    },
  },
}

Map values:

| Value | Behavior | | ---------- | ------------------------------------ | | true | Encrypt/decrypt using default method | | false | Skip field | | "AES" | Use AES for this field | | "DES" | Use DES for this field | | "RSA" | Use RSA for this field | | "Base64" | Use Base64 for this field | | "Custom" | Use custom handler | | { ... } | Recursively process object | | [ ... ] | Process array items |

Array example:

encryption: {
  method: "AES",
  request: {
    users: [
      {
        password: true,
      },
    ],
  },
}

This applies the first array map to every item unless an index-specific map exists.


Endpoint-Level Headers

Headers can be defined on the endpoint.

const contracts = {
  user: {
    createUser: {
      method: "POST",
      path: "/users",
      request: z.object({
        body: z.object({
          name: z.string(),
        }),
      }),
      response: z.object({
        id: z.string(),
        name: z.string(),
      }),
      headers: {
        "X-App": "typefetch",
      },
    },
  },
} as const;

Headers can also be generated from input:

headers: (input) => ({
  "X-Tenant": input.headers?.["X-Tenant"] ?? "default",
});

Per-request headers can be passed through the structured request input:

await api.user.createUser({
  headers: {
    "X-Request-ID": "req-123",
  },
  body: {
    name: "Taha",
  },
});

Custom Middleware

A middleware receives:

type Middleware = (
  ctx: MiddlewareContext,
  next: () => Promise<Response>,
  options?: unknown,
) => Promise<Response>;

Example:

client.use(async (ctx, next) => {
  const startedAt = Date.now();

  const response = await next();

  console.log(`${ctx.init.method} ${ctx.url} took ${Date.now() - startedAt}ms`);

  return response;
});

With options:

const timingMiddleware = async (ctx, next, options) => {
  const response = await next();

  if (options?.debug) {
    console.log("Timing middleware enabled");
  }

  return response;
};

client.use(timingMiddleware, {
  debug: true,
});

Type Inference

TypeFetch infers endpoint input and output types automatically from Zod schemas.

const user = await api.user.getUser({
  path: {
    id: "123",
  },
});

user is inferred as:

{
  id: string;
  name: string;
}

Invalid input fails at compile time when possible and at runtime through Zod validation.


Recommended Project Structure

src/
  api/
    contracts.ts
    client.ts
  middlewares/
    logging.ts
    retry.ts
    cache.ts
    auth.ts
    encryption.ts

Example:

// api/client.ts
import { ApiClient } from "@tahanabavi/typefetch";
import { contracts } from "./contracts";

export const client = new ApiClient(
  {
    baseUrl: import.meta.env.VITE_API_URL,
    tokenProvider: async () => localStorage.getItem("token") ?? "",
  },
  contracts,
);

client.init();

export const api = client.modules;

Testing

TypeFetch is designed to be easy to test with mocked fetch.

Example:

global.fetch = vi.fn();

(fetch as any).mockResolvedValueOnce({
  ok: true,
  json: async () => ({
    id: "1",
    name: "Taha",
  }),
});

const user = await api.user.getUser({
  path: {
    id: "1",
  },
});

expect(user.name).toBe("Taha");

Recommended test coverage:

  • Request validation
  • Response validation
  • Path parameter handling
  • Query string generation
  • JSON body serialization
  • Header merging
  • Auth token injection
  • Token provider behavior
  • Middleware execution order
  • Retry behavior
  • Timeout and abort behavior
  • Mock mode
  • Response wrappers
  • Error normalization
  • Encryption middleware

Notes

  • Always call client.init() before using client.modules.
  • All request inputs are validated with Zod.
  • All successful responses are validated with Zod.
  • Structured request schemas are recommended for new APIs.
  • Flat request schemas are still supported for backward compatibility.
  • GET requests do not send a body.
  • form-data endpoints should use bodyType: "form-data".
  • Auth tokens are only required for endpoints with auth: true.
  • Mock data bypasses network calls but still validates responses.

License

MIT