-
Notifications
You must be signed in to change notification settings - Fork 25
/
Copy pathtype_check.ex
367 lines (285 loc) · 14.8 KB
/
type_check.ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
defmodule TypeCheck do
require TypeCheck.Type
@moduledoc """
Fast and flexible runtime type-checking.
The main way to use TypeCheck is by adding `use TypeCheck` in your modules.
This will allow you to use the macros of `TypeCheck.Macros` in your module,
which are versions of the normal type-specification module attributes
with an extra exclamation mark at the end: `@type!`, `@spec!`, `@typep!` and `@opaque!`.
It will also bring all functions in `TypeCheck.Builtin` in scope,
which is usually what you want as this allows you to
use all types and special syntax that are built-in to Elixir
in the TypeCheck specifications.
Using these, you're able to add function-specifications to your functions
which will wrap them with runtime type-checks.
You'll also be able to create your own type-specifications that can be used
in other type- and function-specifications in the same or other modules later on:
defmodule User do
use TypeCheck
defstruct [:name, :age]
@type! age :: non_neg_integer()
@type! t :: %User{name: binary(), age: age()}
@spec! new(binary(), age()) :: t()
def new(name, age) do
%User{name: name, age: age}
end
@spec! old_enough?(t(), age()) :: boolean()
def old_enough?(user, limit) do
user.age >= limit
end
end
Finally, you can test whether your functions correctly adhere to their specs,
by adding a `spectest` in your testing suite. See `TypeCheck.ExUnit.spectest/2` for details.
## Types and their syntax
TypeCheck allows types written using (essentially) the same syntax as [Elixir's builtin typespecs](https://hexdocs.pm/elixir/typespecs.html#types-and-their-syntax).
This means the following:
- literal values like `:ok`, `10.0` or `"my string"` desugar to a call to `TypeCheck.Builtin.literal/1`, which is a type that matches only exactly that value.
- Basic types like `integer()`, `float()`, `atom()` etc. are directly supported (and exist as functions in `TypeCheck.Builtin`).
- tuples of types like `{atom(), integer()}` are supported (and desugar to `TypeCheck.Builtin.fixed_tuple/1`)
- maps where keys are literal values and the values are types like `%{a: integer(), b: integer(), 42 => float()}` desugar to calls to `TypeCheck.Builtin.fixed_map/1`.
- The same happens with structs like `%User{name: binary(), age: non_neg_integer()}`
- sum types like `integer() | string() | atom()` are supported, and desugar to calls to `TypeCheck.Builtin.one_of/1`.
- Ranges like `lower..higher` are supported, matching integers within the given range. This desugars into a call to `TypeCheck.Builtin.range/1`.
### Currently unsupported features
The following typespec syntax can _not_ currently be used in TypeCheck. This will hopefully change in future versions of the library.
- Literal maps with `required(...)` and `optional(...)` keys. (TypeCheck does already support literal maps with a fixed set of keys, as well as maps with any number of key-value-pairs of fixed types. It is the special syntax that might mix these approaches that is not supported yet.)
### Extensions
TypeCheck adds the following extensions on Elixir's builtin typespec syntax:
- fixed-size lists containing types like `[1, 2, integer()]` are supported, and desugar to `TypeCheck.Builtin.fixed_list/1`.
This example matches only lists of 3 elements where the first element is the literal `1`, the second the literal `2` and the last element any integer.
Elixir's builtin typespecs do not support fixed-size lists.
- named types like `x :: integer()` are supported; these are useful in combination with "type guards" (see the section below).
- "type guards" using the syntax `some_type when arbitrary_code` are supported, to add extra arbitrary checks to a value for it to match the type. (See the section about type guards below.)
- `lazy(some_type)`, which defers type-expansion until during runtime. This is required to be able to expand recursive types. C.f. `TypeCheck.Builtin.lazy/1`
## Named Types Type Guards
To add extra custom checks to a type, you can use a so-called 'type guard'.
This is arbitrary code that is executed during a type-check once the type itself already matches.
You can use "named types" to refer to (parts of) the value that matched the type, and refer to these from a type-guard:
```
type sorted_pair :: {lower :: number(), higher :: number()} when lower <= higher
```
iex> TypeCheck.conforms!({10, 20}, sorted_pair)
{10, 20}
iex> TypeCheck.conforms!({20, 10}, sorted_pair)
** (TypeCheck.TypeError) `{20, 10}` does not match the definition of the named type `TypeCheckTest.TypeGuardExample.sorted_pair`
which is: `TypeCheckTest.TypeGuardExample.sorted_pair
::
(sorted_pair :: {lower :: number(), higher :: number()} when lower <= higher)`. Reason:
`{20, 10}` does not check against `(sorted_pair :: {lower :: number(), higher :: number()} when lower <= higher)`. Reason:
type guard:
`lower <= higher` evaluated to false or nil.
bound values: %{higher: 10, lower: 20, sorted_pair: {20, 10}}
Named types are available in your guard even from the (both local and remote) types that you are using in your time, as long as those types are not defined as _opaque_ types.
## Manual type-checking
If you want to check values against a type _outside_ of the checks the `@spec!` macro
wraps a function with,
you can use the `conforms/2`/`conforms?/2`/`conforms!/2` macros in this module directly in your code.
These are evaluated _at compile time_ which means the resulting checks will be optimized by the compiler.
Unfortunately it also means that the types passed to them have to be known at compile time.
If you have a type that is constructed dynamically at runtime, you can resort to
`dynamic_conforms/2` and variants.
Because these variants have to evaluate the type-checking code at runtime,
these checks are not optimized by the compiler.
### Introspection
To allow checking what types and specs exist,
the introspection function `__type_check__/1` will be added to a module when `use TypeCheck` is used.
- `YourModule.__type_check__(:types)` returns a keyword list of all `{type_name, arity}`
pairs for all types defined in the module using `@type!`/`@typep!` or `@opaque!`.
- `YourModule.__type_check__(:specs)` returns a keyword list of all `{function_name, arity}`
pairs for all functions in the module wrapped with a spec using `@spec!`.
Note that these lists might also contain private types / private function names.
"""
defmacro __using__(options) do
case __CALLER__.module do
nil ->
quote generated: true, location: :keep do
require TypeCheck
require TypeCheck.Type
import TypeCheck.Builtin
:ok
end
_other ->
type_require =
if __CALLER__.module == TypeCheck.Type do
:ok
else
quote do: require(TypeCheck.Type)
end
builtin_import =
if __CALLER__.module == TypeCheck.Builtin do
:ok
else
quote do: import(TypeCheck.Builtin)
end
quote generated: true, location: :keep do
use TypeCheck.Macros, unquote(options)
require TypeCheck
unquote(type_require)
unquote(builtin_import)
:ok
end
end
end
@doc """
Makes sure `value` typechecks the type description `type`.
If it typechecks, we return `{:ok, value}`
Otherwise, we return `{:error, %TypeCheck.TypeError{}}` which contains information
about why the value failed the check.
`conforms` is a macro and expands the type check at compile-time,
allowing it to be optimized by the compiler.
C.f. `TypeCheck.Type.build/1` for more information on what type-expressions
are allowed as `type` parameter.
Note: _usually_ you'll want to `import TypeCheck.Builtin` in the context where you use `conforms`,
which will bring Elixir's builtin types into scope.
(Calling `use TypeCheck` will already do this; see the module documentation of `TypeCheck` for more information))
"""
@type value :: any()
# Note: Below spec highlights how the macro functions when it is _used_:
@spec conforms(value, TypeCheck.Type.expandable_type()) ::
{:ok, value} | {:error, TypeCheck.TypeError.t()}
defmacro conforms(value, type, options \\ Macro.escape(TypeCheck.Options.new())) do
{evaluated_options, _} = Code.eval_quoted(options, [], __CALLER__)
evaluated_options = TypeCheck.Options.new(evaluated_options)
type = TypeCheck.Type.build_unescaped(type, __CALLER__, evaluated_options)
check = TypeCheck.Protocols.ToCheck.to_check(type, value)
res =
quote generated: true, location: :keep do
case unquote(check) do
{:ok, bindings, altered_value} ->
{:ok, altered_value}
{:error, problem} ->
exception =
TypeCheck.TypeError.exception({problem, unquote(Macro.Env.location(__CALLER__))})
{:error, exception}
end
end
if(evaluated_options.debug) do
value_str = value |> Macro.to_string() |> Code.format_string!()
TypeCheck.Internals.Helper.prettyprint_spec(
"TypeCheck.conforms(#{value_str}, #{inspect(type)}, #{inspect(options)})",
res
)
end
res
end
@doc """
Similar to `conforms/2`, but returns `true` if the value typechecked and `false` if it did not.
The same features and restrictions apply to this function as to `conforms/2`.
"""
@spec conforms?(value, TypeCheck.Type.expandable_type()) :: boolean()
defmacro conforms?(value, type, options \\ Macro.escape(TypeCheck.Options.new())) do
{evaluated_options, _} = Code.eval_quoted(options, [], __CALLER__)
evaluated_options = TypeCheck.Options.new(evaluated_options)
type = TypeCheck.Type.build_unescaped(type, __CALLER__, evaluated_options)
check = TypeCheck.Protocols.ToCheck.to_check(type, value)
res =
quote generated: true, location: :keep do
match?({:ok, _, _}, unquote(check))
end
if(evaluated_options.debug) do
value_str = value |> Macro.to_string() |> Code.format_string!()
TypeCheck.Internals.Helper.prettyprint_spec(
"TypeCheck.conforms?(#{value_str}, #{inspect(type)}, #{inspect(options)})",
res
)
end
res
end
@doc """
Similar to `conforms/2`, but returns `value` if the value typechecked and raises TypeCheck.TypeError if it did not.
The same features and restrictions apply to this function as to `conforms/2`.
"""
@spec conforms!(value, TypeCheck.Type.expandable_type()) :: value | no_return()
defmacro conforms!(value, type, options \\ Macro.escape(TypeCheck.Options.new())) do
{evaluated_options, _} = Code.eval_quoted(options, [], __CALLER__)
evaluated_options = TypeCheck.Options.new(evaluated_options)
type = TypeCheck.Type.build_unescaped(type, __CALLER__, evaluated_options)
check = TypeCheck.Protocols.ToCheck.to_check(type, value)
res =
quote generated: true, location: :keep do
case unquote(check) do
{:ok, _bindings, altered_value} -> altered_value
{:error, other} -> raise TypeCheck.TypeError, other
end
end
if(evaluated_options.debug) do
value_str = value |> Macro.to_string() |> Code.format_string!()
TypeCheck.Internals.Helper.prettyprint_spec(
"TypeCheck.conforms!(#{value_str}, #{inspect(type)}, #{inspect(options)})",
res
)
end
res
end
@doc """
Makes sure `value` typechecks the type `type`. Evaluated _at runtime_.
Because `dynamic_conforms/2` is evaluated at runtime:
1. The typecheck cannot be optimized by the compiler, which makes it slower.
2. You must pass an already-expanded type as `type`.
This can be done by using one of your custom types directly (e.g. `YourModule.typename()`),
or by calling `TypeCheck.Type.build`.
Use `dynamic_conforms` only when you cannot use the normal `conforms/2`,
for instance when you're only able to construct the type to check against at runtime.
iex> forty_two = TypeCheck.Type.build(42)
iex> TypeCheck.dynamic_conforms(42, forty_two)
{:ok, 42}
iex> {:error, type_error} = TypeCheck.dynamic_conforms(20, forty_two)
iex> type_error.message =~ "`20` is not the same value as `42`."
true
"""
@spec dynamic_conforms(value, TypeCheck.Type.t()) ::
{:ok, value} | {:error, TypeCheck.TypeError.t()}
def dynamic_conforms(value, type, options \\ TypeCheck.Options.new()) do
evaluated_options = TypeCheck.Options.new(options)
check_code = TypeCheck.Protocols.ToCheck.to_check(type, Macro.var(:value, nil))
if(evaluated_options.debug) do
TypeCheck.Internals.Helper.prettyprint_spec(
"TypeCheck.dynamic_conforms(#{inspect(value)}, #{inspect(type)}, #{inspect(options)})",
check_code
)
end
case Code.eval_quoted(check_code, value: value) do
{{:ok, _, altered_value}, _} ->
{:ok, altered_value}
{{:error, problem}, _} ->
{:current_stacktrace, [_, _, caller | _]} = Process.info(self(), :current_stacktrace)
location = elem(caller, 3)
location = update_in(location[:file], &to_string/1)
exception = TypeCheck.TypeError.exception({problem, location})
{:error, exception}
end
end
@doc """
Similar to `dynamic_conforms/2`, but returns `true` if the value typechecked and `false` if it did not.
The same features and restrictions apply to this function as to `dynamic_conforms/2`.
iex> forty_two = TypeCheck.Type.build(42)
iex> TypeCheck.dynamic_conforms?(42, forty_two)
true
iex> TypeCheck.dynamic_conforms?(20, forty_two)
false
"""
@spec dynamic_conforms?(value, TypeCheck.Type.t()) :: boolean
def dynamic_conforms?(value, type, options \\ TypeCheck.Options.new()) do
case dynamic_conforms(value, type, options) do
{:ok, _value} -> true
_other -> false
end
end
@doc """
Similar to `dynamic_conforms/2`, but returns `value` if the value typechecked and raises TypeCheck.TypeError if it did not.
The same features and restrictions apply to this function as to `dynamic_conforms/2`.
iex> forty_two = TypeCheck.Type.build(42)
iex> TypeCheck.dynamic_conforms!(42, forty_two)
42
iex> TypeCheck.dynamic_conforms!(20, forty_two)
** (TypeCheck.TypeError) At lib/type_check.ex:362:
`20` is not the same value as `42`.
"""
@spec dynamic_conforms!(value, TypeCheck.Type.t()) :: value | no_return()
def dynamic_conforms!(value, type, options \\ TypeCheck.Options.new()) do
case dynamic_conforms(value, type, options) do
{:ok, value} -> value
{:error, exception} -> raise exception
end
end
end