Skip to content
Docs menu / Compile-time catches

Compile-time catches

The main promise of konfig.ts is that a whole class of deployment mistakes fails at tsc time instead of at argocd sync time. The example project proves this with seven files under examples/full-stack/infra/envs/ that are broken on purpose. Each makes one deployment mistake, and this page walks through what the file gets wrong, why the type checker rejects it, and how to fix it. They are part of the example’s tsconfig.json include set, and every intended error is marked with @ts-expect-error, so CI (bun run --cwd examples/full-stack check) fails if any of them ever starts compiling. The errors shown here are guaranteed to stay errors.

Two things to keep in mind when reading them:

  • None of these files is listed in konfig.json, so konfig build never loads them. They exist for the type checker only.
  • The @ts-expect-error comment sits directly above the offending line. Delete the comment and you see the raw diagnostic reproduced below each block.

broken.ts: a module needs a provider nobody listed

A workload declares what it needs from other modules: here the api module asks for the ghcr-pull image pull secret and the api image inside its build function, and the worker module asks for the same pull secret plus the worker image. The environment lists only api and worker in its modules. The modules that would provide those things (imagePulls, apiBuild, workerBuild) are missing.

examples/full-stack/infra/envs/broken.ts 1 error
const api = defineApi({
  name: "api",
  source: src("api"),
  replicas: 1,
  sopsBase: "infra/secrets"
})
const worker = defineWorker({
  name: "worker",
  source: src("worker"),
  replicas: 1,
  sopsBase: "infra/secrets"
})

export default AppOfApps.fromModules({error TS2345: Missing provider for Image "api", Image "worker" and Secret "ghcr-pull".
Add a module that provides them to AppOfApps.fromModules({ modules }), or check that providers come before consumers in the list.
  target: { repoURL: cluster.repositoryUrl, branch, rootPath },
  defaults: {},
  modules: [api, worker]
})
$ bun run check
infra/envs/broken.ts(30,3): error TS2345: Property '_konfig_unsatisfied' is missing … "Missing provider for Image \"api\"…" | "Missing provider for Image \"worker\"…" | "Missing provider for Secret \"ghcr-pull\"…"

Why it fails: AppOfApps.fromModules walks the module list in order and keeps a running tally of what has been provided and what is still needed. Whatever is still needed at the end stays as an unsatisfied requirement; here that is the api image, the worker image, and the ghcr-pull secret. AppOfApps.fromModules accepts the module list only when nothing is left over beyond the render services. It reports each leftover through the _konfig_unsatisfied hint, at the fromModules call site, as one Missing provider for <Kind> "<name>" message.

Fix: add the provider modules to the list, before the consumers, or satisfy the need with a group-level provides: layer on fromModules. prod.ts is the working version of this file.

unbound-secret.ts: an env contract with an unbound secret

An environment contract can declare secret members; the api contract declares three (db, s3, jwt). When a module binds that contract with Environment.bind, the type of the contract dictates a required secrets record: one entry per secret member, and for backends that need a source of values, a source next to the backend.

examples/full-stack/infra/envs/unbound-secret.ts 3 errors
const missingJwt = Environment.bind({
  env: apiEnv,
  namespace: "app",
  secretserror TS2741: Property 'jwt' is missing in secrets.
Every secret member of apiEnv (db, s3, jwt) needs a backend.: {
    db: { backend: dbBackend },
    s3: { backend: s3Backend }
  }
})

const emptyDb = Environment.bind({
  env: apiEnv,
  namespace: "app",
  secrets: {
    db: {}error TS2322: Type '{}' is not assignable to SecretMemberOptions<"db-creds">.
A member needs at least a backend.,
    s3: { backend: s3Backend },
    jwt: { backend: jwtBackend }
  }
})

// `Sops.backend` requires a `source` at the type level.
const sopsBackend = Sops.backend({
  recipients: {
    age: ["age1demo000000000000000000000000000000000000000000000000000example"]
  }
})
const missingSource = Environment.bind({
  env: apiEnv,
  namespace: "app",
  secrets: {
    db: { backend: sopsBackend }error TS2322: Property 'source' is missing.
Sops.backend requires a source; its type says requiresSource: true.,
    s3: { backend: s3Backend },
    jwt: { backend: jwtBackend }
  }
})
$ bun run check
infra/envs/unbound-secret.ts(39,3): error TS2741: Property 'jwt' is missing in type '{ db: …; s3: … }' but required in type 'SecretMembersOpts<…>'.

