This document is a sum-up of TS/ES/CommonJS interop import/exports. As Node.js framework, we need to priorities developer experience and support in the best possible way as much as we can.
Fastify, fastify-plugin, and other "core" packages are written in JavaScript. The Fastify's team maintains TypeScript typings internally. These typings have to be as much as possible related to the original JavaScript code.
Since Fastify is written in JS, we all know that it uses CommonJS (module.exports
): it would be nice to have the typings express it.
To mimic an already written JavaScript code, we need to find what best fits for every case. This is the list of the 3 possible export in TS code:
function dummyFastify() {}
export default dummyFastify;
compiles to
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function dummyFastify() {}
exports.default = dummyFastify;
function dummyFastify() {}
export = dummyFastify;
compiles to
"use strict";
function dummyFastify() {}
module.exports = dummyFastify;
function dummyFastify() {}
export { dummyFastify };
compiles to
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.dummyFastify = void 0;
function dummyFastify() {}
exports.dummyFastify = dummyFastify;
Since fastify and other core packages use CommonJS we might think that using export =
as TypeScript representation is the best way to write types.
However, sometimes could happen that we need to expose more than a single function, such as utility functions or typings that are not even present in JS code. export =
is good to export what exists in JS code, but it is not suited at all to export additional TypeScript typings.
Even if fastify export =
is not suited for our needs we can still use it to make a simple example. I am going to omit all of the additional types exported by fastify (that will make this example impossible since, as we already learned, export =
can't export types).
Let's start from JavaScript code:
fastify.fastify = fastify;
fastify.default = fastify;
module.exports = fastify;
If we want to have 1:1 TS code that represents it we have to write it like
function fastify() {} // just a dummy function to illustrate the example
fastify.fastify = fastify;
fastify.default = fastify;
export = fastify;
that compiles to
"use strict";
function fastify() {}
fastify.fastify = fastify;
fastify.default = fastify;
module.exports = fastify;
As you can see handwritten JS code and TS generated code are identical.
However, fastify (and other plugins) exports its types as:
function fastify() {} //dummy fastify
export default fastify;
export { fastify };
that would be compiled to:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.fastify = void 0;
function fastify() {}
exports.fastify = fastify;
exports.default = fastify;
if fastify was written in TS.
As you can see, there is a mismatch: no evidence of module.exports =
at all and only default and named ones are carried on.
Note: we will soon see why this is the way to go, even if it not represents 100% accurately our JS code.
To understand next code snippets we need to recall "TS function interface":
interface Fastify {
(): void;
}
this is same as
declare function Fastify {}
Now we can use "TS function interface" to try to better represent what the JavaScript code:
interface Fastify {
(): void;
default: Fastify;
fastify: Fastify;
}
const fastify: Fastify = function fastify() {};
fastify.default = fastify;
fastify.fastify = fastify;
export = fastify;
compiles to:
"use strict";
const fastify = function fastify() {};
fastify.default = fastify;
fastify.fastify = fastify;
module.exports = fastify;
Removing the code part and leaving only typings from the first snippet, we have
interface fastify {
(): void;
default: fastify;
fastify: fastify;
}
export = fastify;
We might assume that this is the "correct" way of exporting CommonJS module.exports
. The problem emerges when we try to export additional types that don't have any JS correspondence (ex. request, reply, plugin, options interfaces, and so on).
Even if
declare const bar: string
interface fastify {
(): void;
default: fastify;
fastify: fastify;
bar: typeof bar
}
export = fastify;
is a valid export, the property bar
has to exist in JavaScript too since it is treated as executable code and not just as a type alias. (see https://basarat.gitbook.io/typescript/project/declarationspaces for more info)
Why export default fastify
and export { fastify }
usually work and are a better choice for the fastify ecosystem?
To answer this question, we need to first take a look at the different ways of importing modules in TypeScript. Here, we are assuming that esModuleInterop is set to false.
This is used for ES/TS module default imports. It implies that there is an export default fastify
in the fastify module.
This is used for ES/TS module named imports. It implies that there is an export { fastify }
or export const fastify
in the fastify module.
This is used for CommonJS namespace imports. That's it: we are importing CommonJS code in ES module codebase. Here, we have our first quirk behavior (but that makes sense): ES module spec says that namespace import (* as
) can only be a plain object (module) and not a callable nor a newable.
import * as Fastify from "fastify";
const fastify = Fastify(); // this doesn't work!!!!
This is a (legacy) way of importing CommonJS modules. More info at https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require
Note: The main difference between import Fastify = require('fastify')
and import * as Fastify from "fastify"
is that the first one doesn't enforce Fastify to not be callable and therefore it doesn't follow ES module spec. This is one of the main reasons nowadays import * as
is preferred to import CommonJS modules.
Also called Dynamic Import and its behavior heavily relies on the settings put in the tsconfig.json. It needs module: "esnext"
to make code splitting work properly, but since we are on the server-side we can almost ingore it. If "module": "commonjs"
it will be compiled to Promise.resolve().then(function () { return require('fastify'); })
.
This is one of the best descriptions of dynamic import feature.
Important note: this import type can break our exported types in some scenarios since it uses require('')
and automatically adds .default
export no matter what (we had an issue opened that said there was .default.default
export even it the types were correct).
Now is finally time to answer why export default fastify
and export { fastify }
are great choices for us.
If we check our current types we can see that we export default
on L47 and export a named export on L134: these exports work because in JavaScript code we have these 3 lines https://github.com/fastify/fastify/blob/master/fastify.js#L563 that covers all of the scenarios we saw earlier. Even if it is wrong to use export default
to represent module.exports
we have module.exports.default that will be used by TS/ES module. What I am saying here, is that we need to enforce that our in-house plugins always export at least these 3 lines (probably at the fastify-plugin level if a .default
is not defined).
Setting esModuleInterop: true
allows the users to import CommonJS modules in compliance with ES6 modules spec. Now we know that CommonJS modules are defined in TS using export =
, but if we define our types as mentioned before, we don't care about this setting since we mimic a TS/ES module export already.
Exporting this snippet in JS code:
fastify.fastify = fastify;
fastify.default = fastify;
module.exports = fastify;
and adding this typings:
export default fastify; // this represents fastify.default = fastify;
export { fastify }; // this represents fastify.fastify = fastify
// there is no need to represent module.exports = fastify; in TS since it will use the other two.
enable us to support all of the possible scenarios without relying on the user-provided tsconfig.json
Has this blog post been published?