Skip to content

Instantly share code, notes, and snippets.

@RyanCavanaugh
Created August 21, 2020 15:52
Show Gist options
  • Save RyanCavanaugh/702ebd1ca2fc060e58e634b4e30c1c1c to your computer and use it in GitHub Desktop.
Save RyanCavanaugh/702ebd1ca2fc060e58e634b4e30c1c1c to your computer and use it in GitHub Desktop.

Strict Environment

Problem Statement

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.

Problem 1: Augmentation Pollution

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 like Map, but does not require an ES6 environment
  • The some-cool-lib definition file has /// <reference lib="es6"> so that it can legally refer to Map
  • 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

Why Not Placeholder Types?

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.

Problem 2: Global Pollution

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 like Buffer but does not require a node.js environment
  • The some-cool-lib definition file has /// <reference lib="node"> so that it can legally refer to Buffer
  • 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.

Problem 3: Mixed-Environment

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.

Desired Behavior

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 or types 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.

--strictEnvironment

Under a new setting --strictEnvironment:

  • <reference lib="es6"> pulls in only lib.es6.types.d.ts (see "splitting lib targets" below)
  • Each file's global scope is determined individually

Splitting lib targets

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.

Determining the Global Scope

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 or target settings in the config
  • type directives in tsconfig
  • Direct import, require, and reference directives of all kinds

This excludes files which might be in your program for other reasons:

  • Files in the include list
  • Transitive import, require, and reference 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) { /** **/ }

Known Limitations

Referencing Augmentations from Ambient Contexts

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.

Global Augmentations from Non-lib Sources

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.

Representation of Return Values

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.

@DanielRosenwasser
Copy link

@alexrecuenco
Copy link

alexrecuenco commented Apr 14, 2021

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 into lib.dom.interfaces.d.ts and lib.dom.d.ts? Has this bene talked about already?.

  1. lib.dom.interfaces.d.ts would contain interfaces and not any declare global variable or function,
  2. 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.

@happycollision
Copy link

Perhaps I am misreading this, but would this force projects with colocated test files to add triple slash directives to each test file? For example, a React project with Jest tests sitting right next to their respective Components would probably need to have

/// <reference types="jest"/>

at the top of each *.test.ts file, right?

Would it instead be feasible to extend the tsconfig such that you might describe your entire project in one location?

{
  "compilerOptions": {  ...BASE_CONFIG... },
  "environments": [
    {
      "name": "web",
      "compilerOptions": {
        "types": [ "react" ] // etc...
      },
      "include": [ "src/**/*" ],
    },
    {
      "name": "tests",
      "extends": "web", // any files matched by "web" will be augmented here
      "compilerOptions": { "types": [ "jest" ] },
      "include": [ "src/**/*.test.ts" ]
    }
  ]
}

I am sure that the above is not workable as is. But I imagine there are lots of projects like mine that want to basically match certain files in one environment and match other files with an extended environment, or a different one altogether.

Hey there, Typescript. Could you pull in ES2017 for everything, plus this list of types: [ ... ]. Also, for any file matching *.test.ts, could you also add the "jest" types?

@thw0rted
Copy link

I'm not sure that just splitting out interfaces would be enough.

I found this gist because I'm running into an issue where my frontend app is calling Blob#stream(), but it's been polluted to return a NodeJS.ReadableStream. (The language service thinks that both the Node types for Blob and the DOM lib are loaded, but Node seems to be winning for some reason.) Anyway, point is, "just" having the Node interface definitions in the global type-space creates that kind of problem, and Ryan's suggestion of keeping transitive-dep type references completely hidden from the calling code seems like the only way to fix it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment