Starting with Elixir 1.11 / OTP 23, one can easily define a guard to check the argument is a particular struct. That might be handy in rare cases when the single function clause is ready to accept a mixed argument, and pattern matching directly on the argument would not work. To be backward compatible, though, we cannot simply do

defguard is_foo(value)
  when is_map(value) and value.__struct__ == Foo

the above would raise on earlier versions during compilation.

Direct if in the code on the top level, surrounding defguard clause wouldn’t work either, the compiler would nevertheless attempt to parse value.__struct__ == Foo and raise (I am unsure if it’s a desired behaviour, but still.)

The solution would be to introduce another macro, declaring this guard, that will check the version before returning the AST. Somewhat along these lines would work.

@spec can_struct_guard? :: boolean()
@doc "Returns `true` if we can declare a proper guard, false otherwise"
defp can_struct_guard? do
  String.to_integer(System.otp_release()) > 22 and
    Version.compare(System.version(), "1.11.0") != :lt
end

@spec maybe_struct_guard(name :: atom(), struct :: module()) :: Macro.t
@doc "Returns the AST that would compile for the current Elixir/OTP"
defmacro maybe_struct_guard(name \\ nil, struct) do
  # Derive the guard name from the struct name unless passed explicitly
  name =
    if is_nil(name),
      do: struct |> Module.split() |> List.last() |> Macro.underscore(),
      else: name
  name = :"is_#{name}"

  if can_struct_guard?() do
    quote do
      @doc """
      Helper guard to match instances
        of struct #{inspect(unquote(struct))}
      """
      defguard unquote(name)(value)
        when is_map(value) and value.__struct__ == unquote(struct)
    end
  else
    quote do
      @doc """
      Stub guard to match instances
        of struct #{inspect(unquote(struct))}.
      Upgrade to 11.0/23 to make it work.
      """
      defguard unquote(name)(value) when is_map(value)
    end
  end
end

Now we can declare guards as

maybe_struct_guard(MyStruct)

and use it as

def foo(value) when is_list(value) or is_my_struct(value),
  do: IO.inspect(value, label: "Expected value")

Happy guarding!