Strict Environment is intended to address three distinct problems that are frequently encountered by TypeScript developers.
This is an alternative approach to solving the problems that placeholder types were intended to address.
Augmentation pollution occurs when a project includes an external definition file that transitively depends on a lib
that is higher than the project's actual target
.
The chain of events typically goes:
- The user creates a project with the intent of targeting ES5, and explicit sets
target: "ES5"
- User installs
some-cool-lib
, which is capable of operating on ES6-specific objects likeMap
, but does not require an ES6 environment - The
some-cool-lib
definition file has/// <reference lib="es6">
so that it can legally refer toMap
- The user accidently writes ES6-specific code
someArray.keys()
without getting an error - Their code crashes when run in the ES5 environment, and the user is sad
The long-speculated solution to this has been placeholder types, but we don't find this to be a plausible path forward. Placeholder types would require effectively everyone to opt in to a new awkward syntax for reference types, requires that definition file authors would actually realize they are referring to a target-specific type, and would need to be adopted across the vast majority of DefinitelyTyped and shipped definitions before it had any real effect. Developers might have dozens of type definitions in their project, and only one of them not using a placeholder type would defeat the entire effect.
Global pollution is similar to target pollution. A common pattern is:
- The user creates a project with the intent of targeting the DOM
- User installs
some-cool-lib
, which is capable of operating on node.js-specific types likeBuffer
but does not require a node.js environment - The
some-cool-lib
definition file has/// <reference lib="node">
so that it can legally refer toBuffer
- The user accidently refers to the global
process
without getting an error - Their code crashes when run in the DOM environment, and the user is sad
Again here, placeholder types would fall short unless every package on DefinitelyTyped correctly decoupled itself from any upstream dependencies which introduced globals.
A common scenario is that someone is writing a "mixed-environment" codebase, where some code runs only on the node backend, some code runs only on the browser frontend, and some code runs in both. The increasing adoption of webworkers also represents a new environment that developers want to target as part of their applications.
The traditional solution here has been to decouple the project into separate tsconfigs and use project references to represent the dependency graph. This is cumbersome, especially for scenarios like running a single particular function in a webworker context. TypeScript should support this scenario natively instead of requiring artificial divvying up of the user's codebase.
Observing the above problems, we need the following behaviors to occur:
- Referencing a particular
lib
from a definition file should not cause new global augmentations to appear in the user's codebase - Referencing a
lib
ortypes
from a definition file should not cause new global types or values to appear in other files
With these two principles in place, everything falls into place naturally. Users won't see ES6-specific types, values, or augmentations unless they've set target
or lib
appropriately, but definition files may still reference those libs for the sake of referring to definitions.
Under a new setting --strictEnvironment
:
<reference lib="es6">
pulls in onlylib.es6.types.d.ts
(see "splittinglib
targets" below)- Each file's global scope is determined individually
To prevent augmentation pollution, we'll need to split out the existing e.g. lib.es6.d.ts
files into two files, one which has only new type declarations, and another which has value declarations and interface merges.
Today, the global scope is formed by merging the symbol tables from all files in the program. Under --strictEnvironment
, this process is instead per-file (though should be memoized). A file collects globals from its declared dependencies, which is defined as:
- Anything that occurs as a result of
lib
ortarget
settings in the config type
directives intsconfig
- Direct
import
,require
, andreference
directives of all kinds
This excludes files which might be in your program for other reasons:
- Files in the
include
list - Transitive
import
,require
, andreference
directives
The nontransitivity is important because you could easily imagine something like this:
// frontend.ts
import { trim } from "./utils";
// In this file, we should *not* see node types
// utils.ts
/// <reference types="node" />
export function trim(s: string | Buffer) { /** **/ }
Splitting the built-in lib files means that you could not write e.g.:
/// <reference lib="es6">
declare const myKeys: Array<string>["keys"];
This seems very unlikely to be a problem in practice.
This proposal doesn't offer a mechanism to prevent global augmentations from sources other than the built-in lib
files.
We could take this up separately if desired.
Certain speculative versions of placeholder types had a nice property wherein a definition file might look like this:
// Definition code
// Returns a Buffer if running in node, or a DOM element if run in the browser
declare function getThing(): Buffer | HTMLElement;
// User code, "just does the right thing" if placeholder types treat unresolved identifiers as 'never'
const x = getThing();
Under strict environment, this pattern is a bit worse - you'd need to assert that x
isn't a Buffer
. And if you called some function that did return a Buffer
(but threw an exception in a browser context), you wouldn't really have any way of noticing this happened. However, this isn't any worse than the status quo.
I've been thinking about this issue, most of the time this issue comes about in libraries that want to target both node and the browser by using well-known interfaces that are part of the dom API. They don't usually use the global variables defined in the browser.
For that reason, would it make sense to divide the
lib.dom.d.ts
intolib.dom.interfaces.d.ts
andlib.dom.d.ts
? Has this bene talked about already?.lib.dom.interfaces.d.ts
would contain interfaces and not any declare global variable or function,lib.dom.d.ts
would include the interfaces through linking and add all the global variables.It would provide a very quick time to implementation, since it is just dividing a file in two. And it is both backwards compatible, and would make sure that we can start asking people that are polluting to switch to reference
lib.dom.interfaces.d.ts
instead.Is this idea what you refer to as "splitting lib targets"? I am not sure that is what was being mentioned there.