Why it fails: the bind input makes secrets mandatory as soon as the environment has any secret member, and each entry must be a backend, a source, or both. Whether a source is required is part of the backend’s type. Sops.backend returns a backend whose type says it requires a source, so leaving the source out is a type error. Sops.passthrough returns a backend that does not require one.

Fix: bind every secret member, and pair Sops.backend with Sops.source({ file, keys }). See Secrets with sops.

port-mismatch.ts: a probe or Service names a port that does not exist

A container declares its ports with Port.make. Everything that later refers to a port by name, a readiness probe, a liveness probe, a Service targetPort, has to use Port.ref with one of those declared names. Here the container declares only http, the readiness probe asks for htp, and a second workload’s Service asks for metrics.

examples/full-stack/infra/envs/port-mismatch.ts 2 errors
const api = Container.define({
  name: "api",
  image: "ghcr.io/example/api:1.0.0",
  ports: [Port.make({ name: "http", containerPort: 8080 })],
  readinessProbe: {
    httpGet: { path: "/healthz", port: Port.ref("htp")error TS2322: Type 'PortName<"htp">' is not assignable to type 'number | PortName<"http">'.
The container declares only the port "http". }
  }
})

const wrongTarget = Workload.web({
  name: "api",
  namespace: "app",
  deployment: { containers: [api] },
  service: { ports: [{ port: 80, targetPort: Port.ref("metrics")error TS2322: Type 'PortName<"metrics">' is not assignable to type 'number | PortName<"http">'.
No container in this workload declares a port named "metrics". }] }
})
$ bun run check
infra/envs/port-mismatch.ts(10,34): error TS2322: Type 'PortName<"htp">' is not assignable to type 'number | PortName<"http">'.

Why it fails: Container.define records the declared port names in the container’s type. Probe targets accept a number or a port name from that set, and Workload.web builds its Service port type from the names of all containers it receives. A name outside that set is a type error at the call site, so a typo cannot reach the cluster as a pod that never becomes ready or a Service with no endpoints.

Fix: use the declared name (Port.ref("http")), or declare the missing port with Port.make on the container.

volume-mismatch.ts: a mount or PVC claim that does not match a declared volume

A pod declares its volumes once; each container mounts them by name. A PersistentVolumeClaim is referenced through a branded reference rather than a plain string. Here one container mounts dat while the pod declares data, and a second volume passes the claim name as a raw string.

examples/full-stack/infra/envs/volume-mismatch.ts 2 errors
const data = Volume.fromPvc({ name: "data", claim: PvcRef.of("postgres-data") })

const typo = Pod.define({
  volumes: [data],
  containers: [
    Container.define({error TS2322: Type 'ContainerSpec<"pg", "dat">' is not assignable to type 'ContainerSpec<string, "data">'.
The pod declares only the volume "data"; the mount names "dat".
      name: "postgres",
      image: "postgres:16",
      ports: [Port.make({ name: "pg", containerPort: 5432 })],
      volumeMounts: [{ name: Volume.mountRef("dat"), mountPath: "/var/lib/postgresql/data" }]
    })
  ]
})

const rawClaim = Volume.fromPvc({
  name: "data",
  claim: "postgres-data"error TS2322: Type 'string' is not assignable to type 'PvcRef<string>'.
Use PvcRef.of("postgres-data") or a Dep.Pvc provider so the claim is tracked.
})
$ bun run check
infra/envs/volume-mismatch.ts(10,5): error TS2322: Type 'ContainerSpec<"pg", "dat">' is not assignable to type 'ContainerSpec<string, "data">'.

Why it fails: Pod.define compares the mount names of every container against the pod’s declared volume names, and Volume.fromPvc only accepts a PvcRef. A branded reference comes from PvcRef.of(name) when the claim is managed elsewhere, or from a Dep.Pvc provider when another module creates it, so the graph knows the claim exists.

Fix: mount Volume.mountRef("data"), and pass PvcRef.of("postgres-data") (or a provided reference) as the claim.

widened-name.ts: an Application name that is not a literal

Names are the vertices of the dependency graph, so they have to be known at compile time. Application.define therefore accepts name and namespace only as literal strings, and the Module.fixedNs / Module.dynamicNs wrappers do the same for the names they take. A value typed as plain string, here read from Config.string(...), is rejected.

