This is an unfinished post, where I’m thinking about the friction between my creativity and OCaml tools, particularly as it compares to UX in other language ecosystems.
If you read on, know that it’s incomplete :).
Context for this post. Warning--long, unintentionally kind of rant-ish
I started another OCaml project yesterday. It had been a while since I had used OCaml. I tripped over every possible little thing and grew quite frustrated. My IDE not showing the right stuff, different error messages between shells, opam init scripts not loading my local switch, my local switch missing a dep I thought was already present, dune unable to locate a lib because of a rogue project file, ...just to name a few. There were many, many more. None of individual problems are deal breakers, but I continue to experience OCaml tooling problems, and it's death by a thousand cuts. This seems to happen each time I re-pickup OCaml. This time, I thought "this isn't worth it, do your project in a different language".But OCaml is my favorite to think with. So I’m persisting. Here’s a claim: OCaml tooling far less productive than it could be.
I have been working in OCaml as a hobbyist for a short while. I often go weeks with no OCaml activity, followed by a week or so of moderate activity. Admittedly, I am hot & cold with OCaml programming cycles, with long cold periods. I am still a user with less than one year of experience, so my opinions should be weighed as such. However, I do have over a decade of professional software development experience in a diverse set of languages as well as a couple of engineering degrees, which perhaps should counter balance my OCaml newb-ness.
Each time I return to OCaml, I struggle to define a grokkable, repeatable, productive workflow for managing my work. In studying public user-land projects, I don’t observe many universal processes either. Some common guidances are “build with dune” or “manage your env with opam switches”. These tooling guidances are settled. However, common behaviors do not seem to have well-known recipes. Recipes like
- declaring and reproducing a development environment (switch, deps, etc)
- segmenting production & dev-time only dependencies
- project structuring
- etc etc
do not appear to have established community expectations. This is possibly because the tooling doesn’t enforce such behaviors or perhaps merely because the publishing ecosystem is (relatively) small.
I feel embarrassed to myself for sinking hours to tweaking, tuning, searching over configuration docs/manpages just in order to do actions what I perceive should be plainly obvious and resistant to user error. It oft frustrates me, and evokes emotion of distaste towards ocaml, when such emotion is unreasonable–I love ocaml! The people in the community and the excitement for effects as a first class language feature keep me coming back.
The ideal artifact produced from this exercise would be a document (or video, perhaps) capturing idiomatic project management across the development lifecycle, starting with a simple project, and evolving to a complicated, mutli-lib multi-bin project. The goal is to get a developer from 0 => productive with minimal noise. Assume all parties have decent knowledge of the language itself, and instead focus only on the tools and processes executed before and after coding. Questions and answers refining the following hypotheticals are desired. How do we scaffold projects? What is their structure? When do we deviate? How can we configure as few things as possible, and have the rest just work? How can compilers, dependencies, IDE systems snap into ready mode with minimal friction? How we keep things that need to be synchronized do so automatically? How can we minimize the numbers of steps needed between ideation and experimentation?
OCaml + opam + dune have lots of documentation, but there are holes between the required and implicit integrations. drom
is an effort to unify these entities, which is noble. make
is still used regularly in the community. There are hundreds of flags, knobs, tags, <whatever>
in all of the tools. Project management is not simple in OCaml. I’ve been here long enough, though, to know that this all can be simpler, and we can grow adoption and expertise in our community iff we can ease the onboarding process.
So, with that said…
What common project management processes do OCaml developers execute, and which are normalized in the community?
Help me by brainstorming “processes I do as an OCaml library or executable author”.
Together, let us:
- declare those processes
- discuss if our community has best-known-methods (BKM) to fulfill those processes
- compare status-quo user-experience (UX) to a (debatable) target UX
- debate what we like about each others’ flows.
Here is a draft set of high frequency workflows observed in a repository’s development lifecycle:
Process | OCaml BKM exists? |
---|---|
clone, install project deps | |
add, update, delete, & run tests | |
run default executable | |
update a dependency, track it | |
update a devDependency, track it | |
swap compiler versions, track it | |
create projects with multi-file library | |
create projects with multiple libraries | |
create projects with executables | |
publish | |
setup IDE |
Key: not exists, partially exists, clearly exists
- The following are only my perceptions & opinions.
- I will compare
ocaml/opam/dune
tonode/npm
as a UX BKM reference, as the node community has excellent UX when it comes to minimizing friction between ideation and execution. I could userust
,python
, maybejava
too.rust/cargo
offers perhaps a more equitable comparison, due to the fact that ocaml & rust both target native compilations, thus additional concerns & constraints. However, UX is the metric I seek to improve together, thus will stick withnode
.
Process - clone, install
ocaml
BKM not established
- git clone
- maybe setup a switch?
- then ??? options:
-
make dependencies
? -
opam switch import ./my-deps --switch .
? -
opam install all-of-my-packages-things
?
-
Observations:
-
[opam|dune] install
mutate a switch env. they do not update any package-local lockfile -
[opam|dune]
do not have a unified approach to declaring dependency types- there is some tagging available, but not well documented, and deep in the opam docs
- Development-time dependency and env prep is often first or second class in other languages that require explicit pkg declaration (as opposed to
go
ordeno
, which just use URLs to implicitly declare dependencies). In OCaml, we have a decent spec for production time env & deps realized via<name>.opam
files, but we do not have a clear recipe for development.
node
BKM established
git clone
-
nvm use
(optional), (e.g. akin to select switch) npm install
-
npm build
(optional)
Most libraries or executables are now ready for all development activity. It’s not 100%, but the expectation is there in the community.
ux comparison
Tracking and reproducing envs & dependencies as a first class process in the ocaml tooling space is plain missing. Adding dependencies has a clear solution, but restoration does not have an idiomatic process in ocaml. esy tries to offer this to our community, but I suspect there is very little alignment to it.
Process - add, update, delete, & run tests
ocaml
BKM partially established
- tests are placed in an independent dune project that you configure to compile and execute your tests, or, tests are co-located with lib/bin source and authored inline
- executed via
dune test
Observations
- it is very common to co-locate your tests and source. you cannot do this easily with ocaml/dune, without specifying
(modules ...)
which is tedious and unproductive. every file move needs to be accompanies with dune file updates. - dune treats common test writing patterns as “custom”, and focuses heavily on inline/expect and cram testing
- this is not a problem per se. however, by nature of dune being the defacto tool of choice, the reader is immediately pulled off the rails and implicitly encouraged by means of documentation volume and heirarchy to investigate expect & cram style tests, which (subjectivity warning, opinion warning) are more eccentric than your traditional integration/unit test.
Tools are generally in place for testing, but the tools and their associated docs do not guide users to a familiar recipe.
node
BKM established
- tests land in
<root>/tests
orsrc/**__tests__
npm test
Sometimes, pre-build steps are needed. pretest
hooks tracked in package.json often fulfill this need.
ux comparison
The OCaml recipe makes sense, but forcibly externalizing tests to their own dune project is inconvenient and feels foreign. Some dune sugar to declare tests/**.test.ml
modules as test-only, and include/exclude such contents from the release/test-only binaries would feel more natural, in the context of a single dune project.
Process - run
ocaml
BKM partially established
-
dune exec <path/to/thing>.exe
is generally a well established way to run project binaries- however, this approach lacks configuration and possible mandatory preprocessing
- perhaps through some combination of
rule
andalias
a default execution mode could be established
Some builtin aliases exist, but omit a default for “run the software”.
node
BKM established
-
npm start
/npm dev
are defacto entrypoints for executing the developed artifact, or, tooling to exercise the developed artifact(s).
ux comparison
This functionality is not critical in the big picture, but I include it for thoroughness. Ultimately we design software to run applications. Having a first class mechanism to run applications hosted in a project seems like an easy UX win. npm start
, cargo run
, etc exist for this purpose. dune exec path/to/thing.exe
works, but has shortcomings:
- an implicit (subjectively obscure, as the file never exists) mapping from
.ml
to.exe/.bc
- a required knowledge of the to the executable path, even after dune has already parsed all of the projects and knows about all executable things anyway
- a required knowledge and execution of any pre-requisites
npm
package developers often configure all setup work (prestart
scripts, ENV setting, etc) in their package.json, often minimizing the amount of work between git clone
and npm start
to get the user in motion as fast as possible. It’s akin to many projects just needing to git pull
and make && make run
.
This topic is definitely a nit, but dune run
or dune start
to execute a configured default executable, if present, would be cute.
Process - update a dependency, track it
ocaml
BKM not established
-
opam install my-dep@new-version
is the current offering - The opam integration sort-of has ideas around this. However, as previously discussed, there’s no lockfile for our projects at development time. This could be
opam
's responsibility, butopam
(perhaps intentionally) does not position itself as a project manager. It is purely an environment manager. This leaves a void that neither opam nor dune fill. drom and esy are both attempting to solve this. The community wants it–we should bake it into our default tooling as well, vs leaving it up to user space. User-space is great, but this creates fragmentation and productivity loss on an absolutely fundamental step in developing OCaml. - My hack is to do a manual
opam switch export ./switch
to dump my dependencies for reproducibility, andimport
them back in as needed.
node
BKM established
-
npm install --save my-dep@new-version
, lockfile updated
For what it’s worth, the package-lock.json
in npm
stinks. Other tools, like yarn
& pnpm
in the node ecosystem execute on this much better than npm
does, and are generally drop in replacements for npm
, and behave similarly on all workflows.
ux comparison
node
/npm
changes explicitly update artifacts to capture your changes. package.json and your lockfile both update to improve downstream reproducibility by peers or CI systems. opam makes changes in your switch, but those are default hidden to your project. You need to design your own workflow to track your switch changes, which is less desirable.
esy.lock
does this now, unsure about drom
. Our default workflows should track our changes.
Process - update a devDependency, track it
ocaml
BKM not established
- See
#update a dependency
above - See
#clone, install
above
node
BKM established
-
package.json::dependencies
get installed and maybe included by the runtime at runtime -
package.json::devDependencies
are installed at development time, and never installed in production (even if the package requires a native build phase)
ux comparison
Runtime, build-time, & dev-time dependencies are important for supporting your software during its lifecycles. OCaml has some support here, it’s just opaque with confusing UX. Further, it may span multiple tools and files. A simpler workflow around categorizing and bootstrapping dependency types is strongly desired. npm/cargo/esy/friends have a much more clear and consolidated story on the topic. There are reasons npm
/cargo
/other-friends
combine dependency management, compilation, and development execution–these topics are tightly coupled. OCaml segmentation here fragments us into our own scripts, or toolkits that fulfill the same goal, but with radically different ergonomics (compare esy
and drom
).
Process - swap compiler versions, track it, check it in
ocaml
BKM not established
There’s probably a way to do this. I don’t know how. I create a new switch and manually bring everything in.
node
BKM established
-
nvm use <version>
, or just put a different node/npm version on your PATH -
npm rebuild
, recompile any native dependencies
ux comparison
OCaml intentionally blends the compiler package with other packages in the switch, with little to no discrimination except for a few key places in the opam
CLI.
node
makes your runtime explicit, as does rustup
(err, kinda, rust). The OCaml approach may actually be superior, but I’m unclear on how do it.
Process - create project with multi-file library
ocaml
BKM established
- freely add files
- any files/modules that need to be exposed must be re-exported via entry module
- can add N
(library ...)
entries, but creates tedious and confusing scenarios where(modules ...)
must be managed manually
node
BKM established
- freely add files
- import export from anywhere in any file. it’s the wild west.
ux comparison
Comparable, given their distinct domains. Node is clearly easier, but has less constraint.
Process - create project with multiple libraries
ocaml
BKM established
- discrete libraries get discrete folders get discrete dune configurations
I am personally have not tried to multi-publish using such a scheme.
node
BKM partially established
-
lerna
/nx
and other monorepo tools required for a multi-library scenario that also effectively links them together for concurrently development - import export from anywhere in any library. it’s the wild west.
ux comparison
Comparable, given their distinct domains. OCaml is actually easier for multi-lib work with out-of-the box tooling. Node needs user-space tooling to support linked, concurrent lib/app development, most of the time.
Process - create project with executable
ocaml
BKM established
- discrete executable gets discrete folder with get discrete dune configuration
As with other dune
things, you can mix libs & executables, but this adds easily avoidable complexity in your dune
file. Dune’s recommendation is a small paragraph on their docs site. I kind of wish they just forbid co-locating unless you opted out, first recommending a separate project.
node
BKM established
- add a
bin: <path>
orbin: { [exectutable-key-name(s)]: path-to-executable }
entry
ux comparison
node
/npm
make adding executables to your project extremely easy, and simple.
OCaml’s module system and dune
add a little bit of overhead, and do not immediately
promote isolation of libs & bins. Coaching our users to separate their executables
into independent dune projects from the start will make their lives easier getting started.
Process - publish
ocaml
BKM partially established
-
<package>.opam
is established. However, its generation &/or synchronization does not have ubiquitous mechanisms associated with it. do you usedune
to generate it,make
,manual
, ordrom
? Do you use the custom.opam
DSL, s-expressions, or JSON? Will your declaration be verified/linted by your tools naturally just by using them?
node
BKM established
-
package.json
drives all inputs for publishing, not including registry location, credentials, etc.
In rare cases, package.json
is patched or generated, such as if using semantic-release.
ux comparison
npm
does this well. npm
's consolidated, one-stop-shop for defining your project makes the cognitive burden for this concern easy and simple. even more so, it adds other benefits. Consider that all of the node tools center around the package.json
. The package.json
, and its associated lockfile,
are the heart of the project. The tools consume and act on it. In one place you can simulate a build publish npm publish --dry-run
, which often has prebuild step. One tool, one build flow, one publish flow.
OCaml, on the other hand, has various project management files and tools, that optimistically work together. This means more tools, more code, more configuration files, and thus a less unified process.
It is akin to the python community, with requirements.txt
, setup.py
, Piplock
, and <whatever poetry uses now>
. We have scattered tools to fill the voids of a missing spec for dependency management. Because tools want to own different aspects of project management, the python community struggled (struggles?) for years to have a clear unanimous story on dependency synchronization. There was a different dependency solution based on your project’s usage intent, and if you had multiple intents (app + publishable lib), you may end up with three tools just to access and use the same general piece of software (e.g. pip, setuptools, venv, +maybe-more). It was terrible. Let’s not do what they did. Let’s do what node did. Let’s do what esy and drom are trying to do.
Process - setup IDE
ocaml
BKM established
- VSCode OCaml platform is
- simple to install
- select a sandbox
- VSCode debugger via earlybird is funded, and has positive first impression.
Neither extensions recover particularly well from errors, or propose corrective action to you when things go wrong yet.
They’re new. I’m grateful for clear direction on these fronts from the OCaml Platform!
node
BKM established
VSCode internally ships salsa, which gives autocomplete for various JS-isms. Of
course JS is not statically typed, so support is limited. TypeScript support is baked in,
and the whole IDE is essentially oriented towards node & web development, in all aspects.
ux comparison
OCaml requires two installations (debugger + platform) to do “all of the OCaml” things,
which can be challenging for discovery. However, both are new, and are being signaled
as the official supported tools for the job (err, may not be true for debugger ;)).
IDE setup is satisfactory in OCaml. Merlin working out-of-the-box is a delight!