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
For Linux
sudo apt-get install opam
For Windows
winget install Git.Git OCaml.opam
Initialize OCaml's global configuration:
opam init -y
Activate the global configuration by running:
eval $(opam env)
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
-
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
This will be your project structure:
-
lib/: Contains your modules (.mlfiles). -
bin/: Contains executable/compiled programs. -
_opam/: Stores project dependencies. -
_build/: Contains build artifacts. -
dune-project: It's the equivalent topackage.jsonin JavaScript orrequirements.txtin 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
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
Activate the Switch
Run at your project directory:
eval $(opam env)
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
Install Dev Tools for the Switch
Run:
opam install ocaml-lsp-server odoc ocamlformat
Configure Git
Dune projects do not include a .gitignore file by default. Create it manually:
# .gitignore
_opam/
_build/
Initialize Git
Run:
git init
Run the Project
Compile and execute your project:
dune build
dune exec my_project
Alternatively, use watch mode to accomplish both commands at once:
dune exec -w my_project
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
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))
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
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 *)
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
🟣 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. *)
Run the Project
Run:
dune exec -w my_project
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 *)
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. *)
🟣 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
Install the Module
Run:
opam install ANSITerminal
Run:
dune build
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
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"
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
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
And then:
dune build
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
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;
];
]
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
Next Steps
- If you want to dig into OCaml I wrote Basic OCaml and its second part Practical OCaml
- Explore the standard library in the OCaml Manual
- Try Real World OCaml
- Browse different OCaml packages on OCaml Packages
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
.gitignorefile to avoid manual setup. - We should include a
duneCLI command to install a package, register the library name in thedune-projectand update theocaml_dune.opamfile all at once. - It would be great if we didn't have to define a
dunefile for each module, and instead, this would be the default behavior
Top comments (0)