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 OTP's sets:set() data type. This type was defined in `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's 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) ->

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 , etc.
  • 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 and querying/deconstructing intances 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 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: For example, determined consumer of sets can still do things that reveal the structure of the set, such as by printing, serializing, or using a set as term() and then inspecting via functions like is_map or maps:get/2 . And Dialyzer must make some approximations . Opacity checking has limitations, but is still a vital tool in scalable Erlang development.