So the extension methods discussion has been closed. Which, maybe the mechanism is hard to automate with JavaScript semantics. There does however still appear to be a gap with how TypeScript can accomodate existing JavaScript behaviour with prototypes:
If I do the following:
import { MyType } from "./MyType";
MyType.prototype.newMethod = () => { /* ... */ };
It seems like presently, there's no way to tell TypeScript that from now on, anything that imports that type will have newMethod available.
It seems like there should be some way for TypeScript to just automatically know this based on whether the consuming module imports the module that has performed the modification:
import { MyType} from "./MyType";
const blah = new MyType();
blah.existingMethod();
blah.newMethod(); // Will never type check, but could work depending on import order, not good.
So, as a rule to make this work:
import { MyType } from "./MyType";
import "./MyTypeExtension"; // Imagine this contains the first code block in this ticket.
const blah = new MyType();
blah.existingMethod();
blah.newMethod(); // Type checks *and* always works!
It seems like so long as the module consuming any kind of prototype modification pulls in the module that performs the modification TypeScript can count on the new method existing.
I've read up on declaration merging and have yet to successfully use it, or it might be that it's not useful for addressing this scenario.
The main reason why this feature would be nice is so that I don't have to force people down the road of writing any kind of boilerplate or inherit/compose multiple base classes I need to offer to obtain base functionality. I can elaborate on this some more in a subsequent comment if desired, but it's very much a similar thing to extension methods in C#, or traits and interfaces in PHP.
You can use module augmentation to perform this but there are a few caveats.
src/MyType.ts
export class MyType {
existingMethod(): void;
}
src/MyTypeExtension.ts
import {MyType} from './MyType';
declare module './MyType' {
interface MyType {
newMethod(): void;
}
}
MyType.prototype.newMethod = () => { /* ... */};
The issue is that the augmentation is now visible to _all_ code using MyType regardless of whether or not MyTypeExtension is imported. In order to work around this, MyTypeExtension needs to be in an excluded location.
Consider:
src/test.ts
import {MyType} from './MyType';
const mt = new MyType();
mt.newMethod(); // no error
To work around this you have to modify tsconfig.json as follows
{
"compilerOptions": {},
"exclude": [
"node_modules",
"jspm_packages",
"src/MyTypeExtension.ts"
]
}
After making this change (not the file name extension is required in the "exclude" entry, you will now have the desired behavior.
src/test.ts
import {MyType} from './MyType';
const mt = new MyType();
mt.newMethod(); // error, add the import to fix.
src/test.ts
import {MyType} from './MyType';
import "./MyTypeExtension";
const mt = new MyType();
mt.newMethod(); // no error
RxJS uses this pattern and in general it works fine for library consumers since the packages are installed into automatically excluded directories.
I've tried the MyType extension approach with an external package and it doesn't work. Nothing I import ends up knowing about it and I sometimes end up with TS telling me that it already exists. There are limitations that go against what I originally described: Can this even work with having to exclude it from my builds. This basically prevents me from distributing the extension.
I think there are still flaws with how prototype modification is handled in TS. Even if it's just as a result of not being intuitive. I'm not concerned whether a method that's been added is available to a module that hasn't imported the extension yet.From TS, only the modules that import the extension will see it and successfully compile.
From JS, it doesn't know/care in the first place. As soon as the prototype is modified, the change is global.
What would be beneficial here is for TS to learn how an export has been altered by modules and then expose that new signature to any common consumers of the modified and modifying modules.
I'm not concerned whether a method that's been added is available to a module that hasn't imported the extension yet.From TS, only the modules that import the extension will see it and successfully compile.
If that is the case then you can omit the exclude.
I do agree that there should be a better way of correlating the import with the augmentation. Right now it feels like a hack that works in some situations and gives false assurance in others.
Yeah. ideally it should be explicit and opted-in at the call/import point. Not forced outwards from the declaration. On top of that, it should ideally be based on the actual code itself.
This is existing JS behaviour and if we look at it from a very literal standpoint, doing anything.prototype.anything has no way of ever working in TS. At least with no implicit any turned on.
What I'm asking for isn't magic. I'm not expecting TS to somehow know all the alterations made to a prototype in one branch of an import graph when it has no clue when/whether another branch of the import graph has triggered the augmentation. What I'm saying is when I import the module that does the augmentation, then the compiler has a clear signal that the functionality is available. Whether that import was the one to trigger the modification to the prototype or whether it was another one before effectively becomes immaterial. The module system will only do it once.
Yeah. ideally it should be explicit and opted-in at the call/import point. Not forced outwards from the declaration. On top of that, it should ideally be based on the actual code itself.
cross module side effects is not a recommended practice. modules should not augment other modules, they should wrap.
the module augmentation semantics is meant to be clear in user intent by adding a declaration.
I am not sure i see how this issue is not addressed by http://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation.
The problem is that I haven't been able to augment a package outside of my own. Don't know if this is a known limitation, but I can put something together tonight.
Module augmentation has no notion of packages. If you can import the module you should be able to augment it. Please give as a self contained repro of the issue.
Alright, so based on real packages this time: Referring to both the suggested code above as well as the module augmentation link above, this doesn't appear to be working:
_ServiceProviderExtensions.ts_
import { ServiceProvider } from "protoculture";
declare module "protoculture" {
interface ServiceProvider {
newMethod(): void;
}
}
ServiceProvider.prototype.newMethod = () => {
console.log("New method!");
};
Which gives me:
'ServiceProvider' only refers to a type, but is being used as a value here.
_ProjectServiceProvider.ts_
import { ServiceProvider } from "protoculture";
import "./ServiceProviderExtensions";
export class ProjectServiceProvider extends ServiceProvider {
}
The next error is likely because the augmentation failed and now the import of ServiceProviderExtensions is bringing in an interface, overwriting the original import ServiceProvider?
Cannot extend an interface 'ServiceProvider'. Did you mean 'implements'?
So from the looks of it, instead of augmenting the existing class, it's attempting to re-declare it as an interface. Then, going forward, things run off the rails.
what does protoculture look like?
http://github.com/atrauzzi/protoculture
Built using TypeScript, on NPM.
You have to augment the source of the declaration. i.e.:
declare module "protoculture/lib/ServiceProvider" {
interface ServiceProvider {
newMethod(): void;
}
}
Interesting, will try that. Will that still augment subsequent reexports?
Is there anything I can do to avoid having to have "lib" in that path?
not really, module augmentation augment declarations, and not aliases, so you have to point it to the declaration.
Interesting. I was always under the impression that those just re-exported the type.
My question was more about just how I can set a specific source directory, but that's likely more of an NPM question. Distributing TS based libraries is still something I like to improve upon.
Anyway, your suggestion worked perfectly. I might offer up that the documentation on module augmentation might benefit from a red-box caveat about how you must augment the original module itself. Not any re-exports or aliases. I likely would never have questioned that!
Most helpful comment
Interesting. I was always under the impression that those just re-exported the type.
My question was more about just how I can set a specific source directory, but that's likely more of an NPM question. Distributing TS based libraries is still something I like to improve upon.
Anyway, your suggestion worked perfectly. I might offer up that the documentation on module augmentation might benefit from a red-box caveat about how you must augment the original module itself. Not any re-exports or aliases. I likely would never have questioned that!