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

I’ll be keeping close tabs on the changes to Elixir’s compiler, because if it is possible to let the compiler infer that we have such a struct through TypeCheck instead, then that is of course nice.

But in general, yes, TypeCheck’s checks happen at runtime and thus is no replacement for checks that happen at compile-time. Rather, these two approaches complement each-other nicely :slightly_smiling_face: .

2 Likes

What you are thinking about would be very possible if the elixir compiler emitted a message to hooks when a “def”/“defp” macro gets compiled, containing the finalized ast of the function. I think it would be safe because in general hooks don’t let you modify the actual code in-flight.

1 Like

@Qqwy - You mention that TypeCheck complements some compile-time checks. I know that @ityonemo is just at the outset of work on Selectrix, but is it accurate that these two projects could also possibly complement each other whereby Typecheck provides runtime checks via macro and also generates typespecs for which Selectrix uses for compile time checks?

I’m already getting great value out of TypeCheck and Selectrix sounds interesting. Is there any opportunity for collaboration between the projects or is that mostly unnecessary or detrimental to each? My hope is that these sorts of concepts end up in core one way or another.

happy to collaborate! I have some “unusual” ideas about BEAM types that I’ve outlined in https://github.com/ityonemo/mavis/blob/master/typesystem.md, which is why i’ve broken this out into a separate library, in case my ideas are too crazy. However, everything will use typespecs at the end, and I’m not even parsing them, I’m pulling them from the module binaries, so it should “just work” with TypeCheck.

It would be very reasonable to use TypeCheck to generate runtime checks (for dev and test) and use Selectrix for compile-time guards. Selectrix would keep your type logic correct and TypeCheck would keep your type declarations honest. I believe these are orthogonal concerns and Selectrix “depends” on the fact that the declarations are correct; IIRC TypeCheck won’t tell you AOT that you’re plugging a square peg into a round hole.

3 Likes

Cool. Seems like both projects are all-in on leveraging typespecs. As for far future, I was surprised to see that José does not seem to be too excited about typespecs, but I guess more important to get something working and useful rather than worrying about whether there is any chance at ever getting it into core.

3 Likes

I am definitely discovering warts in the erlang typespec system as I go along. The biggest one is what does it mean when a map requires a type that is a collection (it’s sensible when the type is a singleton, like literal atom or literal number). I’m still not 100% sure what I want to do in that case.

2 Likes

I just created a new Phoenix project, added type_check and always get this error:

** (Mix) Could not start application stream_data: could not find application file: stream_data.app. Do I need to setup anything else?

Thank you for mentioning!
As a quick fix, try installing :stream_data as extra dependency.

However, I’ll investigate what is going on here because :stream_data is supposed to be an optional dependency so TypeCheck should still work when it is not available. I made an issue here

1 Like

Great, thanks! I also noticed that I cannot do something like

@spec! fun(Ecto.Changeset.t()) :: Ecto.Changeset.t()

It tells me that function Ecto.Changeset.t/0 is undefined or private. Is this some misunderstanding on my side, a bug or a limitation of TypeCheck?

Ecto is an external library and would not have a t function defined. The t type is, by convention, added for your own structs. For example, in a User module you might have the following from the docs:

@type! t :: %User{name: binary, age: integer}

This can then be used at a callsite as…

@spec! my_fun(User.t()) :: User.t()

This allows you to ensure that a received argument is not only a User struct, but also that its name is a binary and age is an integer.

If you didn’t care about requiring those types, this would also work…

@spec! my_fun(%User{}) :: %User{}

This tells you that you can do the same with any struct. So in the case of a changeset, you can use…

@spec! fun(%Ecto.Changeset{}) :: %Ecto.Changeset{}

As an example, you can even make your own custom types such as this one:

@type! error_changeset() :: {:error, %Ecto.Changeset{}}

This can be used at the callsite with…

@spec! fun(%Ecto.Changeset{}) :: error_changeset()

In practice, I’ve found that I need only a handful of custom types (other than t definitions). The library provides you with most of the builtins you would expect with regular typespecs.

2 Likes

To add to what @baldwindavid already said: It is somewhat of a current limitation of TypeCheck. As TypeCheck does not infer its type from pre-existing already-compiled typespecs (but rather the other way around, building typespecs as well as runtime-checks and property-test-generators from the written signature) it cannot handle types that were written in other libraries/modules that did not use TypeCheck.

At some point in the near future, TypeCheck will implement all remote types that are builtin in the Elixir core libraries themselves (c.f. issue #5). Besides this, my current idea of making TypeCheck work well with existing libraries that did not use TypeCheck is to allow you to configure a couple of overrides in your application, saying essentially “if you encouter Ecto.Changeset.t(foo), replace it by …”.


Until this is implemented, your best bet is to use the strategies that @baldwindavid mentioned above. Since Ecto.Changeset.t is a public type, you might even use %Ecto.Changeset{data: User.t()}.

2 Likes

Thanks for your responses! I’d like to use just %Ecto.Changeset{} in my specs, but it always returns an error. Should it work like this?

defmodule Test do
  use TypeCheck

  @spec! fun(%Ecto.Changeset{}) :: %Ecto.Changeset{}
  def fun(%Ecto.Changeset{} = changeset) do
    changeset
  end
end

Error:


Call does not have expected term of type 
  {TypeCheck.Builtin.List.t()
   | TypeCheck.Builtin.Map.t()
   | %{
       :__struct__ => atom(),
       :element_types => [any()],
       :keypairs => [{_, _}],
       :name => atom(),
       :range => _,
       :type => _,
       :value => _
     }, atom(), map(), _}
 (with opaque subterms) in the 1st position.

TypeCheck.TypeError.exception(
  {%TypeCheck.Spec{
     :name => :fun,
     :param_types => [%TypeCheck.Builtin.FixedMap{:keypairs => [any(), ...]}, ...],
     :return_type => %TypeCheck.Builtin.FixedMap{:keypairs => [{_, _}, ...]}
   }, :return_error,
   %{
     :arguments => [any(), ...],
     :problem =>
       {%TypeCheck.Builtin.FixedMap{
          :keypairs => [
            {:__struct__, %TypeCheck.Builtin.Literal{:value => Ecto.Changeset}},
            ...
          ]
        }, :missing_keys | :not_a_map | :value_error,
        %{
          :key => :__struct__,
          :keys => [:__struct__, ...],
          :problem =>
            {%TypeCheck.Builtin.Literal{:value => Ecto.Changeset}, :not_same_value, %{}, _}
        }, _}
   }, _}
)

That should work. Are you calling the function with an %Ecto.Changeset{} struct?

I’m not calling the function at all – this output comes straight from VSCode or the ElixirLS I think. Sorry, should have included that information.

Hm. I don’t use VSCode, but would probably see the same if I did. I think you’ll find that your tests pass and it compiles, but must be a Dialyzer warning.

Version 0.2.3 has been released which fixes the :stream_data dependency issue as well as an issue with compiling under Elixir 1.11.

@zimt28 if you still have problems with your Ecto.Changeset example, please open an issue for that on the GitHub-repository, then we can keep discourse in this topic about the library in general :slight_smile: .

2 Likes

This is a great project! Congrats on making it reality :slight_smile:

Here’s something related to what I’m working on and some of the issues I’ve run into with TypeCheck:

defmodule Sandbox do
    use TypeCheck

    @spec! foo(arg1 :: String.t(), arg2 :: String.t(), arg3 :: float) :: float

    def foo("bing", "bang", "bow") do
      # some process
  end
end
$ iex -S mix
Erlang/OTP 23 [erts-11.1] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [hipe]

Compiling 22 files (.ex)

== Compilation error in file lib/Sandbox.ex ==
** (CompileError) lib/Sandbox.ex:4: misplaced operator ::/2

The :: operator is typically used in bitstrings to specify types and sizes of segments:

    <<size::32-integer, letter::utf8, rest::binary>>

It is also used in typespecs, such as @type and @spec, to describe inputs and outputs
    (stdlib 3.13.2) lists.erl:1358: :lists.mapfoldl/3
    (stdlib 3.13.2) lists.erl:1359: :lists.mapfoldl/3
    (elixir 1.10.3) expanding macro: Kernel.@/1
    lib/Sandbox.ex:4: Sandbox (module)

What’s the correct way to TypeCheck the multiple function arguments in foo/3?

1 Like

It seems like perhaps TypeCheck is not even loaded. Is it installed via mix? In a Phoenix project?

Other than that, the syntax you have here should work. You don’t even need the argument names (e.g. arg1 :: if you don’t want them. I don’t include them.

However, while TypeCheck supports most of the builtin types, it does not support remote types like String.t(). There is an issue at https://github.com/Qqwy/elixir-type_check/issues/5

You could use binary in place of that. I personally use this custom placeholder type for strings:

@type! utf8_binary :: binary

Once String.t() is supported I’ll probably replace references to utf8_binary with that or continue to use utf8_binary and replace binary with String.t().

1 Like

You don’t even need the argument names (e.g. arg1 :: if you don’t want them. I don’t include them.

However, while TypeCheck supports most of the builtin types, it does not support remote types like String.t(). There is an issue at Overrides for builtin remote types like String.t,Enum.t, Range.t, MapSet.t etc. · Issue #5 · Qqwy/elixir-type_check · GitHub

You could use binary in place of that. I personally use this custom placeholder type for strings:

@type! utf8_binary :: binary

defmodule Sandbox do
    use TypeCheck

    @spec! foo(binary, binary, float) :: float

    def foo("bing", "bang", "bow") do
      # some process
  end
end

Thanks so much for your feedback. Is that what you meant?

To your other question:

$ mix deps.get
Resolving Hex dependencies...
Dependency resolution completed:
Unchanged:
  certifi 2.5.2
  connection 1.0.4
  crypto_rand 1.0.2
  db_connection 2.2.2
  decimal 1.8.1
  ecto 3.4.6
  ecto_sql 3.4.5
  erlport 0.10.1
  export 0.1.1
  gen_stage 1.0.0
  hackney 1.16.0
  httpoison 1.7.0
  idna 6.0.1
  jason 1.2.1
  metrics 1.0.1
  mimerl 1.2.0
  parse_trans 3.3.0
  poolboy 1.5.2
  postgrex 0.15.5
  puid 1.1.1
  ssl_verify_fun 1.1.6
  telemetry 0.4.2
  type_check 0.1.2
  unicode_util_compat 0.5.0
All dependencies are up to date
$ mix compile
Compiling 22 files (.ex)

== Compilation error in file lib/Sandbox.ex ==
** (CompileError) lib/Sandbox.ex:4: misplaced operator ::/2

The :: operator is typically used in bitstrings to specify types and sizes of segments:

    <<size::32-integer, letter::utf8, rest::binary>>

It is also used in typespecs, such as @type and @spec, to describe inputs and outputs
    (stdlib 3.13.2) lists.erl:1358: :lists.mapfoldl/3
    (stdlib 3.13.2) lists.erl:1359: :lists.mapfoldl/3
    (elixir 1.10.3) expanding macro: Kernel.@/1
    lib/Sandbox.ex:4: Sandbox (module)

Ah. You just need to update to the latest version. @spec! was not the syntax yet on 0.1.2. As such, the compiler just sees a regular module attribute and then blows up at ::.

2 Likes