Elixir : Basics of structs
A struct is a map based data model in elixir. It is a tagged map with a name associated to it, holding predefined key-value pairs, which can be used to contain, manage and propagate related data across functions, modules and processes. It is just an extension of maps, providing automatic population of predefined key-value pairs on creation, key based compile time checks and default values for keys. Even though you can just use a map to hold and propagate data, structs give a clear structure to the data being propagated and lets you have transparency on what to expect beforehand when reading data. This article is on the basics of structs, its syntax and its usage.
Syntax: Definition
A struct can only be created inside a module. A struct takes up the same name associated with the module within which it was defined. Hence a single module can contain only one struct inside it. A struct is created using defstruct
usually at the top of the module. This is because structs must be defined before their usage in the module. Since the struct will take the same name as the module name, the defstruct
keyword will directly be followed by a keyword list with the predefined keys and their default values. Unlike a map, the field names or keys present in the struct definition must be atoms.
defmodule User do
defstruct [{:name, "John Doe"}, {:age, 18}] #keyword list basic syntax
end
---------------------------------------------------------------------------
defmodule User do
defstruct [name: "John Doe", age: 18] #keyword list alternate syntax
end
---------------------------------------------------------------------------
defmodule User do
defstruct name: "John Doe", age: 18 #keyword list without square braces
end #since it is the last argument
In the above example you can see the definition of a struct under the module User
with two atom keys such as :name
and :age
, each with a default value of "John Doe"
and 18
respectively.
You can also provide just the atom keys, for which nil will be implicitly set as the default value. If the struct’s definition is going to have atom keys without an explicit default value, then a list must be used with all the atom keys present first, followed by the keys with explicit default values as a keyword list.
defmodule User do
defstruct [name: "John Doe", :age]
end
** (SyntaxError) iex:35:19: unexpected expression after keyword list.
Keyword lists must always come last in lists and maps.
---------------------------------------------------------------------------
defmodule User do
defstruct [:age, name: "John Doe"]
end
Syntax: Creation
Similar to how a map is denoted and created by %{}
syntax, a struct is denoted and created by the %StructName{}
. Creating an empty struct will populate all the defined keys and their default values. The default values can be overridden by specifying the keys to be overridden with their new values during the creation of the struct.
defmodule User do
defstruct [:age, name: "John Doe"]
end
---------------------------------------------------------------------------
%User{}
%User{age: nil, name: "John Doe"}
%User{age: 18}
%User{age: 18, name: "John Doe"}
%User{name: "Aki"}
%User{age: nil, name: "Aki"}
%User{age: 18, name: "Aki"}
%User{age: 18, name: "Aki"}
Structs can also be created using the __struct__()
construct inside the module that has defined the struct. A keyword list of attributes can also be passed into the above construct to override the default values of keys.
defmodule TestStruct do
defstruct [test: "default test msg"]
def new(), do: __struct__()
def new(args), do: __struct__(args)
end
---------------------------------------------------------------------------
TestStruct.new()
%TestStruct{test: "default test msg"}
TestStruct.new(test: "overridden test msg")
%TestStruct{test: "overridden test msg"}
Structs provide compile time checks that will raise a KeyError if an undefined key that is not present in the struct definition, is set during the creation of the struct.
defmodule User do
defstruct [:age, name: "John Doe"]
end
---------------------------------------------------------------------------
%User{age: 18, name: "Aki", hair: "black"}
** (KeyError) key :hair not found
Syntax: Accessing and updating data
Since structs are internally maps, the Map module functions can be used on structs for accessing, updating, deleting and performing other operations on its data. Please note that the compile time checks for undefined keys will not be enforced during the run time read or update operations on the structs, done using the Map module functions. Hence any unknown key can be created, accessed and updated in a struct using the Map module functions without any restrictions. This is because structs are internally maps and so, these structs will behave like a plain map when the map module functions operate on them.
Similar to a map, the atom keys can also be accessed using the static access operator, .
syntax. This syntax can only be used to access the predefined keys present in the struct definition. Trying to access an undefined key using the .
syntax will throw a KeyError. Internally, an implicit special key __struct__
with a value of the struct name, will be added to all the structs and it can only be accessed using the .
syntax. This key is what converts a normal map into a struct of a specific type.
defmodule User do
defstruct [:age, name: "John Doe"]
end
---------------------------------------------------------------------------
user = %User{}
%User{age: nil, name: "John Doe"}
user = Map.put(user, :age, 18)
%User{age: 18, name: "John Doe"}
Map.get(user, :age)
18
user.age
18
user.name
"John Doe"
user.__struct__
User
Map.put(user, :hair, "black") # putting undefined key
%User{name: "John Doe", __struct__: User, age: 18, hair: "black"}
user.hair
** (KeyError) key :hair not found in: %User{age: nil, name: nil}
Map.get(user, :hair)
"black"
The alternate map put syntax that uses |
can also be used to update values of struct fields. This syntax allows only updating the values of the keys that are already present in the map. But when this syntax is used with a struct, it makes use of the predefined set of keys in the struct definition and only allows updating one of those keys. If an undefined key is being updated for a struct using the |
syntax, an unknown key error will be thrown. Like mentioned above, if you require to update an undefined key in a struct, the Map module functions, put/3
, update/4
etc can be used.
user = %User{}
%User{age: nil, name: "John Doe"}
user = %User{user | age: 20, name: "Aki"}
%User{age: 20, name: "Aki"}
user = Map.put(user, :hair, "black")
%{name: nil, __struct__: User, age: nil, hair: "black"}
%User{user | hair: "brown"}
error: unknown key :hair for struct User
Map.put(user, :hair, "brown")
%{name: nil, __struct__: User, age: nil, hair: "brown"}
Unlike maps, structs do not inherit any of the protocols and behaviours that the maps implement. Hence the access behaviour syntax map[key]
cannot be used on structs. Similarly, the Enum and Stream modules and all other functionalities which require the Enumerable protocol to be implemented, will not operate on structs. The above protocols must be explicitly implemented for the structs in order to use the protocol’s respective functionalities.
Enforcing keys
Elixir provides a module attribute, @enforce_keys [key1, key2,...keyn]
that can be used above the struct definition in order to provide compile time enforcement of explicit values for it’s enforced keys during the time of struct creation. Similar to the compile time check on undefined keys, the @enforce_keys
attribute does not affect run time update operations on the structs and does not provide any type validation for the values. It only makes sure that when a particular struct is created using the %StructName{}
syntax, the keys mentioned in the @enforce_keys
attribute are provided with an explicit value.
defmodule User do
@enforce_keys [:age]
defstruct [:age, name: "John Doe"]
end
---------------------------------------------------------------------------
%User{}
** (ArgumentError) the following keys must also be given when
building struct User: [:age]
user = %User{age: 20}
%User{age: 20, name: "John Doe"}
Map.delete(user, :age)
%{__struct__: User, name: "John Doe"}
Dynamic struct creation
Structs can be created in runtime from plain maps or keyword lists using the Kernel.struct/2
and the Kernel.struct!/2
functions. It takes the struct name as the first argument and the keys with explicit values as a keyword list or a map as the second argument. The first version Kernel.struct/2
discards all the undefined keys present in the second argument and picks up only the predefined keys mentioned in the struct definition. It also doesn’t enforce the keys mentioned under @enforce_keys
attribute during the struct’s creation.
defmodule User do
@enforce_keys [:age]
defstruct [:age, name: "John Doe"]
end
___________________________________________________________________________
Kernel.struct(User)
%User{age: nil, name: "John Doe"}
Kernel.struct(User, [age: 20, name: "Aki", hair: "black"])
%User{age: 20, name: "Aki"}
Kernel.struct(User, %{age: 20, name: "Aki", hair: "black"})
%User{age: 20, name: "Aki"}
The second version Kernel.struct!/2
enforces all the compile time checks related to the structs and raises an error if any of the compile time checks fail during the struct’s dynamic creation. The checks include having only the allowed keys and having all of the enforced keys with explicit values in the second argument.
defmodule User do
@enforce_keys [:age]
defstruct [:age, name: "John Doe"]
end
___________________________________________________________________________
Kernel.struct!(User)
** (ArgumentError) the following keys must also be given when building
struct User: [:age]
Kernel.struct!(User, [age: 20, name: "Aki", hair: "black"])
** (KeyError) key :hair not found
Kernel.struct!(User, %{age: 20, name: "Aki", hair: "black"})
** (KeyError) key :hair not found
Kernel.struct!(User, [age: 20])
%User{age: 20, name: "John Doe"}
Kernel.struct!(User, %{age: 20, name: "Aki"})
%User{age: 20, name: "Aki"}
Pattern matching structs
Pattern matching individual keys and values in structs is very similar to pattern matching maps. In addition to assertion of keys, values and binding of values to variables, the struct name can also be asserted and bound to variables. Since structs are internally maps, any struct can be pattern matched into a plain map.
defmodule User do
defstruct [:age, name: "John Doe"]
end
---------------------------------------------------------------------------
defmodule Bot do
defstruct [:model, :price]
end
---------------------------------------------------------------------------
user = %User{}
%User{age: nil, name: "John Doe"}
%Bot{} = user
** (MatchError) no match of right hand side value: %User{age: nil, name: "John Doe"}
%s_name{} = user
s_name # User
%User{age: age, name: name} = user
age # nil
name # "John Doe"
%User{age: age, name: "John Doe"} = user
age # nil
%User{age: 20, name: name} = user
** (MatchError) no match of right hand side value: %User{age: nil, name: "John Doe"}
%{} = user # valid match
%{age: age, name: name} = user
age # nil
name # "John Doe"
Structs and protocols
Protocols enable achieving polymorphism in elixir. The main application of protocols is their usage with user-defined struct data types, thus extending functionalities to them. In order to get a deeper understanding of implementation of protocols on structs, please go through this article.