{"slug": "type-inference-and-narrowing-with-reverse-arrows", "title": "Type inference and narrowing with reverse arrows", "summary": "Elixir v1.21, the upcoming release described by creators José Valim and Guillaume Duboc, passes all tests in the If-T: Type Narrowing Benchmark by Guo et al., using a type system that refines the dynamic type by intersecting it with function argument types rather than treating it as an unconstrained black box. The work, supported by a CNRS and Remote partnership and sponsored by Supermethod and Dashbit, lets the checker flag violations such as passing a value inferred as dynamic(float() or integer()) to byte_size/1, which expects a bitstring. Valim and Duboc say the approach will yield more precise types and fewer false positives once type signatures are introduced.", "body_md": "# Type inference and narrowing with reverse arrows\n\n November 10, 2026  by José Valim, Guillaume Duboc  \nin   [Internals](https://elixir-lang.org/blog/?category=Internals)   \n\nThis article explains how Elixir implements type inference and type narrowing using reverse arrows: a small set of composable ideas, rather than special cases hardcoded into the type checker.\n\nThanks to this work, the upcoming Elixir v1.21 passes all tests in the\n[If-T: Type Narrowing Benchmark by Guo et al.](https://github.com/utahplt/ifT-benchmark).\nThis means v1.21 can find even more sure bugs in existing Elixir programs and, once\ntype signatures are introduced, narrowing will give us more precise types and fewer\nfalse positives.\n\nThe type system was made possible thanks to a partnership between [CNRS](https://www.cnrs.fr/)\nand [Remote](https://remote.com/). The development work is currently sponsored by\n[Supermethod](https://www.supermethod.com/) and [Dashbit](https://dashbit.co/).\n\n## Type inference\n\nElixir performs type inference by refining the dynamic type. This is quite different from how inference and gradual typing are implemented in most type systems, so let’s break it down.\n\nIn many gradual languages, the dynamic type, often known as `any`, is a “black box” and\ncan be used in any context. For example, imagine this Elixir function, where `x` starts\nwith type `dynamic()`, as there is no type signature:\n\n``` python\ndef using_x_as_number_and_binary(x) do\n  IO.puts x * 2\n  IO.puts byte_size(x)\n  :ok\nend\n```\n\nThe program above uses `x` both as a number (more precisely, as `integer() or float()`\nin `x * 2`) and as a bitstring in `byte_size(x)`. In some gradual type systems, the type\nchecker will accept the program above, even though it will crash at runtime, because\nthe dynamic type can be used in any context.\n\nHowever, in Elixir, the `dynamic()` type can be refined, and the above will report the\nfollowing typing violation:\n\n```\nwarning: incompatible types given to Kernel.byte_size/1:\n\n    byte_size(x)\n\ngiven types:\n\n    dynamic(float() or integer())\n\nbut expected one of:\n\n    bitstring()\n\nwhere \"x\" was given the type:\n\n    # type: dynamic(float() or integer())\n    # from: iex:3\n    x * 2\n\n└─ iex:4: Foo.using_x_as_number_and_binary/1\n```\n\nAs you can see in the error message, Elixir infers that the type of `x` after\n`x * 2` is a `dynamic(float() or integer())`. This means that `x` can be given\nto any function that accepts at least one of `float()` or `integer()`. However,\nbecause `byte_size(x)` expects a bitstring, we have a typing violation.\n\nThis is possible because `dynamic()` in Elixir actually means `dynamic(term())`,\nwhere `term()` is the top type. Then, as a variable is used throughout the code,\nits dynamic type is refined by intersecting its type with the arguments of the\nfunctions the code invokes. In other words, we go through the following steps:\n\n1. \n`x` starts as`dynamic(term())`\n2. \n`x` is given to`x * 2` . The left operand of`*` expects`integer() or float()` ,\nwhich we then intersect with`term()` , and therefore`x` gains the type`dynamic(integer() or float())`\n3. \n`x` is given to`byte_size(x)` , which expects a bitstring. The intersection\nof`integer() or float()` with`bitstring()` is empty, which will lead to a\ntyping violation\n\nNow, let’s take a look at this program:\n\n``` python\ndef using_x_as_integer(x) do\n  IO.puts x * 2\n  IO.puts rem(x, 2)\n  :ok\nend\n```\n\nThis time, we pass `x` to the remainder function, `rem`, which expects all of its\ninputs to be integers. This means that, at the end of this function, Elixir will\ncorrectly infer that `x` can only be an integer, because `x` was refined from\n`dynamic(term())` to `dynamic(integer() or float())` and then only to `dynamic(integer())`.\nThe inferred type for `using_x_as_integer` is a function, commonly written using\nthe arrow notation: `dynamic(integer()) -> :ok`.\n\nThe best part is that all dynamic types in Elixir are refined and therefore the type system is always doing its best to find bugs, even in the presence of dynamism. And the refinement itself is often quite precise, thanks to reverse arrows, which we will explore next.\n\n## Reverse arrows\n\nAn arrow is the type of a function: `integer() -> boolean()` is an arrow that\nsays “given an integer, this function returns a boolean”. The type on the left\nof the arrow is its domain (what it accepts) and the type on the right is its\ncodomain (what it returns). Every function has arrows, whether they come from\na type signature you wrote or are inferred by Elixir from the function’s clauses.\n\nFunctions in Elixir can be typed with multiple arrows. As an exercise, let’s take\na function that negates numbers, such as `negate(1)` returning `-1`:\n\n``` python\ndef negate(number) when is_integer(number), do: 0 - number\ndef negate(number) when is_float(number), do: 0.0 - number\n```\n\nYou could give it this type signature with a single arrow:\n\n``` php\n$ integer() or float() -> integer() or float()\ndef negate(number)\n```\n\nHowever, this type says that, if you give it an integer, it may return a float, which is never the case! This signature is too broad. Instead, we could give it this signature with two arrows:\n\n``` php\n$ integer() -> integer()\n$ float() -> float()\ndef negate(number)\n```\n\nThis precisely captures that, if you give it an integer, it returns an integer. If you give it a float, it returns a float. Multiple arrows are an excellent way to type functions with multiple clauses, which are common in both Erlang and Elixir.\n\nWith this in mind, and remembering that `rem` only works on integers (its type is\n`integer(), integer() -> integer()`), what do you believe is the inferred type of\n`x` in the function below?\n\n``` python\ndef negated_remainder(x), do: rem(negate(x), 2)\n```\n\nYou may think `x` is `dynamic(integer() or float())`, because that’s the domain of\n`negate`. However, `x` will once again be inferred to be `dynamic(integer())`,\nbecause Elixir propagates types through the arrows in reverse: it starts from\nwhat the outermost call expects and walks inwards, from results to arguments,\nuntil it reaches the variables. Let’s break it down.\n\n1. \nWe start type checking the function and we find `rem(negate(x), 2)` .`rem` has\na single arrow,`integer(), integer() -> integer()` , so we know that`negate(x)` must return an`integer()` for this to type check\n2. \nNow we type check `negate(x)` .`negate` has two arrows:`integer() -> integer()` and`float() -> float()` . Since its result must be an integer, we can discard\nthe`float() -> float()` arrow, as it never returns an integer. The only remaining\narrow is`integer() -> integer()` , so the argument of`negate` must be`integer()`\n3. \nNow we type check `x` itself.`x` starts with the type`dynamic(term())` . We then\nintersect it with`integer()` , resulting in`dynamic(integer())`\n\nIn other words, inference goes in two directions. Going forward, from arguments to results, arrows tell us what each call returns. Going in reverse, from results to arguments, arrows tell us what each argument must have been for the call to produce the expected result. As we give precise type signatures to our programs using multiple arrows, the type system will use these same arrows to infer precise types. As we will learn next, Elixir uses this technique to infer types from guards and to perform type narrowing (also known as occurrence typing).\n\n## Guards\n\nNow we have the foundation to understand how Elixir performs type inference of guards. Take this function clause:\n\n``` python\ndef something(x, y) when is_integer(x) and is_bitstring(y) do\n  ...\n```\n\nBoth `x` and `y` start with type `dynamic(term())`. We also know that a guard only\nsucceeds if it returns `true` and we have the following type signatures (roughly):\n\n``` php\n$ true, true -> true\n$ true, false -> false\n$ false, true -> false\n$ false, false -> false\ndef and(left, right)\n\n$ integer() -> true\n$ not integer() -> false\ndef is_integer(value)\n\n$ bitstring() -> true\n$ not bitstring() -> false\ndef is_bitstring(value)\n```\n\nTherefore, to infer the types of `when is_integer(x) and is_bitstring(y)`,\nwe start with `true`, which is the only result for which a guard succeeds,\nand propagate it through the arrows in reverse:\n\n1. \nThe only arrow of `and` that can return`true` is`true, true -> true` ,\ntherefore we know that both operands must be`true`\n2. \nThe only arrow of `is_integer` that can return`true` is`integer() -> true` ,\ntherefore we know`x` must be an integer\n3. \nThe only arrow of `is_bitstring` that can return`true` is`bitstring() -> true` ,\ntherefore we know`y` must be a bitstring\n\nIf multiple arrows could return `true`, we would then simply union their domains.\n\nIt is worth emphasizing that this behaviour is not hardcoded to a handful of functions the type checker knows about. It works with any function, in any context. Let’s see more examples.\n\n## Type narrowing (also known as occurrence typing)\n\nOccurrence typing refines the type of variables and expressions based on the branch taken in a conditional or pattern match, so the same variable may have different types in different branches. For example:\n\n``` js\nif is_bitstring(x) do\n  x #=> x has type: bitstring()\nelse\n  x #=> x has type: not bitstring()\nend\n```\n\nThe mechanism we use to perform occurrence typing is quite similar to the one\nfrom the previous sections. Within the `do`-branch, we consider the scenarios\nwhere `is_bitstring(x)` will return `true`. Within the `else`-branch, we consider\nthe ones where it returns `false`.\n\nAnd since our foundation is built on reverse arrows, the narrowing also applies to\npatterns and `case`-expressions! For example:\n\n``` php\ndef f(x) do\n  case parse(x) do\n    {:ok, _} -> ... # x is integer()\n    :error -> ...   # x is bitstring()\n  end\nend\n\ndefp parse(x) when is_integer(x), do: {:ok, x}\ndefp parse(x) when is_bitstring(x), do: :error\n```\n\nHere, `parse` has no type signature, but Elixir infers its arrows from its clauses:\n`integer() -> {:ok, integer()}` and `bitstring() -> :error`. Within the first branch,\nwe know `parse(x)` returned a tuple, and the only arrow that returns a tuple has\n`integer()` as its domain, so `x` must be an integer.\n\nThe [If-T benchmark](https://github.com/utahplt/ifT-benchmark) is a collection of\ntest scenarios designed to evaluate both the precision and soundness of type narrowing.\nElixir v1.20 already handled 12 out of the 13 tests, but it could not infer the\nproper types for this example:\n\n```\ny = is_integer(x)\n\nif y do\n  # y is true, x remains dynamic\nelse\n  # y is false, x remains dynamic\nend\n```\n\nTo narrow `x`, we need to reverse the arrows of `is_integer(x)`. In Elixir v1.20, we\nonly did so for the expression given directly to `if` or `case`. Here, the expression\ngiven to `if` is the variable `y`. All v1.20 knew about `y` is that it was a `boolean()`.\nInside the `do`-branch, `y` is `true`, but by then the connection between `y` and\n`is_integer(x)` had been lost at the assignment, so there were no arrows to reverse.\n\nThis would be quite harmful once we add type signatures, because it means trivial refactoring techniques, such as extracting an expression into a variable, could lead to false positives.\n\nElixir v1.21 solves this by keeping track of the expression each variable was assigned\nto. Now, when `y` is narrowed to `true`, we know `y` came from `is_integer(x)`, so we\ncontinue propagating `true` in reverse through the arrows of `is_integer`, narrowing `x`\nto `integer()`. This works across any number of assignments:\n\n```\ny = is_integer(x)\nz = not y\n\nif z do\n  # z is true, y is false, x is not integer()\nelse\n  # z is false, y is true, x is integer()\nend\n```\n\nOur implementation also performs the narrowing lazily, by cross-referencing the variables\nin the `if` and `case` expressions with the variables used in each clause, trying to find\nany assignment they share with each other. If the clauses above do not access `z`, `y`, or\n`x`, then no narrowing is performed.\n\nFinally, because Elixir defines reverse arrows for almost all operations, it can refine\nexpressions [which are not currently refined in TypeScript](https://www.typescriptlang.org/play/?#code/PTAEHUFMBsGMHsC2lQBd5oBYoCoE8AHSAZVgCcBLA1UABWgEM8BzM+AVwDsATAGiwoBnUENANQAd0gAjQRVSQAUCEmYKsTKGYUAbpGF4OY0BoadYKdJMoL+gzAzIoz3UNEiPOofEVKVqAHSKigBmXLCoFPBeBGxEZKh4APoM0BQMgkmC7LAWgoIAFHgAXKCCqJSczKAAPqCc7IjSkGQAlKUNTS2gAN6KoCbR5aAAHqAAvL2gIfDwpXigAL4A3P0iIaAFiUTwGyMBMxjjx6AAROWVzKetvWsDTqjsZF54Ae5VqJirA4ugMIIoPoDe6QR7PUALADUoAAjN8lopFkA),\nsuch as data constructors:\n\n``` python\ndef f(foo) when is_bitstring(foo) or is_integer(foo) do\n  map = %{foo: foo}\n\n  if is_bitstring(map.foo) do\n    byte_size(foo)\n  else\n    foo + 1\n  end\nend\n```\n\nIn the program above, even though `foo` is set as the value of a map,\nrefining the map still refines the original variable.\n\n## Summary\n\nElixir’s approach to type inference and narrowing is built on a few ideas that compose well. Dynamic types are refined as variables are used, multiple arrows give precise types to functions, and propagating types through those arrows in reverse lets us infer and narrow types across conditionals, patterns, and assignments, all without hardcoding knowledge about specific functions.\n\nIn Elixir v1.21, we use these techniques to find sure bugs in existing Elixir programs, without requiring any changes to your code. The same mechanisms will apply once type signatures are introduced: the more precise the signatures, the more precise the narrowing and the fewer false positives.", "url": "https://wpnews.pro/news/type-inference-and-narrowing-with-reverse-arrows", "canonical_source": "https://elixir-lang.org/blog/2026/11/10/inference-narrowing-reverse-arrows/", "published_at": "2026-11-10 00:00:00+00:00", "updated_at": "2026-11-10 16:24:54.525800+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["Elixir", "José Valim", "Guillaume Duboc", "CNRS", "Remote", "Supermethod", "Dashbit", "If-T: Type Narrowing Benchmark"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/type-inference-and-narrowing-with-reverse-arrows", "markdown": "https://wpnews.pro/news/type-inference-and-narrowing-with-reverse-arrows.md", "text": "https://wpnews.pro/news/type-inference-and-narrowing-with-reverse-arrows.txt", "jsonld": "https://wpnews.pro/news/type-inference-and-narrowing-with-reverse-arrows.jsonld"}}