DEV Community

Cover image for OCaml/Dune Tutorial
david2am
david2am

Posted on • Edited on

OCaml/Dune Tutorial

More project-oriented tutorials are needed to help developers learn OCaml effectively. This tutorial is my contribution to the community. 🫰

References

Note: This article also includes notes for OCaml maintainers. The OCaml community is welcoming, and I trust that my feedback will be well-received.


Index


🟣 Global Setup

Install OCaml

Start by installing Opam, the OCaml package manager, which is similar to npm in JavaScript. It manages packages and compiler versions.

For macOS

brew install opam
Enter fullscreen mode Exit fullscreen mode

For Linux

sudo apt-get install opam
Enter fullscreen mode Exit fullscreen mode

For Windows

winget install Git.Git OCaml.opam
Enter fullscreen mode Exit fullscreen mode

Initialize OCaml's global configuration:

opam init -y
Enter fullscreen mode Exit fullscreen mode

Activate the global configuration by running:

eval $(opam env)
Enter fullscreen mode Exit fullscreen mode

Note: You need to run eval $(opam env) every time you open a new terminal to activate the Opam global configuration. Consider adding this command to your .bashrc or .zshrc file to automate this process.

Install Platform Tools

Install tools to assist you:

opam install ocaml-lsp-server odoc ocamlformat utop
Enter fullscreen mode Exit fullscreen mode
  • ocaml-lsp-server: Language server for editor integration (code completion, etc.)
  • odoc: Documentation generator
  • ocamlformat: Code formatter
  • utop: Enhanced REPL (interactive console)
  • dune: Build system and project manager

🟣 Create a Project

Dune is OCaml's default build system, it helps you create and manage projects. With the previous commands Dune is already installed.

Create a new project

dune init proj my_project
cd my_project
Enter fullscreen mode Exit fullscreen mode

This will be your project structure:

  • lib/: Contains your modules (.ml files).
  • bin/: Contains executable/compiled programs.
  • _opam/: Stores project dependencies.
  • _build/: Contains build artifacts.
  • dune-project: It's the equivalent to package.json in JavaScript or requirements.txt in Python.
  • bin/main.ml: The application’s entry point.

Most of your work will happen in the lib/ folder, and the main.ml file is the application's entry point.

Create a Switch

Switches in OCaml are similar to Python's virtual environments. They isolate compilers and package versions from another projects or global configurations.

Create a switch with a compiler version (5.3.0 in this example):

opam switch create . 5.3.0 --deps-only
Enter fullscreen mode Exit fullscreen mode

This command creates and stores switch artifacts in the _opam/ folder. The --deps-only flag ensures that only dependencies are installed, and not the current project been taken as another dependency.

If you want to know the available compiler versions run this command and pick one:

opam switch list-available
Enter fullscreen mode Exit fullscreen mode

Activate the Switch

Run at your project directory:

eval $(opam env)
Enter fullscreen mode Exit fullscreen mode

Enable Automatic Switch Detection

You only need to run this command once, it enables automatic switch detection when moving from one OCaml project to another:

opam init --enable-shell-hook
Enter fullscreen mode Exit fullscreen mode

Install Dev Tools for the Switch

Run:

opam install ocaml-lsp-server odoc ocamlformat
Enter fullscreen mode Exit fullscreen mode

Configure Git

Dune projects do not include a .gitignore file by default. Create it manually:

# .gitignore
_opam/
_build/
Enter fullscreen mode Exit fullscreen mode

Initialize Git

Run:

git init
Enter fullscreen mode Exit fullscreen mode

Run the Project

Compile and execute your project:

dune build
dune exec my_project
Enter fullscreen mode Exit fullscreen mode

Alternatively, use watch mode to accomplish both commands at once:

dune exec -w my_project
Enter fullscreen mode Exit fullscreen mode

This creates the _build/ folder containing OCaml's compiled artifacts.

Congratulations! You have created your first OCaml/Dune project!


🟣 Use Modules and Libraries

Let's add some modules, interface files, tests, and more!

Create a Module

In OCaml the concept of module is similar to the one in Python or JavaScript where every file is considered an independent unit of work with its own namespace.

Create a calc.ml file in the lib/ folder and add the following functions:

(* lib/calc.ml *)
let add x y = x + y

let sub x y = x - y
Enter fullscreen mode Exit fullscreen mode

Create a dune File

In Dune it's not enough to create a file to use it as a module, you need to explicitly say it through adding metadata in a dune file.

But before setting the metadata it's important to differentiate between a module and a library in OCaml:

  • Module = single unit of code organization. They form what it's called a compilation unit, a fully independent program from the compiler's perspective
  • Library = packaged collection of modules for distribution

In this occasion you will define everything inside the lib/ folder as a library named math so you could later call the Calc module through it.

Create a dune file in lib/:

; lib/dune
(library
 (name math))
Enter fullscreen mode Exit fullscreen mode

Libraries in OCaml are similar to index files in JavaScript; they expose modules.

Register a Library

In a similar way, if you want to consume a library in another .ml file you need to register it in its adjacent dune file to make it accessible, let's make it accessible to the bin/main.ml file:

In the bin/ folder open the dune file and register the library by its name:

; bin/dune
(executable
 (public_name ocaml_dune)
 (name main)
 (libraries math)) ; Include your module here
Enter fullscreen mode Exit fullscreen mode

Use a Library

Use the open keyword to access the library in main.ml:

(* bin/main.ml *)
open Math

let () = (* in other programming languages this would be to your main function equivalent *)
  let result = Calc.add 2 3 in
  print_endline (Int.to_string result); (* Output: 5 *)
Enter fullscreen mode Exit fullscreen mode

Notice that you also use the Int module from the standard library to convert a number to a string.

Run the Project

Compile and execute your project in watch mode:

dune exec -w my_project
Enter fullscreen mode Exit fullscreen mode

🟣 Create an Interface File

In Dune it's possible to separate your interfaces from your implementation through .mli files. They serve as a way for:

  • Encapsulation: helps define the public interface of a module.
  • Hide implementation details: makes possible to change module internals without affecting other dependent parts.
  • Documentation: it's a good practice to include specifications for clarity.

In the lib/ folder create an .mli file with the same name as its corresponding module, calc.mli in this case:

(* lib/calc.mli *)
val add : int -> int -> int
(** [add x y] returns the sum of x and y. *)
Enter fullscreen mode Exit fullscreen mode

Run the Project

Run:

dune exec -w my_project
Enter fullscreen mode Exit fullscreen mode

Add the sub function to the main.ml file

(* bin/main.ml *)
open Math

let () = (* in other programming languages this would be to your main function equivalent *)
  let result = Calc.add 2 3 in
  print_endline (Int.to_string result); (* Output: 5 *)

  let result = Calc.sub 3 1 in
  print_endline (Int.to_string result) (* Output: 2 *)
Enter fullscreen mode Exit fullscreen mode

As you see if you attempt to use sub it will result in an error, it's because the sub function has not been exposed yet, to solve it add the sub interface in calc.mli:

(* lib/calc.mli *)
val add : int -> int -> int
(** [add x y] returns the sum of x and y. *)

val sub : int -> int -> int
(** [sub x y] returns the difference of x and y. *)
Enter fullscreen mode Exit fullscreen mode

🟣 Add a Dependency

Let's add ANSITerminal, a lightweight package for colored terminal output.

Subscribe the Module in your Project

If you remember your dune-project file works as a registry for your project dependencies (as package.json if you are familiar with JavaScript).

Add the dependency in the depends node:

...
(package
 (name my_project)
 (depends
  ocaml
  (ANSITerminal (>= 0.8.5))) ; add package here
Enter fullscreen mode Exit fullscreen mode

Install the Module

Run:

opam install ANSITerminal
Enter fullscreen mode Exit fullscreen mode

Run:

dune build
Enter fullscreen mode Exit fullscreen mode

dune build creates the ocaml_dune.opam file, similar to package.lock.json in JavaScript to register the exact package versions.

Add It to the dune File

In the dune file in the bin/ folder do:

(executable
 (public_name ocaml_dune)
 (name main)
 (libraries math ANSITerminal)) ; add package here
Enter fullscreen mode Exit fullscreen mode

Use It

Update you main.ml file in this way:

(* Import the module *)
open ANSITerminal

(* Print nicely formatted text in the terminal *)
let () =
  print_string [Bold; green] "\n\nHello in bold green!\n";
  print_string [red] "This is in red.\n"
Enter fullscreen mode Exit fullscreen mode

Testing

Alcotest is a simple and efficient testing framework for OCaml. It provides a straightforward way to write and run tests ensuring reliability and correctness. This is the tool we are going to use.

Add Alcotest to your Project Dependencies

To integrate Alcotest into your project, you need to add it as a dependency in your dune-project file. This ensures that Alcotest is available for your test suite.

(package
 (name my_project)
 (depends
  ocaml
  (ANSITerminal (>= 0.8.5))
  (alcotest :with-test))) ; Add Alcotest as a test-only dependency
Enter fullscreen mode Exit fullscreen mode

The :with-test constraint specifies that Alcotest is a test-only dependency, meaning it will only be used during testing and not included in the final build.

Install Alcotest

After updating the dune-project file, install the dependency by running:

opam install alcotest
Enter fullscreen mode Exit fullscreen mode

And then:

dune build
Enter fullscreen mode Exit fullscreen mode

This registers the dependency in the ocaml_dune.opam file, which serves a similar purpose to package.lock.json in other ecosystems.

Register Alcotest

Register Alcotest and your libraries in the test/dune file:

; test/dune
(test
 (name test_my_project)
 (libraries alcotest math)) ; Include Alcotest and your math library
Enter fullscreen mode Exit fullscreen mode

By doing this, any .ml files in the test/ folder will have access to both Alcotest and your library's modules.

Create a Test

Add the following code to the test/test_my_project.ml file:

(* test/test_my_project.ml *)
let test_hello () =
  Alcotest.(check string) "same string" "hello" "hello"

let () =
  Alcotest.run "My Project" [
    "hello", [
      Alcotest.test_case "Hello test" `Quick test_hello;
    ];
  ]
Enter fullscreen mode Exit fullscreen mode

This dummy test checks if the string "hello" is equal to "hello", which should always pass. It's a good starting point to verify that your testing setup is functional.

Run the Test

dune test
Enter fullscreen mode Exit fullscreen mode

Next Steps


Happy coding with OCaml! 🚀


Feedback for OCaml Maintainers

  • Consider adding a direct link to available compiler versions in the switch documentation, it would be helpful.
  • --deps-only: consider this the default behavior so the developer skip this flag.
  • opam init -y: consider making it the default so the developer skip this step.
  • opam init --enable-shell-hook: consider making it the default so the developer skip this step.
  • dune exec -w my_project: consider avoiding writing the project name to run the project.
  • Automatically create a .gitignore file to avoid manual setup.
  • We should include a dune CLI command to install a package, register the library name in the dune-project and update the ocaml_dune.opam file all at once.
  • It would be great if we didn't have to define a dune file for each module, and instead, this would be the default behavior

Top comments (0)