Nominals

View Source

Rationale and Syntax

For user-defined types defined with -type, the Erlang compiler will ignore their type names. This means the Erlang compiler uses a structural type system. Two types are seen as equivalent if their structures are the same. Type comparison is based on the structures of the types, not on how the user explicitly defines them. In the following example, meter() and foot() are equivalent, and neither differs from the basic type integer().

-type meter() :: integer().
-type foot() :: integer().

Nominal typing is an alternative type system. Two nominal types are equivalent if and only if they are declared with the same type name. The syntax for declaring nominal types is -nominal.

If meter() and foot() are defined as nominal types, they will no longer be compatible. When a function expects type meter(), passing in type foot() will result in a warning raised by the type checker.

-nominal meter() :: integer().
-nominal foot() :: integer().

The main use case of nominal types is to prevent accidental misuse of types with the same structure. Within OTP, nominal type-checking is done in Dialyzer. The Erlang compiler does not perform nominal type-checking.

Nominal Type-Checking Rules

In general, if two nominal types have different names, and one is not derived from the other, they are not compatible. Dialyzer's nominal type-checking aligns with the examples' expected results in this section.

If we continue from the example above:

-spec int_to_meter(integer()) -> meter().
int_to_meter(X) -> X.

-spec foo() -> foot().
foo() -> int_to_meter(24).

A type checker that performs nominal type-checking should raise a warning. According to the specification, foo/0 should return a foot() type. However, the function int_to_meter/1 returns a meter() type, so foo/0 will also return a meter() type. Because meter() and foot() are incompatible nominal types, Dialyzer raises the following warning for foo/0:

Invalid type specification for function foo/0.
The success typing is foo() -> (meter() :: integer())
But the spec is foo() -> foot()
The return types do not overlap

On the other hand, a nominal type is compatible with a non-opaque, non-nominal type with the same structure. This compatibility goes both ways, meaning that passing a structural type when a nominal type is expected is allowed, and vice versa.

-spec qaz() -> integer().
qaz() -> int_to_meter(24).

A type checker that performs nominal type-checking should not raise a warning in this case. The specification says that qaz/0 should return an integer() type. However, the function int_to_meter/1 returns a meter() type, so qaz/0 will also return a meter() type. integer() is not a nominal type. The structure of meter() is compatible with integer(). Dialyzer can analyze the function above without raising a warning.

There is one exception where two nominal types with different names can be compatible: when one is derived from the other. For nominal types s() and t(), s() can be derived from t() in the two following ways:

  1. If s() is directly derived from t().
-nominal s() :: t().
  1. If s() is derived from other nominal types, which are derived from t().
-nominal s() :: nominal_1().
-nominal nominal_1() :: nominal_2().
-nominal nominal_2() :: t().

In both cases, s() and t() are compatible nominal types even though they have different names. Defining them in different modules does not affect compatiblity.

In summary, nominal type-checking rules are as follows:

A function that has a -spec that states an argument or a return type to be nominal type a/0 (or any other arity), accepts or may return:

  • Nominal type a/0
  • A compatible nominal type b/0
  • A compatible structural type

A function that has a -spec that states an argument or a return type to be a structural type b/0 (or any other arity), accepts or may return:

  • A compatible structural type
  • A compatible nominal type

When deciding if a type should be nominal, here are some suggestions:

  • If there are other types in the same module with the same structure, and they should never be mixed, all of them can benefit from being nominal types.
  • If a type represents a unit like meter, second, byte, and so on, defining it as a nominal type is always more useful than -type. You get the nice guarantee that you cannot mix them up with other units defined as nominal types.
  • If an opaque type does not require its type information to be hidden, it can benefit from being redefined as a nominal type. This makes Dialyzer's analysis faster.