Skip to content

Prototype: TypeScript 7.1 content mapper, ENV typed straight from .env.schema - #1290

Draft
voiys wants to merge 14 commits into
dmno-dev:mainfrom
voiys:ts-content-mapper-v2
Draft

voiys wants to merge 14 commits into
dmno-dev:mainfrom
voiys:ts-content-mapper-v2

Conversation

@voiys

@voiys voiys commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

stacked on #1289. only the last 2 commits are new, all under packages/ts-content-mapper.

// tsconfig.json
"contentMappers": [{ "package": "@varlock/ts-content-mapper", "extensions": [".schema"] }]

// src/index.ts
import { ENV } from 'varlock/env'
const wrong: string = ENV.PORT
const typo = ENV.API_TOKN

$ tsc -p . --runExternalCode
src/index.ts(7,7): error TS2322: Type 'number' is not assignable to type 'string'.
src/index.ts(8,18): error TS2551: Property 'API_TOKN' does not exist on type 'TypedEnvSchema'.
                    Did you mean 'API_TOKEN'?

TypeScript starts the mapper, the mapper does a shape load of .env.schema and hands back types.

what that takes care of

  • no env.d.ts: nothing to commit, forget to regenerate, or collide on in a monorepo

  • watch mode ([Feature Request] Watch mode for type generation #925) for free. plain tsc --watch, then edit the schema:

    $ tsc -p . --runExternalCode --watch
    Found 0 errors. Watching for file changes.
    # change PORT from a port to a word in .env.schema
    src/index.ts(2,14): error TS2322: Type 'string' is not assignable to type 'number'.
    Found 1 error. Watching for file changes.
    
  • the schema becomes part of the program: hover, go to definition, rename and find references go from ENV.PORT in code to the line in .env.schema

  • no command runs and no secret is fetched during a type check, because it's a shape load. that matters for the same reason as in the shape mode PR: a type checker runs on whatever folder you open

  • a schema regex written to backtrack forever can't stall the type check (the mapper turns on V8's non-backtracking fallback for its own process)

not for merging yet

  • needs TypeScript 7.1, which is still in beta. tests pass on 7.1.0-beta and the nov 9 nightly
  • CI has to run tsc --runExternalCode
  • type-aware oxlint fails on any tsconfig that has contentMappers (a fix is open on their side)
  • it builds on a third-party SDK (ts-content-mapper 0.1.0)
  • it copies a bit of varlock's type generation instead of calling it. that should become an exported helper before this is real
  • a leftover generated env.d.ts silently wins over the mapper
  • TypeScript only. codegen stays for everything else

the package is private: true. it's here so you can try it, not to ship it.

voiys added 14 commits November 13, 2026 00:46
`new EnvGraph({ mode: 'shape' })` builds a graph for tools that only
inspect a schema (type checkers, language servers, CI linters):

- only resolvers marked `pure: true` run, all others resolve to undefined
- an item whose value depends on a skipped resolver, directly or through
  references, is unknown: it is left unset and is not coerced, validated or
  reported as a missing or failed value
- a @disable or @import condition that is unknown keeps the file enabled
- root decorator `execute()` is skipped (the items they reference are still
  resolved, so they keep their final validation state)
- no cache store is created
- plugins that are not installed or cached are never downloaded
- process.env overrides are ignored

Skipped work is recorded in `graph.shapeSkipped`. Default mode is
unchanged. The built-in resolvers that only read other values (concat, ref,
fallback, forEnv, if, ...) and the builtin VARLOCK_* vars are marked pure;
VARLOCK_BRANCH skips its local `git branch` lookup in shape mode.
Documents shape mode for tool authors, including the `pure` flag for
plugin resolvers.
- builtin variables no longer read the real shell env (an env passed in
  explicitly is still used), so CI variables cannot change results
- generated code is only written inside the root schema's directory; a path
  that leaves it (.., absolute, or through a symlink) is a schema error
- an import that is not a regular file (a named pipe) is a loading error
  instead of a read that never returns
