8  Opaques

8 Opaques

The main use case for opacity in Erlang is to hide the implementation of a data type, enabling evolving the API while minimizing the risk of breaking consumers. The runtime does not check opacity. Dialyzer provides some opacity-checking, but the rest is up to convention.

This document explains what Erlang opacity is (and the trade-offs involved) via the example of the sets:set() data type. This type was defined in the sets module like this:

-opaque set(Element) :: #set{segs :: segs(Element)}.

OTP 24 changed the definition to the following in this commit.

-opaque set(Element) :: #set{segs :: segs(Element)} | #{Element => ?VALUE}.

And this change was safer and more backwards-compatible than if the type had been defined with -type instead of -opaque. Here is why: when a module defines an -opaque, the contract is that only the defining module should rely on the definition of the type: no other modules should rely on the definition.

This means that code that pattern-matched on set as a record/tuple technically broke the contract, and opted in to being potentially broken when the definition of set() changed. Before OTP 24, this code printed ok. In OTP 24 it may error:

case sets:new() of
    Set when is_tuple(Set) ->
        io:format("ok")
end.

When working with an opaque defined in another module, here are some recommendations:

  • Don't examine the underlying type using pattern-matching, guards, or functions that reveal the type, such as tuple_size/1 .
  • Instead, use functions provided by the module for working with the type. For example, sets module provides sets:new/0, sets:add/2, sets:is_element/2, and so on.
  • sets:set(a) is a subtype of sets:set(a | b) and not the other way around. Generally, you can rely on the property that the_opaque(T) is a subtype of the_opaque(U) when T is a subtype of U.

When defining your own opaques, here are some recommendations:

  • Since consumers are expected to not rely on the definition of the opaque type, you must provide functions for constructing, querying, and deconstructing instances of your opaque type. For example, sets can be constructed with sets:new/0, sets:from_list/1, sets:add/2, queried with sets:is_element/2, and deconstructed withsets:to_list/1.
  • Don't define an opaque with a type variable in parameter position. This breaks the normal and expected behavior that (for example) my_type(a) is a subtype of my_type(a | b)
  • Add specs to exported functions that use the opaque type

Note that opaques can be harder to work with for consumers, since the consumer is expected not to pattern-match and must instead use functions that the author of the opaque type provides to use instances of the type.

Also, opacity in Erlang is skin-deep: the runtime does not enforce opacity-checking. So now that sets are implemented in terms of maps, an is_map/1 check on a set will pass. The opacity rules are only enforced by convention and by additional tooling such as Dialyzer, and this enforcement is not total. A determined consumer of sets can still reveal the structure of the set, for example by printing, serializing, or using a set as a term() and inspecting it via functions like is_map/1 or maps:get/2. Also, Dialyzer must make some approximations.