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.

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"

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.

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