Qqwy

Qqwy

TypeCheck Core Team

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

hex.pm version Documentation ci Coverage Status

Core ideas

  • Type- and function specifications are constructed using (essentially) the same syntax as Elixir’s built-in typespecs.
  • When a value does not match a type check, the user is shown human-friendly error messages.
  • Types and type-checks are generated at compiletime.
    • This means type-checking code is optimized rigorously by the compiler.
  • Property-checking generators can be extracted from type specifications without extra work.
  • Flexibility to add custom checks: Subparts of a type can be named, and ‘type guards’ can be specified to restrict what values are allowed to match that refer to these types.

Usage Example

defmodule User do
  use TypeCheck
  defstruct [:name, :age]

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

defmodule AgeCheck do
  use TypeCheck

  @spec! user_older_than?(User.t, integer) :: boolean
  def user_older_than?(user, age) do
    user.age >= age
  end
end

Now we can try the following:

iex> AgeCheck.user_older_than?(%User{name: "Qqwy", age: 11}, 10)
true
iex> AgeCheck.user_older_than?(%User{name: "Qqwy", age: 9}, 10)
false

So far so good. Now let’s see what happens when we pass values that are incorrect:

iex> AgeCheck.user_older_than?("foobar", 42)
** (TypeCheck.TypeError) At lib/type_check_example.ex:28:
The call to `user_older_than?/2` failed,
because parameter no. 1 does not adhere to the spec `%User{age: integer(), name: binary()}`.
Rather, its value is: `"foobar"`.
Details:
  The call `user_older_than?("foobar", 42)` 
  does not adhere to spec `user_older_than?(%User{age: integer(), name: binary()},  integer()) :: boolean()`. Reason:
    parameter no. 1:
      `"foobar"` does not check against `%User{age: integer(), name: binary()}`. Reason:
        `"foobar"` is not a map.
    (type_check_example 0.1.0) lib/type_check_example.ex:28: AgeCheck.user_older_than?/2
iex> AgeCheck.user_older_than?(%User{name: nil, age: 11}, 10)
** (TypeCheck.TypeError) At lib/type_check_example.ex:28:
The call to `user_older_than?/2` failed,
because parameter no. 1 does not adhere to the spec `%User{age: integer(), name: binary()}`.
Rather, its value is: `%User{age: 11, name: nil}`.
Details:
  The call `user_older_than?(%User{age: 11, name: nil}, 10)` 
  does not adhere to spec `user_older_than?(%User{age: integer(), name: binary()},  integer()) :: boolean()`. Reason:
    parameter no. 1:
      `%User{age: 11, name: nil}` does not check against `%User{age: integer(), name: binary()}`. Reason:
        under key `:name`:
          `nil` is not a binary.
    (type_check_example 0.1.0) lib/type_check_example.ex:28: AgeCheck.user_older_than?/2
iex> AgeCheck.user_older_than?(%User{name: "Aaron", age: nil}, 10) 
** (TypeCheck.TypeError) At lib/type_check_example.ex:28:
The call to `user_older_than?/2` failed,
because parameter no. 1 does not adhere to the spec `%User{age: integer(), name: binary()}`.
Rather, its value is: `%User{age: nil, name: "Aaron"}`.
Details:
  The call `user_older_than?(%User{age: nil, name: "Aaron"}, 10)` 
  does not adhere to spec `user_older_than?(%User{age: integer(), name: binary()},  integer()) :: boolean()`. Reason:
    parameter no. 1:
      `%User{age: nil, name: "Aaron"}` does not check against `%User{age: integer(), name: binary()}`. Reason:
        under key `:age`:
          `nil` is not an integer.
    (type_check_example 0.1.0) lib/type_check_example.ex:28: AgeCheck.user_older_than?/2
    
iex> AgeCheck.user_older_than?(%User{name: "José", age: 11}, 10.0) 
** (TypeCheck.TypeError) At lib/type_check_example.ex:28:
The call to `user_older_than?/2` failed,
because parameter no. 2 does not adhere to the spec `integer()`.
Rather, its value is: `10.0`.
Details:
  The call `user_older_than?(%User{age: 11, name: "José"}, 10.0)` 
  does not adhere to spec `user_older_than?(%User{age: integer(), name: binary()},  integer()) :: boolean()`. Reason:
    parameter no. 2:
      `10.0` is not an integer.
    (type_check_example 0.1.0) lib/type_check_example.ex:28: AgeCheck.user_older_than?/2

And if we were to introduce an error in the function definition:

defmodule AgeCheck do
  use TypeCheck

  @spec! user_older_than?(User.t, integer) :: boolean
  def user_older_than?(user, age) do
    user.age
  end
end

Then we get a nice error message explaining that problem as well:

** (TypeCheck.TypeError) The call to `user_older_than?/2` failed,
because the returned result does not adhere to the spec `boolean()`.
Rather, its value is: `26`.
Details:
  The result of calling `user_older_than?(%User{age: 26, name: "Marten"}, 10)` 
  does not adhere to spec `user_older_than?(%User{age: integer(), name: binary()},  integer()) :: boolean()`. Reason:
    Returned result:
      `26` is not a boolean.
    (type_check_example 0.1.0) lib/type_check_example.ex:28: AgeCheck.user_older_than?/2

While TypeCheck is not stable yet, it is mature enough to be used for simple tasks.

Please try it out and share your experiences and feedback here! :slight_smile:


If you like videos, also see my 2022 ElixirConf.EU talk about TypeCheck:

Showing Posts 1 to 10

hauleth

hauleth

What is runtime penalty for using this?

ityonemo

ityonemo

I recommend checking the @spec values themselves. If you’re curious how to do this I made this library (which I later decided I hated) which can inspect the type specs of remote modules: GitHub - ityonemo/typed_headers: typed headers for elixir · GitHub

Also this is superficial but in your example note the convention with regard to is_ functions and ? functions.

Qqwy

Qqwy OP

TypeCheck Core Team

Great question! Because the type-checking code is generated and added at compile-time it gets optimized by the compiler, which means that redundant checks are eliminated from the code and you end up with something that is quite performant.

How fast it will be exactly of course depends on what kind of type you are checking against. TypeCheck performs exhaustive checking of e.g. the elements in a list, which takes longer when the list is longer.
For simple datatypes, the type-check is a constant-time operation.

As an example, the following:

defmodule Example do
  use TypeCheck
  spec add(number(), number()) :: number()
  def add(a, b) do
    a + b
  end
end

is turned into something like:

defmodule Example do
  use TypeCheck
  @spec add(number(), number()) :: number()
  def add(a, b) do
    unless is_number(a) do
      raise "param a does not match type number()"
    end
    unless is_number(b) do
      raise "param b does not match type number()"
    end

    result = a + b

    unless is_number(result) do
      raise "the result does not match type number()"
    end
  end
end

(This is somewhat simplified; in the actual implementation we pass nested errors back up as values before they are raised as exception to be able to have human-friendly error messages.)

So a general answer would be ‘for most situations the performance penalty is probably negligible’.

I definitely want to write a couple of benchmarks to be able to give a more scientific answer in the future. And it might also be able to further optimize the code that is generated (allowing the compiler to do even more optimizations) in the future as well.

Qqwy

Qqwy OP

TypeCheck Core Team

@ityonemo thank you! The example in the first post (and the README) has been updated. That’s what you get when writing documentation while burning the midnight oil :sweat_smile:.

The main reason I chose to not read the @spec-values themselves is to allow people to ‘opt-out’ of the @spec/@type etc. that are being generated, which can be useful in some cases. It also allows support for certain kinds of extensions that Elixir’s builtin typespecs cannot handle, like adding named types, ‘type guards’ or certain type-shorthands like tuple(3).

I do wonder what happened which made you dislike your library later on; it seems like quite a bit of effort went into writing that! :+1:


