---
title: "aadhar.sh/garage/typed-config: The config that types itself"
description: "Wrangler's TypeScript config, re-measured on wrangler 4.147.0 and cf: a byte-identical upload and a Durable Object type that now threads across Workers."
path: "/garage/typed-config"
section: "garage"
kind: "content"
updated: "2026-10-05"
source: "https://aadhar.sh/garage/typed-config"
---

> Site index: https://aadhar.sh/llms.txt
> Section index: https://aadhar.sh/garage/llms.txt
> This is the Markdown twin of a page on aadhar.sh. The HTML at the source
> URL below is the original, and is hand-written and unminified on purpose.

# The config that types itself

A Worker config is data, so every [binding][binding] name in it is a string that nothing checks. [Wrangler][wrangler] can load a config that is a [TypeScript][typescript] program instead, behind a flag it still hides, and Cloudflare's new `cf` CLI loads the same file with no flag at all. Both infer the Worker's `Env` from it. This page measured that twice, in August and again on 2026-10-05. The upload still comes out byte-identical. The [Durable Object][durable-object] type still degrades for a Worker that binds its own class, and now carries the class through when the binding names a second Worker. And one August claim was wrong: `wrangler types` already wrote a plain-text variable's value into the type.

## Two runs, two toolchains

The first run, on 2026-08-24, used wrangler 4.125.0 with `@cloudflare/config` 0.7.0. The second, on 2026-10-05, used wrangler 4.147.0 (the `pkg.pr.new` build at `f025bbf` this site pins), `@cloudflare/config` 0.23.0, `cf` 1.0.0-beta.12, TypeScript 7.0.2 and [Node 26][nodejs].10.0. Config 0.23.0 is what `cf` installs today, so it is what a reader gets. Every row was cross-checked on 0.20.0, this site's own pin, and came back the same except where a row says otherwise.

Where the two runs disagree, the page says so in place and keeps the August result beside the October one.

## The thing a config cannot do

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

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

`any`. Every [KV namespace][workers-kv], every [D1 database][d1], the [R2 bucket][r2], 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. JSON cannot export a type. The usual repair is `wrangler types`, which reads the config and writes a `.d.ts`, and it works: it produces the full interface, Durable Object [generics][generics] and all. On 4.125.0 and on 4.147.0 alike, 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 is, and the door beside it

Wrangler loads a `cloudflare.config.ts`, and an optional `wrangler.config.ts` beside it, behind `--x-new-config`. On 4.147.0 the flag is still marked `hidden: true`, and it appears in the help of none of `build`, `deploy`, `dev`, `types` or `versions upload`. The package still describes itself as "not yet stable enough for external use".

Two things moved around the flag since August. Cloudflare now documents the file as an open beta, in a page last updated 2026-09-30, where in August a search found no documentation at all. And the documented door is `cf`, which imports the helpers from `cf/config` and, for a JavaScript Worker, hands the build to wrangler. On 2026-10-02 a wrangler maintainer answered workers-sdk#16002 with "we don't intend to support `cloudflare.config.ts` in more Wrangler commands" and recommended `cf`. So the flag keeps working where it works today, and new commands for this format arrive in `cf`.

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, on 4.147.0 as on 4.125.0:

```
compatibilityDate is not a supported field in wrangler.config.ts. Move it to cloudflare.config.ts.
```

## Build a config and watch three things move

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

bindings

exports

cloudflare.config.ts · what you write

The resolved pane is the Build Output Specification wrangler writes to `.cloudflare/output/v0/workers/default/worker.config.json`, which is the readable answer to "what did my config actually become". In August that file was `config.json` and opened with `"type": "worker"`; today it drops that key and ends with a `manifest`. The Env pane is what `tsc` reported for each key of the generated interface.

The two front doors agree. `cf build` (1.0.0-beta.12) printed "Delegating to Wrangler" and wrote the same four files `wrangler build` did, [sha256][sha-256] for sha256. The snippet imports from `@cloudflare/config/public`, as this site's own `cloudflare.config.ts` does. The documentation's `cf/config` re-exports that module in one line, so either import works, and the resolved output is identical.

One trap sits between the build and the types. The generated `.cloudflare/types/index.d.ts` imports `cf/config`. Install wrangler and `@cloudflare/config` without `cf`, keep `skipLibCheck` on as most templates do, and nothing complains: `Env` comes back empty, and every binding read is TS2339 on a value typed `any`. Only with `skipLibCheck` off does TS2307 name the missing module. Add `cf` as a dev dependency, as the docs say, or map `cf/config` in `paths`, which is what this site does.

## A variable arrives in the type as its own value, in both formats

Thirteen binding kinds went through the compiler. Ten land where you would guess, and three carry more than a bare type.

