Remap typescript moment.Moment type(s) to my extended type

825 Views Asked by At

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?

1

There are 1 best solutions below

1
jcalz On

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 Moment to include your extra properties, possibly do a find-and-replace of the word Moment with MyMoment, 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 type T and merges U into it recursively, so that all references to T inside the definition of T also have U merged into them. Moreover, let's say DeclMerge<T, U, S> takes a type T and merge U into all references to S inside of it, where S defaults to T. That way we can describe the recursive nature of DeclMerge by keeping U and S fixed while changing T.

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 private and protected properties 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 of DeclMerge:

type DeclMerge<T, U, S = T> =
   T extends any ? ((T extends S ? U : unknown) & (
      T extends readonly any[] ? { [K in keyof T]: DeclMerge<T[K], U, S> } :
      T extends object ? (
         { [K in keyof T]: DeclMerge<T[K], U, S> } &
         (T extends {
            (...args: infer P): infer R
         } ? {
            (...args: DeclMerge<P, U, S>): DeclMerge<R, U, S>
         } : unknown) &
         (T extends {
            new(...args: infer P): infer R
         } ? {
            new(...args: DeclMerge<P, U, S>): DeclMerge<R, U, S>
         } : unknown)) :
      T)) : never;

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 if Foo becomes Bar, we want Foo | string to become Bar | string... we do this by wrapping the whole thing in the distributive conditional type T extends any ? ... : never.

  • If T is assignable to S, let's intersect the output type with U. Otherwise, don't.

  • If T is an arraylike thing, use a mapped type on arrays/tuples to transform each of the elements of T separately.

  • If T is 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 T is a primitive type, leave it alone.


Let me make my own class and see how the compiler maps it:

declare class Foo {
   static instance: Foo;
   constructor(x: string, otherFoo?: Foo);
   x: string;
   y: number;
   z: Foo | null;
   acceptFoo(x: Foo): number;
   produceFoo(): Foo;
   arr: Foo[];
}

type MyFooCtor = DeclMerge<typeof Foo, { extraProp: number }, Foo>;

You can see that the Foo class has a lot of self-references in it. The MyFooCtor type is what you get if you take the type of the Foo constructor and merge {extraProp: number} into all occurrences of Foo. Here's the test:

declare const MyFoo: MyFooCtor;
const m = new MyFoo("hello");
m.extraProp.toFixed();
m.x.toUpperCase();
m.y.toFixed();
m.produceFoo().extraProp
m.arr[0].arr[0].extraProp
m.acceptFoo(m)
m.z!.extraProp;
new MyFoo("", m)
m.acceptFoo(new Foo("")); // error! expecting MyFoo, not Foo
MyFoo.instance.extraProp

That all looks good to me. The compiler is dutifully adding extraProp where I expect it to, and specifically the transformed acceptFoo() wants its argument to be an instance of MyFoo and not Foo.


For Moment, then, you might want to write this:

type MyMoment = DeclMerge<moment.Moment, { isMyMoment: true }>;

declare const myMoment1: MyMoment;
declare const myMoment2: MyMoment;
myMoment1.diff(myMoment2);

declare const moment: moment.Moment;
myMoment1.diff(moment); // error!
// Property 'isMyMoment' is missing in type 'Moment' 

That looks like what you wanted, right? Maybe... but I wouldn't trust it.


Playground link to code