Post conditions in Elixir

What do you think about pre and post condition checks in Elixir?

I’ve just started learning Elixir and can see how guards are similar to pre-condition checks. Is there anything that could be used for post-condition checks?

Last night I realised the pipe operator could be used (abused?) to help implement post-condition checks and wrote this marcro:

defmacro ensure input, pattern1, pattern2 do
  quote do
    case unquote(input) do
      unquote(pattern1) -> unquote(input)
      unquote(pattern2) -> unquote(input)
      _ -> raise PostConditionError
    end
  end
end

It would be used like:

def is_food(input) do
  case input do
    "Cheese" -> true
    "Beer" -> {:error, "Item is not food"}
    "Phone" -> false
  end

  |> ensure(true, {:error, _reason})
end

A is_food("Phone") call would result in a PostConditionError being raised.

Thoughts?

You might be able to create a macro def/3, which allows for a syntax like

def is_foot(input) when is_binary(food) do
  case input do
    "Cheese" -> true
    "Beer" -> {:error, "Item is not food"}
    "Phone" -> false
  end
after
  result == true or match?({:error, _reason}, result)
end
2 Likes

Worth noting this is already valid syntax with very different implications: it creates an implicit try block around the function and ensures your code runs after it whether or not it raised; and does not allow accessing the result of the function (since there may be none since it could have raised).

Playing around with a couple of observations:

  • What you propose is still actually def/2: the macro receives a call and a keyword list of blocks, so in our example the macro would receive:

    defmacro def(head, [do: body, after: postcheck])
    
  • Semantically what we want is actually closer to the else block in a try that matches on the result iff no error was raised, allowing for post-checks and result transformation:

    def is_foot(input) when is_binary(food) do
      case input do
        "Cheese" -> true
        "Beer" -> {:error, "Item is not food"}
        "Phone" -> false
      end
    else
      true -> true
      {:error, reason} -> {:error, {:not_foot, reason}}
      _ -> raise PostConditionError # possibly implicit, though traditionally not
    end
    
  • You can’t make up your own block keywords, the parser only allows usage of the existing secondary-block terms after a do: catch, rescue, after and else

  • The existing implementation of def with an implicit try only uses catch, rescue, and after

Conclusion: We could make a custom def/2 macro that employs an else block and transforms the provided implementation into something traditionally allowed in Kernel.def/2, and else is already the only syntactically valid unused block keyword in Kernel.def/2.

Cons: def(head, do: body, else: postcheck) does not indicate intent nearly as well as def(head, do: body, after: postcheck). But such a macro would definitely be possible!

Alternatively:

  • Since block arguments must come last, the only form of def/3 the parser would recognize would be:

    defmacro def(head, postcheck, [do: body])
    
  • This isn’t that great though:

    def is_foot(input) when is_binary(food), true or {:error, _reason} do
      case input do
        "Cheese" -> true
        "Beer" -> {:error, "Item is not food"}
        "Phone" -> false
      end
    end
    
  • Interestingly we could employ a fictional ensure/1 function that the parser doesn’t choke on, to make the intent more legible:

    def is_foot(input) when is_binary(food), ensure true or {:error, _reason} do
      case input do
        "Cheese" -> true
        "Beer" -> {:error, "Item is not food"}
        "Phone" -> false
      end
    end
    

Cons: certain complicated expressions could make the parser decide it no longer likes this construct, it’s arguably even hackier though it reads well…

2 Likes