TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Add synthetic TypeScriptSettings interface that exposes some compiler options to type system

Open rbuckton opened this issue 1 year ago • 8 comments

This adds a synthetic TypeScriptSettings interface to the global scope that reflects the state of current compilation's compiler options. The synthetic interface looks something like the following:

interface TypeScriptSettings {
  version: string; // e.g., "5.5.0-dev", etc.
  versionMajorMinor: string; // e.g., "5.5", etc.
  locale: string | undefined; // e.g., "en-US", etc.
  target: string; // e.g., "es2023", etc.
  module: string; // e.g., "node16", etc.
  moduleResolution: string; // e.g., "node16", etc.
  customConditions: readonly string[] | undefined;
  exactOptionalPropertyTypes: boolean;
  noImplicitAny: boolean;
  noUncheckedIndexedAccess: boolean;
  strictBindCallApply: boolean;
  strictFunctionTypes: boolean;
  strictNullChecks: boolean;
  useDefineForClassFields: boolean;
}

Some compiler options can already be detected by the type system, such as --strictNullChecks or --exactOptionalPropertyTypes:

type StrictNullChecks = (1 | undefined extends 1 ? false : true);
type ExactOptionalPropertyTypes = { _?: true | undefined } extends { _?: true } ? false : true;

TypeScript playground

But most compiler options are not so easily derived.

Supporting stricter Iterable/IterableIterator

One of the key benefits of this approach is that types can be tailored for specific compiler options without the need to introduce new syntax or new intrinsic types. For example, we found that the changes in #58243, which introduces new type parameters for a stricter definition of Iterable, caused far too many breaks were we to ship those changes unflagged. When we shipped --strictBindCallApply, we were able to control strictness by swapping out the base type of a function or constructor type with something stricter than Function. Doing the same for all of the different JS built-ins that are iterable becomes far tricker, and is more likely to run afoul of users writing custom iterators, or would cause complications with the new Iterator constructor introduced by #58222.

Instead, we can leverage TypeScriptSettings to handle this case:

type BuiltinIteratorReturnType = TypeScriptSettings extends { noUncheckedIndexedAccess: true } ? undefined : any;

...

interface Array<T> {
  value(): IterableIterator<T, BuiltinIteratorReturnType>;
}

Here, we can use the existing --noUncheckedIndexedAccess flag to control the strictness of the TReturn type passed to IterableIterator. When the flag is unset, we fallback to the current behavior for IterableIterator (where TReturn is any). When the flag is set, we instead pass undefined for TReturn.

Why use an interface and not an intrinsic type alias?

Rather than use intrinsic, the new TypeScriptSettings type is introduced as an interface to allow for declaration merging when a library needs to target both newer and older versions of TypeScript:

type __StrictNullAwareType<T> = ...;
type __NonStrictNullAwareType<T> = ...;
type __LegacyType<T> = ...;

export type NullAwareType<T> = 
  TypeScriptSettings extends { strictNullChecks: true } ? __StrictNullAwareType<T> :
  TypeScriptSettings extends { strictNullChecks: false} ? __NonStrictNullAwareType<T> :
  __LegacyType<T>;
  
// stub TypeScriptSettings to support older compilers:
declare global { interface TypeScriptSettings {} }

TODO

While this PR is fully functional, we must still discuss which flags to include/exclude, as well as whether to continue with TypeScriptSetings or use a different name. I've only included options that could have an impact on types. Some options like customConditions have some interesting potential, but may end up being cut as they could be abused.

Fixes #50196 Related #58243

rbuckton avatar May 01 '24 22:05 rbuckton

@typescript-bot: pack this

rbuckton avatar May 01 '24 22:05 rbuckton

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

Command Status Results
: pack this ✅ Started ✅ Results

typescript-bot avatar May 01 '24 22:05 typescript-bot

Hey @rbuckton, 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/161592/artifacts?artifactName=tgz&fileId=D6D74C81D27AA9C95BA21E3472278D27CD883DAAEBE92EA3EF4DF165163C9E1D02&fileName=/typescript-5.5.0-insiders.20240501.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/[email protected]".;

typescript-bot avatar May 01 '24 22:05 typescript-bot

This would address (at least partially) https://github.com/microsoft/TypeScript/issues/50196 , cc @phryneas

Andarist avatar May 02 '24 07:05 Andarist

This is great! ❤️

phryneas avatar May 02 '24 07:05 phryneas

This would address (at least partially) #50196 , cc @phryneas

