How can I provide ergonomic typings for a module that default exports an object that contains a class?

Viewed 126

By "ergonomic" I mean "doesn't require consumers to use InstanceType<typeof mod["Foo"]> syntax"

I am writing a .d.ts for a JS source file. In a few months, that JS file will be converted to TS but we can't convert it yet.

The file is something like this:

// module.js
class Foo {
    sayFoo(){}
}

export default {
    Foo
};

tsc generates a .d.ts like this:

// module.d.ts
declare class Foo {
    sayFoo(): void;
}
declare const _default: {
    Foo: typeof Foo;
};
export default _default;

The caller has to jump through some really weird hoops to get at the class type:

import mod from "./module"

function takesFoo(foo: InstanceType<typeof mod["Foo"]>) {
    foo.sayFoo();
}

But if I lie about the types a bit using namespace:

export default NS;
// module.d.ts
declare namespace NS {
    export class Foo {
        sayFoo(): void
    }
}

then the caller gets a much better type-level API!

import mod from "./module";
function takesFoo(foo: mod.Foo) {
    foo.sayFoo();
}

I could just use namespace, but then we're in a bind when we convert the JS file to TS. Our options at that point will be:

  • (A) Never actually convert the library entrypoint to TS. Author most of the library in TS, then use a tiny JS file with a .d.ts to wrap up the API. This would remove any checks that we are actually conforming to our public API! And it would be confusing.

  • (B) Force the weird type-level API on callers. Perhaps soften the blow by exporting some interfaces, and teach callers about the split personality of TS class types.

Am I missing an option or not understanding something about type-level dots in TS?

1 Answers

UPDATE: It may be much simpler than I thought, though my advice on how to use default exports still stands... Also this may not actually run (further update: it seems fine if I try it out in a simple create-react-app generated environment).

With your existing default export, this seems to compile:

import { Foo } from "./module"

const x = new Foo();

function f(y: Foo) {
   y.sayFoo();
}

f(x);

Original response below:

Do you have the freedom to add export in front of class Foo {?

That way users can say:

import { Foo } from "./module";

and then use Foo directly:

function takesFoo(foo: Foo) {

Alternatively, if Foo is the only thing exported from module and you want it to be default export, it would make more sense to export it alone, rather than wrapping it in an object:

export default Foo; 

so users could import it:

import Foo from "./module";

The way I think of default export is that it makes the most sense if I only have one thing to export, in which case why wrap it in an object? And if I have multiple things to export, I just export them individually so they can be imported with { ... } as needed.

Related