DEV Community

Cover image for Elixir: map casting to structs (with checks at compile time!)
Jakub Lambrych
Jakub Lambrych

Posted on • Edited on

Elixir: map casting to structs (with checks at compile time!)

Intro

In this article you will learn:

  • how to leverage compile-time validation of maps to be merged later with %struct{}, that can save you time and hassle after the deployment
  • review popular runtime validation functions
  • how to use a macro to do the same at compile time (!)
  • import KeyValidator library to your project from HEX: https://hex.pm/packages/key_validator

Runtime validations

Elixir and Ecto has built-in functions that perform the key validity check of maps, but only at runtime:

  • Kernel.struct/2
  • Kernel.struct!/2
  • Ecto.Query.select_merge/3

Kernel.struct!/2

Let's take a look at the following example:

defmodule User do
defstruct name: "john"
end

# Following line is a runtime only check:

Kernel.struct!(User, %{name: "Jakub"})
#=> %User{name: "Jakub"}

# Runtime error on key typo:

Kernel.struct!(User, %{nam__e: "Jakub"})
#=> ** (KeyError) key :nam__e not found
Enter fullscreen mode Exit fullscreen mode

The expression Kernel.struct!(User, %{name: "Jakub"}) uses a map literal (%{name: "Jakub"}). The User struct definition is known beforehand, as well as the map structure. However, the comparison between the keys in the User struct and the map literal will only take place during when the app is running and the code getting actually executed. Thus, any potential typo in the key will be discovered only at runtime.

Ecto.Query.API.select_merge/3

A similar situation takes place when using a popular Ecto.Query.select_merge/3 for populating virtual fields in schemas:

defmodule Post do
    use Ecto.Schema
    schema "posts" do
      field :author_firstname, :string
      field :author_lastname, :string
      field :author, :string, virtual_field: true
    end
end

defmodule Posts do
    import KeyValidator

    def list_posts do
      Post
      |> select_merge([p], %{author: p.author_firstname <> " " <> p.author_lastname})
      |> Repo.all()
    end
end

Posts.list_posts()

Enter fullscreen mode Exit fullscreen mode

In the provided example, Post schema contains a :author virtual field. We want to store a concatenated value of the post's author first name and the last name populated in the query. The select_merge will merge the given expression with query results.

If the result of the query is a struct (in our case Post) and in we are merging a map (in our case %{author: data} literal), we need to assert that all the keys from the map exist in the struct. To achieve this, Ecto uses Ecto.Query.API.merge/2 underneath:

If the map on the left side is a struct, Ecto will check all the fields on the right previously exist on the left before merging.

This, however, is done in runtime only again. You will learn whether the map keys conform to the struct key when the particular line of code got executed.

Compile-time validation

In certain situations, the conformity between map/keyword keys could be already checked at the compile-time.

One example when we can potentially leverage the compile-time validations is when we work with map/keyword literals in our code. These literals are provided directly in the Elixir's AST structure during compilation process. We can build on this fact if our intention is to use the map for casting onto structs at some point in our code.

We deal with map or keyword literals when the structure is given inline:

# Map literal:

%{name: "Jakub", lastname: "Lambrych"}

# Keyword literal

[name: "Jakub", lastname: "Lambrych"]
Enter fullscreen mode Exit fullscreen mode

In contrast, the following expressions do not allow us to work with literals when invoking merging maps with struct:


# map is assigned to a variable
m = %{name: "Artur"}

# Function invoked with variable "m" and not a "map literal".
# No opportunity for compile check.
Kernel.struct(User, m)
Enter fullscreen mode Exit fullscreen mode

Use KeyValidator macro for compile-time checks

In the following example, User module together with the map literal is defined at the compile time. We will leverage the power of compile-time macros whenever our intention is to use a particular map/keyword literal to be merged at some point with a struct.

To support this, I developed a small KeyValidator.for_struct/2 macro (available on Hex). Let's see how we can use it:

defmodule User do
defstruct name: "john"
end

import KeyValidator

# Succesfull validation. Returns the map:

user_map = for_struct(User, %{name: "Jakub"})
#=> %{name: "Jakub"}

Kernel.struct!(User, user_map)
#=> %User{name: "Jakub"}

# Compile time error on "nam__e:" key typo:

user_map2 = for_struct(User, %{nam__e: "Jakub"})
#=>** (KeyError) Key :name_e not found in User
Enter fullscreen mode Exit fullscreen mode

The KeyValidator.for_struct/2 can be also used with Ecto.Query.select_merge/3 to populate virtual_fields:

defmodule Posts do
    import KeyValidator

    def list_posts do
        Post
        |> select_merge([p], for_struct(Post, %{authorrr: "Typo"}))
        |> Repo.all()
      end
end


Posts.list_posts()
#=> ** (KeyError) Key :authorrr not found in Post
Enter fullscreen mode Exit fullscreen mode

The above code will raise a Key Error during compilation.

Install KeyValidator from Hex

For convenience, I wrapped the macro in library. It is available on hex with documentation:

https://hex.pm/packages/key_validator

You can add it as a dependency to your project:

def deps do
  [
    {:key_validator, "~> 0.1.0", runtime: false}
  ]
end
Enter fullscreen mode Exit fullscreen mode

Source code

Macro code together with ExUnit tests can be accessed at the GitHub repo:

https://github.com/utopos/key_validator

Summary

Using metaprogramming in Elixir (macros) give you an opportunity to analyze the Elixir's AST and perform some checks of structs, maps and keywords before deploying your code.

Adding KeyValidator.for_struct/2 macro to your project, allows some category of key conformity errors to be caught at an early stage in the development workflow. No need to wait until the code to crashes at runtime. It can be expensive and time-consuming to fix bugs caused by simple typos - i.e. when map keys are misspelled.

Although the macro usage covers some scenarios, we need to bear in mind that it is not a silver bullet. The KeyValidator cannot accept dynamic variables due to the nature of Elixir macros. Only map/keyword literals are accepted as their content can be accessed during AST expansion phase of Elixir compilation process.

Top comments (0)