Providing JavaScript Developers Helpful Type Errors When They Call Your ReScript Library Incorrectly
Jesse Warden
Posted on April 19, 2021
Introduction
You can’t use ReScript to call ReScript incorrectly because the compiler will help you make it correct. You can’t use ReScript to call JavaScript wrong because the compiler will help you make it correct.
However, when JavaScript calls your ReScript library, it can do it incorrectly because JavaScript has no compiler; you just run it. This can ruin all the benefits of ReScript: null pointers, runtime errors, and super strange internal ReScript standard library errors that make no sense with stack traces that are not helpful.
Instead, the better thing to do is check the types at runtime, and if wrong, provide helpful runtime errors with what the JavaScript developer needs to do to fix the error. If you’re a ReScript developer providing libraries for JavaScript developers, this article is for you.
Problem
You complete a cool ReScript library. You feel great. You publish your finished library for JavaScript and/or ReScript developers to use. However, a JavaScript developer using your library in their Node.js project reaches out to you, saying it doesn’t work. You respond like you do to all programming problems: “Show me your code.”
Their code looks like this:
import { audit } from '@jesterxl/cow-audit'
audit("secret password", 23)
.then(console.log)
.catch(error => console.log("error:", error))
You immediately see the problems in the audit
call. They have the parameters reversed, one is a number that’s supposed to be a string, and they’re only passing 2 parameters, not the 3 required. In ReScript, your function looks like this:
let audit = (id:string, password:string, host:string) => {...}
The JavaScript developer is setup to fail in a multitude of a ways:
- they have no idea what your function signature is besides the docs. They have no compiler to help them, and the intelli-sense is spotty. Despite that, they won’t really know if it works unless they run it; that’s how JavaScript works. Runtime errors are just “expected”.
- You have 3 strings in a row, but no indication of what strings. This is why strongly typed functional developers look at functions with string parameters and think the function is un-typed. It’s easy to mess up and hard to know if you got it right.
- JavaScript isn’t like Python; function arity (how many parameters a function takes) isn’t enforced at runtime. You can pass not enough or too many and the runtime doesn’t tell you that; it may even work.
- The errors that occur are unpredictable. While in “ReScript Land®” things are predictable, when you have chaos calling, chaos then inevitably results.
In short, JavaScript doesn’t have types or a compiler, and the only way they know if the code works if they run it successfully or not with errors. The expectation is that the Errors will tell them what they did wrong, and they and/or the stack trace will help indicate what they did wrong and how to correct it.
That’s not how ReScript works. Null pointers and errors aren’t supposed to occur; that’s the whole point of using a soundly typed language over one that isn’t soundly typed like TypeScript. Yet here we are, having JavaScript screw things up. ReScript helps you at compile time, NOT at runtime. That’s why even if you bind to JavaScript modules or objects, while the bindings may be correct, at runtime JavaScript has no guarantee’s or perhaps you wrote the bindings wrong, and explosions ensue.
We have a communication problem. It’s now your responsibility, as a ReScript developer, to speak in a language the JavaScript developer can understand, and that’s in clear, runtime Exception messages.
Solutions
There are actually 3 solutions here, the last being optional. They are using a configuration object as a single parameter, runtime type checking in ReScript, and more descriptive naming of variables indicating their type.
Quick Note on Code Formatting
For code already written, or code we need to write, we’ll write ...
which indicates “stuff here that’s not relevant right now”.
Single Object Parameter
A lot of JavaScript developers will sometimes use a single Object as a parameter to a function when it gets “too many parameters”. The true amount of “too many” varies. There are a variety of motivations, though, about why they use this technique to alleviate the too many. Text editors will force you to horizontally scroll to see all your parameters; no one likes horizontally scrolling except in video games. Some editors won’t provide any intelli-sense, or it will temporarily break, and so you don’t know what parameter is which. The rise of TypeScript has encouraged developers to create typed Interfaces or Types which allows typed Objects with compiler help. Some hate remembering the order, and want flexibility, especially when default values are involved.
That means, using our ReScript code above, it goes from this:
let audit = (id:string, password:string, host:string) => {...}
To this:
let audit = config => {...}
The id, password, and host are now names on that Object. On the ReScript side, you don’t type it, you leave it as a generic type, like a normal JavaScript Object.
This solves 2 problems:
- The JavaScript developer cannot screw up the order; there is only 1 parameter provided. If they unknowingly provide no parameter, the function will still be invoked on the ReScript side with 1 parameter that is
undefined
“because JavaScript”. - The JavaScript developer knows what string goes where because they now have names on an Object, clearly indicating where they go.
However, there is one other benefit to you the library author, and that’s you now know exactly where they screwed up a parameter vs. they just put the wrong thing in the wrong order, and maybe the wrong type. Let’s see how that works.
Runtime Type Checking in ReScript
Now that you have a public method with a single configuration Object being passed in, you can write some imperative looking code to inspect each variable, and if it doesn’t look correct, let the JavaScript developer know exactly which one is wrong, and how they need to fix it. Despite “being in ReScript”, we’re getting a JavaScript input, so can’t trust anything… but we CAN trust ReScript types! Therefore, we need to account for 4 things:
- If the config itself, or a value, is
undefined
. - If the config itself, or a value, is
null
. - If a particular value is the correct type.
- If a particular value matches our criteria for that type (i.e. String isn’t good enough)
Let’s handle those in order and how you how that looks in practice. First, we need to ensure config
even exists; meaning something other than undefined
or null
. We can convert things to an Option
using the toOption
function in Js.Nullable
package:
let audit = config =>
switch Js.Nullable.toOption(config) {
| None => ...
| Some(opts) => ...
This ensures if the JavaScript developer does something like audit()
or audit(wrongEmptyVariable)
, they’ll recognize where they messed up in their JavaScript calling your library function. We can now provide a more helpful error message in that scenario:
let audit = config =>
...
| None => Js.Exn.raiseError`('Your config does not exist. You need to provide an Object that looks` like { id: "23", password: "yourpass", host: "http://server.com" }')
...
Once you’ve confirmed you have an actual valid options, then we grab all of the variables, and convert them to Options
:
let audit = config =>
...
| Some(opts) =>
let idMaybe = Js.Nullable.toOption(opts["id"])
let passwordMaybe = Js.Nullable.toOption(opts["password"])
let hostMaybe = Js.Nullable.toOptions(opts["host"])
… and then verify each is legit or not:
let audit = config =>
...
if( Js.Option.isNone(idMaybe) ) {
Js.Exn.raiseError(`id is not defined on your config object. It's supposed to be a non-empty string of your id, but you sent: ${idMaybe}`)
} else if { ...
You repeat this for each variable, providing a helpful error. You’ll notice sometimes JavaScript developers pass the wrong thing in the wrong place “because JavaScript”, so we include what they did send above, idMaybe
so they can see what we got on the ReScript side to help them correlate where they went wrong. The None
in ReScript will typically print out as an empty string in JavaScript, but that pattern is helpful for JavaScript developers to see what they sent.
After you’ve verified everything, you can run additional validations, again, common mistakes JavaScript developers can make, such as empty strings, truncated strings, or strings meant for the wrong thing such as a small id and a large password.
let audit = config =>
} else if(Js.Option.getExn(idMaybe) === "") {
Js.Exn.raiseError(`Your id is an empty string. It needs to be at between 2 and 7 characters, like '23', but you sent: ${idMaybe}`)
Notice how we’re writing some pretty imperative code which means order is important. That works in our favor because since we’ve confirmed everything in the above if statements is all the Options are in fact Some's
then we can safely use getExn
without fear.
Lastly, now that we’ve confirmed the JavaScript developer did everything correctly, we can call our function the normal FP way: using a bunch of parameters.
let audit = config =>
...
} else {
_audit(Js.Option.getExn(idMaybe), Js.Option.getExn(passwordMaybe), Js.Option.getExn(hostMaybe))
Anything that goes wrong in _audit
the JavaScript developer will get as a return value or resolved Promise value.
The final version looks something like this:
let audit = config =>
switch Js.Nullable.toOption(config) {
| None => Js.Exn.raiseError
("Your config does not exist. You need to provide an Object that looks
like { id: '23', password: 'yourpass', host: 'http://server.com' }")
| Some(opts) => {
let idMaybe = Js.Nullable.toOption(opts["id"])
let passwordMaybe = Js.Nullable.toOption(opts["password"])
let hostMaybe = Js.Nullable.toOptions(opts["host"])
if(Js.Option.isNone(idMaybe)) {
Js.Exn.raiseError(`id is not defined on your config object. It's supposed to be a non-empty string of your id, but you sent: ${idMaybe}`)
} else if(Js.Option.isNone(passwordMaybe)) {
Js.Exn.raiseError(`password is not defined in your config object. It's supposed to be a non-empty string, but you sent ${passwordMaybe}`)
} else if(Js.Option.isNone(hostMaybe)) {
Js.Exn.raiseError(`host is not defined in your config object. It's supposed to be a non-empty string, but you sent ${hostMaybe}`)
} else if(Js.Option.getExn(idMaybe) === "") {
Js.Exn.raiseError(`Your id is an empty string. It needs to be at between 2 and 7 characters, like '23', but you sent: ${idMaybe}`)
} else {
_audit(
Js.Option.getExn(idMaybe),
Js.Option.getExn(passwordMaybe),
Js.Option.getExn(hostMaybe)
)
}
}
}
Descriptive Names
They say an ounce of prevention is worth a pound of cure, and sometimes that can go a long way in dynamic languages like “convention over configuration” in Ruby. For Dynamic languages as a whole, a tactic some people use is encoding the type in the name. Some love it, some hate it. If you did that here, it’d be: idString
, passwordString
, and url
.
Saying idString
instead of id
implies whether the id is a string or a number… it should be a string for your library.
Now passwordString
could be further expounded to include the rules such as password7Minimum255MaximumString
, making the JavaScript developer insecure in that maybe they should validate it first.
Lastly, while host is technically what we’re looking for, url is a little more correct and implies the string should start with “https://” and have some kind of domain in it. Host makes it seem like just “server.com” is ok, and we’ll magically provide the “https://” part somehow.
However, naming things is hard. Sometimes it’s easier to write validation code with helpful errors than bikeshed with yourself or team over variable names.
Conclusion
Now, on the JavaScript Developer’s side, their code is changed from the dangerous:
audit("secret password", 23)
To the safer and easier for both parties version:
audit( { id: "23", password: "secret", host: "http://server.com" })
Any problems, the catch
will have a helpful error message vs. the weirdness you get throwing bad things at runtime to the ReScript standard libraries & compiled JavaScript.
Quick Note About Errors
You’ll notice I default to a Promise as that is a common coding practice in Functional Programming in ReScript, however, there are 2 types of Errors: sync and async. If your function is synchronous, then using the standard ReScript Js.Exn.raiseError
is fine. However, if it’s asynchronous, then it’s a bit different than you may have been used too in JavaScript. In JavaScript, you’d just return Promise.reject(new Error("reason"))
and move on, but that’s not how Promises work in ReScript. You need to create a new one inline and return that; you can’t just Js.Promise.reject(Js.Exn.raiseError("reason"))
. It’s weird, but works; I typically put this up top:
let reject = reason => Js.Promise.make((~resolve as _, ~reject as _) => {
Js.Exn.raiseError(reason)
})
That way, if you call within a normal function, it acts like Promise.reject
would and returns a Promise.
Posted on April 19, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
April 19, 2021