For the curious, here is an example of what kind of BEAM code ends up being generated for an example like this.

Something cool that you can see here is for instance that the return-type check is completely elided since the compiler sees that add will always return a number.

While I’m already quite happy with this, there are a couple of potential improvements for the future I’d like to make as well:

  • Potentially inline the wrapped function (but how inlining might work when combined with defoverridable is something I need to still figure out)
  • Potentially strip away the code related to tracking ‘named bindings’ wherever they are not used: Currently we return {:ok, []} everywhere, but we can at compile-time figure out whether/where we can just use :ok which would make the job for the compiler a lot simpler; passing a static atom around vs a 2-tuple probably makes quite a difference.
  • Simplify the error-response code by hiding the gist of it in an internal function. I hope this might reduce the number of :jump calls that are added to the bytecode, since I want the ‘happy path’ to be as fast as possible.
baldwindavid

baldwindavid

@Qqwy I’m currently using Norm, but this might end up being a better fit for my needs. I’m solely using Norm for the @contract specifications as a replacement for typespecs, but need to create my own functions to represent everything in typespecs, which feels a bit like recreating the wheel.

Are the specs that can be created in any way limited? One of the things I like about Norm is that anything can be used. Is TypeCheck simply optimizing the parts it can and letting the rest run as it normally would?

I also had the question whether it would be possible to check @spec/@type. It appears that it is and @ityonemo has done it, but that is not a goal here.

I can’t help but envision a package that could check regular @spec/@type at runtime. Those standard typespecs could be used most of the time, but when you need to do something special you can remove the @ and write more complex specifications. If spec could use remote @types that would also be cool, but no idea if that would be possible.

Qqwy

Qqwy OP

TypeCheck Core Team

Correct. TypeCheck optimizes the parts of the type it understands, and if you want to add extra checks you can add a ‘type guard’ which allows arbitrary Elixir code:

type sorted_pair :: {lower :: number(), higher :: number()} when lower <= higher

This indeed optimizes to code that does, in order, the following:

  • Checking that we have a tuple.
  • Checking that the tuple has 2 elements.
  • Checking that the first element is a number (and binding that element to the name ‘lower’)
  • Checking that the second element is a number (and binding that element to the name ‘higher’)
  • Running the guard code, in this case ‘lower <= higher’.

Does that answer your question? :blush:

ityonemo

ityonemo

I disliked it for the following reasons.

  1. adding typing to headers gets burdensome to read. Surprisingly, I actually rather like @type being outside of the headers now.
  2. I stopped and thought, “Do I really need this”? Well. Dialyzer is atrocious, but it’s strictly a matter of ergonomics, and the ergonomics, I find are completely solved by vs_code and elixir_ls. I don’t even bother running dialyzer anymore, my code is fairly well typed and dialyzer really does catch 90% probably of typing errors. If vscode/elixir_ls ever stops making the type suggestions “annoyingly a different size than the code” I will be upset, because that minor “annoyance” is really the thing that drives me to write typespecs. Don’t change that, ever, vscode team.
  3. I did still have one typing error make it to prod in like 20k LOC. I found it months later because i was idle and just killing bugs that cropping up on appsignal. BEAM will really save your ass in these sorts of situations, and honestly, it was really “not a big deal” Of course, in this case, too, vs code had that yellow squiggly that I had just overlooked.
  4. Elixir is already helpfully stronger in typing, if only culturally, in ways that Erlang never was (with the way that structs really nudge you to type out), for example, or how Mocks in Mox require you to define the contract.
  5. the typing things that I think are “the hardest” are “message passing APIs” and dynamically bound modules. But in 99% of cases your entire message passing should live in the same module that defines the GenServer interface, so if you’re careful and organized it shouldn’t be a problem. I also use a coding style where my handle_*s are “as dumb as possible” and follow a strict convention, and my GenServer API functions call a private implementation function that I write directly underneath. Ex: erps/lib/erps/server.ex at master · ityonemo/erps · GitHub and so this is not a problem. I haven’t found a solution to “dynamically bound modules” yet.
  6. It’s bad form to redefine kernel macros (like def/2) unless you really really really have a good reason to. On the other hand, if I were to rename it (defc/2) or something it could cause the reader to have to think a bit. In the end, since I’m hiring out my team, my preference is to stick as much as possible to the existing elixir standard to prevent confusion ahead of having to grow my team (especially as the hires will probably not know elixir to start off).
baldwindavid

baldwindavid

This is a really impressive and useful package. I tested it out on quite a bit of code (about 400 functions) and didn’t really run into any issues with the code. Ergonomics are the only issue for me in that spec is not syntax highlighted as nicely as something like @spec/@contract and empty newlines appear between the spec and multiline functions, which is almost always for me.

I can’t help but wonder if something like @contract or @check rather than spec might solve these problems.

I still think the ultimate would be if this could additionally do type checking on @spec and @type. Teams could install it and immediately get the benefit of runtime checks that are optimized. And if someone needs to do more, just remove the @ and spec unleashes the power of what this package already does.

Regardless, really nice work!

Qqwy

Qqwy OP

TypeCheck Core Team

Thank you, great to hear! :green_heart:

I am currently trying out some tests to extract the specs from the @spec/@type/@typep/@opaque attributes directly. It seems promising, but I’ll need to do some more tests before I’m sure that it (i.e. overriding @) does not have some weird edge cases. :blush:

baldwindavid

baldwindavid

I went ahead and tried out your test branch applying it across my entire codebase switching all uses of spec/type to @spec/@type. My test suite continues to pass and I don’t seem to have hit any edge cases. I’m not sure how it could get much better than this just using @spec/@type everywhere. Would be neat if Elixir just worked like this out of the box (maybe with ability to disable in prod for zero cost).

Where Next? Top

Trending in Announcing Top

wojtekmach
Hey everyone! Req is an HTTP client for Elixir that I’ve been working on for quite some time. There is already a lot of HTTP clients out...
New
handnot2
Samly can be used to enable SAML 2.0 Single Sign On in a Plug/Phoenix application. This library uses Erlang esaml to provide plug enabl...
New
woylie
Flop is an Elixir library that applies filtering, ordering and pagination parameters to your Ecto queries. offset-based pagination with...
New
restlessronin
The repo is at GitHub - cyberchitta/openai_ex: Community maintained Elixir library for OpenAI API · GitHub. Docs are at OpenaiEx User Gu...
152 11030 135
New
shahryarjb
The Chelekom project is a library of Phoenix and LiveView components generated via Mix tasks to fit developer needs seamlessly. One of i...
New
garrison
Hobbes is a low-level distributed database for the Elixir programming language. Hobbes provides a simple, safe, and scalable storage lay...
New
woylie
Phoenix components for pagination, sortable tables and filter forms with Flop and (optionally) Ecto. pagination cursor pagination sorta...
New

Other Trending Topics Top

mudasobwa
I am seeing a lot of aplications of Argumentum ad Vericundiam in software discussions. They do link some piece of writing and point us to...
New
bartblast
Hey folks, I just published a post about Hologram’s funding and where the project goes next - the short version: Curiosum as Main Spons...
New
alexslade
Fly’s CEO posted this recently - Turn And Face The Strange · The Fly Blog It says that Fly is going all-in on sprites, which is a worry ...
New
Herve37
We’re evaluating API mocking tools for OpenAPI-based projects and would love to hear what other teams are using. We’re particularly inte...
New
sorenone
Today we’re releasing Oban for Python. Not an Oban client in Python. Not a pythonx wrapper embedded in Elixir. Nope, it’s a fully operati...
New
lawik
I was thinking since Goatmire Elixir turned out pretty good I should maybe do another one. 30th of Sep - 2nd of Oct this year./ The firs...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews