How to augment interface with specific overload when it has a catch all?

Viewed 90

I'm trying to add a method overload signature in an interface from an npm package that defines several specific signatures and then a catch-all signature, but my overload doesn't get picked up when I write a call that should match it; the catch-all gets picked up instead. It seems to be order-related (though of course I could be wrong), as though the catch-all is getting checked against the call before my new overload is and since it matches, is being used. How can I get my overload to be picked up?

  • I've ensured that I'm extending the right interface.
  • I've "proven" the catch-all is the problem by commenting it out in the original module's types — when I do that, my added overload gets picked up where it didn't before. (And then if I also comment out my overload, the call shows as an error because nothing matches.)
  • I've checked that I'm not hitting the max number of overloads. There are only six originals, plus my addition would be seven; a quick check suggests the limit is much higher than that.
  • I've checked that if I set aside my .d.ts file for a moment and add my new overload directly to the original interface, above the catch-all, it works. (Thanks for that suggestion, Eldar)
  • I've tried referencing my .d.ts file explicitly in tsconfig.json (though I suspect that doesn't make sense).
  • I've tried referencing my .d.ts file via a triple-slash reference directive (though I suspect that doesn't make sense).
  • I've tried both of the above with a simple .ts instead (though I suspect... ).
  • I've looked at various questions about augmenting interfaces (1, 2, 3, 4, and others), but couldn't figure it out based on them.

This is meant to be a general question, but a specific example helps focus the question and possible answers: The specific case is lodash's extend method.

In @types/lodash/common/object.d.ts, extend is defined like this:

declare module "../index" {
    // ...lots of other stuff...

    interface LoDashStatic {
        /**
         * @see _.extend
         */
        extend<TObject, TSource>(object: TObject, source: TSource): TObject & TSource;
        /**
         * @see _.extend
         */
        extend<TObject, TSource1, TSource2>(object: TObject, source1: TSource1, source2: TSource2): TObject & TSource1 & TSource2;
        /**
         * @see _.extend
         */
        extend<TObject, TSource1, TSource2, TSource3>(object: TObject, source1: TSource1, source2: TSource2, source3: TSource3): TObject & TSource1 & TSource2 & TSource3;
        /**
         * @see _.extend
         */
        extend<TObject, TSource1, TSource2, TSource3, TSource4>(object: TObject, source1: TSource1, source2: TSource2, source3: TSource3, source4: TSource4): TObject & TSource1 & TSource2 & TSource3 & TSource4;
        /**
         * @see _.extend
         */
        extend<TObject>(object: TObject): TObject;
        /**
         * @see _.extend
         */
        extend<TResult>(object: any, ...otherArgs: any[]): TResult;
    }

    // ...lots of other stuff...
}

As you can see, there are various overloads with specific numbers of source objects, and then a "catch-all" at the very end using rest syntax.

Through machinations in @types/lodash/index.d.ts that I frankly don't understand, apparently that LoDashStatic interface ends up in the namespace _, which is where we see it when using it.

I wanted to add an overload that accepts five source objects rather than topping out at four (as an example for my answer to this question. So in a lodash-extensions.d.ts file, I did this:

declare namespace _ {
    export interface LoDashStatic {
        tjc(): number; // ** I'll explain this below
        /**
         * @see _.extend
         */
        extend<TObject, TSource1, TSource2, TSource3, TSource4, TSource5>(
            object: TObject,
            source1: TSource1,
            source2: TSource2,
            source3: TSource3,
            source4: TSource4,
            source5: TSource5
        ): TObject & TSource1 & TSource2 & TSource3 & TSource4 & TSource5;
    }
}

Then in test.ts, I have:

import { extend, tjc } from "lodash";

const n = tjc();
console.log(n);
const fourSource = extend({ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }, { e: 5 });
console.log(fourSource);
const fiveSource = extend({ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }, { e: 5 }, { f: 6 });
console.log(fiveSource);

The tjc there and in my lodash-extensions.d.ts is just to check that I'm augmenting the right interface. It would appear so, since TypeScript is happy with the import and the type of n is number. (Naturally, if I actually ran this code, that wouldn't work — I haven't implemented tjc anywhere.)

In the above, the type of fourSource is what you'd expect, it's picking up the four-source overload from lodash's types and so fourSource's type is a union of those source object types (and the {} target). The type of fiveSource is unknown, though, because (apparently) it picks up the catch-all extend at the end of the original module's definition, not my added overload, and since I haven't provided a type argument for TResult it's had to punt on the inference.

If I go into @types/lodash/common/object.d.ts and comment out the catch-all, my new overload gets picked up just fine. This is my "proof" (such as it is) that the catch-all is getting in the way.

I can't think of a way of setting this up in the TypeScript playground (or, of course, on-site Stack Snippets), but I've done a stackblitz with the code above, or of course if you create a project with TypeScript, install lodash and @types/lodash, and then drop the lodash-extensions.d.ts and test.ts files above into it, it should replicate the problem locally.

I can think of a number of workarounds, like declaring my own extends that is really just a reference to lodash's and specifying the overloads. But is it possible to add my overload so it gets picked up when using extends, and if so, how?

1 Answers

The documentation for module augmentation says you should use declare module for this purpose:

declare module 'lodash' {
  export interface LoDashStatic {
    extend<TObject, TSource1, TSource2, TSource3, TSource4, TSource5>(
      object: TObject,
      source1: TSource1,
      source2: TSource2,
      source3: TSource3,
      source4: TSource4,
      source5: TSource5
    ): TObject & TSource1 & TSource2 & TSource3 & TSource4 & TSource5;
  }
}

That would work just fine if you put it in the same file where you use lodash. But if you want to put it in its own file by itself, then you will run into the annoying issue that the compiler will interpret it as a new ambient module instead of modifying an existing imported module. See microsoft/TypeScript#49227. So if you want to do this in a separate file, that file should have at least one top-level import or export:

import 'lodash'; // or whatever 
declare module 'lodash' {
  export interface LoDashStatic {
    extend<TObject, TSource1, TSource2, TSource3, TSource4, TSource5>(
      object: TObject,
      source1: TSource1,
      source2: TSource2,
      source3: TSource3,
      source4: TSource4,
      source5: TSource5
    ): TObject & TSource1 & TSource2 & TSource3 & TSource4 & TSource5;
  }
}

You can see this in action in this version of the stackblitz project.

Related