Both!
It depends a bit on C API you need to bind to, large or small and which C idioms it uses. Some C APIs are straightforward to bind to (say an abstract pointer and functions acting upon it) and the OCaml FFI poses no problem, other are less (use of records, callbacks etc.)
In any case I advise you to always make thin bindings: that is map as straightforwardly to the C functionality, and then pretty things and make usage safe in OCaml itself behind your .mli
(versus starting to construct elaborate OCaml values on the C side, using the more fancy stuff like callbacks or custom operations, except if really needed for finalizers).
Here are a few examples of mine:
-
SDL binding, large API using bare C records, so with ctypes.
-
zstd
,zlib
,xxhash
,md
,blake3
bindings. This is all trivial bytes crunching so OCaml FFI. -
Monotonic and POSIX clock bindings, totally trivial so OCaml FFI.
-
sqlite3
bindings. This is quite an interesting case if you compare it to the size and complexity of the very oldocaml-sqlite
bindings which makes a lot of errors that I would also have made if I had written the binding when it was originally writtten (notably IIRC using finalized values for prepared statements is not a good idea: it’s a ressource, you can’t close your database handle if they are still lingering). This is a good example of thin bindings + making things safe and ergonomic on the OCaml side. -
tweetnacl bindings this also all trivial bytes crunching so OCaml FFI.
-
OpenGL bindings these use a hand made custom generator to generate them from XML files describing the functions.
Note, there is the possibility of generating stubs, but I never used. dune
should have built-in support for it.