Recently, we went over some ideas for new and interesting ways to merge config files. Ever since, or maybe, since forever, I've been thinking about the problem. The progression, give or take a step, for most command-line tools is this:
- First, there were command line arguments
- Second, there were response files, for when the command line arguments got too long (
@filenameon the CLI) - Third, there is a structured data file in a known location (
.npmrc,.vscode/settings.json,eslint.json,tsconfig.json) - and Finally, there is just more Code as Configuration (
webpack.js,eslint.js,Herebyfile.mjs)
We have the first three and have been reluctant to make the leap to the fourth. There are various reasons stated in different places, but mostly it comes down to not wanting or being able to execute untrusted code (especially in the editor). We're not in the business of sandboxing a dynamic, interpreted language, like javascript. Philosophically, there is little disagreement that a config file like this:
import * as ts from "typescript"; import base from "./tsconfig.base.js"; import mixin from "./tsconfig.mixin.js"; export default ts.config({ compilerOptions: { ...base.compilerOptions, jsx: "react-jsx", paths: { "dep": ["./node_modules/typescript"], ...mixin.compilerOptions.paths }, traceResolution: true }, include: [ "./app/**/*.ts" ] });
is fairly compelling - solving most of the issues we have with the static files of today (inflexible merging and imports, inability to create calculated fields generally) without needing to reinvent the wheel and build a custom procedural transform definition language in a static context.
But I couldn't help but wonder - sure, we have our reasons why we didn't like Code as Configuration for TypeScript - but what if we went beyond that - what if we evolved our configuration into something even greater - more expressive, yet structured than code itself. What if we used types as configuration. After all, why not use what we have available? For us, checking is basically as easy as JSON parsing. And our types are, somehow, a sandboxed, dynamic, interpreted language unto themselves. People do a lot of crazy things in our typesystem, and it's only gotten more expressive primitives over time. Imagine with me, for a moment. You could pretty easily define the shape of a tsconfig as a type, eg:
type Config = { compilerOptions: { strict: true } };
and then stick it in a known location
and that's that - just look the type pointed at by default, and transform it into a config object1.
Now that... could work... but composability? Something like
import ConfigA from "./tsconfig.base"; type Config = { compilerOptions: { strict: true } & Omit<NonNullable<ConfigA["compilerOptions"]>, "strict"> }; export default Config;
maybe? That could work... it is flexible and expressive enough, but it is a bit wordy, especially compared to options today. Maybe some aliases would help. How's completion support inside the type? Missing? Well, that's a downgrade. Guaranteeing schema conformance would be nice. So we also need a helper like
type Config<T extends RawTSConfig> = T;
so we can write
import { Config } from "typescript"; import ConfigA from "./tsconfig.base"; type MyConfig = { compilerOptions: { strict: true } & Omit<NonNullable<ConfigA["compilerOptions"]>, "strict"> }; export default Config<MyConfig>;
and that gives us safety... still no completions, though. Only way to get that would be.... in... an... inference... context...
And this is where you realize we've been going about this exercise all wrong - types are great - we let you write a type that can exactly describe the precise single value of a json object (mostly), but a lot of the greatness of our typesystem isn't in its' constructors, rather it is in how it maps to the host language - javascript. In fact, it's more powerful in the context of javascript expressions than what type constructors alone can provide. That's when you realize if you write a definition like
export function config<const T extends RawTSConfig>(config: T): T;
you can invoke it like so
import * as ts from "typescript"; export default ts.config({ compilerOptions: { strict: true } });
and get full completions and typechecking on the call, and still get exact literal types for the type of the expression, provided all operations done within the expression resolve to exact literal types. Which means we've come full circle - the idealized form of types as configuration is actually
import * as ts from "typescript"; import base from "./tsconfig.base.js"; import mixin from "./tsconfig.mixin.js"; export default ts.config({ compilerOptions: { ...base.compilerOptions, jsx: "react-jsx", paths: { "dep": ["./node_modules/typescript"], ...mixin.compilerOptions.paths }, traceResolution: true }, include: [ "./app/**/*.ts" ] });
or... visibly identical to code as configuration. The only difference being that I can't believe we didn't use eval to get there2.
This PR is a working proof of concept - it will load a tsconfig.d.ts, tsconfig.ts, or tsconfig.js (or any other code extension), and look for the type of the default member of that module3, convert the type to a config object, and use that config object. For example, the above:

this should work at a basic level both on the command line and in the editor, but this shouldn't be regarded as anything close to production ready - it'd need tests, a dedicated language service project for type config files, more tests, actual diagnostics for when the default type is, say, circularly referential or non-literal, proper integration with caching in the language service layer, a less layered approach to the API (type -> json -> compiler options is indirect - type -> compiler options directly probably yields better UX), and tests.
Still, this is potentially useful, even as incomplete as this - playing around with it has made it apparent, to me at least, just how much I didn't care about the file being executed, but really did just want the syntactic niceties like imports and spreads. And maybe a function call or two. Things not hard to represent purely at compile time in the type system we have today, but that are terribly painful in a JSON document.
Footnotes
-
Waves hand at the specifics of the transform in the cases of unions, intersections, optionals, generics, etc. - Only literal, object, and tuple types matter here - you probably wanna error on types that contain anything else. ↩
-
Though you could
evalit and get the same thing... Unless you void your warranty by casting. ↩ -
More handwaving about how that module is loaded. - Suffice to say, you just pick one set of compiler settings to load configs with (
nodenext-y,moduleDetection: "force") and stick with it. ↩