examples/full-stack/infra/envs/widened-name.ts 1 error
const dynamicName: string = Effect.runSync(Config.String("MY_APP_NAME").pipe(Config.withDefault("api")))
const widened = Application.define({
  name: dynamicNameerror TS2322: Type 'string' is not assignable to type '{ readonly _konfig_error: "Module name/namespace must be a string literal." }'.
Make the wrapper generic (<const Name extends string>) and forward via Module.LiteralName<Name>.,
  namespace: "app",
  source: src("api"),
  build: () => []
})
$ bun run check
infra/envs/widened-name.ts(23,3): error TS2322: Type 'string' is not assignable to type '{ readonly _konfig_error: "Module name/namespace must be a string literal…" }'.

Why it fails: if a name could be string, a module providing “some app” would satisfy any module needing a specific app, and the missing-provider check above would silently pass. The literal-name type resolves to the literal itself when given one, and to an object carrying a _konfig_error message when given a widened string, so the widened value no longer matches the parameter.

Fix: keep names literal at the call site and pass them in from the env entry rather than reading them from process configuration. A wrapper that forwards a name must stay generic, declared as <const Name extends string> with the parameter typed Application.LiteralName<Name>, as defineApiBuild in infra/modules/builds.ts does. The diagnostic text says exactly that.

envname-collision.ts: two members claim one env var

Every member of an environment contract (secret, literal, or downward) ends up as one env var in the pod. Environment.define computes the set of env var names across its top-level members and rejects a duplicate at the type level. Nested environments are covered by a runtime check in the same call, which throws EnvNameCollision when the module is loaded.

examples/full-stack/infra/envs/envname-collision.ts 1 error
const secretLiteralDup = Environment.define({error TS2345: Property '_konfig_error' is missing but required in type _EnvNameCollisionError<"DATABASE_URL">.
Two members claim the env var DATABASE_URL.
  db: Secret.define({
    name: "db",
    namespace: "app",
    env: { url: "DATABASE_URL" }
  }),
  shadow: Literal.define({ envName: "DATABASE_URL", value: "x" })
})
$ bun run check
infra/envs/envname-collision.ts(23,46): error TS2345: Property '_konfig_error' is missing … but required in type '_EnvNameCollisionError<"DATABASE_URL">'.

Why it fails: two members writing the same variable name would produce two env entries with the same name in the pod spec, and which value wins is a Kubernetes ordering detail. The check catches literal against literal, secret key against literal, and secret against secret.

Fix: rename one of the env var names (or one key in a Secret.define env map). If both members are meant to be the same value, keep one member and reference it from both places.

app-name-collision.ts: two Applications with the same name

Two Application.define calls with name: "api" are passed to AppOfApps.fromModules.

examples/full-stack/infra/envs/app-name-collision.ts 1 error
const apiV1 = Application.define({
  name: "api",
  namespace: "app",
  source: src("api"),
  build: () => []
})

const apiV2 = Application.define({
  name: "api",
  namespace: "app",
  source: src("api"),
  build: () => []
})

const collision = AppOfApps.fromModules({
  target: { repoURL: cluster.repositoryUrl, branch: "main", rootPath: "./out" },
  defaults: {},
  modules: [apiV1, apiV2]error TS2345: Duplicate App "api": two modules in AppOfApps.fromModules({ modules }) provide the same name; the later one silently shadows the earlier.
Rename one of them. as const
})

export default collision
$ bun run check
infra/envs/app-name-collision.ts(26,41): error TS2345: Property '_konfig_duplicate' is missing … "Duplicate App \"api\"…"

Why it fails: fromModules checks the module list for duplicate provided names. It folds the names of everything modules can provide across the tuple:

  • Applications
  • Secrets and secret values
  • ConfigMaps
  • ServiceAccounts
  • PersistentVolumeClaims
  • Images

Namespaces are exempt because sharing a namespace between modules is normal (the Application kind is exempt as well, since it is always emitted alongside App and would report every collision twice). If two modules provided the same Application name, the later one would shadow the earlier one at runtime: both entries in the module list would resolve to the shadowing Application, the first would be lost silently, and both would write the same Application-api.yaml. The check rejects it before that can happen, with a _konfig_duplicate hint.

Fix: give each Application a distinct name. If you want two instances of one module, call the factory twice with different names, for example defineApi({ name: "api", ... }) and defineApi({ name: "api-canary", ... }).

Next: Dependency graph explains the need / provide machinery behind the first, third, and fifth case.