Skip to content

Instantly share code, notes, and snippets.

@fox1t
Last active November 27, 2020 20:22
Show Gist options
  • Save fox1t/314e5fe9784ff7bb695b4603b97d978d to your computer and use it in GitHub Desktop.
Save fox1t/314e5fe9784ff7bb695b4603b97d978d to your computer and use it in GitHub Desktop.

Introduction

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.

Assumptions

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.

Recap on module types supported by TypeScript

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:

1. export default (ESM style)

function dummyFastify() {}

export default dummyFastify;

compiles to

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function dummyFastify() {}
exports.default = dummyFastify;

2. export = (namespace export)

function dummyFastify() {}

export = dummyFastify;

compiles to

"use strict";
function dummyFastify() {}
module.exports = dummyFastify;

3. export {} (named export)

function dummyFastify() {}

export { dummyFastify };

compiles to

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.dummyFastify = void 0;
function dummyFastify() {}
exports.dummyFastify = dummyFastify;

Choosing the correct export type for fastify ecosystem

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.

Exporting more than a single function using export = syntax

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.

How to write accurate (but not correct) typings?

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.

import fastify from 'fastify'

This is used for ES/TS module default imports. It implies that there is an export default fastify in the fastify module.

import { fastify } from 'fastify'

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.

import * as fastify from 'fastify'

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!!!!

import fastify = require('fastify')

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.

import('fastify')

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).

Reference: https://blog.josequinto.com/2017/06/29/dynamic-import-expressions-and-webpack-code-splitting-integration-with-typescript-2-4/#Expected-TypeScript-configuration-for-Code-Splitting-with-webpack

Current Typings

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).

Final notes on esModuleInterop

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.

Wrap up

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

@Eomm
Copy link

Eomm commented Nov 15, 2020

Has this blog post been published?

@fox1t
Copy link
Author

fox1t commented Nov 27, 2020

I am working on it right now! Sorry, I was really busy lately! :)

@Eomm
Copy link

Eomm commented Nov 27, 2020

I asked because some PR has been submitted to some plugins, and I wanted to share your best practice 👍

I think it will be a great article when completed!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment