@effected/tsconfig-json
v0.8.1
Published
Composable tsconfig.json handling for Effect: schemas, extends-chain resolution, and config discovery.
Maintainers
Readme
@effected/tsconfig-json
Composable tsconfig.json handling for Effect: document and compiler-option schemas, extends-chain resolution with tsc's own merge semantics, nearest-config discovery and a portable-config filter for virtual TypeScript environments. Every parse is JSONC — comments and trailing commas are legal everywhere, exactly as tsc treats them — and options the schemas do not know pass through decode and encode untouched instead of being dropped.
Pre-release. This package is part of the
@effected/*kit, in pre-1.0.0development against a single pinned Effect v4 prerelease. Packages graduate to1.0.0once Effect4.0.0ships. To hold your owneffectversions at exactly the ones the kit is built and tested against, install@effected/pnpm-plugin-effect.Stability: unstable. This package's API surface is not yet considered complete and may change across
0.xreleases. Pin an exact version — even a package marked stable before1.0.0can introduce a breaking change by accident, and an exact pin turns that into a type-check error rather than a runtime surprise. Full policy: release strategy.
Why @effected/tsconfig-json
Reading a tsconfig.json correctly means reproducing what tsc does, and what tsc does is more than JSON.parse plus Object.assign. An extends target resolves like a module: a bare specifier walks ancestor node_modules directories, a package's exports map can redirect or block it, and a tsconfig field in its manifest can point somewhere else entirely. Merging the chain is per-field, path options absolutize against the config that declared them rather than the one you loaded, and ${configDir} substitutes once at the end against the top config's directory. These rules were extracted from the TypeScript compiler's source and encoded here as data-driven tests, so the resolution you get is the resolution tsc computes.
The package does all of this without importing typescript, not even as a type. It works at the string level — "target": "es2023" stays a string through schema, merge and discovery — and the version-coupled numeric enum mappings live in TsEnumCodec as plain data tables, so converting to the numeric shape a real compiler expects is an explicit final step rather than a dependency you carry everywhere. Malformed input always fails through a typed error channel, and the recursive extends walk carries cycle and depth guards, because a config file is untrusted input.
Install
npm install @effected/tsconfig-json @effected/jsonc @effected/walker effectpnpm add @effected/tsconfig-json @effected/jsonc @effected/walker effectRequires Node.js >=24.11.0. effect v4, @effected/jsonc and @effected/walker are peer dependencies; there are no runtime dependencies of its own.
All @effected/* packages are ESM-only: the exports maps publish only import conditions, so require() — including tools that resolve in CJS mode — fails with Node's ERR_PACKAGE_PATH_NOT_EXPORTED rather than loading a CJS build that does not exist. Import from an ES module.
All IO goes through FileSystem and Path from effect core, not a platform package, so a consumer provides them once at the edge (@effect/platform-node on Node, @effect/platform-bun on Bun) and a test provides Path.layer and FileSystem.layerNoop straight from core with nothing else installed.
Quick start
Resolve a config and its full extends chain:
import { TsconfigLoader } from "@effected/tsconfig-json";
import { NodeFileSystem, NodePath } from "@effect/platform-node";
import { Effect, Layer } from "effect";
const PlatformLive = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);
const resolved = await Effect.runPromise(TsconfigLoader.resolve("./tsconfig.json").pipe(Effect.provide(PlatformLive)));
console.log(resolved.extendedPaths);
// every config on the extends chain as normalized absolute paths, base-most first and your own config last
console.log(resolved.compilerOptions);
// the merged options after folding the whole chain — later configs win per field, paths replaced wholesaleTsconfigLoader.compilerOptions("./tsconfig.json") is the same pipeline projected down to the merged options, for when the effective options are all you want.
Find the nearest config first when you only have a starting directory:
import { TsconfigDiscovery } from "@effected/tsconfig-json";
import { Effect, Option } from "effect";
const nearest = TsconfigDiscovery.findNearest(process.cwd());
// Effect<Option<string>, never, FileSystem | Path> — absence is Option.none(), never an errorHand the result to a real compiler by encoding the enum families to their numeric form, or narrow it to the portable subset a virtual TypeScript environment can safely reuse:
import { PortableTsconfig, TsEnumCodec } from "@effected/tsconfig-json";
console.log(TsEnumCodec.encodeCompilerOptions({ target: "es2023", strict: true, lib: ["esnext"] }));
// { target: 10, strict: true, lib: [ 'lib.esnext.d.ts' ] }
console.log(PortableTsconfig.make(resolved).compilerOptions.noEmit);
// true — always forced, whatever the source config declared
console.log(PortableTsconfig.make(resolved, { includeTypes: true }).compilerOptions.types);
// carries the source config's `types` package names when it declares them, omitted when absentincludeTypes is opt-in and defaults to false because emitting types makes tsc demand those @types packages resolve — a hard error in a virtual environment with no node_modules. Pass it when your environment materializes @types itself; leave it off for the permissive default where TypeScript auto-includes whatever it finds.
Synchronous loading
Bundler plugin hooks and config factories often cannot await. TsconfigLoaderSync runs the unchanged loader pipeline synchronously over file and path operations you supply — the package still imports no node:* module, and Node's built-ins satisfy the operations directly:
import { existsSync, readFileSync } from "node:fs";
import * as path from "node:path";
import { TsconfigLoaderSync } from "@effected/tsconfig-json";
const options = {
fileSystem: { exists: existsSync, readFile: (p: string) => readFileSync(p, "utf8") },
path, // node:path satisfies SyncPath verbatim; path.win32 / path.posix force a convention
};
const compilerOptions = TsconfigLoaderSync.compilerOptions("./tsconfig.json", options);
// the merged options for the full extends chain — the same result TsconfigLoader.resolve computesload and resolve have the same synchronous forms. Failures are the async pipeline's own typed errors thrown as themselves — TsconfigParseError, TsconfigExtendsError or a PlatformError wrapping whatever your readFile threw — never a fiber-failure wrapper.
TypeScript 7 and the classic compiler API
TypeScript 7's native tsc ships a version-only stub as its . export — there is no JS compiler API behind it. A package that drives the typechecker as a library (@typescript/vfs, the language service, the Program API) can neither type against nor runtime-load a TypeScript 7 install, and the obvious fix of pinning the dev typescript back to 6 forfeits the native tsc and leaves toolchain peers on ^7 unmet. The recipe that keeps both starts here, because this package is what removes the compile-time half of the dependency.
- Type against the tsconfig JSON form, not the compiler. Public option types use
CompilerOptions.Type—{ target: "es2022" }, strings all the way down — so nothing insrcor the tests importstypescript, not even as a type. The devtypescriptstays on 7, nativetsc --noEmitstill runs the typecheck, and the toolchain's^7peer stays satisfied. - Convert at a single runtime seam.
TsEnumCodec.encodeCompilerOptionsmaps the string form to the numeric enums a real compiler expects and returnsProgrammaticCompilerOptions, the shape ats.CompilerOptions-typed API takes without a cast. Only that one call site knows a compiler exists. The inbound direction isCompilerOptionsFromProgrammatic, a schema rather than a function: hand it a livets.CompilerOptions, a numeric enum literal or the output ofencodeCompilerOptionsand validatedCompilerOptions.Typecomes back, with an unmappable numeric failing decode instead of leaking through. - Alias a classic install for the test runtime, where the JS API is genuinely needed: a dev-only
"typescript-classic": "npm:typescript@^6.0.3"plus a vitestresolve.aliasmappingtypescriptto it. The consumer-facing peer stays an optional^6— runtime-only, for callers of the compiler-touching module. - Pass
tsLibDirectoryexplicitly when you drive@typescript/vfs. It locateslib.*.d.tsthroughrequire.resolve("typescript"), which under this setup resolves the TypeScript 7 install, and that install has no lib directory: the map comes back empty, silently, with no error to follow. Derive the directory from the module you actually loaded.
{
"devDependencies": {
"typescript-classic": "npm:typescript@^6.0.3"
}
}import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: { alias: { typescript: "typescript-classic" } },
});import { dirname } from "node:path";
import { TsEnumCodec } from "@effected/tsconfig-json";
import { createDefaultMapFromNodeModules } from "@typescript/vfs";
import ts from "typescript";
const fsMap = createDefaultMapFromNodeModules(
TsEnumCodec.encodeCompilerOptions({ target: "es2023", lib: ["esnext"] }),
ts,
dirname(ts.sys.getExecutingFilePath()),
);
console.log(fsMap.size);
// the lib files the loaded compiler ships; drop the third argument and this is 0Features
TsconfigJson/TsconfigJsonFromString— the document schema and its JSONC string codec. Comments and trailing commas are legal in every parse; there is no JSON-strict path.CompilerOptions— string-level schemas forcompilerOptions: enum values decode case-insensitively and encode to canonical lowercase, and unknown or removed options survive a round trip as passthrough.TsconfigLoader.load/TsconfigLoader.resolve/TsconfigLoader.compilerOptions— read and decode one config, resolve its fullextendschain depth-first with per-branch cycle stacks (diamond chains are legal), a depth guard and tsc's target resolution for relative, rooted and bare-specifier targets includingexportsmaps, or project the resolved chain straight down to its merged options.TsconfigLoaderSync— the synchronous facade for sync-only hosts:load,resolveandcompilerOptionsover consumer-supplied{ fileSystem, path }operations, running the same pipeline and throwing the same typed errors.ResolvedTsconfig— the pure merge engine behindresolve: per-field merge semantics, path-option absolutization against the declaring config's directory, final${configDir}substitution andpathsBaseprovenance, with no filesystem access at all.TsconfigDiscovery.findNearest— the nearesttsconfig.json(or any filename viaoptions.filename) at or above a starting directory, over@effected/walker; one unreadable ancestor cannot hide a config above it.TsEnumCodec— the string↔numeric enum tables as plain data with zerotypescriptimports.encodeCompilerOptionsreturns the exportedProgrammaticCompilerOptionstype — the numeric shapets.CompilerOptionsexpects, so you hand it to ats.CompilerOptions-shaped API without a cast — withlibentries in the file-name form the compiler resolves verbatim;decodeCompilerOptionsreverses it.CompilerOptionsFromProgrammatic— the same tables as a validating codec, for callers holding TypeScript's programmatic spelling. Decode accepts numeric enums, canonical strings, case-varying strings, any mixture of the three andlibin any of its three spellings; encode returns the numeric form. A numeric with no table entry fails decode as a typed schema issue rather than passing through, which is the checkTsEnumCodec.decodeCompilerOptionsdeliberately does not make.PortableTsconfig.make— an allow-list projection down to machine-independent type-semantics options, withcomposite: falseandnoEmit: trueforced: the slice a virtual TypeScript environment (Twoslash, API Extractor, an in-memory language service) can safely inherit. An optional{ includeTypes }argument carries the source config'stypespackage names onto the portable shape too, for a caller whose virtual environment can resolve@typespackages itself;typeRootsstays dropped either way, since it names machine-specific, config-location-dependent directories.JsxConfig.fromCompilerOptions— the JSX transform a bundler can configure, projected from decoded options:react-jsx/react-jsxdevselect the automatic runtime with its import source (defaulting toreact, tsc's own default),reactselects classic, andpreserve,react-nativeor an absentjsxyieldOption.none().- Typed failures everywhere: a malformed file is a
TsconfigParseErrorcarrying its path, a broken chain is aTsconfigExtendsErrorwith anot-found/cycle/depth/emptyreason and the full resolution chain, and IO errors flow through asPlatformError. Nothing fails as a defect.
