Skip to content

Typegen: per-package ENV type without global module augmentation (monorepo cross-package imports) #848

Description

@theoephraim

Summary

Cross-package env types still break in monorepos when one package imports another and both have their own .env.schema. The fix in #594 (unique hashed type aliases) only addressed duplicate-identifier collisions when both generated env.d.ts files land in one compilation — it does not (and can't) address the underlying issue from #582. We should revisit the typegen system to offer a per-package ENV type that doesn't rely on global module augmentation.

Reproduction

@_/lib-b imports a function from @_/lib-a; both have their own .env.schema/env.d.ts. Running tsc in lib-b:

../lib-a/src/bar.ts(5,14): error TS2339: Property 'MY_VAR' does not exist on type 'TypedEnvSchema'.

(Standalone repro available; can attach.)

Root cause

varlock/env exports ENV: TypedEnvSchema, where TypedEnvSchema is an empty interface that each package's generated env.d.ts augments via declare module 'varlock/env'. There is exactly one varlock/env module in the whole TS program, so ENV's type is the merge of every env.d.ts in the current compilation.

When lib-b follows an import into lib-a's source (bar.ts), tsc type-checks that body — including ENV.MY_VAR — but only lib-b's env.d.ts is loaded, so TypedEnvSchema has only lib-b's keys → MY_VAR "does not exist". This is an inherent limitation of global module augmentation; no small typegen tweak fixes it while keeping import { ENV } from 'varlock/env'.

Current workaround (no varlock change)

Consume sibling packages through their built .d.ts, not source — tsc infers and inlines ENV.*-derived types at the build boundary (foobar(): string | undefined), so the augmentation never has to agree across packages. Concretely: types/default export conditions pointing at dist, TS project references + tsc -b, and disableSourceOfProjectReferenceRedirect: true.

Downside: forces build-first ordering and gives up source-first (ts-src) cross-package type-checking — which is exactly the DX our own packages rely on.

Proposed direction: package-local generated ENV module (opt-in)

Have typegen emit a real per-package module that exports an already-typed ENV, instead of augmenting the global varlock/env:

// packages/lib-a/src/env.ts  (AUTOGENERATED)
import { ENV as _ENV } from 'varlock/env';   // runtime proxy, unchanged
export type CoercedEnvSchema = { MY_VAR?: string };
export const ENV = _ENV as Readonly<CoercedEnvSchema>;
// user code
import { ENV } from './env';   // package-local, not 'varlock/env'

The type now travels with the package by module identity, so it resolves correctly even when lib-a's source is compiled inside lib-b — with no build step and full ts-src source-first dev. Runtime is identical (same underlying proxy; only the type is package-scoped).

Notes / gotchas

  • Reference the local module via a relative import (./env) or a package self-reference (@_/lib-a/env via an exports entry). A tsconfig path alias (@/env) is resolved against the current compilation's tsconfig and so reproduces the exact bug — must be called out loudly in docs and the generated file.
  • NodeJS.ProcessEnv / import.meta.env can likely stay globally augmented — union-merge of additive string keys across packages is harmless there. Only the strict typed ENV object needs to be package-local.
  • Likely an opt-in / hybrid: keep the current global-augmentation env.d.ts as the default (nicer zero-wiring DX for single apps), add a "module mode" for library packages consumed in monorepos. Directly delivers the reporter's suggestions 1 & 3 from [BUG]: declare module 'varlock/env' breaks in Monorepo setups #582.

Scope

Before implementing, we may want to revisit the typegen system overall (how @generateTypes is configured, whether to emit .ts vs .d.ts, how integrations declare their preferred mode) rather than bolt this on. Filing to track.

Related: #582, #594

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions