ReScript on Deno: Command Line Tools and the Flags module
We will learn how to bind to the Flags module in Deno's standard library to make it easy and type-safe to pass in arguments to our command-line tools.
Last time we saw how to get started with a small project that would print out Hello, Deno!
. It was fun but small. This time around, we will learn how to bind to the Flags
module in Deno's standard library to make it easy to pass in arguments to our command-line tools.
When designing command-line tools, it is a good idea to keep in mind these Command Line Interface Guidelines to make sure we end up building something that really helps us get our job done.
We can think of a CLI as a function from a string (input arguments) to some strings (outputs, either on the standard out channel, or the standard error channel). Normally CLIs have access to a big range of things in an environment. They may modify that Environment too! Their type would look a little bit like this:
let cli : (env, ~args: array<string>) => (env, string, string)
It is this input, the args
input, that primarily determines how we command this tool. Should it commit
, should it build
, should it test
, etc. There are also other ways of modifying these commands, such as --verbose
flag, or passing named inputs, like --name="leandro"
. So the first thing we'll do is to make sure we figure out how to work out what this data is. Once we have this data, we'll have a quick look at how to make decisions with it.
The Flags module
Deno comes packed with a module called flags
, that lets you parse command-line arguments of many kinds, and returns a nice data structure you can work with. Ok, it is nice in TypeScript or Javascript. For us, in ReScript this data structure is a little tricky to work with.
In any case, this module lives here and has a very small API. You've got a single function and two data types:
Flags.parse
, taking an array of strings, and some parsing optionsArgParsingOptions
, defining how to parse the arguments, andArgs
, which is the object of arguments that have been parsed
Args
is an interesting data type because it is rather dynamic in nature β its definition states that it has string keys of any type. Definitely not something straightforward to type in a sound type-system as the one ReScript has!
The rest of the things in that module aren't as surprising or interesting. parse
is just a function, nothing too big about it, and ArgParsingOptions
is fixed in the options it does take.
In order to use this module, we need to write bindings, so we'll take an iterative approach, starting from the cheapest bindings we could write, and making them safer and more ergonomic.
Cheapest Bindings to Flags
The fastest bindings we can write are the ones that just give us access to this function and let us run with it. Of course, this function needs an array of strings for an input, which we also want to be tied to the actual arguments of our command-line tool. Deno provides a global variable for this, called Deno.args
, that we can use to access the raw arguments passed to our CLI as an array<string>
.
So a quick test would look like this:
// file: demo.res
// 1. create a binding to a global array of strings
@scope("Deno") @val external args: array<string> = "args"
// 2. create the binding to our flags parse function
@module("https://deno.land/std/flags/mod.ts")
external parse : array<string> => 'a = "parse"
// parse our arguments and log them out
parse(args)->Js.log
After compiling this code, we get an executable that we can call that will print out the parsed arguments:
$ ./demo hello --name=joe -v
{ _: [ "hello" ], name: "joe", v: true }
Neat! But for the most part, we would like to make use of these values within our program, rather than just print them out. We can see here that the parsing returns an object with a key called _
(underscore), which includes all the parts that are not flags (the parts that don't start with a single or a double dash). Then we have one key in this Javascript object for each flag. If the flag has a single dash, it is considered a boolean flag and set to true
. If it has a double dash, we expect a value to be passed to it. So let's see what we can do with these current bindings.
We will try now to make our program say Hello with a hello
command, and if you have a specified a --name
, then it will print out a friendly hello message:
// file: demo.res
@scope("Deno") @val external args: array<string> = "args"
@module("https://deno.land/std/flags/mod.ts")
external parse : array<string> => 'a = "parse"
switch parse(args) {
| { _: ["hello"], name: name } => `Hello, ${name}!`->Js.log
| { _: ["hello"] } => `Hello, stranger!`->Js.log
}
But when we try to compile this we get an error! π Turns out that _
is just not a valid field identifier for a ReScript record, so we have already have to think how to map this Javascript data into ReScript data. Here's the error:
So how do we get access to the data that is returned by parse(args)
? First we need to point out what is wrong with these bindings.
The Problem with Raw Bindings
These bindings are pretty raw. They do not ensure a lot of safety because of one tiny little thing. The parse
binding is saying that the input should be an array<string>
, but it does not make any claim about the output. In fact it is saying the return type of this function is some type variable 'a
that we don't yet know the value of. This can be very handy when we're starting out, but it tells the compiler that whatever type it finds fit there, that should be a valid type.
So for example, if we were to call parse(args) + 2
, where 2
is an integer, and +
is a function that adds two integers together, then the compiler will do the math, and assume that our type variable 'a
should in fact be an int
.
This means that using unconstrained type variables for return type can lead to creating unsound values. Or values that lie about their type.
1. The type variable is also a part of the parameters of the function β this means that we would have to know the type of the variable, which will indicate the type is sound.
2. Or this function is completely private and nobody can use it out of context β this will prevent us from accidentally creating a value of the wrong type.
Typing Parsed Arguments
So how do we fix this? We can specify the return type of our function to make sure that the compiler and the developer know what to expect when calling it. Let's revisit our binding now. We will include the _
field that we wanted to access, and the name
field:
// file: demo.res
// A type representing our parsed arguments
type parsed_args = {
// NOTE: because underscore is not a valid field name,
// we use the @as annotation to specify an alias
@as("_") cmd: array<string>,
name: string
}
@module("https://deno.land/std/flags/mod.ts")
external parse : array<string> => parsed_args = "parse"
Amazing! Now we can refactor our code to rely on this:
// file: demo.res (cont.)
switch parse(args) {
| { cmd: ["hello"], name: name } => `Hello, ${name}!`->Js.log
| { cmd: ["hello"] } => `Hello, stranger!`->Js.log
}
Then build and run it:
$ ./demo hello --name=Joe
Hello, Joe!
$ ./demo hello
Hello, undefined!
Huh. Why didn't we go into the switch branch that doesn't have a name?
When we are typing Javascript objects on bindings, we are making a promise to the type system. If we tell the type system that the parsed_args
record has a field called name
of type string
...then it will believe us. And since there is no null or undefined values un ReScript, this means that ReScript will expect the field name
to always exist and have a value.
Yikes. This is not the case. If we look at the outputs from before, when we called our little tool without the --name
flag, there was no name
field. This means that if we peek at the Javascript output, we will likely find that we are accessing an object like args.name
, and this is undefined
. So how do we deal with this?
Ad-hoc Typing for Objects of Unknown Shape
When the shape of an object is unknown, sometimes the best thing to do is to not type it at all. Some other times, we can reach a pragmatic compromise that gives us good ergonomics without sacrificing too much type-safety.
Let's assume for now that we are only writing these bindings for our own project. This means that we know that there are certain fields that we expect to exist, and know how to deal with them. It also means that there is another infinity of fields that we simply do not care about.
For our tool, we care about the command, and we care about the field name
. When writing the shape of the parsed_args
record, we can include them both. We also know that name
is an optional argument, so we can make sure to tell the compiler that this argument may not be present. This will look like this:
type parsed_args = {
// We expect to always have a `cmd` field, even if its an empty array
@as("_") cmd: array<string>,
// We expect the `name` field to sometimes not be there
name: option<string>
}
With only this change, we are forced by the compiler to refactor our little switch to handle this new information: name
is not a string
, it is actually an option<string>
. Here's the type error:
Optional values can either be set to None
when there is nothing in them, or to Some(value)
when a value
is inside them. When writing bindings, saying that a field is an option<string>
is saying that this is an optional field. So now we can adjust our switch to handle the case where the name
is set to None
:
// file: demo.res (cont)
switch parse(args) {
| { cmd: ["hello"], name: Some(name) } => `Hello, ${name}!`->Js.log
| { cmd: ["hello"], name: None } => `Hello, stranger!`->Js.log
}
And after we compile it, we can run it:
$ ./demo hello --name=Joe
Hello, Joe!
$ ./demo hello
Hello, stranger!
Amazing! This is what we wanted. We say that the typing we did is ad-hoc, because it only handles our specific use case.
1. When you have a dynamic Javascript object, that may have varying shapes, and you only care about a specific subset of that shape.
2. When you have a really big Javascript object that is impractical to cover entirely, and you only care about a specific subset of it.
In both cases, it is important to keep the ad-hoc typings private to the code that is using them, to prevent these assumptions from spilling into other parts of your codebase.
If you find that you need the same binding with a different type, duplicate it and write the exact type you need.
If you find that you need the binding to be reused across several contexts, and it never is quite right, consider a General Typing instead.
This is the kind of typing of objects you want to be doing most of the time in your application code because it is cheap, it is safe, and you can do it as many separate times as you need to.
To elaborate on that last point, if you had more than one command-line tool in your codebase, you can define this binding several times, in different modules, so that each one represents the exact arguments that you were expecting. For example, if one of your command-line tools is a clone of cp
to copy files, it may take an --src
and --dst
arguments, which you can type as:
type cp_args = {
src: option<string>,
dst: option<string>,
}
@module("https://deno.land/std/flags/mod.ts")
external parse_cp_args : array<string> => cp_args = "parse"
Note how I changed the name on the left side of the external
, but the name of the right side is the same.
There will be other situations where you actually want to do the bindings once and reuse them across the board. Let's see what that looks like.
General Typing for Objects of Unknown Shape
When we have exhausted ad-hoc typings, and decide that we need to support the general case of an object having dynamic properties, ReScript has some excellent tools we can use to make sure that nobody makes any wrong assumptions about this data. However, we will have to make some concessions and accept that our record type may need to be treated like a dictionary.
Let's rewrite our example with the hello
command to use a general typing that will work for any arguments with any flags and any commands. We will start by making sure that nobody can make any assumptions about the data we return:
// This is an Abstract Type -- it is a type that exists, but nobody
// knows its shape, so nobody can see inside it directly!
type args
@module("https://deno.land/std/flags/mod.ts")
external parse : array<string> => args = "parse"
Excellent. Now our switch
complains rightfully:
We want to access our previous cmd
field (remember it was originally called _
, underscore). This one is a single external:
@get external command : args => array<string> = "_"
Now in order to support our variable attributes for each one of the flags, we need to take a step back and ask ourselves: what is a type that could let us hold any number of flags, some of which have a string value, some of which have a boolean value?
We can analyze a little bit the data structure we originally logged out and come to some conclusions:
- Keys that have a single letter have type
bool
- Keys that are longer than one letter have type
string
So perhaps what we can do is offer 2 functions to check for flags (single letter arguments without values associated with it) and parameters (many letter arguments with a value associated with it). Alright, let's sketch them out:
let getFlag: (~name: string, args) => option<bool> =
(~name, args) => { ... }
let getParam: (~name: string, args) => option<string> =
(~name, args) => { ... }
Fantastic. Now that we have an idea of the shape they have, and since we know what the shape of args
will be (even tho this is hidden to the compiler!), then we can play a little looser. Roughly speaking, what we want to do is leverage the fact that we know Flags.parse
will return a Javascript Object, to dynamically access its fields. In regular Javascript, we would write something like args[fieldName]
, but in ReScript we don't have this notation. What we do have is the Js.Dict
module, with functions to safely access a dictionary.
However, we don't actually have a dictionary. We have a completely opaque piece of data the compiler does not know about, our abstract type args
! π At this point is where we need to make our type concrete instead of abstract and hide it from our users so they don't accidentally. ReScript comes equipped with module interfaces for exactly this.
This will help you expose a cleaner, more focused API that reflects the domain of the problem you are trying to solve while giving you all of the flexibility to swap the internal details, optimize them, or replace them with an external library.
So we will wrap our bindings in a tiny module to avoid creating a separate file, and we will create an interface that will allow us to treat our args
as a good old dictionary from inside the module, while making it look completely opaque from the outside.
// The interface to our Flags module
module type Flags_intf = {
type args
let parse: array<string> => args
let command: args => array<string>
let flag: (~name: string, args) => option<bool>
let param: (~name: string, args) => option<string>
}
// This is the implementation of our Flags module
module Flags: Flags_intf = {
// Our type is a flexible dictionary internally, with
// a flexible type for its values
type args = Js.Dict.t<Js.Json.t>
@module("https://deno.land/std/flags/mod.ts")
external parse: array<string> => args = "parse"
@get external command: args => array<string> = "_"
}
Excellent. Now we just need to build our flag
and our param
function to regain all the power we originally had and more. Since we know inside our Flags
module that we are working with a dictionary, we can just rely on the Js.Dict
and the Js.Json
modules to find values and check they have the expected types.
// file: demo.res
module Flags : Flags = {
//...
let flag = (~name, args) =>
switch Js.Dict.get(args, name) {
| Some(flag) => Js.Json.decodeBoolean(flag)
| None => None
}
let param = (~name, args) =>
switch Js.Dict.get(args, name) {
| Some(param) => Js.Json.decodeString(param)
| None => None
}
}
Changing how we use the API now, our tiny switch
becomes this:
// file: demo.res
// ...
let args = Flags.parse(args)
let cmd = Flags.command(args)
let name = Flags.param(args, ~name="name")
switch (cmd, name) {
| (["hello"], Some(name)) => `Hello, ${name}!`->Js.log
| (["hello"], None) => `Hello, stranger!`->Js.log
}
And running our program we see our expected results:
$ ./demo hello --name=Joe
Hello, Joe!
$ ./demo hello
Hello, stranger!
This API is far from the most elegant one we could've chosen, but it is definitely a straightforward API to use, it has minimal overhead, and it already is a gigantic leap in type-safety from the original TypeScript typings provided with Deno:
There are definitely next steps to take, but we'll take this deep dive into the world of declarative APIs in follow-up posts where we'll really showcase the kind of developer experience and type-safety we can achieve with ReScript.
Wrapping Up
We've seen 2 strong ways to type bindings for your projects, and we've typed the Deno Flags module to make some command-line tools with ReScript that can be shipped as single executables. In both cases, the arguments become this nicely typed value we can use regular ReScript control code (like switch
or if
) to help us route to different functions that do the actual work.
Depending on the command-line tool you are trying to build you may have more or fewer commands, parameters, or flags, so this part really depends on what you're trying to build.
That's really it, nothing more is needed to get started.
Now it's your turn to try and build something cool with ReScript on Deno, and don't forget to tell me how it went on Twitter! Just @ me: @leostera
See you on the next issue of Practical ReScript ππ½