Per @RyanCavanaugh's comment in #50196, the biggest issue with future extensibility would be how people choose to depend on a type like this. It may be that the best we can hope for is that developers would code defensively against future changes, such as:

type Foo =
  TypeScriptSettings extends { noUncheckedIndexAccess: false } ? something :
  TypeScriptSettings extends { noUncheckedIndexAccess: true } ? somethingElse :
  never;

The other missing piece to #50196 is a way to conveniently perform range tests against TypeScriptSettings["version"]. It's possible to do with template literal types, but is by no means convenient:

import { type N, type A } from "ts-toolbelt";

type Eq<X extends number, Y extends number> = A.Is<X, Y, "equals">;

type VersionGreaterEq<A extends `${bigint}.${bigint}`, B extends `${bigint}.${bigint}`> =
    A extends `${infer AMajor extends number}.${infer AMinor extends number}` ?
        B extends `${infer BMajor extends number}.${infer BMinor extends number}` ?
            [Eq<AMajor, BMajor>, N.GreaterEq<AMinor, BMinor>] extends [1, 1] ? true :
            N.Greater<AMajor, BMajor> extends 1 ? true : false :
        false :
    false;

type TSVer = TypeScriptSettings["versionMajorMinor"];
//   ^? type TSVer = "5.5"

type T1 = VersionGreaterEq<TSVer, "5.5">;
//   ^? type T1 = true

type T2 = VersionGreaterEq<TSVer, "5.6">;
//   ^? type T2 = false

TypeScript Playground

It's not likely that we would introduce a comparison mechanism like this as part of this PR, however.

rbuckton avatar May 02 '24 13:05 rbuckton

Another scenario I'd investigated recently was how a library could ensure that specific compiler options are set for it to be used correctly:

// @exactOptionalPropertyTypes: false

interface TypeScriptSettingsError<Message extends string> { error: Message }

type ExpectedSetting<K extends keyof TypeScriptSettings, V, Message extends string> =
    TypeScriptSettings extends { [P in K]: V } ? never :
    TypeScriptSettingsError<Message>;

type CheckSetting<_T extends never> = never;

type _ = CheckSetting<ExpectedSetting<"exactOptionalPropertyTypes", true, `To use this package you must set 'exactOptionalPropertyTypes' to 'true' in your tsconfig.json.`>>;
//                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~...
// error: Type 'TypeScriptSettingsError<"To use this package you must set 'exactOptionalPropertyTypes' to 'true' in your tsconfig.json.">' does not satisfy the constraint 'never'.

TypeScript Playground

Unfortunately, this check would defeated by --skipLibCheck, but could at least catch some incorrect usages of a library until such time as a more comprehensive mechanism for settings validation is adopted.

rbuckton avatar May 02 '24 16:05 rbuckton

And here is a similar example to the previous one, utilizing TypeScriptSettings["locale"] to provide a localized error message:

// @exactOptionalPropertyTypes: false
// @locale: fr-FR

interface TypeScriptSettingsError<Message extends string> { error: Message }

type ExpectedSetting<K extends keyof TypeScriptSettings, V, Message extends string> =
    TypeScriptSettings extends { [P in K]: V } ? never :
    TypeScriptSettingsError<Message>;

type CheckSetting<_T extends never> = never;

interface LocalizedMessages {
    default: {
        exactOptionalPropertyTypes: `To use this package you must set 'exactOptionalPropertyTypes' to 'true' in your tsconfig.json.`
    },
    "fr-FR": {
        exactOptionalPropertyTypes: `Pour utiliser ce package, vous devez définir 'exactOptionalPropertyTypes' sur 'true' dans votre tsconfig.json.`
    },
}

type Messages = {
    [P in keyof LocalizedMessages["default"]]:
        TypeScriptSettings extends { locale: infer Locale extends keyof LocalizedMessages } ?
            P extends keyof LocalizedMessages[Locale] ?
                LocalizedMessages[Locale][P] :
                LocalizedMessages["default"][P] :
            LocalizedMessages["default"][P];
};

type _ = CheckSetting<ExpectedSetting<"exactOptionalPropertyTypes", true, Messages["exactOptionalPropertyTypes"]>>;
//                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~...
// error: Type 'TypeScriptSettingsError<"Pour utiliser ce package, vous devez définir 'exactOptionalPropertyTypes' sur 'true' dans votre tsconfig.json.">' does not satisfy the constraint 'never'.

TypeScript Playground

rbuckton avatar May 02 '24 17:05 rbuckton

We've opted not to pursue this mechanism at this time.

rbuckton avatar May 24 '24 13:05 rbuckton