`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][type-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.

**This section used to say a JSON config structurally cannot do this. That was wrong, and it was already wrong on 4.125.0.** Run against an equivalent `wrangler.jsonc`, `wrangler types` writes `AI_GATEWAY: "default"` on both 4.125.0 and 4.147.0. It types the required secret as `string` too, and it narrows the JSON variable further than inference does, to `{"dcz":true,"tier":2}`. What the TypeScript config changes is *when* the type is computed. `wrangler types` takes a snapshot that the reminder above asks you to retake. The inferred `Env` reads the config every time the compiler runs, so a renamed binding is a type error with no regeneration step.

One row is new since August. `bindings.analyticsSQL()`, added in config 0.22.0, resolves to `{"type":"analytics"}` and types as `AnalyticsSQLBinding`. Wrangler 4.147.0 builds it. `cf` 1.0.0-beta.5, which carries config 0.20.0, lets wrangler write the build output and then refuses that output on its own schema check, with "Invalid discriminator value", and exits 1. Beta.12 accepts it.

## The row that went backwards, and the way around it

The Durable Object type is where the new format lost in August, and the October run found a way around it that works for some Workers and leaves the rest where they were. Against a JSON config, `wrangler types` threads the class through, on 4.147.0 as on 4.125.0:

```
COUNTER: DurableObjectNamespace<import("./src/index").Counter>;
BOOKING: Workflow<Parameters<import("./src/index").Booking['run']>[0]['payload']>;
```

Under inference, a binding that names its Worker by string still comes back as `DurableObjectNamespace<undefined>`, so a stub method call fails with `Property 'increment' does not exist on type 'DurableObjectStub<undefined>'`. That holds with the entrypoint given as a path string and with it given as a module through the `{ type: "cf-worker" }` import attribute, the same two forms August tried.

What changed is the binding helper. Its `worker` option (spelled `workerName` in August) now takes either a name or a Worker *definition*. Bind a class that a second Worker exports, pass that Worker's definition, and give that definition a module entrypoint:

```
import * as counterModule from "./src/index.ts" with { type: "cf-worker" };

export const counterWorker = defineWorker({
  name: "probe-counter",
  entrypoint: counterModule,
  exports: { Counter: exports.durableObject({ storage: "sqlite" }) },
});

// in the binding Worker's env:
COUNTER: bindings.durableObject({ worker: counterWorker, exportName: "Counter" }),
```

Now `env.COUNTER` types as `DurableObjectNamespace<Counter>`, and `await stub.increment(1)` is a `number` the compiler checks. A [Workflow][cloudflare-workflows] bound the same way types as `Workflow<{ slot: string }>`. The resolved JSON is the same as the string form, `"worker": "probe-counter"`, so the definition buys a type and changes nothing the platform receives.

Two controls say which half does the work. The same definition with a path-string entrypoint gives `DurableObjectNamespace<DurableObjectBranded>`, which still has no methods, so the module import is load-bearing. And a Worker that binds its *own* class cannot take this route: pointing the binding at its own definition through a factory is circular, tsc reports TS7022, and the binding collapses to `never`. The build also never type-checks the config. A Workflow binding whose `exportName` the definition did not declare was a compile error, and `wrangler build` still exited 0.

So the scoreboard moved. Cross-Worker Durable Objects and Workflows now type as precisely as `wrangler types` types them, and stay current without a regeneration step. A Worker that binds its own Durable Object, the common single-Worker case, still types worse than the snapshot does.

## 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.toml | cloudflare.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. Wrangler 4.147.0 still decides between them in one function, `resolveDoLifecyclePayload`: 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.

In August that left one question a [dry run][dry-run] structurally cannot answer, because a dry run never computes a migration: would the API accept the export form for a class that already exists under an applied migration tag? This site's demo Worker answered it on 2026-09-21. It deployed on this format (wrangler 4.136.0, pin `b168333`) against a `Counter` created under `tag = "v1"`, the API accepted it, and the counter read 18 and then 20 on the calls after, where a recreated namespace would have restarted from 0. That answer covers `wrangler deploy`. It says nothing about `versions upload`, which refuses Durable Object lifecycle changes on its own path.

## One thing it still will not do, and one it now does

**The dependency instrumentation key still has no home.** The old config set `[dependencies_instrumentation] enabled = true`, and config 0.23.0 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: 4.147.0 still 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 file now comes from a build.** `wrangler types --x-new-config` still fails, with "Unknown arguments: x-new-config, xNewConfig". In August the generated file came only from `wrangler dev`, so the inferred types existed on a machine somebody had run the dev server on. On 4.147.0, `wrangler build --x-new-config --x-cf-build-output` writes it too, and so do `cf build` and `cf workers types`, which wrote the same 622,861 bytes. That is the build step this page asked for in August.

## The bun trap, and why it is a trap

The loader refuses [bun][bun], in a message that names itself, and on 4.147.0 a second warning follows it:

```
cloudflare.config.ts loading is not supported on Bun. Please use Node.js v22.18.0 or higher.
Wrangler does not support the Bun runtime. Please try this command again using Node.js via `npm` or `pnpm`.
```

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, whichever package manager launched it. Re-run on bun 1.4.3-canary.1:

| invocation | result |
| --- | --- |
| `bun x --no-install wrangler … --x-new-config` | loads, builds |
| `bun run <script>`, resolving the same shim | loads |
| `bun run --bun <script>` | refused |
| `bun ./node_modules/wrangler/bin/wrangler.js …` | refused |

The first two resolve `node_modules/.bin/wrangler`, whose `#!/usr/bin/env node` [shebang][shebang] hands the process to node before any config is read. The last two put bun in front of the loader, one by overriding the shebang and one by running wrangler's entry file directly. So "does this tool support bun" turned out to be a question about a shebang, and the answer changed with the invocation while the tool stayed the same.

