I'm using the MomentJS library but instead of directly using the Moment objects I use my own extension with some extras on top of the Moment instances:
function myMoment(...args: any[]) : MyMoment {
let instance = <MyMoment>moment.apply(this, args);
instance.toString = () => {
return 'Think for a Moment';
}
instance.isMyMoment = true;
return instance;
}
I've also written a definition file MyMoment.d.ts that remaps existing moment.Moment so that all functions (...) => Moment become (...) => MyMoment.
declare const myMoment = moment;
declare type MyMoment = {
[K in keyof moment.Moment]: ReturnType<moment.Moment[K]> extends moment.Moment
? moment.Moment[K] extends (...a: infer A) => moment.Moment
? (...a: A) => MyMoment // remap the return type of functions that return Moment instance
: never // this will never map
: moment.Moment[K]; // other return types should be preserved
} & {
isMyMoment: true; // distinguish my moments
};
Problem is that some functions take parameters of type 'Moment' i.e. diff(). If I have two instances of MyMoment and call diff() I get a type incompatibility error:
TS2345: Argument of type 'MyMoment' is not assignable to parameter of type 'MomentInput'.
Type 'MyMoment' is not assignable to type 'Moment'.
My type mapper above should also remap all function parameters' types from Moment to MyMoment. Unfortunately some of those parameter types (i.e. MomentInput) are even union types of which one of the types is type Moment.
I don't have a slightest clue (yet) how to rewrite my mapper to also remap those?
Or use an approach to fool the compiler
I could convince the compiler that MyMoment is actually just a Moment from the type perspective which almost is except for the extra boolean prop so in reality MyMoment extends Moment, but execution wise it's not important and it wouldn't flip the compiler. I tried to rewrite the definitions file.
declare const myMoment = moment;
declare namespace moment {
interface Moment extends Object {
isMyMoment?: true;
}
}
declare type MyMoment = moment.Moment;
The interesting thing is that I don't see any errors in VSCode and even intellisense works, but my compiler complains:
TS2339: Property 'diff' does not exist on type 'Moment'.
So still doesn't work as it should.
I may be close with this solution, but I would need your extra pair of eyes on the code above or...
Maybe there's some third way that I'm not even thinking about?
I think your "fool the compiler" approach using declaration merging should work. If it's not working I expect the problem to be with your compiler setup and not with declaration merging itself.
My actual recommended approach here is probably to make a copy of the type declaration file for moment.js, modify the definition of
Momentto include your extra properties, possibly do a find-and-replace of the wordMomentwithMyMoment, and save it as your own declaration file. This is a manual process which could obviously diverge from the definitions in moment.js, but it is straightforward, which is nice.I'm intrigued by your "type mapping" approach, where you essentially simulate declaration merging. Let's say
DeclMerge<T, U>takes a typeTand mergesUinto it recursively, so that all references toTinside the definition ofTalso haveUmerged into them. Moreover, let's sayDeclMerge<T, U, S>takes a typeTand mergeUinto all references toSinside of it, whereSdefaults toT. That way we can describe the recursive nature ofDeclMergeby keepingUandSfixed while changingT.I think such a thing is fraught with peril. Mapped types and other type juggling don't do very well in the face of generics, or overloads, or
privateandprotectedproperties and methods... so I'd expect some edge cases where things fail spectacularly. That being said, the idea is so interesting that I will attempt it, even though I wouldn't recommend using it in actual production code without a lot of testing. I can't resist; here is my attempt at an implementation ofDeclMerge:Blah, that's long and complicated, and really screams "there will be terrible edge cases". Anyway the approach here is:
Make sure that we distribute the replacement across any unions in
T. So ifFoobecomesBar, we wantFoo | stringto becomeBar | string... we do this by wrapping the whole thing in the distributive conditional typeT extends any ? ... : never.If
Tis assignable toS, let's intersect the output type withU. Otherwise, don't.If
Tis an arraylike thing, use a mapped type on arrays/tuples to transform each of the elements ofTseparately.If
Tis an object type, then map each of its properties, as well as the parameters and return types of its call or construct signature if it has one.If
Tis a primitive type, leave it alone.Let me make my own class and see how the compiler maps it:
You can see that the
Fooclass has a lot of self-references in it. TheMyFooCtortype is what you get if you take the type of theFooconstructor and merge{extraProp: number}into all occurrences ofFoo. Here's the test:That all looks good to me. The compiler is dutifully adding
extraPropwhere I expect it to, and specifically the transformedacceptFoo()wants its argument to be an instance ofMyFooand notFoo.For
Moment, then, you might want to write this:That looks like what you wanted, right? Maybe... but I wouldn't trust it.
Playground link to code