{"article":{"slug":"typeclasses-vs-modules","title":"Typeclasses vs Modules","subtitle":null,"summary":"A careful comparison of typeclasses and ML-style modules: what each abstraction is for, where the persistent confusion comes from, and how to choose between them when designing APIs and language features.","content_type":"essay","language":"en","canonical_url":"https://sm2n.ca/articles/typeclasses-vs-modules/","author":{"name":"sm2n","url":"https://sm2n.ca/","person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"sm2n.ca","url":"https://sm2n.ca/","listing_slug":null,"listing":null},"topics":[{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Software Engineering","slug":"software-engineering","url":"https://listedarticles.com/topics/software-engineering"},{"name":"Education","slug":"education","url":"https://listedarticles.com/topics/education"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2361,"reading_minutes":10,"published_at":"2025-12-10T00:00:00.000Z","added_at":"2026-10-02T12:10:51.768Z","updated_at":"2026-10-02T12:10:51.768Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":false},"profile_url":"https://listedarticles.com/articles/typeclasses-vs-modules","markdown_url":"https://listedarticles.com/articles/typeclasses-vs-modules.md","example":false,"citation":"sm2n, sm2n.ca. \"Typeclasses vs Modules.\" 10 Dec 2025. https://sm2n.ca/articles/typeclasses-vs-modules/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://sm2n.ca/articles/typeclasses-vs-modules/"},"body_markdown":"# Typeclasses vs Modules\n\nThey do different things\n\n\nPublished on 2025-12-10\n\nLast updated on 2025-12-15 For a long time I’ve noticed that there’s a lot of persistent confusion about the differences and similarities between module systems (as seen in languages like OCaml) and typeclasses (as seen in languages like Haskell or Rust). This is an attempt to clear up the confusion.\n\n## The short answer\n\nThe main goal of typeclasses as a language construct is to provide ad-hoc polymorphism . This is also known as operator overloading or type-based dispatch . The main point of ad-hoc polymorphism is as a convenience feature for programming in the small: it lets you reuse the same identifiers across types. For example, I might want to use the `+` symbol to denote both addition on integers and addition on vectors of floats (Importantly, the vector type might be defined in a random library, so we can’t hardcode this behaviour in the language).\n\nThe main goal of module systems as a language construct is to provide modular abstraction . Some parts of this feature have names in the broader software industry, like dependency injection , encapsulation , and information hiding . The main point of modular abstraction is to enable more efficient programming in the large, by allowing you to express your program decomposition explicitly. It gives you a compositional way to break down programs into component pieces recursively.\n\nThe main factors that I think lead to confusion are:\n\n\n- historically, programming languages have tended to pick one or the other\n\n- they share some of the underlying mechanism\n\n- a way to specify an interface\n\n- and a way to show that some bundle of code conforms to the interface\n\n\n\n- you sometimes can kinda sorta emulate one using the other (i.e use typeclasses to provide modular abstraction, and use modules to provide ad-hoc polymorphism).\n\n\nHowever, while emulation is possible in many cases it’s usually not optimal.\n\n## Why and What: Typeclasses\n\nThe motivational idea behind typeclasses is basically, if you’re going to overload a symbol, it should “morally” mean the same thing. For example the symbol `+` denotes the abstract idea of addition, and we can have different implementations for different types. This is in contrast to for example C++ where ` >=`, and use them across a variety of different concrete structures.\n\nIf we want to be a bit more serious about this we need to consider the semantics as well. How do we make sure `+` does in fact denote an abstract addition operation? Well, we can add laws to the typeclass. For instance we can say that addition has to satisfy ( a + b ) + c = a + ( b + c ) (a + b) + c = a + (b + c) ( a + b ) + c = a + ( b + c ) (though this isn’t true for floats), and then check that new implementations do in fact satisfy the law using a property test or a proof witness. In general, each typeclass can be bundled with some description of semantics, ideally machine checked in the form of a test suite or proof obligation.\n\nFor taste, here is the definition of the `Num` typeclass in Haskell, taken from [ghc](https://hackage.haskell.org/package/ghc-internal-9.1201.0/docs/src/GHC.Internal.Num.html#Num). Notice that it gives some laws, but in this case they are only customary. For instance, floating point multiplication is not associative but it still has a `Num` instance.\n\n```\n-- | Basic numeric class.\n--\n-- The Haskell Report defines no laws for 'Num'. However, @('+')@ and @('*')@ are\n-- customarily expected to define a ring and have the following properties:\n--\n-- [__Associativity of @('+')@__]: @(x + y) + z@ = @x + (y + z)@\n-- [__Commutativity of @('+')@__]: @x + y@ = @y + x@\n-- [__@'fromInteger' 0@ is the additive identity__]: @x + fromInteger 0@ = @x@\n-- [__'negate' gives the additive inverse__]: @x + negate x@ = @fromInteger 0@\n-- [__Associativity of @('*')@__]: @(x * y) * z@ = @x * (y * z)@\n-- [__@'fromInteger' 1@ is the multiplicative identity__]:\n-- @x * fromInteger 1@ = @x@ and @fromInteger 1 * x@ = @x@\n-- [__Distributivity of @('*')@ with respect to @('+')@__]:\n-- @a * (b + c)@ = @(a * b) + (a * c)@ and @(b + c) * a@ = @(b * a) + (c * a)@\n-- [__Coherence with 'toInteger'__]: if the type also implements 'GHC.Real.Integral', then\n-- 'fromInteger' is a left inverse for 'GHC.Internal.Real.toInteger', i.e. @fromInteger (toInteger i) == i@\nclass Num a where\n(+), (-), (*) :: a -> a -> a\n-- | Unary negation.\nnegate :: a -> a\n-- | Absolute value.\nabs :: a -> a\n-- | Sign of a number.\n-- The functions 'abs' and 'signum' should satisfy the law:\n--\n-- > abs x * signum x == x\n--\n-- For real numbers, the 'signum' is either @-1@ (negative), @0@ (zero)\n-- or @1@ (positive).\nsignum :: a -> a\n-- | Conversion from an 'Integer'.\n-- An integer literal represents the application of the function\n-- 'fromInteger' to the appropriate value of type 'Integer',\n-- so such literals have type @('Num' a) => a@.\nfromInteger :: Integer -> a\n\nx - y = x + negate y\nnegate x = 0 - x\n```\n\n## Why and What: Modules\n\nThe point of modules is to allow you to ergonomically abstract over parts of your program. To do that, we split the program up into parts (“modules”, “structures”), and then explicitly specify how they slot together. Importantly, we can reduce the total effort to reason about the full program by making each module hide parts of itself (encapsulation!) from other modules that interact with it.\n\nThat’s good for outputs, but we also have to consider abstracting over inputs. To do that, we can make parametrized modules (these are often called functors for historical reasons) that are not themselves modules but produce a module when instantiated with some other module(s). Which modules are we allowed to instantiate with? All the modules that conform to a particular interface (which are often called signatures in the literature)!\n\nAgain, if we’re being serious about this we might attach some semantics to the interface, such as customary laws, a test suite or a proof obligation. After all, one of the most common reasons to structure your program with a modular decomposition is to increase ease of testing. Identifying important interfaces and testing at the interface is a quite effective way to get a lot of coverage for cheap.\n\nLet’s go through an example that uses many features. I have taken the following code from the [unison file synchronization project](https://github.com/bcpierce00/unison/blob/master/src/fsmonitor/watchercommon.mli) and lightly edited it for clarity.\n\nFirst, we declare that we have a `StringMap` type which conforms to the `Map.S` signature from the standard library, but with the key type specialized to String. That means any code that depends on the interface the stdlib exposes can use our custom map, and it’ll even check that all callsites have the correct key type.\n\nNext we have a parametrized module. The high level idea is that this is an interface that abstracts over platform-specific apis for file watching.\n\n\n- We declare a parametrized module `Watcher`\n\n- that takes a module `WatchDescriptor`\n\n- that defines a `watch` handle type\n\n\n\n- and returns a module that supports the limited file descriptor-esque API declared afterwards.\n\n- We declare a signature `WatchBackend` that exposes an api for working with watchers\n\n- there’s also a parametrized module `Run`\n\n- that can be used to run the `Watcher`\n\n- using a module that implements `WatchBackend` (since the parametrized module returns something with an empty signature, it can only be used for its side-effects.)\n\n\n\n\n\n\n\n\n\n```\nmodule StringMap : Map.S with type key = string\n\nmodule Watcher (WatchDescriptor : sig type watch end) : sig\n\ntype t\n\nval get_id : t -> int\nval get_watch : t -> WatchDescriptor.watch option\nval set_watch : t -> WatchDescriptor.watch option -> unit\nval get_subdirs : t -> t StringMap.t\nval is_root : t -> bool\n\nval file_by_id : (int, t) Hashtbl.t\nval dir_path : t -> string -> string\n\nval signal_change :\nfloat -> t -> string option -> [> `CREAT | `DEL ] -> unit\nval signal_overflow : unit -> unit\n\nmodule type WatchBackend = sig\nval add_watch : string -> t -> bool -> unit\nval release_watch : t -> unit\nval watch : unit -> unit\nval clear_event_memory : unit -> unit\nend\n\nmodule Run (Backend : WatchBackend) : sig end\n\nend\n```\n\n## How: Modularity with Typeclasses\n\nTypeclasses are often used for abstraction. For example, here’s some simple code from a project of mine:\n\n```\npub trait FileSystem {\n/// Check if a path exists\nfn exists(&self, path: &Path) -> bool;\n\n/// Get the file type of a path\n/// Returns an error if the path doesn't exist or can't be accessed\n/// Note: follows symlinks, so a symlink to a directory will return FileType::Dir\nfn file_type(&self, path: &Path) -> Result ;\n\n/// List entries in a directory\n/// Returns an iterator of (name, file_type) pairs\n/// Note: follows symlinks, so a symlink to a directory will return FileType::Dir\nfn read_dir(&self, path: &Path) -> Result >>;\n\n/// Read the contents of a file as a string\nfn read_to_string(&self, path: &Path) -> Result ;\n}\n```\n\nSince I don’t need many filesystem operations, I only declare the ones I need, which makes it tractable to test at that interface easily.\n\nWhat have we lost relative to using modules instead?\n\n\n- The way we decompose things isn’t compositional. For instance, here I’ve said that `read_dir` may return something that satisfies the `Iterator` trait with a specialized `Item` type. But I can’t express “this method returns something that matches this particular signature I just made up” (i.e inline interfaces aren’t a thing). All interfaces must be tied to some type (which exist in a scope) and can’t be nested in other interfaces.\n\n- Now, every function that wants to write against this interface acquires a trait bound, which is more verbose than the concrete version. It’s annoying and noisy when you end up with extremely long function signatures. With modules, you can put the bound at the module level and each function doesn’t have to declare the dependency explicitly (Rust sort of has this with `impl` blocks but they’re also not composable).\n\n- Since typeclasses are designed for ad-hoc polymorphism, instance search is usually part of the implementation, which can be expensive at compile time. (eg. in rust `foo.bar()` has to first infer the type of foo, then look at everything that might transitively `impl foo`, then see what possible implementations for `bar` there are)\n\n\nIn general, I think this approach promotes what I call a “set of functions” approach to program decomposition. The typeclass worldview is that there is a global type database, and functions may see different parts of this database depending on scoping, but ultimately the program is just a set of functions. Each function stands alone and you have to reason at the function level.\n\nModules give us a richer way to think about program decomposition. We’re not limited to drawing the boundaries at functions. We can reason about larger parts of our program at once. In terms of the earlier analogy, modules let us think in terms of nontrivial subsets of the program at a time.\n\n## How: Ad-hoc polymorphism with Modules\n\nWell there’s one thing left, which is going the other way.\n\nMany ML programmers will say “you don’t need ad-hoc polymorphism, and maybe it’s bad actually”. This isn’t as unreasonable an argument as it sounds: with careful use of lexically scoped imports, the lack of ad-hoc polymorphism is only slightly more verbose, and is probably more mechanically easy to understand. Plus since everything is explicit, the compiler doesn’t have to do any search to resolve polymorphic operations, which speeds up compilation. I think part of the appeal of Zig over Rust for many Zig users is because Zig programmers tend to fall on that side of the fence as well.\n\nAll of that is a long way to say that at least in OCaml, the way to achieve ad-hoc polymorphism is to simply not have it. For example here is some code from [Real World OCaml](https://dev.realworldocaml.org/files-modules-and-programs.html) that illustrates some ways to use `Int64` arithmetic without having to namespace every arithmetic operation.\n\n```\nlet average x y =\nlet open Int64 in\n(x + y) / of_int 2;;\n\nlet average x y =\nInt64.((x + y) / of_int 2);;\n```\n\nLet’s say we really want ad-hoc polymorphism. If we think about it, traditional typeclasses can be expressed in terms of modules in the following way:\n\n\n- We have a global database of signatures\n\n- We have a global database of types\n\n- We have a global database of modules, each of which is associated with a type (i.e every module has a type, but a type can have multiple modules)\n\n- Each signature also knows all the modules that conform to it, and hence their associated type\n\n\nAnd then, whenever we use something from a signature, we infer the type and then lookup the concrete module using it.\n\nIn theory, you could implement ad-hoc polymorphism pretty much the exact same way as the typeclass-first approach like this, with no real loss in ergonomics. This is basically what the [Modular Implicits](https://www.cl.cam.ac.uk/~jdy22/papers/modular-implicits.pdf) proposal for OCaml suggests.\n\nScala 3 also supports what it calls [“Contextual Abstraction”](https://docs.scala-lang.org/scala3/reference/contextual/index.html), which is essentially a way of doing the above. The main difference with more traditional typeclass systems is that it doesn’t require coherence which is something out of the scope of this post.\n\n## Modules AND Typeclasses?\n\nYes! Haskell has the Backpack module system that nobody uses, in addition to the typeclass system everybody uses. In theory, it has good support for both modular abstraction and ad-hoc polymorphism. Unfortunately, Backpack came kind of late to the ecosystem and has not seen much adoption.\n\nI believe Rocq (formerly Coq) also has independent module and typeclass systems.\n\nRust has a module system separate from its trait system, but it’s too weak to support full modular programming. The module system only supports encapsulation and has no way to specify abstract module signatures, and therefore can’t have parametrized modules.\n\nElixir has a module system, and has signatures which it calls “behaviours”, and also has typeclass-style ad-hoc polymorphism through what it calls “protocols”. Additionally, Elixir has gradual typing that lets you check modules against behaviours for conformance. However, it’s currently not possible to declare that you want any module that conforms to a signature as an input. So while you do have parametrized modules, they are not typechecked.\n\n## Conclusion\n\nPlease remember the distinction between modular abstraction and ad-hoc polymorphism . They can both be useful features in a programming language, and they have different concerns: one is for programming in the large, and the other in the small. Typeclasses and Modules are simply means to those ends.\n\nIf you’re designing a new language, I think a lot of people reach for typeclasses and good support for ad-hoc polymorphism first, with modular abstraction an afterthought. I personally think that is backwards: I find, in practice, good support for modular abstraction more valuable than ad-hoc polymorphism for writing high quality programs.","body_html":"<h1 id=\"typeclasses-vs-modules\">Typeclasses vs Modules</h1>\n<p>They do different things</p>\n<p>Published on 2025-12-10</p>\n<p>Last updated on 2025-12-15 For a long time I’ve noticed that there’s a lot of persistent confusion about the differences and similarities between module systems (as seen in languages like OCaml) and typeclasses (as seen in languages like Haskell or Rust). This is an attempt to clear up the confusion.</p>\n<h2 id=\"the-short-answer\">The short answer</h2>\n<p>The main goal of typeclasses as a language construct is to provide ad-hoc polymorphism . This is also known as operator overloading or type-based dispatch . The main point of ad-hoc polymorphism is as a convenience feature for programming in the small: it lets you reuse the same identifiers across types. For example, I might want to use the <code>+</code> symbol to denote both addition on integers and addition on vectors of floats (Importantly, the vector type might be defined in a random library, so we can’t hardcode this behaviour in the language).</p>\n<p>The main goal of module systems as a language construct is to provide modular abstraction . Some parts of this feature have names in the broader software industry, like dependency injection , encapsulation , and information hiding . The main point of modular abstraction is to enable more efficient programming in the large, by allowing you to express your program decomposition explicitly. It gives you a compositional way to break down programs into component pieces recursively.</p>\n<p>The main factors that I think lead to confusion are:</p>\n<ul><li>historically, programming languages have tended to pick one or the other</li><li>they share some of the underlying mechanism</li><li>a way to specify an interface</li><li>and a way to show that some bundle of code conforms to the interface</li></ul>\n<ul><li>you sometimes can kinda sorta emulate one using the other (i.e use typeclasses to provide modular abstraction, and use modules to provide ad-hoc polymorphism).</li></ul>\n<p>However, while emulation is possible in many cases it’s usually not optimal.</p>\n<h2 id=\"why-and-what-typeclasses\">Why and What: Typeclasses</h2>\n<p>The motivational idea behind typeclasses is basically, if you’re going to overload a symbol, it should “morally” mean the same thing. For example the symbol <code>+</code> denotes the abstract idea of addition, and we can have different implementations for different types. This is in contrast to for example C++ where <code> &gt;=</code>, and use them across a variety of different concrete structures.</p>\n<p>If we want to be a bit more serious about this we need to consider the semantics as well. How do we make sure <code>+</code> does in fact denote an abstract addition operation? Well, we can add laws to the typeclass. For instance we can say that addition has to satisfy ( a + b ) + c = a + ( b + c ) (a + b) + c = a + (b + c) ( a + b ) + c = a + ( b + c ) (though this isn’t true for floats), and then check that new implementations do in fact satisfy the law using a property test or a proof witness. In general, each typeclass can be bundled with some description of semantics, ideally machine checked in the form of a test suite or proof obligation.</p>\n<p>For taste, here is the definition of the <code>Num</code> typeclass in Haskell, taken from <a href=\"https://hackage.haskell.org/package/ghc-internal-9.1201.0/docs/src/GHC.Internal.Num.html#Num\" rel=\"nofollow ugc noopener\">ghc</a>. Notice that it gives some laws, but in this case they are only customary. For instance, floating point multiplication is not associative but it still has a <code>Num</code> instance.</p>\n<pre><code>-- | Basic numeric class.\n--\n-- The Haskell Report defines no laws for &#39;Num&#39;. However, @(&#39;+&#39;)@ and @(&#39;*&#39;)@ are\n-- customarily expected to define a ring and have the following properties:\n--\n-- [__Associativity of @(&#39;+&#39;)@__]: @(x + y) + z@ = @x + (y + z)@\n-- [__Commutativity of @(&#39;+&#39;)@__]: @x + y@ = @y + x@\n-- [__@&#39;fromInteger&#39; 0@ is the additive identity__]: @x + fromInteger 0@ = @x@\n-- [__&#39;negate&#39; gives the additive inverse__]: @x + negate x@ = @fromInteger 0@\n-- [__Associativity of @(&#39;*&#39;)@__]: @(x * y) * z@ = @x * (y * z)@\n-- [__@&#39;fromInteger&#39; 1@ is the multiplicative identity__]:\n-- @x * fromInteger 1@ = @x@ and @fromInteger 1 * x@ = @x@\n-- [__Distributivity of @(&#39;*&#39;)@ with respect to @(&#39;+&#39;)@__]:\n-- @a * (b + c)@ = @(a * b) + (a * c)@ and @(b + c) * a@ = @(b * a) + (c * a)@\n-- [__Coherence with &#39;toInteger&#39;__]: if the type also implements &#39;GHC.Real.Integral&#39;, then\n-- &#39;fromInteger&#39; is a left inverse for &#39;GHC.Internal.Real.toInteger&#39;, i.e. @fromInteger (toInteger i) == i@\nclass Num a where\n(+), (-), (*) :: a -&gt; a -&gt; a\n-- | Unary negation.\nnegate :: a -&gt; a\n-- | Absolute value.\nabs :: a -&gt; a\n-- | Sign of a number.\n-- The functions &#39;abs&#39; and &#39;signum&#39; should satisfy the law:\n--\n-- &gt; abs x * signum x == x\n--\n-- For real numbers, the &#39;signum&#39; is either @-1@ (negative), @0@ (zero)\n-- or @1@ (positive).\nsignum :: a -&gt; a\n-- | Conversion from an &#39;Integer&#39;.\n-- An integer literal represents the application of the function\n-- &#39;fromInteger&#39; to the appropriate value of type &#39;Integer&#39;,\n-- so such literals have type @(&#39;Num&#39; a) =&gt; a@.\nfromInteger :: Integer -&gt; a\n\nx - y = x + negate y\nnegate x = 0 - x</code></pre>\n<h2 id=\"why-and-what-modules\">Why and What: Modules</h2>\n<p>The point of modules is to allow you to ergonomically abstract over parts of your program. To do that, we split the program up into parts (“modules”, “structures”), and then explicitly specify how they slot together. Importantly, we can reduce the total effort to reason about the full program by making each module hide parts of itself (encapsulation!) from other modules that interact with it.</p>\n<p>That’s good for outputs, but we also have to consider abstracting over inputs. To do that, we can make parametrized modules (these are often called functors for historical reasons) that are not themselves modules but produce a module when instantiated with some other module(s). Which modules are we allowed to instantiate with? All the modules that conform to a particular interface (which are often called signatures in the literature)!</p>\n<p>Again, if we’re being serious about this we might attach some semantics to the interface, such as customary laws, a test suite or a proof obligation. After all, one of the most common reasons to structure your program with a modular decomposition is to increase ease of testing. Identifying important interfaces and testing at the interface is a quite effective way to get a lot of coverage for cheap.</p>\n<p>Let’s go through an example that uses many features. I have taken the following code from the <a href=\"https://github.com/bcpierce00/unison/blob/master/src/fsmonitor/watchercommon.mli\" rel=\"nofollow ugc noopener\">unison file synchronization project</a> and lightly edited it for clarity.</p>\n<p>First, we declare that we have a <code>StringMap</code> type which conforms to the <code>Map.S</code> signature from the standard library, but with the key type specialized to String. That means any code that depends on the interface the stdlib exposes can use our custom map, and it’ll even check that all callsites have the correct key type.</p>\n<p>Next we have a parametrized module. The high level idea is that this is an interface that abstracts over platform-specific apis for file watching.</p>\n<ul><li>We declare a parametrized module <code>Watcher</code></li><li>that takes a module <code>WatchDescriptor</code></li><li>that defines a <code>watch</code> handle type</li></ul>\n<ul><li>and returns a module that supports the limited file descriptor-esque API declared afterwards.</li><li>We declare a signature <code>WatchBackend</code> that exposes an api for working with watchers</li><li>there’s also a parametrized module <code>Run</code></li><li>that can be used to run the <code>Watcher</code></li><li>using a module that implements <code>WatchBackend</code> (since the parametrized module returns something with an empty signature, it can only be used for its side-effects.)</li></ul>\n<pre><code>module StringMap : Map.S with type key = string\n\nmodule Watcher (WatchDescriptor : sig type watch end) : sig\n\ntype t\n\nval get_id : t -&gt; int\nval get_watch : t -&gt; WatchDescriptor.watch option\nval set_watch : t -&gt; WatchDescriptor.watch option -&gt; unit\nval get_subdirs : t -&gt; t StringMap.t\nval is_root : t -&gt; bool\n\nval file_by_id : (int, t) Hashtbl.t\nval dir_path : t -&gt; string -&gt; string\n\nval signal_change :\nfloat -&gt; t -&gt; string option -&gt; [&gt; `CREAT | `DEL ] -&gt; unit\nval signal_overflow : unit -&gt; unit\n\nmodule type WatchBackend = sig\nval add_watch : string -&gt; t -&gt; bool -&gt; unit\nval release_watch : t -&gt; unit\nval watch : unit -&gt; unit\nval clear_event_memory : unit -&gt; unit\nend\n\nmodule Run (Backend : WatchBackend) : sig end\n\nend</code></pre>\n<h2 id=\"how-modularity-with-typeclasses\">How: Modularity with Typeclasses</h2>\n<p>Typeclasses are often used for abstraction. For example, here’s some simple code from a project of mine:</p>\n<pre><code>pub trait FileSystem {\n/// Check if a path exists\nfn exists(&amp;self, path: &amp;Path) -&gt; bool;\n\n/// Get the file type of a path\n/// Returns an error if the path doesn&#39;t exist or can&#39;t be accessed\n/// Note: follows symlinks, so a symlink to a directory will return FileType::Dir\nfn file_type(&amp;self, path: &amp;Path) -&gt; Result ;\n\n/// List entries in a directory\n/// Returns an iterator of (name, file_type) pairs\n/// Note: follows symlinks, so a symlink to a directory will return FileType::Dir\nfn read_dir(&amp;self, path: &amp;Path) -&gt; Result &gt;&gt;;\n\n/// Read the contents of a file as a string\nfn read_to_string(&amp;self, path: &amp;Path) -&gt; Result ;\n}</code></pre>\n<p>Since I don’t need many filesystem operations, I only declare the ones I need, which makes it tractable to test at that interface easily.</p>\n<p>What have we lost relative to using modules instead?</p>\n<ul><li>The way we decompose things isn’t compositional. For instance, here I’ve said that <code>read_dir</code> may return something that satisfies the <code>Iterator</code> trait with a specialized <code>Item</code> type. But I can’t express “this method returns something that matches this particular signature I just made up” (i.e inline interfaces aren’t a thing). All interfaces must be tied to some type (which exist in a scope) and can’t be nested in other interfaces.</li><li>Now, every function that wants to write against this interface acquires a trait bound, which is more verbose than the concrete version. It’s annoying and noisy when you end up with extremely long function signatures. With modules, you can put the bound at the module level and each function doesn’t have to declare the dependency explicitly (Rust sort of has this with <code>impl</code> blocks but they’re also not composable).</li><li>Since typeclasses are designed for ad-hoc polymorphism, instance search is usually part of the implementation, which can be expensive at compile time. (eg. in rust <code>foo.bar()</code> has to first infer the type of foo, then look at everything that might transitively <code>impl foo</code>, then see what possible implementations for <code>bar</code> there are)</li></ul>\n<p>In general, I think this approach promotes what I call a “set of functions” approach to program decomposition. The typeclass worldview is that there is a global type database, and functions may see different parts of this database depending on scoping, but ultimately the program is just a set of functions. Each function stands alone and you have to reason at the function level.</p>\n<p>Modules give us a richer way to think about program decomposition. We’re not limited to drawing the boundaries at functions. We can reason about larger parts of our program at once. In terms of the earlier analogy, modules let us think in terms of nontrivial subsets of the program at a time.</p>\n<h2 id=\"how-ad-hoc-polymorphism-with-modules\">How: Ad-hoc polymorphism with Modules</h2>\n<p>Well there’s one thing left, which is going the other way.</p>\n<p>Many ML programmers will say “you don’t need ad-hoc polymorphism, and maybe it’s bad actually”. This isn’t as unreasonable an argument as it sounds: with careful use of lexically scoped imports, the lack of ad-hoc polymorphism is only slightly more verbose, and is probably more mechanically easy to understand. Plus since everything is explicit, the compiler doesn’t have to do any search to resolve polymorphic operations, which speeds up compilation. I think part of the appeal of Zig over Rust for many Zig users is because Zig programmers tend to fall on that side of the fence as well.</p>\n<p>All of that is a long way to say that at least in OCaml, the way to achieve ad-hoc polymorphism is to simply not have it. For example here is some code from <a href=\"https://dev.realworldocaml.org/files-modules-and-programs.html\" rel=\"nofollow ugc noopener\">Real World OCaml</a> that illustrates some ways to use <code>Int64</code> arithmetic without having to namespace every arithmetic operation.</p>\n<pre><code>let average x y =\nlet open Int64 in\n(x + y) / of_int 2;;\n\nlet average x y =\nInt64.((x + y) / of_int 2);;</code></pre>\n<p>Let’s say we really want ad-hoc polymorphism. If we think about it, traditional typeclasses can be expressed in terms of modules in the following way:</p>\n<ul><li>We have a global database of signatures</li><li>We have a global database of types</li><li>We have a global database of modules, each of which is associated with a type (i.e every module has a type, but a type can have multiple modules)</li><li>Each signature also knows all the modules that conform to it, and hence their associated type</li></ul>\n<p>And then, whenever we use something from a signature, we infer the type and then lookup the concrete module using it.</p>\n<p>In theory, you could implement ad-hoc polymorphism pretty much the exact same way as the typeclass-first approach like this, with no real loss in ergonomics. This is basically what the <a href=\"https://www.cl.cam.ac.uk/~jdy22/papers/modular-implicits.pdf\" rel=\"nofollow ugc noopener\">Modular Implicits</a> proposal for OCaml suggests.</p>\n<p>Scala 3 also supports what it calls <a href=\"https://docs.scala-lang.org/scala3/reference/contextual/index.html\" rel=\"nofollow ugc noopener\">“Contextual Abstraction”</a>, which is essentially a way of doing the above. The main difference with more traditional typeclass systems is that it doesn’t require coherence which is something out of the scope of this post.</p>\n<h2 id=\"modules-and-typeclasses\">Modules AND Typeclasses?</h2>\n<p>Yes! Haskell has the Backpack module system that nobody uses, in addition to the typeclass system everybody uses. In theory, it has good support for both modular abstraction and ad-hoc polymorphism. Unfortunately, Backpack came kind of late to the ecosystem and has not seen much adoption.</p>\n<p>I believe Rocq (formerly Coq) also has independent module and typeclass systems.</p>\n<p>Rust has a module system separate from its trait system, but it’s too weak to support full modular programming. The module system only supports encapsulation and has no way to specify abstract module signatures, and therefore can’t have parametrized modules.</p>\n<p>Elixir has a module system, and has signatures which it calls “behaviours”, and also has typeclass-style ad-hoc polymorphism through what it calls “protocols”. Additionally, Elixir has gradual typing that lets you check modules against behaviours for conformance. However, it’s currently not possible to declare that you want any module that conforms to a signature as an input. So while you do have parametrized modules, they are not typechecked.</p>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>Please remember the distinction between modular abstraction and ad-hoc polymorphism . They can both be useful features in a programming language, and they have different concerns: one is for programming in the large, and the other in the small. Typeclasses and Modules are simply means to those ends.</p>\n<p>If you’re designing a new language, I think a lot of people reach for typeclasses and good support for ad-hoc polymorphism first, with modular abstraction an afterthought. I personally think that is backwards: I find, in practice, good support for modular abstraction more valuable than ad-hoc polymorphism for writing high quality programs.</p>","headings":[{"level":1,"text":"Typeclasses vs Modules","id":"typeclasses-vs-modules"},{"level":2,"text":"The short answer","id":"the-short-answer"},{"level":2,"text":"Why and What: Typeclasses","id":"why-and-what-typeclasses"},{"level":2,"text":"Why and What: Modules","id":"why-and-what-modules"},{"level":2,"text":"How: Modularity with Typeclasses","id":"how-modularity-with-typeclasses"},{"level":2,"text":"How: Ad-hoc polymorphism with Modules","id":"how-ad-hoc-polymorphism-with-modules"},{"level":2,"text":"Modules AND Typeclasses?","id":"modules-and-typeclasses"},{"level":2,"text":"Conclusion","id":"conclusion"}]}}