## What this cost, and what it did not

The conversion ran first on this site's smallest deployed Worker, the one behind `/garage/cf/*`, chosen because it is a demo that a person deploys by hand. In August the upload came out at 8.83 KiB, 3.07 KiB gzip, the same as before to the byte, with the same four bindings. Re-measured on 4.147.0 against the old `wrangler.toml` with its flags and minify setting brought level, both configs produce the same `index.js`, sha256 for sha256, at 5.94 KiB, 2.63 KiB gzip. The source maps differ only in their output-relative paths.

In August the main Worker still typed `env` as `any`, because converting it meant 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. It moved on 2026-09-28, with an upload byte-identical to the JSON's. Its handlers take `env: Env` now, from a hand-written interface that a check diffs against `cloudflare.config.ts` in both directions. That choice is about optional secrets, which the interface marks with `?` so every read has to face their absence, and about the Durable Object: its `Counter` lives in a second Worker that still has a `wrangler.jsonc`, so there is no definition to import yet.

## What would change the verdict

Two of August's three triggers have fired. The API accepted the export-based Durable Object form on a real deploy, so the migration table is a translation. Documentation arrived, though the flag stayed hidden and the documented door became `cf`.

The third half-fired: the class type now threads through a binding to another Worker. What would finish it is a self-bound Durable Object that types as its class, which would remove the last column where `wrangler types` wins. The other result worth watching is the format leaving open beta, since several shapes on this page moved between the two runs: the import path, the default export, the Durable Object binding key and the build output file.

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

[binding]: https://developers.cloudflare.com/workers/runtime-apis/bindings/ "A named handle in a Worker's environment that connects its code to a resource, such as a database, a storage bucket or another Worker."
[wrangler]: https://developers.cloudflare.com/workers/wrangler/ "Cloudflare's command-line tool for developing, configuring and deploying Workers."
[typescript]: https://en.wikipedia.org/wiki/TypeScript "JavaScript with type annotations, checked by a compiler that catches mismatched types before the code runs."
[durable-object]: https://developers.cloudflare.com/durable-objects/ "A Cloudflare Workers object with its own built-in storage, where each named instance lives in one place so every request to it is coordinated."
[nodejs]: https://en.wikipedia.org/wiki/Node.js "A program that runs JavaScript outside the browser, widely used for servers and build tools."
[workers-kv]: https://developers.cloudflare.com/kv/ "Cloudflare's key-value store: values saved under names and copied worldwide, so reads are fast everywhere while a new write can take a minute to spread."
[d1]: https://developers.cloudflare.com/d1/ "Cloudflare's SQL database for Workers, built on SQLite."
[r2]: https://developers.cloudflare.com/r2/ "Cloudflare's object storage for files. It speaks Amazon S3's API and charges nothing for downloading data back out."
[generics]: https://en.wikipedia.org/wiki/Generic_programming "Code written in terms of a type parameter, so one definition, such as a list, works with many specific types."
[sha-256]: https://en.wikipedia.org/wiki/SHA-2 "A hash function that turns any input into a 256-bit fingerprint. Change one byte of the input and the fingerprint changes completely."
[type-inference]: https://en.wikipedia.org/wiki/Type_inference "A compiler working out the types of values from how they are written and used, without the programmer spelling them out."
[cloudflare-workflows]: https://developers.cloudflare.com/workflows/ "A Cloudflare Workers feature for multi-step jobs that saves progress after each step and retries the ones that fail."
[dry-run]: https://en.wikipedia.org/wiki/Dry_run_(testing) "Running a command so it reports what it would do without actually doing it."
[bun]: https://en.wikipedia.org/wiki/Bun_(software) "A JavaScript runtime that also bundles a package manager, bundler and test runner, built to work as a replacement for Node.js."
[shebang]: https://en.wikipedia.org/wiki/Shebang_(Unix) "The #! line at the top of a script that tells the operating system which program should run it."

Source: https://aadhar.sh/garage/typed-config