- guide: documents these, that plugin pure flags are trusted like the plugin
  code that already runs, and that schema regexes have no timeout
Tests for each fail on the previous commit.
…known parts

- the named-pipe check moves from the direct-import branch to
  FileBasedDataSource, so a pipe found through a directory import, or the root
  schema itself, is refused too instead of hanging the load
- an error is only treated as caused by a partial value when the resolver it
  was first thrown from had an unknown input, so an error from a fully known
  part of the same expression is still reported
- the CI-branch test also checks that git is not run
Tests for the pipe cases and the mixed expression fail on the previous commit.
- ConfigItem keeps the unknown flag computed when its value resolver ran,
  so each dependent read no longer walks the resolver tree
- FileBasedDataSource does one stat instead of an access check plus a stat
…lk values

A file kept only because its @import enabled= or @disable condition could
not be evaluated might be skipped entirely by a normal load, so a @plugin
in it (or in a file it imports) is no longer loaded.

@setValuesBulk now runs in shape mode: it only resolves its args and edits
the graph. Data written in the schema is applied as in a normal load. When
the data or its enabled= condition comes from a skipped resolver, the items
it could set are unknown while empty instead of failing the required check.

A local plugin whose package.json is a named pipe or device is refused in
both modes instead of blocking the read forever.
… project folder

In shape mode the loader does not clear or open a cache store.
… on a skipped value as unknown

A computed @required/@optional or @sensitive/@public built from a skipped resolver is now
unknown instead of whatever the empty value happened to give: the item is treated as optional
and sensitive, and isRequiredUnknownInShapeLoad / isSensitiveUnknownInShapeLoad say so.
forEnv() with an unknown @currentEnv is unknown instead of an error, so a @disable or
@required built on it no longer fails the load. Checks that only hold for a sensitive item are
skipped while sensitivity is unknown. Default mode and generated types are unchanged.
finishLoad returned before the cycle check when any item had a schema
error. An item on a cycle was then left unmarked, waited on itself when
values were resolved, and the CLI gave up with "varlock stopped before
the command finished" instead of showing either error.
…ith a generated soundness test

A shape load now reports an item as unknown, instead of as valid, when one
of its dependencies is unknown (a normal load fails an item whenever a
dependency is invalid, read or not), when it is empty and its requiredness
is unknown, or when its sensitivity is unknown and a sensitive-only check
would fail.

shape-soundness.test.ts generates 1,200 schemas and checks, for every item,
that a shape load says unknown or gives exactly the value and errors of a
normal load, and the same required and sensitive unless flagged unknown.
It fails on every seed without these rules.
…d list; classify every built-in resolver

A named pipe or device is skipped and recorded (kind nonRegularFile)
instead of failing the load: 1Password mounts a .env that way. It could
set any item, so every value is then unknown.

shapeSkipped no longer lists root decorators that have no execute().

A test lists every built-in resolver as pure or skipped, so a new one
fails until someone decides.

Also: resolution-context is imported once, and errors from unknown inputs
are tracked in a WeakSet instead of a property written onto the error.
packages/ts-content-mapper (@varlock/ts-content-mapper, private) types
`import { ENV } from 'varlock/env'` straight from .env.schema while
`tsc --runExternalCode` runs, with no generated env.d.ts. It loads the
schema with EnvGraph shape mode, so exec(), secret stores, root
decorator execute() and plugin downloads never run during a type check.

example/ is a small workspace app wired through `contentMappers` in its
tsconfig. `bunx turbo run check --filter=@varlock/ts-content-mapper-example`
builds varlock and the mapper and type-checks it.

The tests run the real tsc against the built mapper: correct types (with a
control that the assertions fail on a changed schema), wrong-typed app
code, schema errors located in .env.schema, exec() never run, no network
request, and sensitive values kept out of the virtual code.
A regex like ^(a+)+$ against a long value would stall every type check.
The mapper process now turns on V8's fallback to its non-backtracking
engine after excessive backtracking. Patterns with lookaround or
backreferences are not covered. Process-wide, which is fine in a process
that only loads schemas.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant