Skip to content

Configuration Profiles #50997

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

Closed
RyanCavanaugh opened this issue Sep 29, 2022 · 3 comments
Closed

Configuration Profiles #50997

RyanCavanaugh opened this issue Sep 29, 2022 · 3 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@RyanCavanaugh
Copy link
Member

TypeScript Configuration Profiles

(This is a proposal, but written in terms of a blog post for clarity)

The Problem

Many times when improving correctness or allowing for increased strictness, we (the TypeScript team) face some challenging questions:

  • In a new codebase, is this behavior always desirable, or just sometimes?
  • Is this behavior something that all declaration files need to agree on?
  • In an existing codebase, if this behavior is enabled, what is the predicted mix of false and true positives?

Let's look at a concrete example to understand how these factors play out.

Unconstrained Type Parameter / { } assignability

In TypeScript 4.8, we fixed a longstanding issue that unconstrained generic type parameters were unsoundly assignable to { }. Critically, this was a correctness fix -- in any situation, there was the potential for a bug in user code to be hiding.

In a new codebase, there is zero reason to allow those sorts of incorrect assignments to occur. You would easily add the needed constraint and/or if check. In a sense, this fix is really a subset of the behaviors implied by strictNullChecks -- that null and undefined are not allowed to inhabit types that don't declare them.

However, this check could (and did) cause errors in declaration files. This is a problem, because not all declaration files are necessarily under your control. Types shipped within an npm module, for example, cannot be easily overriden, and types in those modules could be declared in a way that caused errors. For 4.8, we did a great deal of legwork to find and fix these issues, as well as in DefinitelyTyped.

In existing codebases, the odds of hitting at least one new error due to this change were reasonably high. But in practice, most unconstrained type parameters are rarely inhabited by null or undefined, and in some cases we saw type parameters that were only ever referenced as instantiations using properly-constrained type parameters. For the most part, these codebases didn't have lurking bugs. While the possibility of null/undefined sneaking in is always present, we only saw a handful of cases where a true bug was found.

This combination of factors created a difficult situation:

  • The change is unquestionably correct: the old behavior was an unjustifiable soundness hole
  • No rational person would disable this behavior: it is never broadly desirable to not have this check
  • But.
  • Many existing declaration files would need to be updated
  • No extant codebase is likely to see a net benefit from enabling it

We went back and forth, a lot, over whether to add a flag to control this behavior. The key decision points were:

  • This is undeniably a strict flag, and should be on by default, but doing this creates the low-value breaks described above
  • Adding a flag now means supporting it "forever", which is a maintenance burden even from a mechanical perspective
  • Allowing this to be configurable expands the configuration matrix, all cells of which need to be thought about in future decisions

On net, we decided to live with the breaks and ship this without a flag. The reception to this has been largely positive, but it was unsatisfying to ship this kind of change. We want upgrading TypeScript to be a gratifying process that makes you think "Ah yes, I see an older version of TypeScript missed that problem", not "This new version of TypeScript is complaining about something I never had to think about before".

That said, we know this experience wasn't optimal, and we had to do a lot of work to ship a change like this without being overly disruptive.

The Strictness Dilemma

Working from this example, we basically see four kinds of "new errors" you might encounter today:

  • Opinionated checks: For example noUncheckedIndexedAccess - while clearly more sound, we think this flag has a high "noise floor" that isn't necessarily broadly-desirable
  • Bugfix checks: those that are effectively bug fixes and were never truly intentional in the first place. Unconstrained type parameter -> { } assignability falls into this category.
  • Easy good checks: Unambiguously-good checks that can be turned on, with modest effort, in an existing codebase. For example, strictPropertyInitialization can usually be turned on without too much effort, and often finds bugs in the process.
  • Hard good checks: Unambiguously-good checks that can't be enabled in an existing codebase. For example, strictNullChecks - it'd be a very odd choice to start a new project in 2022 with this flag disabled, but enabling this flag for an existing project is known to be an extremely large amount of work

Opinionated checks are easy to reason about: These don't need to be on by default, ever.

Bugfix checks are also usually easy to reason about: Bug fixes are bug fixes and all software can fix bugs in a way that is observable during the upgrade process. In rare cases, like the unconstrained type parameters, bug fix checks can result in an unusually high number of new errors in codebases. When practical, we can consider a migration flag that is deprecated during the next deprecation wave.

"Easy good checks" present a small problem: Users, especially those upgrading by multiple releases at once, can be overwhelmed by the quantity of new errors they see. Even a dozen errors can feel like an enormous task if you're not familiar with the code. While these flags can be disabled, it's not always clear which errors are due to which flag, and the complaint of "I didn't ask for this in a minor version" is a reasonable one.

"Hard good checks" present a big problem: We want these checks to be in the strict family, but as most users enable strict (which we want them to do!), turning them on by default risks presenting a huge wall of errors to people trying to do a routine TypeScript version update.

What can be done?

Configuration Profiles

Let's imagine a new compiler option, profile:

{
    "compilerOptions": {
        "profile": "argon", // 🌟 naming TBD 🌟
        "target": "ESNext",
        "noImplicitReturns": true
    }
}

What is it?

profile is a setting that changes the default values of all other config options.

By providing a profile value, we can allow TypeScript users to "lock in" their current compiler options regardless of versioning, while still allowing an empty tsconfig to have as few explicitly-specified properties as possible to get the "best" TypeScript experience. This also lets us reduce the confusing interaction between various settings -- for example, today strict also turns on noImplicitAny, and there are other dependencies that are more complex.

With profile, tsc --init can produce a much more minimal file, containing "profile": "whatever the newest one is", plus commented-out lines for opinion-based settings like noUncheckedIndexedAccess. We can also expect existing tsconfig.json files to get much shorter, since they can quickly opt in to their current settings modulo any overrides needed.

Scheduling

New profiles can be created on an as-needed basis. During major version upgrades, we can upgrade the default profile to whatever the latest is at the time.

At the 5.0 release, the default profile will correspond to compiler settings as they were computed in TypeScript 4.9. At the 6.0 release, we'll "upgrade" the versioning policy, and at that time the default profile will be whatever the newest profile at that time is. This gives people plenty of time to lock in a profile in the meantime to avoid unwanted surprises.

What It Isn't, and Naming

profile changings the default compiler options. Critically, it has no other effect.

Some compilers, like C#, have a "compiler version" setting. This setting lets newer compiler versions "act like" older versions, to the greatest extent feasible. This is not what profile is intended to be. Given the surrounding ecosystem, it's reasonably straightforward to install any specific TypeScript version on a per-project basis. For this reason, we don't want to name this in a way that implies it is a version selection mechanism. While the profiles will obvious correspond to a TypeScript version in which they were first present, these profiles aren't intended to allow for mimicing of older versions.

This is also not intended to be a "strictness dial". Our intent when we first made --strict was that --strict would always opt you in to the very strictest TypeScript settings. It quickly became apparent that people really wanted to opt themselves into this mode, and that projects with no appetite to incur large breaky changes on version upgrades. If we use profile and start creating options like "strict", "strictier", "stricest", "strictest-latest", etc, we're putting ourselves back into the boat of having to figure out which changes are palatable to existing codebases and which aren't. The preferred mechanism for opting into a well-known configuration with some settings is to extend from a shipping package containing TypeScript defaults.

Given these constraints, we'd like some feedback on how to name both the setting itself, and its allowed values.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Sep 29, 2022
@nopeless
Copy link

@RyanCavanaugh I have a question

How is this different from extends: "node_modules/some_package/config.json" where some_package updates the major version for breaking changes?

It seems to satisfy

  • Minor config updates
  • Does not break routine minor version upgrades
  • Allows projects to be initialized with newer versions of TypeScript and latest config tsc --init

@RyanCavanaugh
Copy link
Member Author

Good question. One key difference is that we can't automatically insert that line into every existing tsconfig.json, so if we ever want to change TS's default settings (which we certainly do), we don't really have any other way to do that.

The other problem is that an upstream config file can't exactly mimic all behavior from various settings, since some settings behave differently if they're explicitly specified vs left blank (due to depending on other settings).

For example, today (if not otherwise specified) noImplicitAny is on if strict is set, otherwise off. In a hypothetical world where we wanted noImplicitAny on by default, but strict off by default, there's no config file we could write that would mimic the current behavior in that newer version of TS.

@koshic
Copy link

koshic commented Sep 30, 2022

It's a very good idea - to keep compatibility with old codebases and tons of existing code. But proposed implementation is kind of anti-pattern: it increases complexity of existing thing already has a lot of issues.

Examples? 'Extend' in tsconfig, project references, 'exports / imports' in package.json, old-style eslint config, etc.

If you really want to improve ts ecosystem, just take looks at something like ts.config.js / .ts with some default / recommended packages / presets / codemods, and freeze current state of tsconfig.

@RyanCavanaugh RyanCavanaugh closed this as not planned Won't fix, can't repro, duplicate, stale Jul 18, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

3 participants