At today's meeting, some people expressed interested in a more complete description of the context and background around use case 42. Here's the usecase, for reference:
Rachel is a TypeScript user who is importing some JavaScript code that uses CommonJS. She uses declaration files that were written on DefinitelyTyped, but were authored as ES module top-level exports as so:
export function foo() {
}
export function bar() {
}
When she imports them from TypeScript, she gets code-completion on the namespace import for foo and bar
import * as thing from "some-package";
thing./* completions here*/
When she compiles her code to run on either the 'commonjs' or 'esnext' module targets, she expects both to run correctly.
So, first, some background. TypeScript has these things called "declaration files". They're additional metadata about a .js file that includes additional type information for a module (written in files with a .d.ts extension); this is how vscode can provide good completions for things like lodash and jquery. They usually look something like this:
// Type definitions for abs 1.3
// Project: https://github.com/IonicaBizau/node-abs
// Definitions by: Aya Morisawa <https://github.com/AyaMorisawa>
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
/**
* Compute the absolute path of an input.
* @param input The input path.
*/
declare function Abs(input: string): string;
export = Abs;
or this:
// ... some more definitions
export * from "./createBrowserHistory";
export * from "./createHashHistory";
export * from "./createMemoryHistory";
export { createLocation, locationsAreEqual } from "./LocationUtils";
export { parsePath, createPath } from "./PathUtils";
export { createBrowserHistory, createHashHistory, createMemoryHistory };
that is to say, normal es6 syntax with the addition of export= (to describe the commonjs only pattern of replacing the namespace object) and type annotations, without any expressions or blocks. Someone went and put in the effort to write these type definitions at some point in the past, and there's now a community of people authoring these and keeping them up-to-date. The hub of that community is DefinitelyTyped - every definition file published there is automatically published to the @types npm namespace under the same name as the package it corresponds with - this means that, for example, jquery has types available via @types/jquery. It's a kind of crowd-sourced documentation/metadata store.
So, the dilemma. The typescript compiler (and by extension the vscode js language service, as it is really just the typescript compiler behind a thin facade) follows node's module reolution scheme to find js and/or ts files. In addition, it will also look for an adjacent .d.ts file to provide type information for a .js file, and, failing that, an @types/packagename package with a declaration file to provide the types. (Failing either of those in some configurations it will fall back to the actual JS, if it is able to, but this is costly - there's a lot of JS and it needs to be processed a lot to get good type data from it, which is why declarations are preferred.) We have two unique issues to deal with in the esm transition, both of which come into play here in this use-case. The simpler one is emit - providing a node-esm emit target that interoperates decently. The more complicated one is typechecking.
To start with typechecking (for both js files and ts ones): You'll note in my description of declaration files above, I didn't mention anything about any encoding of the library's available module format(s). This is important - we expect that no matter if you're targeting cjs or amd or esnext that the same declaration file will be able to accurately represent the module. This is critical, as it turns out, because some of our consumers will target esnext with us, but then actually transpile to cjs using another bundling tool, like rollup or webpack (retaining the es6 style imports for dead code elimination). We (strongly) operate under the assumption that interop between various module formats is invisible to the end-user - this carries into the js editing experience, where we assume that weather you wrote import * as x from "lodash" or const x = require("lodash") it will produce roughly the same module[1], and have the same members when you look for completions on x. Now, clearly we're capable of changing this assumption (likely behind a flag, but w/e), but this would (will?) fracture our ecosystem of existing type definition files; anything already written would need to only be interpreted as a cjs package, and we'd have to introduce a marker (file extension, pragma, or otherwise) to flag a declaration file as node-esm so that we can reject the old one and only accept the other for resolution depending on the exact interop scheme. It's not exactly pretty, and goes about as far away from a single "universal" declaration file as you can get (and, naturally, starts to require extra maintenance work to maintain the doubled APIs). Compound that with the fact that nobody usually bothers to tell their editor anything about the files they're working with (ie, will this random .js file be targeting node-esm, esm, or cjs? - at least at first), and we might really have to start arbitrarily guessing about what types an import should actually map to on disk, depending on any exact interop scheme, which is no good from a type safety perspective.
Our emit issues are more clear, and mostly center around exactly how interop might work. The typescript compiler, being a transpiler with multiple supported output formats, allows you to write the same es6-style input code and transpile it to either cjs or esm module formats (or amd or umd or systemjs). It will also auto-generate a declaration file for you. Generally, it is expected that your code will function the same way when targeting any of these module runtimes and present the same interface to consumers who can understand the format (and the same declaration file is currently produced for all of them). Some constructs (like export namespace assignment) aren't supported on some emit targets (ie, esnext), but otherwise interop is generally expected (after all, that's a big part of a transpiler's job). Node's interop scheme, if not fully transparent, would probably require us to emit helpers/perform transforms mapping from the more transparent interop we support today to any more explicit form of module system interop supported by the platform, thus requiring a new, independent module target, different from normal un-transformed esnext modules. Failing that, it would require a flag that at least alters our checking and resolution to only allow any stricter platform interop scheme, which would, naturally, not be able to be the default so as to not break long time users.
We also have relatively strong compatibility needs, since our service needs to keep working on code that was written 1, 2, 3 years ago, as nobody wants to launch their editor on their legacy codebase and just be greeted with a bunch of confused warnings and incorrect types, which necessitates a lot of changes be non-default flags. Our stance is, typically, we only intentionally introduce "breaks" if said break is identifying real problems in your code (ie, you were accessing a property which could never actually exist, but we didn't catch before). And then we'll usually have a flag to turn off the new check. Even for the 3.0 release that we have a milestone up for now, we don't have any really major breaks in the backlog - just larger features (it's more of a pragmatic answer to "what comes after 2.9 when incremented by .1" than a semver major release).
[1]We have a flag for placing the module on an es6-style import's default and disallowing calling namespace objects (esModuleInterop), to align our typecheck and emit with babel's emit, however this isn't currently our default for fear of breaking longtime users.
cc @DanielRosenwasser I hope I've explained your concerns, but you should feel free to chime in.
At today's meeting, some people expressed interested in a more complete description of the context and background around use case 42. Here's the usecase, for reference:
So, first, some background. TypeScript has these things called "declaration files". They're additional metadata about a
.jsfile that includes additional type information for a module (written in files with a.d.tsextension); this is howvscodecan provide good completions for things likelodashandjquery. They usually look something like this:or this:
that is to say, normal es6 syntax with the addition of
export=(to describe the commonjs only pattern of replacing the namespace object) and type annotations, without any expressions or blocks. Someone went and put in the effort to write these type definitions at some point in the past, and there's now a community of people authoring these and keeping them up-to-date. The hub of that community is DefinitelyTyped - every definition file published there is automatically published to the@typesnpm namespace under the same name as the package it corresponds with - this means that, for example,jqueryhas types available via@types/jquery. It's a kind of crowd-sourced documentation/metadata store.So, the dilemma. The
typescriptcompiler (and by extension thevscodejs language service, as it is really just thetypescriptcompiler behind a thin facade) followsnode's module reolution scheme to find js and/or ts files. In addition, it will also look for an adjacent.d.tsfile to provide type information for a.jsfile, and, failing that, an@types/packagenamepackage with a declaration file to provide the types. (Failing either of those in some configurations it will fall back to the actual JS, if it is able to, but this is costly - there's a lot of JS and it needs to be processed a lot to get good type data from it, which is why declarations are preferred.) We have two unique issues to deal with in theesmtransition, both of which come into play here in this use-case. The simpler one is emit - providing a node-esm emit target that interoperates decently. The more complicated one is typechecking.To start with typechecking (for both js files and ts ones): You'll note in my description of declaration files above, I didn't mention anything about any encoding of the library's available module format(s). This is important - we expect that no matter if you're targeting
cjsoramdoresnextthat the same declaration file will be able to accurately represent the module. This is critical, as it turns out, because some of our consumers will targetesnextwith us, but then actually transpile tocjsusing another bundling tool, like rollup or webpack (retaining the es6 style imports for dead code elimination). We (strongly) operate under the assumption that interop between various module formats is invisible to the end-user - this carries into the js editing experience, where we assume that weather you wroteimport * as x from "lodash"orconst x = require("lodash")it will produce roughly the same module[1], and have the same members when you look for completions onx. Now, clearly we're capable of changing this assumption (likely behind a flag, but w/e), but this would (will?) fracture our ecosystem of existing type definition files; anything already written would need to only be interpreted as acjspackage, and we'd have to introduce a marker (file extension, pragma, or otherwise) to flag a declaration file asnode-esmso that we can reject the old one and only accept the other for resolution depending on the exact interop scheme. It's not exactly pretty, and goes about as far away from a single "universal" declaration file as you can get (and, naturally, starts to require extra maintenance work to maintain the doubled APIs). Compound that with the fact that nobody usually bothers to tell their editor anything about the files they're working with (ie, will this random.jsfile be targeting node-esm, esm, or cjs? - at least at first), and we might really have to start arbitrarily guessing about what types animportshould actually map to on disk, depending on any exact interop scheme, which is no good from a type safety perspective.Our emit issues are more clear, and mostly center around exactly how interop might work. The
typescriptcompiler, being a transpiler with multiple supported output formats, allows you to write the same es6-style input code and transpile it to eithercjsoresmmodule formats (oramdorumdorsystemjs). It will also auto-generate a declaration file for you. Generally, it is expected that your code will function the same way when targeting any of these module runtimes and present the same interface to consumers who can understand the format (and the same declaration file is currently produced for all of them). Some constructs (like export namespace assignment) aren't supported on some emit targets (ie,esnext), but otherwise interop is generally expected (after all, that's a big part of a transpiler's job). Node's interop scheme, if not fully transparent, would probably require us to emit helpers/perform transforms mapping from the more transparent interop we support today to any more explicit form of module system interop supported by the platform, thus requiring a new, independent module target, different from normal un-transformedesnextmodules. Failing that, it would require a flag that at least alters our checking and resolution to only allow any stricter platform interop scheme, which would, naturally, not be able to be the default so as to not break long time users.We also have relatively strong compatibility needs, since our service needs to keep working on code that was written 1, 2, 3 years ago, as nobody wants to launch their editor on their legacy codebase and just be greeted with a bunch of confused warnings and incorrect types, which necessitates a lot of changes be non-default flags. Our stance is, typically, we only intentionally introduce "breaks" if said break is identifying real problems in your code (ie, you were accessing a property which could never actually exist, but we didn't catch before). And then we'll usually have a flag to turn off the new check. Even for the
3.0release that we have a milestone up for now, we don't have any really major breaks in the backlog - just larger features (it's more of a pragmatic answer to "what comes after2.9when incremented by.1" than a semver major release).[1]We have a flag for placing the module on an es6-style import's
defaultand disallowing calling namespace objects (esModuleInterop), to align our typecheck and emit with babel's emit, however this isn't currently our default for fear of breaking longtime users.cc @DanielRosenwasser I hope I've explained your concerns, but you should feel free to chime in.