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:
/**
* 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>;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):
/**
* @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'.
},
});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
skipLibCheckstays on. The setup page’s config sets it, so tsc does not spend time checking the large runtime declaration files.- No
@types/nodeby 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 putarrives onenvlike a variable; add it toEnvas astring.
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
- Fetch Handler · Cloudflare docs
- TypeScript (types for the runtime and wrangler types) · Cloudflare docs
- @cloudflare/workers-types · npm
Last reviewed 21 October 2026.