Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal for correct version lookup in deeply embedded non-TS external modules #4673

Closed
poelstra opened this issue Sep 6, 2015 · 27 comments
Closed
Assignees
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript @types Relates to working with .d.ts files (declaration/definition files) from DefinitelyTyped

Comments

@poelstra
Copy link

poelstra commented Sep 6, 2015

Update 20150908: applied feedback from comments. Thanks @jbrantly! Also renamed "Legacy mode" to "Mixed mode" and clarified what it's about.
Update 20150909: more feedback from @jbrantly.

Now that #2338 is implemented, we have nice resolution of typings for 'native TS modules'.
However, exporting typings for non-TS modules can be problematic in case of deeper dependency trees.

Issue #2839 (by me) contains a proposal to solve this, but does not talk about deeper-than-one dependencies in case of conflicting versions. Issue #4665 (by @mhegazy) also addresses non-TS resolution, but does not explicitly mention the lookup logic.

Example dependency tree

Assumptions about this tree:

  • Mainly talking about NodeJS (CommonJS) environment here, not trying to solve the bower problem
  • None of the JS modules pollute the global namespace, i.e. they are 'normal' CommonJS modules

Problem description

In this case, because mylib is the 'first' package that knows about TS, it somehow needs to provide the typings for all non-TS stuff it exposes: [email protected], [email protected] but also both [email protected] and [email protected].

