View Source ElixirScribe.Behaviour.TypedContract behaviour (Elixir Scribe v0.2.0)

The Elixir Scribe Typed Contract guarantees the shape of your data throughout your codebase.

Use it as a contract for your data shape to eliminate potential bugs that may be caused by creating the data with the wrong type. This leads to more robust code and fewer tests needed to guarantee data correctness, compared to when a struct or a plain map was previously used.

Typed Contract

Let's imagine that the Business requested a new feature, a Marketing funnel where they only want to allow personas who have a company email to enter the funnel, and they require them to provide a name, email and optionally their role in the company.

We can use a Typed Contract to translate these business rules into a contract that guarantees data correctness across all our code base.

For example:

defmodule Persona do
  @moduledoc false

  require PersonaValidator

  @keys %{
    required: [:name, :email],
    optional: [role: nil]
  }

  use ElixirScribe.Behaviour.TypedContract, keys: @keys

  @impl true
  def type_spec() do
    schema(%__MODULE__{
      name: is_binary() |> spec(),
      email: PersonaValidator.corporate_email?() |> spec(),
      role: PersonaValidator.role?() |> spec()
    })
  end
end

Specs

  • When defining the type_spec/0 the schema can use the built-in specs from Norm or your own ones.
  • All specs defined with PersonaValidator.* are custom ones.

Required and Optional Keys

  • A typed contract needs to declare the required and optional keys, plus a type spec for them.
  • The optional keys MUST have a default value.

Contract Guarantees

  • To take advantage of the safety guarantees offered by the Elixir Scribe Typed Contract, you MUST not create or update it directly, as allowed by Elixir. Instead, you need to use the built-in functions new/1, new!/1, update/3, or update!/3 that will guarantee it conforms with the type specs when one is created or updated.
  • Use the conforms?/1 function at the place you use the typed contract to ensure that you still have a struct that conforms with the type spec, because it may have been manipulated directly between the point it was created and where you are using it.
  • For introspection, the fields/0 and type_spec/0 functions are provided.

Usage Examples

To run the usage examples:

iex -S mix

With Valid Data Types

To create a new Person:

iex> Persona.new! %{name: "Paulo", email: "exadra37@company.com"}
%Persona{name: "Paulo", email: "exadra37@company.com", role: nil, self: Persona}

To update the Person:

iex> persona = Persona.new! %{name: "Paulo", email: "exadra37@company.com"}
%Persona{name: "Paulo", email: "exadra37@company.com", role: nil, self: Persona}
iex> persona.self.update! persona, :role, "Elixir Scribe Engineer"
%Persona{
  name: "Paulo",
  email: "exadra37@company.com",
  role: "Elixir Scribe Engineer",
  self: Persona
}

Later, some layers deep in the code we can confirm that the Persona still conforms with the type spec defined in the contract:

iex> persona = Persona.new! %{name: "Paulo", email: "exadra37@company.com"}
%Persona{name: "Paulo", email: "exadra37@company.com", role: nil, self: Persona}
iex> persona.self.conforms? persona
true

Alternatively, you can use new/1 and update/3 which will return a {:ok, result} or {:error, reason} tuple.

With Invalid Data Types

Passing the role as an atom, instead of the expected string:

iex> Persona.new %{name: "Paulo", email: "exadra37@company.com", role: :cto}
{:error, [%{input: :cto, path: [:role], spec: "PersonaValidator.role?()"}]}

The same as above, but using new!/1 which will raise:

Persona.new! %{name: "Paulo", email: "exadra37@company.com", role: :cto}

It will raise the following:

** (Norm.MismatchError) Could not conform input:
val: :cto in: :role fails: PersonaValidator.role?()
    (norm 0.13.0) lib/norm.ex:65: Norm.conform!/2
    iex:6: (file)

Invoking update/3 and update!/3 will output similar results.

Less Tests and Fewer Bugs

  • The Elixir Scribe typed contract acts as a contract for the businesse rules to guarantee data correctness at anypoint it's used in the code base.
  • Now the developer only needs to test that a Persona complies with this business rules in the test for this contract, because everywhere the Persona contract is used it's guaranteed that the data is in the expected shape.
  • This translates to fewer bugs, less technical debt creeping in and a more robust code base.

Introspection

To introspect the fields used to define the typed contract at compile time:

iex> Persona.fields
%{
  config: %{
    extra: [self: Persona],
    required: [:name, :email],
    optional: [role: nil]
  },
  defstruct: [:name, :email, {:role, nil}, {:self, Persona}]
}

To introspect the Norm type spec:

iex> Persona.type_spec

The output:

#Norm.Schema<%Persona{
  name: #Norm.Spec<is_binary()>,
  email: #Norm.Spec<PersonaValidator.corporate_email?()>,
  role: #Norm.Spec<PersonaValidator.role?()>,
  self: Persona
}>

The :self field is an extra added at compile time by the typed contract macro to allow you to self reference the typed contract like you already noticed in the above examples.

Summary

Callbacks

Accepts the Elixir Scribe Typed Contract itself to check it still conforms with the specs.

Returns the fields definition used to define the struct for the Elixir Scribe Typed Contract at compile time.

Accepts a map with the attributes to create an Elixir Scribe Typed Contract.

Accepts a map with the attributes to create an ELixir Scribe Typed Contract.

Returns the type specification for the Elixir Scribe Typed Contract.

Updates the Elixir Scribe Typed Contract for the given key and value.

Updates the Elixir Scribe Typed Contract for the given key and value.

Callbacks

@callback conforms?(struct()) :: boolean()

Accepts the Elixir Scribe Typed Contract itself to check it still conforms with the specs.

Creating or modifying the struct directly can cause it to not be conformant anymore with the specs.

Useful to use by modules operating on the Typed Contract to ensure it wasn't directly modified after being created with new/1 or new!/1.

@callback fields() :: map()

Returns the fields definition used to define the struct for the Elixir Scribe Typed Contract at compile time.

@callback new(map()) :: {:ok, struct()} | {:error, [map()]}

Accepts a map with the attributes to create an Elixir Scribe Typed Contract.

Returns {:ok, struct} on successful creation, otherwise returns {:error, reason}.

@callback new!(map()) :: struct()

Accepts a map with the attributes to create an ELixir Scribe Typed Contract.

Returns the typed contract on successful creation, otherwise raises an error.

@callback type_spec() :: struct()

Returns the type specification for the Elixir Scribe Typed Contract.

Used internally by all this behaviour callbacks, but may also be useful in the case more advanced usage of Norm is required outside this struct.

Link to this callback

update(struct, atom, any)

View Source
@callback update(struct(), atom(), any()) :: {:ok, struct()} | {:error, [map()]}

Updates the Elixir Scribe Typed Contract for the given key and value.

Returns {:ok, struct} on successful update, otherwise returns {:error, reason}.

Link to this callback

update!(struct, atom, any)

View Source
@callback update!(struct(), atom(), any()) :: struct()

Updates the Elixir Scribe Typed Contract for the given key and value.

Returns the typed contract on successful update, otherwise raises an error.