Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: Types as Configuration #58025

Closed
wants to merge 6 commits into from

Conversation

weswigham
Copy link
Member

@weswigham weswigham commented Apr 1, 2024

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 (@filename on 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

export default Config;

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:
config result of the above, showing merged configs
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

  1. 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.

  2. Though you could eval it and get the same thing... Unless you void your warranty by casting.

  3. 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.

@typescript-bot typescript-bot added Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug labels Apr 1, 2024
@typescript-bot
Copy link
Collaborator

Looks like you're introducing a change to the public API surface area. If this includes breaking changes, please document them on our wiki's API Breaking Changes page.

Also, please make sure @DanielRosenwasser and @RyanCavanaugh are aware of the changes, just as a heads up.

@weswigham
Copy link
Member Author

@typescript-bot pack this

@typescript-bot
Copy link
Collaborator

typescript-bot commented Apr 1, 2024

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
pack this ✅ Started ✅ Results

@typescript-bot
Copy link
Collaborator

typescript-bot commented Apr 1, 2024

Hey @weswigham, I've packed this into an installable tgz. You can install it for testing by referencing it in your package.json like so:

{
    "devDependencies": {
        "typescript": "https://typescript.visualstudio.com/cf7ac146-d525-443c-b23c-0d58337efebc/_apis/build/builds/160889/artifacts?artifactName=tgz&fileId=ACB16D518A0B2E6B679B92A908144F603ED65E52B34A9CE0A110A9957945998302&fileName=/typescript-5.5.0-insiders.20240401.tgz"
    }
}

and then running npm install.


There is also a playground for this build and an npm module you can use via "typescript": "npm:@typescript-deploys/pr-build@5.5.0-pr-58025-3".;

@JoostK
Copy link
Contributor

JoostK commented Apr 1, 2024

Is this also targeting 5.5.555 or is this evil (eval?) enough to land in 6.6.666?

@xiBread
Copy link

xiBread commented Apr 1, 2024

I can't even tell if this is an April Fool's joke or not 😭

@MrHBS
Copy link

MrHBS commented Apr 9, 2024

Code as Configuration is something I have been wanting forever. I hope this was not a April Fool’s joke😭.

@weswigham weswigham closed this May 21, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Author: Team For Uncommitted Bug PR for untriaged, rejected, closed or missing bug
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants