How do I write a binding that imports a function from the default module?

I know how to import the default:

@module("marked") external convert: string => string = "default"
// becomes
import Marked from "marked";

But how do I import a function from the default?

@module("date-fns-tz")
external zonedTimeToUtc: (Js.Date.t, string) => Js.Date.t = "zonedTimeToUtc"

// becomes
import * as DateFnsTz from "date-fns-tz";

// I want:
import DateFnsTz from "date-fns-tz";
...
DateFnsTz.zonedTimeToUtc(date);

I know it’s because I never specify “default” in my binding, but I’m not sure how to do that with the function name as well.

1 Like

If a module’s default export is an object containing functions, then you probably need to write a record or object type and use that in your binding, like this:

type t = {
  zonedTimeToUtc: (Js.Date.t, string) => Js.Date.t,
}
@module("date-fns-tz") external dateFnsTz: t = "default"

Although I’m wondering if you actually should use the default export in this scenario. I’m not familiar with date-fns-tz, but, from glancing at their README, it has this example code:

import { zonedTimeToUtc } from 'date-fns-tz'

That’s a named export, not a default export. In that case, import * as DateFnsTz from "date-fns-tz"; would be correct and import DateFnsTz from "date-fns-tz"; would be incorrect. Are you getting an error from not using the default?

Edit: Something else I just realized is that date-fns-tz has both an es6 module and a commonjs module. If you’re importing it from your own es6 module, then you may be inadvertently using the commonjs version, not the es6. IIRC, Node will let you import commonjs into a module but it puts all of their exports into one default export. From glancing at the repo, you may be able to directly import the es6 module version with the path date-fns-tz/esm

@module("date-fns-tz/esm")
external zonedTimeToUtc: (Js.Date.t, string) => Js.Date.t = "zonedTimeToUtc"
1 Like

I hit this issue in an effort to switch from CommonJS to ES6 modules. My jest tests immediately started to fail with my current import * as ... and when I manually changed the generated Javascript to Import DateFnsTz it worked.

TypeError: DateFnsTz.utcToZonedTime is not a function

       9 |   if (date !== undefined) {
      10 |     return Caml_option.some(
    > 11 |       DateFnsTz.utcToZonedTime(Caml_option.valFromOption(date), timeZone)
         |                 ^

That’s a good find about the ES6. The fix sadly doesn’t work for me, because then Node doesn’t recognize the ES6 module as ES6 because it ends in .js, not .mjs:

/Users/dfalling/Code/ido/node_modules/date-fns-tz/esm/index.js:3
    export { default as format } from './format/index.js'
    ^^^^^^

    SyntaxError: Unexpected token 'export'

      at Runtime.createScriptFromCode (node_modules/jest-runtime/build/index.js:1350:14)

Node’s handling of ES6 modules is such a nightmare. I’ve sunk easily 6 hours into trying to trick it into working.

Yeah, that’s the unfortunate state of ES6 modules in Node. For a long time, there wasn’t any official ES6 module support, so the entire ES6 module ecosystem was built around using transpilers like Babel to convert ES6 modules into commonjs modules. Now that Node has its own ES6 module system, the packages based around using transpilers aren’t necessarily compatible with it. date-fns-tz seems to be in that category.

If you’re committed to using ES6 modules, then the most frictionless way of handling this is probably using a transpiler instead of Node’s built-in ES6 module system. However, I don’t claim to be an expert, so maybe there’s a better solution I’m not aware of.

Normally adding @scope would resolve this:

@module("date-fns-tz") @scope("zonedTimeToUtc")
external zonedTimeToUtc: (Js.Date.t, string) => Js.Date.t = "default"

However doing so turns off the magic ES6 default export handling and produces DateFnsTz.zonedTimeToUtc.default(). The only way to do it right now is with an intermediate type; I’ve been meaning to log a request to improve this, because it’s quite awkward.

type dateFns

@module("date-fns-tz")
external dateFns: dateFns = "default"

@send
external zonedTimeToUtc: (dateFns, Js.Date.t, string) => Js.Date.t = "zonedTimeToUtc"

let zonedTimeToUtc = zonedTimeToUtc(dateFns)

should compile to the code you’re looking for (sort of). In this style I recommend hiding the externals with an interface to avoid confusion.

I agree. The recent changes to ReScript to support ES6 projects helped a lot but there are many weird bits. I came close to migrating, but in the end I still have to compile as both CJS and ES6 because my CI server is too old to run NodeJS 15.

1 Like

Btw one thing that might help is that modern node does some magic so that things like import express from "express" actually works, when if you think about it, it shouldn’t.

The module import/export thing in JS has been so messy; it’s not great. One solution is to write a tiny JS module to import and re-export into the format you’d like ReScript to consume, but we caution against that approach too since there are lots of weird transpiler-specific problems even with this solution. One day it’ll get better…

I took some time to clean up our module bindings page to make this more palpable here: https://github.com/rescript-association/rescript-lang.org/pull/292

Node has its own implementation of es6 modules. I had some headaches about it without using ReScript. This behavior also changed during upgrade from Node 12 (experimental) to Node 14.

TypeScript also has issues regarding this. I want to research this further, but be sure to use at least Node 14, with “type”: “module” set on package.json. You shouldn’t have to rename files to “cjs”.

Also, the import generated by bs doesn’t work well for me:
import * as x from 'x';

should be in some cases:
import x from 'x';

This also changes whether the imported module is CommonJs or ES6. In some cases I couldn’t destructure in javascript CommonJs modules on import.

2 Likes

I achieved it by using a default and a @send

type magicOptions = {locale: string}

@module("magic-sdk") external magic: 'magicD = "default"

@send
external magicNew: ('magicD, string, magicOptions) => 'magic = "Magic"

let magic = magicNew(magic, "key", {locale: "es"})

which outputs

import MagicSdk from "magic-sdk";

var magic = MagicSdk.Magic("key", {
      locale: "es"
    });
1 Like

hey, is there any update on this? I’m basically forced to either:

  1. not use any esm modules (bye ky, bye chalk)
  2. do a really convoluted setup with suffix mjs, module es6, type module and then super awkward bindings?

is there something I am missing?

1 Like

Just as an example, this is how I am importing Chalk (for it to work properly):

@module("chalk")
external chalk: chalk = "default"

@send external magenta_: (chalk, string) => t = "magenta"
let magenta = text => chalk->magenta_(text)

@send external red_: (chalk, string) => t = "red"
let red = text => chalk->red_(text)

@send external white_: (chalk, string) => t = "white"
let white = text => chalk->white_(text)

@send external yellow_: (chalk, string) => t = "yellow"
let yellow = text => chalk->yellow_(text)

Is this the way we do it right now to support ES6 imports?

You can use @scope(“default”)

4 Likes