Recipe · Node and platforms

How do I type a Cloudflare Workers fetch handler and its bindings in JavaScript?

  • Cloudflare Workers
  • Cloudflare Workers
  • @satisfies
  • ExportedHandler
  • Checked with TypeScript 5.9

The problem My Cloudflare Workers project is plain JavaScript. How do I get request, env and ctx typed in the fetch handler, and how do I tell tsc which bindings env has?

The short answer

Describe the bindings from wrangler.jsonc in a @typedef Env, and put @satisfies {ExportedHandler<Env>} on the exported object. The example uses the Cloudflare Workers setup, with @cloudflare/workers-types in types:

prices.js prices.ts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
/**
 * The bindings declared in wrangler.jsonc.
 * @typedef {object} Env
 * @property {KVNamespace} PRICES
 * @property {string} CURRENCY
 */

export default /** @satisfies {ExportedHandler<Env>} */ ({
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const sku = url.searchParams.get("sku");
    if (!sku) return new Response("Missing sku", { status: 400 });

    const cents = await env.PRICES.get(sku);
    if (cents === null) return new Response("Not found", { status: 404 });

    ctx.waitUntil(Promise.resolve(console.log(`price lookup ${sku}`)));
    return Response.json({ sku, cents: Number(cents), currency: env.CURRENCY });
  },
});
interface Env {
  PRICES: KVNamespace;
  CURRENCY: string;
}

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const sku = url.searchParams.get("sku");
    if (!sku) return new Response("Missing sku", { status: 400 });

    const cents = await env.PRICES.get(sku);
    if (cents === null) return new Response("Not found", { status: 404 });

    ctx.waitUntil(Promise.resolve(console.log(`price lookup ${sku}`)));
    return Response.json({ sku, cents: Number(cents), currency: env.CURRENCY });
  },
} satisfies ExportedHandler<Env>;
A fetch handler checked against ExportedHandler with its bindings.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2024",
    "module": "esnext",
    "moduleResolution": "bundler",
    "lib": ["es2024"],
    "types": ["@cloudflare/workers-types"],
    "skipLibCheck": true
  },
  "include": ["*.js"]
}

request is a Request, env has PRICES and CURRENCY, and ctx has waitUntil. The handler must return a Response (or a promise of one), and env.PRICES.get() returns Promise<string | null>.

Why it works

ExportedHandler<Env> is the type @cloudflare/workers-types gives the default export of a module that handles requests: an object whose fetch method receives the request, your bindings and an execution context. The type argument is your Env, so every binding is checked by name and type. @satisfies (TypeScript 5.0 and later) types the method’s parameters from that, without changing the export.

The Env typedef is a promise, not a check: it must match the bindings in wrangler.jsonc, and nothing compares the two unless you generate the types with wrangler types.

The same thing in TypeScript

The TypeScript equivalent tab declares interface Env and ends the object with satisfies ExportedHandler<Env>, the operator form of the same check.

What goes wrong

A misspelled binding is caught with a suggestion (TS2551), a value that may be null must be checked (TS18047), and returning a string instead of a Response fails on the method itself (TS2322):

prices.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: prices.js(8,9): error TS2322: Type '(request: Request<any, IncomingRequestCfProperties<any>>, env: Env) => Promise<string>' is not assignable to type 'ExportedHandlerFetchHandler<Env, any, any>'. Type 'Promise<string>' is not assignable to type 'Response | Promise<Response>'. Type 'Promise<string>' is not assignable to type 'Promise<Response>'. Type 'string' is not assignable to type 'Response'. prices.js(10,32): error TS2551: Property 'PRICE' does not exist on type 'Env'. Did you mean 'PRICES'? prices.js(13,15): error TS18047: 'cents' is possibly 'null'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. prices.js(8,9): error TS2322: Type '(request: Request<any, IncomingRequestCfProperties<any>>, env: Env) => Promise<string>' is not assignable to type 'ExportedHandlerFetchHandler<Env, any, any>'. Type 'Promise<string>' is not assignable to type 'Response | Promise<Response>'. Type 'Promise<string>' is not assignable to type 'Promise<Response>'. Type 'string' is not assignable to type 'Response'. prices.js(10,32): error TS2551: Property 'PRICE' does not exist on type 'Env'. Did you mean 'PRICES'? prices.js(13,15): error TS18047: 'cents' is possibly 'null'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. prices.js(8,9): error TS2322: Type '(request: Request<any, IncomingRequestCfProperties<any>>, env: Env) => Promise<string>' is not assignable to type 'ExportedHandlerFetchHandler<Env, any, any>'. Type 'Promise<string>' is not assignable to type 'Promise<Response> | Response'. Type 'Promise<string>' is not assignable to type 'Promise<Response>'. Type 'string' is not assignable to type 'Response'. prices.js(10,32): error TS2551: Property 'PRICE' does not exist on type 'Env'. Did you mean 'PRICES'? prices.js(13,15): error TS18047: 'cents' is possibly 'null'. checked 21 October 2026
/**
 * @typedef {object} Env
 * @property {KVNamespace} PRICES
 */

export default /** @satisfies {ExportedHandler<Env>} */ ({
  async fetch(request, env) {error TS2322: Type '(request: Request<any, IncomingRequestCfProperties<any>>, env: Env) => Promise<string>' is not assignable to type 'ExportedHandlerFetchHandler<Env, any, any>'.
  Type 'Promise<string>' is not assignable to type 'Response | Promise<Response>'.
    Type 'Promise<string>' is not assignable to type 'Promise<Response>'.
      Type 'string' is not assignable to type 'Response'.
    const fallback = await env.PRICE.get("default");error TS2551: Property 'PRICE' does not exist on type 'Env'. Did you mean 'PRICES'?
    const cents = await env.PRICES.get(new URL(request.url).pathname);
    return `${cents.length} digits, fallback ${fallback}`;error TS18047: 'cents' is possibly 'null'.
  },
});
A misspelled binding, a value that may be null, and a string instead of a Response.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2024",
    "module": "esnext",
    "moduleResolution": "bundler",
    "lib": ["es2024"],
    "types": ["@cloudflare/workers-types"],
    "skipLibCheck": true
  },
  "include": ["*.js"]
}

Gotchas

  • skipLibCheck stays on. The setup page’s config sets it, so tsc does not spend time checking the large runtime declaration files.
  • No @types/node by default. The runtime is not Node.js; Node.js APIs need the compatibility flag in your Wrangler config and their own types, per Cloudflare’s documentation.
  • Secrets are bindings too. Per Cloudflare’s documentation, a secret set with wrangler secret put arrives on env like a variable; add it to Env as a string.

Questions

Should I use wrangler types instead of writing the Env typedef?

Cloudflare's documentation recommends wrangler types, which writes a declaration file with the runtime's types and an Env interface built from your Wrangler config. The JSDoc typedef here does the job by hand and works with the @cloudflare/workers-types package; the setup page covers both.

Why @satisfies and not @type on the exported object?

@satisfies checks the object against ExportedHandler<Env> and types the method's parameters, while the export keeps its own type. @type also works for an object of methods; the difference matters more for single functions, as the Express recipe shows.

Sources

Last reviewed 21 October 2026.