Typle
Expression-level type query library for Elixir 1.20+.
Typle reads inferred type signatures from compiled .beam files and performs
best-effort type inference to answer the question the Elixir compiler can answer
internally but does not expose:
“What type did the compiler infer for this expression at line N, column C?”
The Problem
Elixir 1.20 introduced a powerful set-theoretic type system that infers types across function definitions, guards, patterns, and clauses. The compiler uses these types to detect bugs at compile time—but the type information lives exclusively inside the compiler. There is no public API to query it.
- The ExCk chunk in
.beamfiles stores per-function signatures, but not per-expression types. - Compilation tracers fire events for imports, aliases, and module definitions—but carry no type data.
Module.Typesand its submodules are private (@moduledoc false) and subject to change without notice.
Tools like Credo, LSPs, and custom Mix tasks that need type information are left in the dark.
How Typle Works
Typle operates in layers, from most stable to most experimental:
Layer 1: Beam Signature Reader (Typle.Beam)
Reads the :elixir_checker_v7 data from the ‘ExCk’ chunk in compiled .beam
files. Decodes the internal bitmap/map type representation into human-friendly
Typle.Type structs. This gives you per-function signatures for any compiled
module.
Layer 2: Signature Store (Typle.SignatureStore)
An ETS-backed cache of decoded function signatures. Lazily loads modules on first lookup. Used by the inference engine to resolve return types for remote function calls.
Layer 3: AST Inference Engine (Typle.Inference)
Parses Elixir source files and walks the AST, simulating the compiler's type
inference. Tracks variable types through assignments, pattern matches, guard
checks, and function calls. Produces a map of {line, col} => Typle.Type.t()
for every expression with position metadata.
The engine handles: literals, variables, match operators, pipes, remote/local
calls, case/cond/if/with/try/fn expressions, tuples, lists, maps,
structs, and binary constructions.
Layer 4: Unstable Compiler Replay (Typle.Unstable)
An opt-in layer that hooks into the compiler via compilation tracers for
deeper inference. Lives under the Typle.Unstable namespace to signal that
it depends on private APIs and may break across Elixir versions.
Installation
Add typle to your list of dependencies in mix.exs:
def deps do
[
{:typle, "~> 0.1.0"}
]
endTyple requires Elixir 1.20 or later.
Usage
Programmatic API
# Read function signatures from a compiled module
{:ok, sigs} = Typle.signatures(Integer)
# => [%{fun: :to_string, arity: 1, clauses: [{[integer()], dynamic(binary())}]}, ...]
# Look up the return type of a function
Typle.return_type(Integer, :to_string, 1)
# => #Typle.Type<dynamic(binary())>
# Infer types for all expressions in a source file
{:ok, type_map} = Typle.types_for_file("lib/my_app/user.ex")
# => %{{15, 5} => #Typle.Type<binary()>, {16, 3} => #Typle.Type<integer()>, ...}
# Query the type at a specific position
{:ok, type} = Typle.type_at("lib/my_app/user.ex", 15, 5)
# => #Typle.Type<binary()>Mix Tasks
# Query type at a specific position
mix typle lib/my_app/user.ex:15:5
# => lib/my_app/user.ex:15:5 :: binary()
# Query all types on a line
mix typle lib/my_app/user.ex:15
# => col 3: user :: dynamic()
# => col 8: user.name :: dynamic()
# Dump all types for a module
mix typle.dump MyApp.User
# Output as JSON (for LSP/tooling consumption)
mix typle lib/my_app/user.ex:15:5 --format json
# Use the unstable compiler replay for deeper inference
mix typle lib/my_app/user.ex:15:5 --unstableIntegration with Credo
A Credo check can call Typle.type_at/3 to verify the inferred type of a
suspicious expression before raising an issue:
defmodule MyApp.Credo.Check.TypeAware do
use Credo.Check
def run(%SourceFile{filename: file} = source_file, params) do
# Use Typle to get type information
case Typle.type_at(file, line, col) do
{:ok, %Typle.Type{kind: :binary}} -> :ok
{:ok, other_type} -> issue_for(source_file, line, col, other_type)
_ -> :ok
end
end
endType Notation
Typle formats types using the same notation as the Elixir compiler:
| Type | Notation |
|---|---|
| Integer | integer() |
| Float | float() |
| Binary/String | binary() |
| Atom | atom(), :ok, true, false |
| Tuple | {:ok, integer()} |
| List | list(integer()) |
| Map | map(), %{..., name: binary()} |
| Function | (integer() -> binary()) |
| Union | integer() or binary() |
| Dynamic | dynamic(), dynamic(binary()) |
| Top | term() |
| Bottom | none() |
Limitations
Typle's inference engine is a best-effort approximation of what the Elixir compiler computes. It will never achieve perfect parity because:
- The compiler's type checker is tightly integrated with macro expansion, module compilation order, and cross-module dependency resolution.
- Per-expression type environments are ephemeral—they exist only during the compiler's type checking pass and are discarded afterward.
-
The ‘ExCk’ chunk format (
:elixir_checker_v7) is internal and undocumented; it may change in future Elixir releases.
When Typle cannot determine a type, it honestly returns dynamic() rather
than guessing wrong.
Not yet implemented
- Type annotations / signatures (Elixir does not yet have user-facing type syntax)
- Protocol dispatch type tracking
- Macro expansion type tracking
- Cross-module dataflow analysis beyond function signatures
License
MIT License. See LICENSE for details.