Qqwy / elixir-type_check

TypeCheck: Fast and flexible runtime type-checking for your Elixir projects.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Issues using programatically generated types with guards

lurodrigo opened this issue · comments

First of all, thank you for the great job in this library!

I'm trying to generate some types that depend on n. The example below compiles and works as expected.

defmodule MyTypes do
  use TypeCheck

  @type! address :: String.t() when byte_size(address) == 42

  # generate signed and unsigned int types
  for n <- Range.new(8, 256, 8) do
    @type! unquote({String.to_atom("int#{n}"), [], __MODULE__}) ::
             unquote(Macro.escape(-Integer.pow(2, n - 1)..(Integer.pow(2, n - 1) - 1)))

    @type! unquote({String.to_atom("uint#{n}"), [], __MODULE__}) ::
             unquote(Macro.escape(0..(Integer.pow(2, n) - 1)))
  end

  # generate bytes types
  for n <- 1..32 do
    name = {String.to_atom("bytes#{n}"), [], __MODULE__}

    # when byte_size(unquote(name)) <= unquote(n)
    @type! unquote(name) :: binary()
  end
end
defmodule MyTypesUse do
  use TypeCheck
  alias MyTypes

  @spec! func_int(MyTypes.int32()) :: nil
  def func_int(x) do
    nil
  end

  @spec! func_bytes(MyTypes.bytes32()) :: nil
  def func_bytes(x) do
    nil
  end
end

However, the moment I add a guard to the bytes{n} types, it fails. Changing the bytes lines to

defmodule MyTypes do 
# ...
  # generate bytes types
  for n <- 1..32 do
    name = {String.to_atom("bytes#{n}"), [], __MODULE__}

    @type! unquote(name) :: binary() when byte_size(unquote(name)) <= unquote(n)
  end
end

Results in a compilation error:

== Compilation error in file lib/my_types.ex ==
** (CompileError) lib/type_check/spec.ex:23: undefined function bytes32/0
    (elixir 1.12.1) src/elixir_locals.erl:114: anonymous fn/3 in :elixir_locals.ensure_no_undefined_local/3
    (stdlib 3.15) erl_eval.erl:685: :erl_eval.do_apply/6
    (elixir 1.12.1) lib/kernel/parallel_compiler.ex:319: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/7

Notice that MyTypes compiles correctly. The compilation error only happens when we try to use it in a spec. It happens not only with remote types, but in the same module the types were defined too. If you move func_bytes to MyTypes

defmodule MyTypes do 
# ...
  @spec! func_bytes(bytes32()) :: nil
  def func_bytes(x) do
    nil
  end
# ...
end

you get

== Compilation error in file lib/my_types.ex ==
** (CompileError) lib/my_types.ex:19: imported TypeCheck.Internals.UserTypes.MyTypes.bytes32/0 conflicts with local function
    (elixir 1.12.1) src/elixir_locals.erl:94: :elixir_locals."-ensure_no_import_conflict/3-lc$^0/1-0-"/2
    (elixir 1.12.1) src/elixir_locals.erl:95: anonymous fn/3 in :elixir_locals.ensure_no_import_conflict/3
    (stdlib 3.15) erl_eval.erl:685: :erl_eval.do_apply/6
    (elixir 1.12.1) lib/kernel/parallel_compiler.ex:319: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/7

I'm not sure whether it's a but or an error in my assumptions. I'm willing to contribute to helping solve these if it's indeed a bug, just need to discuss it first as I'm not familiar with the internals.

Thank you for this bugreport!

I'll investigate to find out what is going wrong, because it definitely does seem like this should 'just work'.

I was able to reproduce the problem and debug it.
The issue here is that the variable that is given the name of the type as part of the runtime type-check of the guard, seems to be given too late. Or at least the Elixir compiler decides at some point that it has to refer to a function and then runs into an import conflict (which has to do with how types are built internally).

I will dig deeper to find out how to fix this edge case as part of the library.

For now, there are two simple ways to fix this particular problem however:

defmodule MyTypes do
  use TypeCheck

  for n <- 1..32 do
    name = {String.to_atom("bytes#{n}"), [], __MODULE__}
    @type! unquote(name) :: (str :: binary() when byte_size(str) <= unquote(n))
  end

  @spec! func_bytes(bytes32()) :: nil
  def func_bytes(x) do
    nil
  end
end

In this case we circumvent the problem by just using a different name inside the guard alltogether.

Another is to use Macro.var:

defmodule MyTypes do
  use TypeCheck
  
  for n <- 1..32 do
    name = Macro.var(:"bytes#{n}", nil)
    @type! unquote(name) :: binary() when byte_size(unquote(name)) <= unquote(n)
  end

  @spec! func_bytes(bytes32()) :: nil
  def func_bytes(x) do
    nil
  end
end

In this case we make it clear that name really is a variable that does not refer to anything in the current module. The Elixir compiler will then not try to turn it into a function.


Again, I think there is something odd going on in the code that TypeCheck generates for guards that contain the types' name itself in the guard which I will look to fix,
but I hope that these two examples allow you to keep on working in the meantime.

Thank you so much for the detailed explanation! It's more than enough for now.

The design of how named types / guards works has since been slightly changed; to use a guard you should now always use the syntax:

@type! name :: (val :: binary()) when byte_size(val) <= unquote(n))

That is, do not refer to the name of the type itself in the guard, but add a name inside the type. Guards will no longer recognize the name of the type itself.

There are a number of reasons why this is done:

  1. The type would otherwise read odd if you have a type accepting parameters.
  2. It is always super clear where a particular name comes from. Using a guard to refer to a name somewhere in a nested type (with this I mean, a type that was defined elsewhere) is no longer supported (c.f. #4 ). This improves the 'opaqueness' of types, but it also improves runtime efficiency.