Reviving Legacy Code: Rescript Tips for Modernizing Your JavaScript Projects with AI
Some Takeaways from AI-assisted Rescript Exploration

I'm a web-focused developer with a passion for exploring new ideas and hopefully sharing more of them through this blog 😃
I have been on a journey to revive one of my oldest projects, partly to explore the capabilities of AI agents. This project in particular is around 10 years old, a full stack JavaScript app. The backend API is built with Express & MongoDB, and the frontend Single Page Application is powered by a mix of React and Elm. Turns out this mix of frontend technologies was not such a good idea, and here is where Rescript comes in. I chose to rewrite the Elm parts in Rescript.
Rescript offers an excellent way to work with existing JavaScript code and integrated especially well with React. Each Rescript file compiles into a single JS file, making it easy to use in any JS project or to gradually convert small bits to Rescript. This made it ideal for this experiment.
This leads me to some of the lessons I have had to learn the hard way. I don’t see a lot of content about Rescript out there and I had to discover some of this myself, so just writing it down for anyone else who might run into similar issues.
They are all about interoperating with JS so that’s an area that you need to be careful with in general. Also a lot of this code is AI-generated, so it might reflect the quality or age of the code it was trained with. Also Rescript is changing quite a lot with the recent v12 release being quite aggressive with deprecating old stuff, so also watch out when using AI agents with Rescript.
Always verify the AI output 🪄
I saw the AI agent sprinkling a few Obj.magic calls arond and was left wondering what this function is and what it does. There isn’t much information on this on the official docs, so it seems like an API that is not encouraged or deprecated.
Anyway I asked the Agent to explain:
Obj.magicis a ReScript function that performs an unsafe type cast - it tells the type system "trust me, I know what I'm doing" and bypasses type checking.
Here’s an example
let x: int = 42
// Obj.magic can convert ANY type to ANY other type
// Compiles, but DANGEROUS!
let y: string = Obj.magic(x)
Yikes! Looks dangerous, and looks exactly like the kind of thing that beats the whole point of using a strongly typed language in the first place.
This was the actual code in my case.
// types are not known in advance
let validateBooleanQueryFromJS = (
~mustTerms: 'a,
~shouldTerms: 'b,
~mustNotTerms: 'c,
) => {
// Check if inputs are arrays
if !Array.isArray(mustTerms) {
{"valid": false, "error": Some("mustTerms must be an array")}
} else if !Array.isArray(shouldTerms) {
{"valid": false, "error": Some("shouldTerms must be an array")}
} else if !Array.isArray(mustNotTerms) {
{"valid": false, "error": Some("mustNotTerms must be an array")}
} else {
// Cast to array after the checks
let mustTermsArray: array<string> = Obj.magic(mustTerms)
let shouldTermsArray: array<string> = Obj.magic(shouldTerms)
let mustNotTermsArray: array<string> = Obj.magic(mustNotTerms)
// ...
// Further processing of the arrays
}
And this is how the function is called from the JS side
const result = validateBooleanQueryFromJS(mustTerms || [], shouldTerms || [], mustNotTerms || []);
So I finally understood why the Obj.magic call was included. Since there are no explicit types being specified for mustTerms, shouldTerms and mustNotTerms in the validateBooleanQueryFromJS function, it is possible to call the function passing any data type.
We are validating the data through the explicit Array.isArray calls, then casting each of the arrays to an array<string> type afterwards when we have validated that indeed the data passed in is an array.
There are a couple of ways we can deal with this.
The first option is the more straightforward one: Do the same validation, but on the JS side, then using the concrete array<string> type for the function parameters.
This basically means moving the array checks to before calling the Rescript method validateBooleanQueryFromJS from the JavaScript side
The parameter types can now be written as follows:
let validateBooleanQuery = (
~mustTerms: array<string>,
~shouldTerms: array<string>,
~mustNotTerms: array<string>,
) => {
// No need to check if inputs are arrays
// ...
// Further processing of the arrays
And called from the JS side after doing the validations there
// Option 1: Simple and straightforward validation
// Less clarity on which variable has the wrong type
const terms = [mustTerms, shouldTerms, mustNotTerms];
if (!terms.every(Array.isArray)) {
return res.status(400).json({ error: 'Terms must be arrays' });
}
const result = validateBooleanQuery(mustTerms, shouldTerms, mustNotTerms);
// Option 2: More verbose and more explicit
if (!Array.isArray(mustTerms)) {
return res.status(400).json({ error: 'mustTerms must be an array' });
}
if (!Array.isArray(shouldTerms)) {
return res.status(400).json({ error: 'shouldTerms must be an array' });
}
if (!Array.isArray(mustNotTerms)) {
return res.status(400).json({ error: 'mustNotTerms must be an array' });
}
This could be convenient, but if there is another place where the same function needs to be called and the same validations need to be done again, it can get hectic.
The second option is to keep the unsafe type conversions with %identity which is the supported syntax. To be clear, this is essentially the same as Obj.magic and is generally not recommended to unsafely convert between types. Reserve this for when there’s no other option.
// Use %identity for unsafe conversion from one type to another type.
external convertToArray: 'a => array<string> = "%identity"
let validateBooleanQueryFromJS = (
~mustTerms: 'a,
~shouldTerms: 'b,
~mustNotTerms: 'c,
) => {
// Check if inputs are arrays
if !Array.isArray(mustTerms) {
{"valid": false, "error": Some("mustTerms must be an array")}
} else if !Array.isArray(shouldTerms) {
{"valid": false, "error": Some("shouldTerms must be an array")}
} else if !Array.isArray(mustNotTerms) {
{"valid": false, "error": Some("mustNotTerms must be an array")}
} else {
// Use convertToArray to safely cast after verifying types
let mustTermsArray = convertToArray(mustTerms)
let shouldTermsArray = convertToArray(shouldTerms)
let mustNotTermsArray = convertToArray(mustNotTerms)
// ...
// Further processing of the arrays
}
So you can choose what works best for you. Both are valid options and it helps to know the tradeoffs you are making with each method.
So I’m watching the content from the AI agent more closely, and trying to learn from the agent rather than just accepting whatever code it provides.
Check out this excellent post that is required reading for anyone working with AI-generated code: Treat AI-Generated code as a draft
Conversion between Rescript types and JavaScript
Some parts of Rescript are very nice such as the Result type, which encodes an operation that could succeed (Ok) or fail (Error). It’s particularly helpful for avoiding throwing exceptions and handling errors more gracefully with pattern matching.
type result<'ok, 'error> = Ok('ok) | Error('error)
let divide = (a, b) => {
if b == 0 {
Error("Division by zero")
} else {
Ok(a / b)
}
}
switch divide(10, 0) {
| Error(err) => Console.log("Nice try! Error! " ++ err)
| Ok(result) => Console.log("Success: " ++ Int.toString(result))
}
However, trying to return the Result type from a function and using this directly from JS is a bit weird. For example this divide function compiles to this JavaScript code
function divide(a, b) {
if (b === 0) {
return {
TAG: "Error",
_0: "Division by zero"
};
} else {
return {
TAG: "Ok",
_0: Primitive_int.div(a, b)
};
}
}
And using this does seem a bit strange and inconvenient. Reaching into a field called result._0 definitely seems like an API that was built not to be used directly
const { divide } = require('path/to/compiled_js');
const result = divide(10, 2);
if (result.TAG === "Ok") {
console.log("Success:", result._0);
} else if (result.TAG === "Error") {
console.log("Error:", result._0);
}
So in such cases, we could create and export a new function for use from JavaScript. This function can return a custom object, providing a more user-friendly experience when using the function from JS
// Export a wrapper for JavaScript - return a plain object instead of result
let divideFromJS = (a, b) => {
switch divide(a, b) {
| Ok(value) => {
"success": true,
"value": value,
}
| Error(msg) => {
"success": false,
"error": msg,
}
}
}
And this is much nicer to use from JS
const { divideFromJS } = require('path/to/compiled_js');
const result = divideFromJS(10, 2);
if (result.success) {
console.log('Result:', result.value);
} else {
console.log('Error:', result.error);
}
So what have I learned from all this?
Rescript provides a nice experience for writing type safe JavaScript code with no runtime errors. However the interoperability with JavaScript sometimes can sometimes be hectic, and you can customize your functions and return types for a better experience.
Always review AI-generated code. Check for unsafe or deprecated APIs usage and ensure whatever it generates fits your needs
Languages like Rescript help to tame JavaScript. It’s hard to go back to writing plain JS after experiencing a compiler that gives you confidence when shipping
Back to playing with some typed languages 🤖



