Address
aadhar.shGarageThe config that types itself

The config that types itself

A Worker config is data, so every binding name in it is a string that nothing checks. Wrangler ships a hidden flag that makes the config a TypeScript program instead, and then infers the Worker's Env from it. This site converted one Worker to find out what that buys: the upload came out byte-identical, a plain-text variable arrived in the type as its own value, and the Durable Object class type went the other way and got worse.

The thing a config cannot do

Here is the whole problem in one line. This site's Worker declares 21 bindings across two config files, and its request handler types them like this:

@typedef {(request: Request, env: any, ctx: any, url: URL) => Response} RouteHandler

any. Every KV namespace, every D1 database, the R2 bucket, the Durable Object, all of it. Rename a binding in wrangler.jsonc and nothing in the tree goes red; the failure arrives at runtime as a property read on undefined, in production, on whichever route touched it first.

That is a property of the format rather than an oversight. JSON cannot export a type. The usual repair is wrangler types, which reads the config and writes a .d.ts, and it works: run against this site it produces the full interface, Durable Object generics and all. It also prints this when it finishes.

Remember to rerun 'wrangler types' after you change your wrangler.jsonc file.

A generated file plus a reminder is a snapshot plus a promise. The snapshot is correct on the day it is taken and silently wrong afterwards, which is the same shape as every committed artifact this site has replaced with a build step.

What the flag actually is

Wrangler 4.125.0 loads a cloudflare.config.ts, and an optional wrangler.config.ts beside it, behind --x-new-config. The flag is marked hidden, so it appears in no help output. The package behind it says of itself that it is "not yet stable enough for external use", and searching for documentation on the day this page was written found none.

The two filenames are the reverse of the guess. cloudflare.config.ts holds the Worker: name, compatibility date, entrypoint, triggers, bindings, exports, observability, placement, limits. wrangler.config.ts holds the tooling: the build command, the assets directory, dev settings, tsconfig, source maps. Put a Worker field in the tooling file and the error names the mistake and the fix.

Build a config and watch three things move

Toggle bindings on the left. The middle pane is the config you would write. The right pane switches between what the platform receives and what your env becomes. Every row below was measured through a real Worker: the resolved JSON is what wrangler build --x-cf-build-output wrote, and each type is what the compiler reported for that key.

bindings
exports
cloudflare.config.tswhat you write

The resolved pane is the Build Output Specification wrangler writes to .cloudflare/output/v0/workers/default/config.json, which is the readable answer to "what did my config actually become". The Env pane is what tsc reported for each key of the generated interface.

A variable arrives in the type as its own value

Twelve binding kinds went through the compiler. Most land where you would guess, and three do something a JSON config structurally cannot.

The interesting rows are the last three. bindings.text("default") gives env.AI_GATEWAY the type "default", so the variable's value is in the type and a comparison against a string that was never configured is a compile error. bindings.json({ dcz: true, tier: 2 }) comes back as { dcz: true; tier: number }, where the boolean narrows to a literal and the number widens, which is ordinary TypeScript inference showing through. And bindings.secret() types the secret as string, so a secret you forgot to set is a name the compiler already knows about.

The row that goes backwards

The Durable Object type gets worse, and this is the finding worth carrying away from the page. Against the existing JSON config, wrangler types threads the class through:

COUNTER: DurableObjectNamespace<import("../../.build/src/worker/index").Counter>;
BOOKING_WORKFLOW: Workflow<Parameters<import("...").BookingWorkflow['run']>[0]['payload']>;

Under inference, the same binding comes back as DurableObjectNamespace<undefined>, so every stub method call is untyped. That was measured twice, because the first result looked like holding it wrong: the type stayed undefined with the entrypoint given as a path string, and again with the entrypoint given as a module through the { type: "cf-worker" } import attribute the package documents for exactly this purpose. Both forms boot a Worker and serve requests. Neither carries the class into the namespace.

So the honest scoreboard is split. The new format wins on plain variables and secrets, which the old one types as bare strings, and loses on Durable Objects and Workflows, which the old one types precisely.

Migrations become a state on an export

The cumulative migration list is gone. A class declares what it is on its own export, and the storage engine sits beside it:

wrangler.tomlcloudflare.config.ts
[[migrations]] tag = "v1"
new_sqlite_classes = ["Counter"]
Counter: exports.durableObject({ storage: "sqlite" })
deleted_classes = ["Old"]Old: exports.durableObject({ state: "deleted" })
renamed_classes = [{ from, to }]Old: exports.durableObject({ state: "renamed", renamedTo: "New" })

This is a different upload path rather than a missing feature. Wrangler decides between them in one function: when a config declares Durable Object exports it sends those exports and sends no migrations at all, and it computes a migration only for configs that declare none.

Which leaves the question a dry run structurally cannot answer, because a dry run never computes a migration in the first place. Whether the API accepts the export form for a class that already exists under an applied migration tag is a fact about the server, and the first real deploy is the only instrument that reports it. That deploy has not happened here yet.

Two things it will not do

The dependency instrumentation key has no home. The old config set [dependencies_instrumentation] enabled = true and the new schema has no field for it. The escape hatch offers no route around it, because unsafe carries metadata and capnp schemas alone. This cost nothing, for a reason only readable in wrangler's source: it tests the key as enabled !== false, so an absent block behaves exactly like the explicit true. A dry run reports on none of this, which is why the answer came from reading the upload path.

The types command does not know about the flag. Passing --x-new-config to wrangler types is an unknown argument. The generated file is written by wrangler dev instead, from a types.generate setting in the tooling config that defaults on. So the inferred types exist on a machine somebody has run the dev server on, which is a thinner guarantee than a build step.

The bun trap, and why it is a trap

The loader refuses bun, in a message that names itself:

cloudflare.config.ts loading is not supported on Bun.
Please use Node.js v22.18.0 or higher.

On a repository whose whole toolchain is bun, that reads as a blocker for about a minute. It is a claim about the process that ends up running wrangler rather than about the package manager:

invocationresult
bun x --no-install wrangler … --x-new-configloads, deploys
bun run <script>, resolving the same shimloads
bun ./node_modules/wrangler/bin/wrangler.js …refused

The first two resolve node_modules/.bin/wrangler, whose #!/usr/bin/env node shebang hands the process to node before any config is read. Only running wrangler's entry file directly puts bun in front of the loader. So "does this tool support bun" turned out to be a question about a shebang, and the answer changed with the invocation rather than with the tool.

What this cost, and what it did not

The conversion ran on this site's smallest deployed Worker, the one behind /garage/cf/*, chosen because it is a demo that a person deploys by hand. The upload came out the same size it was before, to the byte, with the same four bindings. The account pin moved to a separate settings export, the route became a fetch trigger, and one line of CI changed a flag.

What it has not bought yet is the thing it exists for. This site's main Worker still types env as any, because converting it means folding two config files of 24,662 and 10,987 bytes into one program and putting a hidden flag on the path that publishes production. A demo Worker is the right place to be wrong. The site is not.

What would change the verdict

Three results would move this page. A real deploy that the API refuses on the export-based Durable Object form would make the migration section a warning instead of a translation table. A release that threads the class type into DurableObjectNamespace would remove the only column where the old format wins, and then the argument for converting the main Worker becomes hard to resist. And the flag losing its hidden marker, with documentation to read, would turn all of this from an experiment into a migration with a date on it.

watching · the understanding check is part of the page, not a gate

garagewatching2026-08-24