Stuck on an old ReScript version? Look here!

Are you stuck on an older ReScript version than v11, and want to upgrade? We know a couple of the latest releases have been massive with lots of changes. It’s a hump to get over for sure, and we’re working on a couple of initiatives to make migrating to newer versions post v12.

Please feel free to use this thread to ask for help, and to share your experiences for upgrading.

5 Likes

At Rohea, we have a large and mature ReScript codebase, with nearly 1000 files (not including interfaces). However, we still have hundreds of legacy JS files using flow types in customer specific parts, which we simply cannot fully migrate to ReScript due to the complexity, cost and risk involved in any other way than over a long period of time.

We try not to write any new code in JS/Flow. However, occasionally we are forced to if a customer needs some old feature updating or fixing. Given the amount of legacy code we still have, and that we try to write only ReScript now, this means there is a lot of interop across the boundary and for that we deeply rely on the type safety offered by @genType.

Obviously, when it was announced that the last version of rescript that would still support @genType to Flow was v9, this was a big blow to us. That was several years ago and we have been stuck ever since.

Some of the things we’d already considered or tried:

  • Mass-converting Flow to TS. Not reliable enough, it failed in at least some way in every single file.
  • Just upgrading ReScript and YOLOing type safety. Far too dangerous, genType/Flow has saved our asses multiple times.
  • Vendoring genType somehow to allow us to upgrade ReScript. Way beyond any of us to achieve.

That is until @zth offered to discuss it with me and suggested a new approach we hadn’t tried before. Here is what we’re doing now, and I am happy to report that at least initially, it seems to be working really well.

The concept

@zth’s idea was to use genType to generate TypeScript files, and then use a custom script to convert those files to Flow definitions. This way, we can keep using genType for type safety in our legacy JS code while still upgrading ReScript. In detail:

  • genType will generate TypeScript files.
  • We use ts-blank-space to strip the types from these TypeScript files, in order to produce files which our legacy code can use that preserve the runtime conversions that genType generates.
  • We use flowgen to convert those TypeScript files to Flow definitions, which provide the type safety over the type-stripped files from the previous step.

Upgrade Rescript

First thing was to upgrade to rescript v10. This was necessary to use the @genType v10 typescript features, specifically the import type syntax (flowgen requires this).

Add build step

I added this to bsconfig.json:

"js-post-build": {
  "cmd": "./tools/build-system/gen.sh"
}

Add gen.sh

#!/bin/bash

# The path of the current rescript-generated .bs.js file,
# passed in by rescript's js-post-build command.
libFilePath="$1"

# The file is relative to "lib/bs", and references the file in "lib/es6"
# rather than the in-source file. Not sure why, see
# https://rescript-lang.org/docs/manual/v10.0.0/build-configuration#js-post-build
targetFilePathWithoutExtension="${libFilePath#../es6/}"
targetFilePathWithoutExtension="${targetFilePathWithoutExtension%.bs.js}"

# This will be the path to the genType-generated TypeScript file, if it exists.
TYPESCRIPT="$(cd ../../ && pwd)/${targetFilePathWithoutExtension}.gen.tsx"

# If the TypeScript file exists, run the legacy interop script to generate the FlowJS code.
if [ -f "$TYPESCRIPT" ]; then
  node ../../tools/build-system/legacy-interop-file.mjs "$1"
fi

Add legacy-interop-file.mjs

import fs from "node:fs/promises";
import path from "node:path";
import { compiler } from "flowgen";
import tsBlankSpace from "ts-blank-space";

// The path of the current rescript-generated .bs.js file,
// passed in by our `gen.sh` script.
const libFilePath = process.argv[2];

// The file is relative to "lib/bs", and references the file in "lib/es6"
// rather than the in-source file. Not sure why, see
// https://rescript-lang.org/docs/manual/v10.0.0/build-configuration#js-post-build
const targetFilePathWithoutExtension = libFilePath
  .replace("../es6/", "")
  .replace(".bs.js", "");

const pwd = process.cwd();

const filenames = {
  TYPESCRIPT: path.resolve(
    pwd,
    "../../",
    `${targetFilePathWithoutExtension}.gen.tsx`
  ),
  FLOWDEF: path.resolve(
    pwd,
    "../../",
    `${targetFilePathWithoutExtension}.gen.js.flow`
  ),
  JAVASCRIPT: path.resolve(
    pwd,
    "../../",
    `${targetFilePathWithoutExtension}.gen.js`
  ),
};

try {
  const [tsContent, flowdefContent, jsContent] = await Promise.all([
    // Read the TypeScript file generated by genType.
    // We have already confirmed this file exists in `gen.sh`.
    fs.readFile(filenames.TYPESCRIPT, "utf8"),
    // Read the Flow definition file that may have been previously generated by flowgen.
    fs.readFile(filenames.FLOWDEF, "utf8").catch(() => ""),
    // Read the JavaScript file that may have been previously generated by ts-blank-space.
    fs.readFile(filenames.JAVASCRIPT, "utf8").catch(() => ""),
  ]);

  // Use ts-blank-space to strip the TypeScript types.
  // This is the file that our legacy JS code will import (and it in-turn imports the .bs.js file).
  const js = tsBlankSpace(tsContent);

  // Compile the TypeScript file to a Flow definition file. Our Flow instance will use this file
  // to typecheck our legacy codebase's usage of the type-stripped JS file from the previous step.
  const flowdef = [
    "// @flow strict",
    "// flowlint nonstrict-import:off",
    compiler.compileDefinitionString(tsContent, {
      interfaceRecords: true,
      quiet: false,
      inexact: false,
    }),
  ].join("\n");

  await Promise.all([
    // If the JS content would be different, write the file.
    js === jsContent
      ? Promise.resolve()
      : fs.writeFile(filenames.JAVASCRIPT, js, "utf8"),
    // If the Flow definition content would be different, write the file.
    flowdef === flowdefContent
      ? Promise.resolve()
      : fs.writeFile(filenames.FLOWDEF, flowdef, "utf8"),
  ]);
} catch (error) {
  console.error(error.message);
}
4 Likes