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

Right. The library supports providing remote type “overrides” such that one could reference the original types. See Remote Type Overrides documentation.

Overrides for most of the standard library ship with the package. Types in external libraries would not exist, but I’m guessing some packages like elixir-type_check-plug and elixir-type_check-phoenix might appear at some point if someone wants to build those things. It’s easy enough to create one-off overrides in your own codebase in the meantime.

2 Likes

@baldwindavid 's explanation is nice and correct. (Thank you! :green_heart: )

TypeCheck cannot extract the types from already-compiled modules: types are stored in a completely different format inside the BEAM, are not always available, and are difficult to parse. Some libraries which are making steps in parsing them do exist (such as Mavis) although they are not very mature at this time and as such are not integrated with TypeCheck either.

For now, either add the types you’re using directly in your modules, or use the ‘remote type overrides’ as mentioned by @baldwindavid.
My hope is that we will have integration with common libraries in the near future, but that will take some time/effort.

1 Like

Last time I announced a version was v0.10.1

Right now, we’re at v0.10.4

As might be seen from the version numbers, there have not been any major changes, but there have been quite a few important bugfixes.

Please make sure you upgrade to the latest version :blush:!


More long-term plans right now are:

  • to support optional and required in map-types (this is still in the conceptual phase, as it is quite the puzzle to figure out. Help is greatly appreciated!)
  • Optimize the current checks that are generated: To add type-check support for function-values, we had to modify the incoming value. For simple types the compiler is able to optimize all of that away if it is not used. However, for more complex types (like nested maps, lists or structs) the checks now reach a complexity where the compiler struggles. Instead, we could check whether a part of our current type might expect a function and if not, compile to a faster runtime typecheck that does not modify the incoming value, making the work of the compiler simpler and the resulting checks faster.
  • Once that is done, maybe some benchmarks? Main goals are to figure out other potential bottlenecks and to compare TypeCheck against other typing approaches in the current ecosystem.
4 Likes

I’ve thought a lot about optional/required works, and I have to say, I don’t fully understand it.

