Detecting TypeScript compiler options
13 min read
The TypeScript compiler is a complex system. As it has grown and changed over the years, it has accumulated a number of configurable compiler options that change its behavior. These flags do a lot of different things, from catching common errors to enforcing clean code practices. Some of the flags change how types are interpreted, meaning that the same code will have a different representation in the type system depending on the flags used.
At work, I maintain a number of TypeScript libraries. Because we can't control the compiler options or the version of TypeScript used by the downstream consumers of the libraries, the types we write must work for all compiler options. It's possible that we could write a type that passes validation with our compiler options, but not with someone else's. In fact, TypeScript itself must write types that work with every combination of compiler options. One example of this is their Awaited<T> type, where they have a guard for strictNullChecks. I'm not maintaining TypeScript, though, so I'd be surprised if I ever got a bug report about anything like this. That being said, I've never been one to let practicality get in the way of curiosity, and I was curious how many different compiler options could be detected via the type system.
A few important notes before we begin:
- The official TypeScript documentation provides in-depth explanations of every compiler option, so I won't cover them in detail here.
- The primary mechanism that we can use to detect compiler options is the conditional type. In short, they take the form
LeftType extends RightType ? TrueType : FalseType. IfLeftTypeis assignable toRightType, the result type isTrueType, otherwise it isFalseType. - TypeScript lets you specify a
libcompiler option so that you can control which globals are available in your project. It also lets you specifynoLib, which prevents the inclusion of any globals. The documentation has a warning that things will break if you set it, because TypeScript cannot compile without certain interfaces being defined. For extraimpracticalityrobustness, we will try to implement our detection types in such a way that they work even ifnoLibis specified.
exactOptionalPropertyTypes
interface JustString {
prop?: string;
}
interface ExplicitUndefined {
prop?: string | undefined;
}
When the exactOptionalPropertyTypes flag is false, then the JustString and ExplicitUndefined interfaces are considered equivalent. At runtime obj.prop = undefined would be permitted for either interface. When the flag is true, then the assignment remains valid for ExplicitUndefined, but becomes invalid for JustString. To detect the flag in the type system, all we have to do is compare the two types.
type ExactOptionalPropertyTypes = ExplicitUndefined extends JustString
? false
: true;
strictBindCallApply
The strictBindCallApply flag ensures that result = fn.call(undefined, value) is typed correctly. When the flag is false, the parameters and the return type are all any. When the flag is true, the types from the definition of fn are used.
Under the hood, this is implemented by having function definitions inherit from the Function interface when the flag is false and from the CallableFunction interface when the flag is true. Here are the relevant portions of the definition from TypeScript's lib.es5.d.ts.
interface Function {
apply(this: Function, thisArg: any, argArray?: any): any;
call(this: Function, thisArg: any, ...argArray: any[]): any;
bind(this: Function, thisArg: any, ...argArray: any[]): any;
}
interface CallableFunction extends Function {
apply<T, R>(this: (this: T) => R, thisArg: T): R;
apply<T, A extends any[], R>(
this: (this: T, ...args: A) => R,
thisArg: T,
args: A,
): R;
call<T, A extends any[], R>(
this: (this: T, ...args: A) => R,
thisArg: T,
...args: A
): R;
bind<T>(this: T, thisArg: ThisParameterType<T>): OmitThisParameter<T>;
bind<T, A extends any[], B extends any[], R>(
this: (this: T, ...args: [...A, ...B]) => R,
thisArg: T,
...args: A
): (...args: B) => R;
}
A reasonable initial approach for solving this would be to check if some arbitrary function extends CallableFunction or not: SomeFunc extends CallableFunction ? true : false. However, because the methods on the Function interface use any, this check evaluates to true even when the flag is false. Instead, we must dig into the type to compare a more specific difference.
If we look at the return types for call, above, we can see that Function["call"] returns any and CallableFunction["call"] returns R. Let's extract those types!
type FunctionCall = ReturnType<Function["call"]>; // => any
type CallableFunctionCall = ReturnType<CallableFunction["call"]>; // => unknown
Wait, why is CallableFunctionCall typed as unknown instead of R? That's because we didn't actually provide a generic parameter. In situations like this, TypeScript uses the broadest possible type. Because R is unconstrained, the unknown type is used. Now, let's use this to detect a type.
type Fn = () => void; // dummy function
type Ret = ReturnType<Fn["call"]>;
When the flag is true, Ret is unknown. When the flag is false, Ret is any. However, because any is assignable to unknown and unknown is assignable to any, we can't compare them directly.
type AnyExtendsUnknown = any extends unknown ? true : false; // => true
type UnknownExtendsAny = unknown extends any ? true : false; // => true
Where they differ is how they behave with other types.
type ExtendsZero<T> = T extends 0 ? true : false;
type AnyExtendsZero = ExtendsZero<any>; // => boolean
type UnknownExtendsZero = ExtendsZero<unknown>; // => false
The any type is assignable to anything, so you might expect its conditional to evaluate to true. However, because it can be anything, it can also be a type that is not assignable to the type on the right. Thus, unless the type on the right is any or unknown, a conditional with any on the left hand side will evaluate to both the true type and the false type. In our case, that's true | false, which gets reduced to boolean.
The unknown type is a lot simpler. It can't be assigned to anything except any and itself.
Now we need a way to differentiate boolean from false. we can do that easily by checking against true.
type TrueExtends<T> = true extends T ? true : false;
type TrueExtendsBoolean = TrueExtends<boolean>; // true
type TrueExtendsFalse = TrueExtends<false>; // false
With this example type, note that TrueExtendsBoolean is true, but boolean comes from any, which comes from Function, which is used when the strictBindCallApply flag is false. When we put this pattern to use, we have to flip the result.
type Fn = () => void;
type Ret = ReturnType<Fn["call"]>;
type RetExtendsZero = Ret extends 0 ? true : false;
type StrictBindCallApply = true extends RetExtendsZero ? false : true;
This type should work for most use cases. However, it relies on ReturnType, Function["call"] and CallableFunction["call"], which aren't available when the noLib flag is set. To create a more robust type, we should avoid relying on any interfaces provided by TypeScript. First, to make replacements easier, let's make one big type definition.
type StrictBindCallApply = true extends (
ReturnType<(() => void)["call"]> extends 0 ? true : false
)
? false
: true;
Next, we know that we need to replace (() => void)["call"] and ReturnType<...> with something else, so let's convert our type into a generic.
type Ret = ReturnType<(() => void)["call"]>;
type SBCA<T> = true extends (T extends 0 ? true : false) ? false : true;
type StrictBindCallApply = SBCA<Ret>;
Next, we need to extract the return type of the call method defined on a function, so let's define a type for that.
type CallPropThatReturns<T> = { call: (...x: never) => T };
Now we have a function type, () => void, and an interface that we expect it to satisfy. But rather than providing a generic parameter to the interface, we need to somehow extract one from it. We can do this by using infer.
type CallPropThatReturns<T> = { call: (...x: never) => T };
type StrictBindCallApply =
(() => void) extends CallPropThatReturns<infer R> ? SBCA<R> : boolean;
We expect () => void to always have a call prop that returns some type R, which we can then use to detect the flag. If, for some reason (noLib) that's not the case, then we don't know whether the flag is true or false, and therefore we use boolean as a fallback value.
strictBuiltinIteratorReturn
An iterator is an object with a next method that returns an object with a value property and a done property. A number of utility methods for JavaScript built-ins return iterators, like Map#entries or Array#values. Generator functions also return iterators when called.
function* example() {
yield "foo";
return 123;
}
const iterator = example(); // => Iterator<"foo", 123>
const value = iterator.next().value; // => "foo" | 123
The TypeScript definition for iterators looks like Iterator<T, TReturn = any, TNext = undefined> (TNext is the the optional parameter that can be provided when calling next). Prior to TypeScript 5.6, the language features that use iterators (maps, arrays, etc.) were typed using Iterator<T>. This led to a subtle problem. Can you spot it?
const arr = [1, 2, 3]; // => number[]
const iterator = arr.values(); // => Iterator<number>
const value = iterator.next().value; // => any
The omitted TReturn parameter defaults to any, which effectively erases the type we expected to see. Oops! The strictBuiltinIteratorReturn flag was introduced in v5.6 to solve this problem. When the flag is set, TReturn defaults to undefined instead of any. In the example above, the type of value would be number | undefined. Fortunately for us, detecting the flag is pretty simple.
type StrictBuiltinIteratorReturn = unknown extends BuiltinIteratorReturn
? false
: true;
The flag controls the value of the intrinsic type BuiltinIteratorReturn. An intrinsic type is essentially a "magic" type that are implemented in the compiler, rather than being defined as part of the standard type system. But, as mentioned, the flag is fairly new. What if we want to support older versions of TypeScript? We'd need to access BuiltinIteratorReturn indirectly, doing the type equivalent of arr.values().next().value.
type StrictBuiltinIteratorReturn = unknown extends ReturnType<
ReturnType<Array<string>["values"]>["next"]
>["value"]
? false
: true;
It's worth noting, briefly, that language features like for-of loops rely on certain type definitions being present, and won't work with noLib enabled (unless type definitions are manually provided). It would be perfectly reasonable for our type to also rely on those type definitions. So, of course, our next step is to not rely on those type definitions! We wouldn't want anyone to accuse us of being sensible.
type RelevantPartsOfArray<T, TReturn> = {
values(): {
next(): { done?: false; value: T } | { done: true; value: TReturn };
};
};
type SBIR<T> = unknown extends T ? false : true;
type StrictBuiltinIteratorReturn =
string[] extends RelevantPartsOfArray<string, infer R> ? SBIR<R> : boolean;
Repeating the pattern that we used for strictBindCallApply, we check if our test value satisfies the expected interface. If it does, we extract the relevant value and provide that to our checker type. If not, then we use boolean as a fallback.
strictFunctionTypes
The strictFunctionTypes flag fixes a simple issue with type checking function parameters.
type StringFunc = (x: string) => void;
type StringNumberFunc = (x: string | number) => void;
const strFn: StringFunc = (x) => {
if (typeof x !== "string") throw new Error("oops");
};
strFn(10); // unsafe at runtime
const strNumFn: StringNumberFunc = strFn; // shouldn't be allowed!
strNumFn(10); // unsafe at runtime
In this example, strFn throws at runtime if called with a number value. Therefore, it is not safe to assign strFn to a function type that accepts a number as a parameter. When the strictFunctionTypes is true, such an assignment becomes a type error. Detecting this behavior is remarkably straightforward.
type StrictFunctionTypes = ((x: string) => void) extends (
x: string | number,
) => void
? false
: true;
strictNullChecks
I'm just going to quote the documentation on this one:
When
strictNullChecksisfalse,nullandundefinedare effectively ignored by the language. This can lead to unexpected errors at runtime.When
strictNullChecksistrue,nullandundefinedhave their own distinct types and you’ll get a type error if you try to use them where a concrete value is expected.
const example = Math.random() < 0.5 ? { object: true } : undefined;
console.log(example.object); // not safe!
In the example above, accessing example.object is not guaranteed to be safe at runtime. It's a type error when the flag is true, but not when it is false. This is another simple one to detect.
type StrictNullChecks = unknown extends {} ? false : true;
In JavaScript, every value except for null and undefined inherits from Object.prototype. Correspondingly, every type in TypeScript inherits from the Object interface, except for null and undefined. And rather than using the Object type interface, which might not be present with noLib, we can use the empty type {}. Note that it's not truly empty; it inherits from the Object interface just like an empty JavaScript object inherits from the Object prototype.
Here, we use unknown as our "everything" type. If we ignore null and undefined (when the flag is false), then Object is "everything". If we treat null and undefined as distinct, then Object is, of course, not "everything".
keyofStringsOnly
Note
This flag has been deprecated and non-functional since TypeScript 5.5. Let's look at it anyway!
The keyofStringsOnly flag changes the result of keyof X from all keys (strings, numbers and symbols) to just the string keys. To check whether the flag is enabled, we just need to check if number is a valid key.
type KeyofStringsOnly = number extends keyof { [x: string]: 0 } ? false : true;
noStrictGenericChecks
Note
This flag has been deprecated and non-functional since TypeScript 5.5. Let's look at it anyway!
The noStrictGenericChecks flag loosens restrictions on how generic parameters are handled.
Copying the example from the documentation:
type A = <T, U>(x: T, y: U) => [T, U]; type B = <S>(x: S, y: S) => [S, S]; function f(a: A, b: B) { b = a; // Ok a = b; // Error }
Type A has two generic parameters, which could be unrelated, while type B has two parameters that are always the same. A user could call a("", 0), but not b("", 0). Therefore, the assignment a = b is an error when the flag is false, but not when the flag is true. We can if the flag is enabled by checking whether B is assignable to A.
type NoStrictGenericChecks = B extends A ? true : false;
noLib
As previously discussed, the noLib flag prevents the automatic inclusion of any types. This is pretty simple to detect. If a primitive value inherits anything from an interface, the interface was included and therefore the flag is false. If not, the flag is true.
type NoLib = keyof 0 extends never ? true : false;
In practice, this is an unreliable detection mechanism. Even if noLib is true, the types are unlikely to be missing, they'll just be user-provided rather than built in. There's no way in the type system to detect who authored a type.
lib
Rather than being a boolean flag, like every other compiler option we've explored so far, lib accepts an array of values, which define which globals are available. For example, "lib": ["ES2020", "DOM"] provides language features from the ECMAScript 2020 specification and types available in browsers. Under the hood, each "top-level" lib option is composed of multiple files. The definition for ES2020 looks like this:
/// <reference lib="es2019" />
/// <reference lib="es2020.bigint" />
/// <reference lib="es2020.date" />
/// <reference lib="es2020.number" />
/// <reference lib="es2020.promise" />
/// <reference lib="es2020.sharedmemory" />
/// <reference lib="es2020.string" />
/// <reference lib="es2020.symbol.wellknown" />
/// <reference lib="es2020.intl" />
Each of the individual files referenced can also be listed as part of the lib array. "lib": ["ES2020"] is identical to "lib": ["ES2019", "ES2020.BigInt", "ES2020.Date", "ES2020.Number", "ES2020.Promise", "ES2020.SharedMemory", "ES2020.String", "ES2020.Symbol.WellKnown", "ES2020.Intl"]. This granularity is great for developers who may be using polyfills for some newer language features, but don't want to include everything. It poses a problem for us, though, because there are 100 different lib files shipped with TypeScript 6. Properly detecting them all is a task too tedious for even this blog post. And even if we wanted to, we can't actually detect them all. Any interface defined in a lib file can only be used directly when that lib file is included. When that lib file is not included, then trying to use the interface results in a type error. This prevents a number of lib files from being detected. However, we can still detect lib files that modify basic language features, like number or string. Lastly, it's important to note that language features are additive; ES2024 includes everything from ES2023, which includes everything from ES2022, and so on. To detect which lib we're using, we can find something new introduced in each one, then check them in order to see when we stop having new features.
type Lib = "toFixed" extends keyof number // es5+
? "copyWithin" extends keyof never[] // es2015+
? "includes" extends keyof never[] // es2016+
? "padEnd" extends keyof string // es2017+
? "dotAll" extends keyof RegExp // es2018+
? "flat" extends keyof never[] // es2019+
? "matchAll" extends keyof string // es2020+
? "replaceAll" extends keyof string // es2021+
? "at" extends keyof string // es2022+
? "with" extends keyof never[] // es2023+
? "isWellFormed" extends keyof string // es2024+
? "isError" extends keyof ErrorConstructor // esnext
? ["esnext"]
: ["es2024"]
: ["es2023"]
: ["es2022"]
: ["es2021"]
: ["es2020"]
: ["es2019"]
: ["es2018"]
: ["es2017"]
: ["es2016"]
: ["es2015"]
: ["es5"]
: []; // noLib
Note that we had to break our "no interfaces" rule for es2018 and esnext because neither spec introduced new features that we could access via builtin types. Using RegExp and ErrorConstructor somewhat mitigates the issue because the interfaces are first defined in es5. The detection type only fails to compile when noLib is specified and the basic types aren't provided by the user.
It's also worth pointing out that none of this matters if the user overrides the lib files using node_modules. They could replace the type definitions we expect in each lib with something entirely different.
Conclusion
Finally, just to admire our work, let's put it all together into one big type. Note that when we inline some of the helper types we can simplify a few of the conditionals.
interface EffectiveCompilerOptions {
exactOptionalPropertyTypes: {
prop?: string | undefined;
} extends {
prop?: string;
}
? false
: true;
strictBindCallApply: (() => void) extends { call: (...x: never) => infer R }
? true extends (R extends 0 ? true : false)
? false
: true
: boolean;
strictBuiltinIteratorReturn: string[] extends {
values(): {
next(): { done?: false; value: string } | { done: true; value: infer R };
};
}
? unknown extends R
? false
: true
: boolean;
strictFunctionTypes: ((x: string) => void) extends (
x: string | number,
) => void
? false
: true;
strictNullChecks: unknown extends {} ? false : true;
keyofStringOnly: number extends keyof { [x: string]: 0 } ? false : true;
noStrictGenericChecks: (<S>(x: S, y: S) => [S, S]) extends <T, U>(
x: T,
y: U,
) => [T, U]
? true
: false;
noLib: keyof 0 extends never ? true : false;
lib: "toFixed" extends keyof number // es5+
? "copyWithin" extends keyof never[] // es2015+
? "includes" extends keyof never[] // es2016+
? "padEnd" extends keyof string // es2017+
? "dotAll" extends keyof RegExp // es2018+
? "flat" extends keyof never[] // es2019+
? "matchAll" extends keyof string // es2020+
? "replaceAll" extends keyof string // es2021+
? "at" extends keyof string // es2022+
? "with" extends keyof never[] // es2023+
? "isWellFormed" extends keyof string // es2024+
? "isError" extends keyof ErrorConstructor // esnext
? ["esnext"]
: ["es2024"]
: ["es2023"]
: ["es2022"]
: ["es2021"]
: ["es2020"]
: ["es2019"]
: ["es2018"]
: ["es2017"]
: ["es2016"]
: ["es2015"]
: ["es5"]
: []; // noLib
}