In an ideal world, using 'proper external modules' (see #4665 and #2338) would solve this if they were all TS modules (i.e. they provide their own typings in their npm package).

In this case though, the import statements need to resolve to e.g. DefinitelyTyped typings, typically located in e.g. mylib/typings/.

So, mylib/typings/ needs to have a utils.d.ts for version 3.0 and 4.0, and the compiler needs to know how to find them, and which version to use.

Especially note the difference between the concept of the current JS module versus current TS module in the following proposal.

Proposed algorithm

Naming (taken from #4665):

  • 'ambient external' module typing means it contains declare module "Y" { ... } declarations
  • 'proper external' module typings don't have declare module "Y" { ... }, but directly export their classes, variables, etc.

When compiling a package X, and looking for typings of an external module Y/Z, let:

  • CurrentTSModule = X
  • CurrentJSModule = X
  • Z = "index" if module is imported without a path (i.e. import "Y" instead of import "Y/Z")

Now:

  • Use the logic of External module resolution logic #2338 to locate Y's package.json (starting atCurrentJSModule`)
    • i.e. also traversing node_modules of parent packages
  • If Y provides its own typing (either index.d.ts or by following typings property in package.json in the package directory)
    • Parse that typing as a 'proper external module' typing
    • Set CurrentTSModule and CurrentJSModule variables to Y, i.e. any external modules used by Y should be resolved by looking in <Y>/node_modules and <Y>/typings/, no longer in <X>/typings/
    • Done
  • Otherwise, search for typings using either:
    • ALGORITHM A: searches for Y in X's typings folder (let's call it typings/):
      • typings/<Y>@<majorY.minorY.patchY>/<Z>.d.ts (proper mode)
      • typings/<Y>@<majorY.minorY>/<Z>.d.ts (proper mode)
      • typings/<Y>@<majorY>/<Z>.d.ts (proper mode)
      • typings/<Y>/<Z>.d.ts (proper mode)
      • typings/<Y>/<Y>.d.ts (mixed mode)
      • typings/<Y>.d.ts (mixed mode)
    • ALGORITHM B: match against .d.ts files with <library ... /> tags
      • For all .d.ts files passed on commandline/tsconfig.json that have a <library ... /> tag, match Y's package.json name and version against the name and semver specification in the <library> tag
  • When a match is found:
    • Keep CurrentTSModule as X, but set CurrentJSModule to Y
      • This ensures that the correct version of any sub-package is found
    • If 'proper mode': parse as 'proper external module', done
    • If 'mixed mode': determine whether it's a 'proper external' or 'ambient external' typing:
      • If 'proper external': use it as-is, done
      • If 'ambient external':
        • Don't expose any ambient declarations (modules, namespaces, variables, types), nor recursive
          <reference>'d declarations, to the global module namespace, and
        • Look for the declare module "Y/Z" { ... } and use its contents as the result (i.e. 'convert to proper external')

Notes:

  • <majorY> etc. are based on the version as found in Y's package.json
  • In contrast to the lookup in node_modules, lookups in typings do not traverse typings/ dirs of parent packages. Only those of (TS-)package X are searched.
  • This algorithm correctly determines that foolib uses [email protected], but barlib uses [email protected] (i.e. CurrentTSModule stays mylib, but CurrentJSModule switches from foolib to barlib)

Mixed mode

Mixed mode (previously called "legacy mode"), is intended to allow existing DefinitelyTyped typings
(which use 'ambient external' scheme) to basically be used as external modules (CommonJS) without making a lot of 'accidental' globals available (e.g. the Promise type in bluebird typings), while still also allowing them to be used in AMD and plain script modes ('isomorphic typings').

These isomorphic typings typically declare lots of things as globals (perfect for plain script mode),
but these are usually not made globally available when loaded as CommonJS.

So, the idea is to wrap the whole typing into its own private space, then only make the actually requested external module part of it available.

Note that if an isomorphic typing includes a 'proper external' typing, and the proper external typing
<reference>'s another typing, that typing is still allowed to declare globals (they will not be 'isolated').

Having two packages declare the same global (e.g. when a proper external typing references a .d.ts, which explicitly marks a variable, class, etc as being globally available) currently leads to a compiler error ("Duplicate identifier").
One idea I had was to 'merge' the types of such globals instead (e.g. if two packages both declare a Promise, but they are in fact of different types, the resulting global will be typed as e.g. declare var Promise: PromiseType1|PromiseType2;
This way, the compiler can error when the global is actually used (as opposed to when declared) in an incompatible way (see #4673 (comment))..

Discussion items

  • I'm not sure whether I prefer Algorithm A or B, yet.
    Algorithm A has the advantage of being 'filesystem based', just like node's environment.
    But B supports more complex semver matches and prevents bikeshedding over e.g. the name and structure of typings/.
  • When using Algorithm A (files), @jbrantly suggested to have an extra check for ES6 typings, i.e. preferring e.g. typings/<Y>.es6.d.ts over typings/<Y>.d.ts when available. Or possibly typings/<Y>.<target>.d.ts where <target> is the --target passed to tsc. See comments below for discussions on pros/cons.
  • How to 'override' the type a global actually will have, in case of conflicting types (see Proposal for correct version lookup in deeply embedded non-TS external modules #4673 (comment))
@weswigham
Copy link
Member

I take it this problem is supposed to arise when mylib reexports foolib and barlib when both reexport utils. We don't have to do anything special to support this in a nice way right now.
Since our support of typings in package.json, we can create "type wrappers" for existing libraries, like this. We can leverage npm, rather than attempting to solve a solved problem ourselves.

In this specific example, you change your TS library (mylib) to use a typed-foolib and typed-barlib package shims (which, in turn, use independently versioned typed-util dependencies for their types). Write every shim using external module syntax, and you're conflict free no matter how you compose your packages, since the types follow the packages.

In effect, I'm saying to stop using tsd and DT with node packages, and start authoring typed shim packages instead, because npm handles all of this as part of package resolution anyway. It also makes it easy to swap to using the original package's typings if they're added later - you just update your dependencies in your package.json and swap your require calls. Additionally, this provides independent versioning of the dts and the underlying library but with semver compatibility (thanks npm!).

Additionally, this makes typings discoverable on npm, which should hopefully be a boon to their usage.

@poelstra
Copy link
Author

poelstra commented Sep 7, 2015

That's an interesting idea.

A big disadvantage I see with it, though, is that I'm then dependent on the provider of that typed package to provide high-quality typings in the first place, and then update it every time the source package changes.

I'm afraid that we'll have tens of these typed-* packages (for one source package), just like we have tens of webpack-typescript, gulp-typescript, etc etc packages.

On DT, this is much less of a problem because 'anyone' can contribute, and there's many people to approve the changes.

It also makes it easy to swap to using the original package's typings if they're added later - you just update your dependencies in your package.json and swap your require calls

Yeah, but that's in my proposal even simpler: as soon as the real package has the typing, it will automatically be used over the DT one, no need to change anything :)

@weswigham
Copy link
Member

If you'd like, you can just restructure the DT org (or make another similar one) to publish npm packages in the new style, and accept contributions in kind. The contribution model is not contingent on the distribution one.

@poelstra
Copy link
Author

poelstra commented Sep 7, 2015

we have tens of webpack-typescript, gulp-typescript, etc etc packages.

Clarification: I meant to say 'per package', there's tens of same-but-different packages.
E.g. for gulp, there's gulp-typescript, gulp-tsc, gulp-type, ..., for webpack there's typescript-loader, awesome-typescript-loader, ts-loader, ....

My fear is that the same will happen for basically every other package. The bigger ones will likely converge into a group of people maintaining them, but for the smaller ones...

What could work, maybe, is if we can automatically generate npm packages out of the DT repo. That would have the best of both worlds. Hmmm :)

@poelstra
Copy link
Author

poelstra commented Sep 7, 2015

@weswigham Haha, my thoughts exactly :)

@weswigham
Copy link
Member

My fear is that the same will happen for basically every other package. The bigger ones will likely converge into a group of people maintaining them, most for the smaller ones...

There's 4 different dts for node on DT already (0.8, 0.11, etc). DT already has this issue (and larger versioning issues). By using npm to handle versioning and packages, we keep people using a familiar workflow, and don't duplicate any work or effort. :D

@weswigham
Copy link
Member

@poelstra Yes. It should be possible to autogenerate external declaration dts files from DT types. I've been going through and doing it by hand for node.d.ts, though. Plus, not everything on DT is a node package.

@jbrantly
Copy link

jbrantly commented Sep 7, 2015

It would be ideal if we could somehow add ES6 module information to the lookup process. For instance, if --target es6 is specified, then look for utils.es6.d.ts first before falling back to utils.d.ts. Or perhaps based on the style of the import statement. ES6-style import statement looks for es6.d.ts, CJS-style import statement does not.

@poelstra
Copy link
Author

poelstra commented Sep 7, 2015

@jbrantly Would an ES6 version of the package really be different from a non-ES6 typing in terms of how the end-user can use it? Can you give an example of such a difference?

Because I think that if the typing simply follows the style of the package itself (either ES6 exports or the 'old' way), the user of the library can already choose to use old or new style imports, and the compiler will do the 'conversion' for you. So it would only really matter if you want your typing to work on e.g. TS 1.4 or lower, but I'm not sure that's worth the trouble.

@jbrantly
Copy link

jbrantly commented Sep 7, 2015

@poelstra Absolutely. The difference is in module assignment (not available in ES6) and default exports (not available in CJS). Here are some examples:

declare module "es6" {
  export default 'a';
}

declare module "cjs" {
  export = 'a';
}

These two concepts are fundamentally incompatible (you can't both use export = and export default in the same module). Additionally, TypeScript treats these differently when using import statements, so if you were targeting ES6 you wouldn't even be able to import the CJS module. Pretty much any module compiled by Babel though supports both modes, and therefore it makes sense for there to (possibly) be typings for both modes. If the module doesn't use a default export or a module assignment then separate typings aren't needed.

@jbrantly
Copy link

jbrantly commented Sep 7, 2015

Addendum:

the user of the library can already choose to use old or new style imports

If you're using --target es6 you can't use the old-style imports (TS won't let you)

@poelstra
Copy link
Author

poelstra commented Sep 7, 2015

Hmyeah, ran into the export = vs export default myself a while ago too, namely SitePen/dts-generator#19.

But that was in fact a case of 'trying to use an ES5 export for an ES6 module', which is why I added the

simply follows the style of the package itself
(i.e. if the package is exporting using ES6 syntax, do the same in the typing, if it's exporting using CJS, same)

Additionally, TypeScript treats these differently when using import statements, so if you were targeting ES6 you wouldn't even be able to import the CJS module

Well, I can always import it using import * as foo from "myCJSLib";, which is equivalent to import foo = require("myCJSLib");. At least, that's how I'm importing CJS modules right now.

Pretty much any module compiled by Babel though supports both modes, and therefore it makes sense for there to (possibly) be typings for both modes

Yes, but as explained in e.g. #2719 (comment), Babel uses a trick both when exporting and importing a package. TS now also applies the same trick when exporting (such that e.g. Babel can use a TS package as-is in both modes), but it doesn't apply the necessary check when importing (as far as I know).

The former case is irrelevant here (because Babel doesn't use these typings), and the latter case means that even if we'd provide both export = and export default typing versions for the package, only one of them would actually work in practice (due to the missing dynamic check when TS imports the package). (I hope I'm wrong here, though :))

If you're using --target es6 you can't use the old-style imports (TS won't let you)

You can always import * as foo from "...", right? Or are you saying the .d.ts itself would be rejected if it uses 'old style exports'? (Have to admit I haven't used --target es6 yet.)

@jbrantly
Copy link

jbrantly commented Sep 7, 2015

Well, I can always import it using import * as foo from "myCJSLib";, which is equivalent to import foo = require("myCJSLib");. At least, that's how I'm importing CJS modules right now.

While true, it's not always "convenient" (debatable, I know).

and the latter case means that even if we'd provide both export = and export default typing versions for the package, only one of them would actually work in practice (due to the missing dynamic check when TS imports the package)

Which is why I suggested the --target es6 check. You're right that TS doesn't do the dynamic check if you're targeting ES5, but it's certainly possible to target ES6 and then use Babel or another transpiler to go to ES5/CJS which does do the dynamic check. FWIW, I think your point does cement that it would need to be based on --target and not the import style.

You can always import * as foo from "...", right? Or are you saying the .d.ts itself would be rejected if it uses 'old style exports'?

If an ambient module declaration uses exports = 'a', then it pretty much can't be imported if targeting ES6. Also, modules can't use exports = 'a' which I think would apply to the concept of "proper external module". I think this point more than any other shows the need for such a feature.

As an aside, it could also (possibly) be useful for built-in things like Promise where the ES5/CJS version has to provide definitions but the ES6 version does not. Maybe. I haven't really thought that part through.

@poelstra
Copy link
Author

poelstra commented Sep 7, 2015

but it's certainly possible to target ES6 and then use Babel or another transpiler to go to ES5/CJS which does do the dynamic check

Interesting point. But this may lead to the situation where the compiler emits ES6 code without complaints, which will only work when you're indeed going to pull them through Babel. If you'd use a non-dynamic-check-loader, it will fail at runtime.

If an ambient module declaration uses exports = 'a', then it pretty much can't be imported if targeting ES6

But simply 'automagically re-typing' it as something else (if it really does exports = internally) is not the right solution either, as it only works if you're using Babel(ish). Even though the package may be generated using Babel (and thus might support both modes), it's not guaranteed that the user will also use Babel.

Also, modules can't use exports = 'a' which I think would apply to the concept of "proper external module"

What do you mean? I think both 'proper external' is equally applicable to CJS and ES6?

I think this point more than any other shows the need for such a feature.

I think our discussion shows that there are valid use-cases for it, but that it should not be the default behaviour :)
Certainly not just based on --target es6. Maybe some other switch that 'tweaks' the resolving behaviour. I'm sure there will be more of these anyway (e.g. the location of the typings folder).
Anyway, this is only really relevant for the Algo A case. In Algo B, people would still explicitly list their references somehow (probably tsconfig.json), and can handle it there.

As an aside, it could also (possibly) be useful for built-in things like Promise where the ES5/CJS version has to provide definitions but the ES6 version does not. Maybe. I haven't really thought that part through.

I've been thinking about that recently as well, but I came to the conclusion that an ES5/CJS package should not provide such definitions if it doesn't also provide the implementation. I.e. I think it would be a good thing that the compiler tells me "unknown type Promise", such that I know I need to provide a polyfill myself when I'm using Node 0.10 (and that polyfill package might in turn make the Promise type available globally). Or that if I'm compiling for 0.12, it will already work without complaints (even if I'm just using --target es5).

Seems the TS team is already working on something to facilitate the latter in #4168, btw.

@jbrantly
Copy link

jbrantly commented Sep 7, 2015

What do you mean? I think both 'proper external' is equally applicable to CJS and ES6?
I think our discussion shows that there are valid use-cases for it, but that it should not be the default behaviour :)

I think you misunderstood what I was saying. Say you've written a module in ES6 and used Babel to compile it. You want to provide typings, so you write a proper external module file for it (which my understanding is just a .ts file? whoops. But point still stands.). You want people using both CJS and ES6 to be able to consume it (thus the Babel compilation). If we don't have ES6 file lookup, you just write one file which let's say looks something like this:

export = 'a';

Now a consumer using TypeScript comes along and is using --target es6.

import * as someModule from 'someModule';

As it stands today they will get an error if they tried to consume the module:
error TS2497: Module '"something"' resolves to a non-module entity and cannot be imported using this construct.

That is what I meant when I said we need the feature.

Even though the package may be generated using Babel (and thus might support both modes), it's not guaranteed that the user will also use Babel.

Agreed, but note that what I'm saying is that would be left up to the definition file authors, not TypeScript. TypeScript would just be providing a way to load an ES6 definition. It's entirely possible that the ES6 definition simply omits a default definition in this case forcing a import * as mymodule instead of import mymodule.

@jbrantly
Copy link

jbrantly commented Sep 7, 2015

So you can reproduce what I'm saying:

// test.d.ts

interface MyTestInterface {
    test: string;
}

export default MyTestInterface;

// app.ts

import * as test from 'test';

Run tsc using tsc --target es6 app.ts.

Now change export default MyTestInterface to export = MyTestInterface.

@jbrantly
Copy link

jbrantly commented Sep 7, 2015

So with my better understanding of what you meant by "Don't expose any declared modules to global module namespace" I feel like we could kind of merge this and #4668.

What would you think of:

  1. Change "Don't expose any declared modules to global module namespace" to incorporate the semantic changes of Proposal: Localized typings for definition files #4668. Namely:
    1. "Any ambient declarations (modules, namespaces, variables, etc) that are referenced by the definition file are scoped to that file only and not made available to the program at large. This includes anything pulled in either through a /// <reference /> tag or an import statement. Ambient declarations actually made in the definition file are made available to the requesting scope (whatever that may be)."
    2. "Any ambient declarations (modules, namespaces, variables, etc) from a "higher level" are allowed, but will be overridden by any ambient declarations referenced by the definition file. No interface/module/namespace merging will occur."
  2. Change so that the above logic is applied not only to "mixed-mode"/"legacy-mode" definitions but also to proper external modules. This is what actually fixes the global leak we talked about (this part isn't currently in your spec). I think most proper external modules won't need this functionality but it ensures that there are no accidental leakages.
  3. With these changes, I actually don't think there would be any difference between mixed-mode and proper external modules. What I mean is there would technically not be a need to do anything fancy with extracting declare module "name" { ... }. It would just become an ambient module declaration just like it does today. Other ambient module declarations in the same file should still be allowed for module merging purposes.

For now this does not address the "global" keyword concept. Pretty much all globals are hidden except for those directly referenced by your application. Like I said, I personally think this is sufficient but would be open to adding another way for deeply nested modules to somehow declare globals that are not hidden.

Thoughts?

@jbrantly
Copy link

jbrantly commented Sep 8, 2015

Re number 2, looking back I'm not sure we actually discussed isolating globals in the context of proper external modules anywhere, but I still hope I can convince you that it is needed (for the same reason it's needed for mixed-mode modules). In other words:

// wtf.d.ts

declare var SomeGlobal: any;

// myutils.d.ts (a proper external module)

/// <reference path="wtf.d.ts" />
export = 'a'

There's nothing stopping someone from writing that, for example. I think we'd still want to isolate globals in that case.

@poelstra
Copy link
Author

poelstra commented Sep 8, 2015

Thanks for the feedback! I don't have the time to address this right now, but I'll look at it later.

@poelstra
Copy link
Author

poelstra commented Sep 8, 2015

@jbrantly Regarding the es6 detection: ok, I'm not into that "--target es6" stuff (yet), so I believe you :)

I've updated the proposal with some of your points, but I don't agree with all of them yet. Again, not so much time, I'll explain later.

@DanielRosenwasser DanielRosenwasser added the Suggestion An idea for TypeScript label Sep 8, 2015
@jbrantly
Copy link

jbrantly commented Sep 9, 2015

@poelstra Thanks for incorporating some of my points. To reinforce the point that proper external modules should also have the same global isolation logic applied I've created some concrete examples that show how not having global isolation can result in issues. You should be able to pull/run these yourself to play with them if you desire.

https://github.com/jbrantly/TS4668_promise
https://github.com/jbrantly/TS4668_reactjsx

Both of these follow this sentence from your current proposal: "Note that if an isomorphic typing includes a 'proper external' typing, and the proper external typing
's another typing, that typing is still allowed to declare globals (they will not be 'de-globalized')."

Please feel free to point out any errors or misconceptions in my examples.

Also, I could very well be putting words into @weswigham's mouth (forgive me and correct me if I am), but I believe he might feel along similar veins based on #4694 (even if the details are slightly different, the basic idea is that it's nice for definitions to be able to create globals, but not for those globals to leak too far up the chain).

@weswigham
Copy link
Member

https://github.com/jbrantly/TS4668_promise captures the essence of the issue I bring up in #4694 perfectly, and illustrates how globals should not be leaked to parent packages unless the parent package has explicitly opted in to receive them, yea. That it's possible is arguably an oversight in how /// ref's are allowed alongside package typings (without their scoping getting changed to match the package) - how the two interact is not well specced. I would really like to see this specific use case ironed out and fixed before typings sees a non-beta release and people start including typings in their packages in earnest and its semantics get locked down.

@poelstra
Copy link
Author

poelstra commented Sep 9, 2015

I would really like to see this specific use case ironed out and fixed before typings sees a non-beta release and people start including typings in their packages in earnest and its semantics get locked down.

Me too!

@weswigham You're absolutely right about not wanting to leak things like Buffer (in #4694). But I think that's actually related to a subtly different issue. In my opinion, a package should not publish the typings of its 'host environment'. Just like no package exports lib.d.ts, a package should neither export node.d.ts. See bottom part of #4673 (comment)
The fact that basically every package does so today, is imo only because there simply was no good way to do it in a better way.

However, note that in my proposal, I think this is actually handled gracefully:

  • 'all' existing package typings (DT) are of the 'non-proper' variant, and typically 'include' (reference) their own copy of node.d.ts
  • when TS 1.6 is released, we can start making proper external typings if we want to. This is opt-in, and we should encourage people to not reference/export the host typings anymore.

In 'proper external' typings, people can't accidentally create globals, unless they directly <reference> other .d.ts files. But because we have this nice automatic lookup in 1.6, there's no need to anymore. Simply import your dependencies.
I suppose/hope that when using proper external modules/typings, you will never have to write a single <reference> line again, unless you explicitly want to make a real global.

In the 'non-proper' variants, my 'mixed mode' will apply, so stuff will be isolated.
Note how this keeps working nicely when mixing proper with non-proper/isomorphic: proper externals include things with import (not reference), so they will be isolated as needed. Isomorphic can either import or reference, and will stay isolated if they already were.

This does assume that people need to be a bit careful when they start writing proper external typings: any use of <reference> is probably a sign of 'bad habit', and will indeed cause trouble.

@poelstra
Copy link
Author

poelstra commented Sep 9, 2015

@jbrantly Thanks for your example repos!

I think our ideas aren't actually very far from eachother :)

In your promise example repo, es6-promise declares a global Promise.
But take special note of how you did that: es6-promise/index.d.ts itself does not declare it (it can't, being a proper external typing). To do it, you specifically used a trick to reference another file, which does declare that this package really really exports a true global.

So, what you're basically saying here, is: this package es6-promise, somewhere in its JS code, does something along the lines of:

window.Promise = Es6Promise;

In a way, this is why I sort-of coined the global keyword before. To explicitly state the intent that you export a real global (even from your CommonJS module), instead of having to resort to this trick. On the other hand, this trick indeed works, and it works today already, so it might be sufficient.

(It's a bit tricky to use the es6-promise package as an example here, because it actually does NOT do the above, unless explicitly asked to, with its polyfill() call. But ok, let's assume it indeed does for now.)

Perfect! So, if I, anywhere in my program, e.g. in mylib, do import "es6-promise" (your auto-polyfilling-version of it), I could then in any other part of the program, e.g. mythirdlib simply use Promise. Even if I don't import es6-promise there! Note: I personally dislike such use just as much as you do, but technically, I could, right?

Where I see things go wrong in your proposal, is when you state that you want to be able to 'opt-in' to making the globals available again. Because how do you know that when you import a package, that global is actually 'the' global that's going to be available at runtime?

Let's modify your example a bit to illustrate this point. Suppose that I create a package new-promise. It also does window.Promise = NewPromise, but this time, NewPromise has this nice .done() method that es6-promise doesn't have.

Let mylib include new-promise', but letmyotherlibstill includees6-promise. (Let's also assume thatmylibandmyotherlibare smart enough not to use the globalPromise`, but indeed use the result of their import statement. So the fact that just one of the globals will actually 'win' at runtime is not a problem here yet.)

Now, with your proposal, in app.ts the global variable Promise is not available (one of them actually will be, at runtime, but alas). That's fine.
If I understand your proposal correctly, you're saying that in app.ts, I could now either import/reference es6-promise or new-promise, to make the global of that package available.

Of course, new is always better :), so I decide to reference new-promise and I start coding against Promise, happily using its .done() method:

// app.ts
import "new-promise";
var p = new Promise();
p.then(...).done();

No complaints from the compiler in your case, because the Promise global of es6-promise had been isolated before.
At runtime though, depending on the order of the imports, it will explode (missing method .done()).

In my proposal, the Promise globals of es6-promise and new-promise would have been 'available' all the time (I suppose its type would somehow resemble Es6Promise|NewPromise), since the moment that you started to import them (directly or through dependencies).
(This indeed would require a change to the compiler to not error on "Duplicate identifier" when they are defined, but on their usage (as I believe @mhegazy suggested as well)).

So, if I'd only been using .then(), the compiler would not have given me an error, because .then() is available in both versions of Promise, no matter which one actually won at runtime. But because I used .done(), the compiler would give me an error that it is not available on the union type Es6Promise|NewPromise.

Now, we probably would then also need some kind of 'override' to tell the compiler that we manually made sure that e.g. NewPromise will always win. Some kind of special comment, tsconfig option, I don't know.

I'm pretty certain that globals that lead to 'real' globals in JS-land (variables, classes, functions) should equally be made available as globals in the typings. Maybe this is less so for things like interfaces, type aliases, etc.

Btw, note that I think this is completely different from the case you mention in #4337, where __MyLib becomes accidentally available in the whole program. In that case, __MyLib is not a 'real' global (it's just a trick in typing-land, not available in JS-land). So yes, in that case, it should definitely be isolated. And it will be, because it's defined within an isomorphic typing, and thus be subject to mixed-mode isolation.

Same for a 'normal' version of the es6-promise package: it would never automatically polyfill, so it should not (and will not, if you stick to the 'proper external best-practices') make its Promise type available globally.

Whoa, this became way longer than I hoped. Thanks if you stayed with me this long ;)

@jbrantly
Copy link

jbrantly commented Sep 9, 2015

@poelstra No worries on the long text, sometimes that's what it takes to get our ideas across.

Where I see things go wrong in your proposal, is when you state that you want to be able to 'opt-in' to making the globals available again

So to summarize (I think we both already knew this), we essentially agree that the example is a problem but we disagree on how to go about resolving it.

I feel like hiding everything and then manually opting-in to globals is best because it puts the user in control and guards better against accidental leaks. It also (I think) makes it easier to write isomorphic typings (more on this later).

You feel that exposing and somehow merging the globals is best because it allows the type system to show potential issues with the "one global wins" scenario.

I suppose its type would somehow resemble Es6Promise|NewPromise

This is a crucial part of the spec in your head but not the spec in the OP 😀 You should add this to your proposal, otherwise your proposal as-is results in duplicate identifier errors as I showed in my example.

Now, we probably would then also need some kind of 'override' to tell the compiler that we manually made sure that e.g. NewPromise will always win. Some kind of special comment, tsconfig option, I don't know.

Yea, this is the part that I think would be a PITA.

It's a bit tricky to use the es6-promise package as an example here, because it actually does NOT do the above, unless explicitly asked to, with its polyfill() call.

I think we should address this. In this case, what actually is the way, if you want to use the polyfilled global, to do so? I would assume first off that the es6-promise definition wouldn't include the global, and would instead put that in an un-referenced definition file. So two things: 1) how does that definition file access the Promise typings in the first place (see isomorphic discussion below, same problem, really) and 2) how does a user reference that definition file to pull in the global? I think something like import "es6-promise/global" would be ideal, but TS would have to know how to resolve that (I don't think it currently does with the node-style resolution, but I could be wrong).

Btw, note that I think this is completely different from the case you mention in #4337, where __MyLib becomes accidentally available in the whole program
And it will be, because it's defined within an isomorphic typing, and thus be subject to mixed-mode isolation

Yes and no. If you look at that proposal it uses this:

// mylib-module.d.ts

/// <reference path="mylib-common.d.ts" />
declare module 'mylib' {
  export = __MyLib;
}

But the whole point of this is to use proper external modules, right? So rewriting:

// mylib-module.d.ts

/// <reference path="mylib-common.d.ts" />
export = __MyLib;

And now __MyLib is available globally (in your proposal). Now you might say why use __MyLib here? The right way would be to write this:

// mylib-module.d.ts

export var foo: string;
export var bar: string;

OK, but now what about the namespace version that I also need to write. There is no way to somehow take the proper external module and throw it into a namespace (#2018). So I would need to duplicate the definitions.

// mylib-namespace.d.ts

declare namespace MyLib {
  export var foo: string;
  export var bar: string;
}

This is no good, and what #4337 is all about. Note that if TS did have better facilities to handle this it wouldn't be an issue (and I think TS definitely needs better facilities for this).

@poelstra
Copy link
Author

poelstra commented Sep 9, 2015

So to summarize (...) I feel like hiding everything and then manually opting-in to globals is best (...) You feel that exposing and somehow merging the globals is best (...)

Yes, good summary :)

This is a crucial part of the spec in your head but not the spec in the OP 😀

You're right, oops! This spec originally wasn't really about mixed mode, but about how to find the files. Guess its focus has shifted... Added it now!

Yea, this is the part that I think would be a PITA.

Added to discussion items.

what actually is the way, if you want to use the polyfilled global, to do so?

I was thinking about wrapping it in a dedicated file/package for that::

// polyfill-promise.ts
<reference path="something-that-declares-Promise.d.ts" />
import { Promise } from "es6-promise";
Promise.polyfill();
// or e.g.: global.Promise = Promise;

Now everytime you import this package/file, you'll have the global Promise available.

I would assume first off that the es6-promise definition wouldn't include the global, and would instead put that in an un-referenced definition file.

The former yes, the latter might not be necessary, see below.

  1. how does that definition file access the Promise typings in the first place (...)

That's tricky indeed. It would look like declare var Promise: typeof Es6Promise;, but now we'd leak the Es6Promise name, because we're in global-land.
This is where the global keyword might still be useful, as it allows 'escaping' from proper external definitions:

// declare-promise.d.ts (a 'proper external' typing)
import { Promise } from "es6-promise";
export global Promise;
// or e.g.: global var Promise = Promise;
// or e.g.: global.Promise = Promise;

Not sure what the syntax inside e.g. polyfill-promise.ts should be to achieve this though.

  1. how does a user reference that definition file to pull in the global?

Answered above: simply import as usual.

I think something like import "es6-promise/global" would be ideal, but TS would have to know how to resolve that (I don't think it currently does with the node-style resolution, but I could be wrong).

Haven't tried, but I think it does. It's certainly possible using ambient declarations (declare module "es6-promise/global", but note that there actually needs to be a real .js file too then, which actually performs the polyfill() call). And I imagine it should work for node_modules lookup too.

But the whole point of this is to use proper external modules, right?

Nah, not sure. For modules that are only intended to work in Node or ES6, sure.
For modules that support UMD, maybe less so.

It'd be nice if the following would work, though:

// my-module-isomorphic.d.ts
declare module "my-module" {
  export * from "my-module"; // trying to reference `my-module.d.ts` here
}

That doesn't work though, because of the circular reference, and because that export doesn't re-export default.

And now __MyLib is available globally (in your proposal)

Yup, but in this case you would indeed 'violate' the 'proper external best-practice' of not using <reference> lines unless you really want to make a global available. If you want an isomorphic typing, then probably just do so, and don't write a proper external for this. It's the reason why I renamed "legacy-mode" to "mixed-mode" :)

This is no good, and what #4337 is all about. Note that if TS did have better facilities to handle this it wouldn't be an issue (and I think TS definitely needs better facilities for this).

Fully agree, and I hope our discussions help to shape them.

@mhegazy mhegazy added the In Discussion Not yet reached consensus label Dec 10, 2015
@mhegazy mhegazy added the @types Relates to working with .d.ts files (declaration/definition files) from DefinitelyTyped label Feb 22, 2016
@mhegazy
Copy link
Contributor

mhegazy commented Jul 21, 2016

The conclusion here was to use npm for distributing declaration files, and use the npm dependency model to resolve conflicts.

Declaration files need to be modules, and not global. for UMD modules they should be using the new export namespace as <id> syntax to export their globals.

Please see more documentation about authoring declaration files and distributing them on npm at https://github.com/Microsoft/TypeScript-Handbook/blob/master/pages/declaration%20files/Introduction.md

@mhegazy mhegazy closed this as completed Jul 21, 2016
@microsoft microsoft locked and limited conversation to collaborators Jun 19, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript @types Relates to working with .d.ts files (declaration/definition files) from DefinitelyTyped
Projects
None yet
Development

No branches or pull requests

6 participants