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.