There are a few gripes that I have:

  • what does it mean to have a “required” key that is an aggregate type? (E.g. “required(atom)”). Does that mean there “must be at least one element of type atom?”. Required makes sense for literal types (e.g. numbers or literal atoms, empty set, empty map, maybe ranges but nothing else, imo.)

  • What happens when two optional types overlap (eg optional(integer), optional(1…10)? What if their targets are disjoint? Or what about an optional and a required literal? Like how structs have required(:__struct__) => module and optional(atom) => any

would love to hear what design decisions you make.

1 Like

@Qqwy posted some ideas on this awhile back in an issue in the repo if you’re interested… Handle %{optional(type) => other_type} and other embellished map-syntaxes · Issue #7 · Qqwy/elixir-type_check · GitHub

2 Likes

yeah the logic for this is really really hard. I wound up treating it like a “function” in the mathematical set theory sense, and here is the code that I have, it’s full of math-ey terms in the comments and documentation: mavis/map.ex at master · ityonemo/mavis · GitHub. The union method is currently flawed, to do a proper union that solves for all possible union values you need a SAT solver.

1 Like

I’ve been thinking more about this.

Trying to support everything at once probably is a fool’s errand, for two reasons:

  1. It is very difficult to build this full implementation
  2. The resulting runtime checks will by necessity be very slow.

Instead, I think a better approach might be to add support for some common subpatterns (but not yet all of them).

Essentially:

  • %{optional(k) => v}: Could be supported right now as it is identical to TypeCheck’s nonstandard map(k, v).
  • %{required(k) => v}: Could be supported right now as it is identical to TypeCheck’s nonstandard m :: map(k, v) when map_size(m) >= 1.
  • %{a: x, b: y, ..., optional(k) => v}: Could be supported by:
    • Using the existing checks on all literal key-values
    • For all (if any) leftover key-values, check whether they match k => v.
  • The approach in the previous bullet can also be used for any number of required( ) followed by a single optional( ) as long as all required types are literal types.

We then explicitly do not (for now) support having multiple non-literal required( ) and/or multiple optional( ) in a single type. I think is OK because (a) such types are to my knowledge very rare and (b) again it is super difficult to implement checks for potentially-overlapping types efficiently; currently I know no better approach to match these than to check whether at least one solution exists in the cartesian product of (keys × powerset(required_keytypes)).

2 Likes

Essentially the problem is that dialyzer doesn’t support subtractive types (or even certain literal types). I’d love to have a type that is like:

special_json :: %{
  required("foo") => integer,
  optional("bar") => number,
  optional(String.t / ("foo" | "bar")) => json
}

Hello,
I try to add typeckeck to my project. Is there some kind of best practice when having to deal with deadlocks / circular dependencies?

Dealing with contexts in Phoenix is usual that top-level contexts communicate with each-other.
In my case, I use lots of structs and end up with many deadlocks.

I know I could move the functions in one of the contexts, but I feel I’m ending up with a super context that deals with everything.

If we start then to add Ecto schemas under the top-level contexts and cross-reference them in between the contexts, results in even more deadlocks.

I know it is not a good design to cross-reference them but think of the classical Phoenix example with Author and Post. You may want to get the full list of posts in the Authors context, and the post author in the Posts context (simplistic example).

Can be considered an option to extract the type definition to their own file? Eg. Autor.Type.t() . I don’t really like it but just considering the options.

Thanks

The main option to resolve the problem of cycles in types, is lazy. This has a slight performance overhead, but for most usage (and definitely for adding types to e.g. has_one/has_many fields in your Ecto schemas) it is good enough.

1 Like

Thanks. I was trying to avoid lazy, as I thought it is more of a design/concept issue on my side.
For now, I managed to avoid it by extracting common top-level context types in a separate file. But maybe I will move them back to their modules and use lazy instead.

And I take this opportunity to thank you for this library!

1 Like

Version 0.10.7 is released!

Fixes:

  • Ensure fixed-maps are checked for superfluous keys (c.f. #96). Thank you very much, @patrikstenmark !
3 Likes

Version 0.11.0 has been released! :rocket:

Wooh, this is a big release!

Most important features:

  • We now support all of Elixir’s builtin basic types! :confetti_ball:
  • We now support all of the remote types of the Elixir standard library! :partying_face:
  • Support for most of the map-type syntactic sugars. :sunglasses:
  • An optional Credo check to enforce that all your functions have a spec. :heavy_check_mark:

Full changelog

Additions

  • Support for fancier map syntaxes:
    • %{required(key_type) => value_type} Maps with a single kind of required key-value type.
    • %{optional(key_type) => value_type} Maps with a single kind of optional key-value type.
    • %{:some => a(), :fixed => b(), :keys => c(), optional(atom()) => any()} Maps with any number of fixed keys and a single optional key-value type.
    • TypeCheck now supports nearly all kinds of map types that see use. Archaic combinations of optional and required are not supported, but also not very useful types in practice.
    • Because of this, the inspection of the builtin type map(key, value) has been changed to look the same as an optional map. This is a minor backwards-incompatible change.
  • Desugaring %{} has changed from ‘any map’ to ‘the empty map’ in line with Elixir’s Typespecs. This is a minor backwards-incompatible change.
  • Support for the builtin types port(), reference() and (based on these) identifier().
  • Support for the builtin type struct().
  • Support for the builtin type timeout().
  • Support for the builtin type nonempty_charlist() and maybe_improper_list and (based on these) iolist() and iodata().
  • Adding types depending on these builtins to the default type overrides. We now support all modules of the full standard library!
  • TypeCheck.Credo.Check.Readability.Specs: an opt-in alternative Credo check which will check whether all functions have either a @spec! or ‘normal’ @spec. (Fixes #102).

Fixes

  • The TypeCheck.Builtin module is now actually spectested itself. Some consistency bugs were found and solved as a result.

We’re very near to a stable 1.0 release now.
But that is not all: in the meantime, some great work is being done by @orsinium to increase interoptability with modules which are not part of your own codebase.
In the near future (which will be part of the next release) we’ll be able to extract types from external modules and combine them with the ones explicitly written out using TypeCheck.
Work on this is still in progress; there are some design choices to still be made. If you find this interesting or have opinions about it, please let us know!

20 Likes

Wow, what a massive release! Basically, all green check marks here now… Comparison to Plain Typespecs — TypeCheck v0.11.0

Great work and thanks for pushing the typing options in the ecosystem forward.

6 Likes

Version 0.12.0 is released! :rocket:

Additions:

Per-dependency configuration

The default options used are now fetched from the application configuration.
This means that you can configure a default for your app as well as for each of your dependencies(!) by adding config :app_name, :type_check [...] to your configuration file(s).

(This configuration can then further be overridden per module, which was already possible in earlier versions.)

Building types and checks for external functions

If you want to use types from modules outside of your control, until now you had to manually recreate them.
Now, the TypeCheck.External module allows fetching the ‘normal’ types and specs from a compiled module.
Specifically:

  • TypeCheck.External module, with functions to work with typespecs in modules outside of your control.

    • fetch_spec to build a TypeCheck type from any function that has a @spec.
    • fetch_type to build a TypeCheck type from any @type.
    • enforce_spec! to wrap a call to any function that has a @spec with a runtime type-check on the input parameters and return value.
    • apply and apply! to wrap a call to any function with the function spec type that you give it.
iex> TypeCheck.External.enforce_spec!(Kernel.abs(-13))
13
iex> TypeCheck.External.enforce_spec!(Kernel.abs("hi"))
** (TypeCheck.TypeError) At lib/type_check/external.ex:175:
    `"hi"` is not a number.

This functionality is still very new, so we consider it to be a little experimental.
In the near future, it might be integrated further with the rest of the library. For instance, we might add syntactic sugar to make ‘type overrides’ easier.

I want to take this opportunity to thank @orsinium greatly. Both the idea and the implementation of this functionality is his work! :star_struck:

Defstruct!

TypeCheck.Defstruct.defstruct!, a way to combine defstruct, @enforce_keys and the creation of the struct’s type, reducing boilerplate and the possibility of mistakes. (c.f. #118 )

Example:

defmodule User do
  use TypeCheck
  use TypeCheck.Defstruct

  defstruct!(
    name: "Guest" :: String.t(),
    age: _ :: non_neg_integer()
  )
end

is syntactic sugar for:

defmodule User do
  use TypeCheck
  use TypeCheck.Defstruct

  @type! t() :: %User{
    name: String.t(),
    age: non_neg_integer()
  }
  @enforce_keys [:age]
  defstruct [:age, name: "Guest"]
end

Fixes:

  • Long-standing issue where Dialyzer would sometimes complain in apps using TypeCheck is resolved. (c.f. #95)
  • Creation of the new maybe_nonempty_list type will no longer get stuck in an infinite loop on creation. (c.f. #120)

This new release is just in time for ElixirConf.EU where I will be speaking about TypeCheck. I look forward to see a lot of you there (live or virtually)!

Assuming they arrive on time, there will also be stickers :blush:

8 Likes

:star_struck:

2 Likes

ElixirConf.EU was a lot of fun!
Being with such a nice group of cool, clever and excited people is a really great way to get new energy to continue any project.
Many people asked questions and gave valuable feedback.

Thank you very much, everyone! :partying_face:

(For who was not there: A video recording of the talk will be released some time later in this year, once the organisers are ready to do so.)

16 Likes

Congrats Marten,
Looking forward to the releasing of the video recording.

1 Like

Been a fun group run to! Hope to do more in the future.

1 Like

FYI, currently TypeCheck does not work well with Elixir v1.17.

There is a change in how ... is parsed in the Elixir AST from v1.16 to v1.17, which the TypeCheck library currently does not handle correctly, as it expects the older format.

GitHub issue here.

Unfortunately, I’m on vacation until the start of August, so it might be a while for a fix to be implemented, unless someone is able to contribute a PR in the meantime.

I apologize for any inconvenience!

1 Like