mypy --strict, for Lua.
A static analysis tool, written in Lua, that reports where LuaLS type annotations are missing or too vague. Report-only — it never edits your files.
typer is not a single-file linter. It builds a project-wide type index, resolves
require against LUA_PATH and friends, reads third-party and standard-library
definitions, and supports ---@meta stub files for code it cannot read. A
missing definition is an error to fix, not something typer papers over.
$ typer lua/
lua/app/client.lua:12:7: error: [table-decl] table 'opts' has no ---@type or ---@class; inference cannot tell a literal shape from a class
lua/app/client.lua:18:1: error: [missing-return] function returns 2 values but declares 0 ---@return; 2 missing
lua/app/client.lua:18:22: error: [missing-param] parameter 'url' has no ---@param annotation
lua/app/client.lua:31:11: error: [vague-table] 'table' is not specific enough
lua/app/stack.lua:3:7: error: [missing-class] table 'Stack' defines ':' methods but has no ---@class annotationluarocks install typerTwo optional dependencies, both detected at runtime:
| Rock | Without it | With it |
|---|---|---|
luafilesystem |
shells out to find; the on-disk cache is disabled |
fast directory walks, cache enabled |
luasocket |
typer daemon unavailable |
daemon mode works |
typer itself runs on Lua 5.1–5.4 and LuaJIT.
Anything that gets a name must be annotated. Anything that lands in a slot with a declared type inherits it.
local f = function() end -- new name -> needs ---@param / ---@return
vim.keymap.set("n", "x", function() end) -- inline -> typed by the callee
local t = {} -- new name -> needs ---@type or ---@class
f({ a = 1 }) -- inline -> typed by the callee
vim.notify = function(msg, level) -- overwrites a slot that is already typed
original(msg, level) -- -> inherits it, nothing to add
endInheriting a type is not about where an expression sits — it is about whether
the slot it lands in already has a declared type. Declaring M.helper for
the first time needs annotations, because nothing else knows what it is.
Overwriting vim.notify, or monkeypatching an annotated module function in a
test, does not: the signature is already stated somewhere typer has read, and
typer does not verify assignments, so a second copy of it here is duplication
that cannot be checked and drift that cannot be caught.
Five ways a slot arrives already typed:
---@type fun(name: string): nil
local callback
callback = function(name) end -- the local's ---@type
---@param seam? fun(n: string): nil
function M.setup(seam)
seam = seam or function(n) end -- the parameter's ---@param
end
---@param x string
---@return string
function M.echo(x) return x end
M.echo = function(x) return x .. "!" end -- an earlier definition
helpers.get_root = function(buffer) end -- a required module's export
vim.fn.jobstart = function(cmd, opts) end -- a global-rooted nameWhat does not stand down is the return arity — the declaration says how many values come back, and a body returning more contradicts it:
---@return fun(): [integer, string]? -- one value: a tuple
function M.iterate(values)
return function()
return index, values[index] -- two. return-arity-mismatch, correctly
end
endNor does a half-written doc block: an inherited signature stands in for
annotations that are absent, so the moment one ---@param appears the ordinary
rules take over and the block has to be complete.
LuaLS infers local t = { name = "x" } as { name: string }. typer still wants
an annotation, because inference cannot answer the question that matters: is
this an ad-hoc literal or a class? A table constructor looks identical either
way. Only you know, and the annotation is how you say it:
---@type table<string, integer> -- a literal shape
---@class Foo -- a classScalars are exempt — local x = 5 has no such ambiguity.
| Code | Fires when |
|---|---|
bare-decl / nil-decl |
local foo / local foo = nil with no ---@type |
table-decl |
a table constructor bound to a name, unannotated |
namespace-decl |
a table whose every member is a function — the local M = {} module preamble (off by default) |
global-decl |
assignment to an undeclared global, whatever its value |
undefined-global |
reading a global that resolves to nothing |
missing-param / missing-vararg |
a parameter, or ..., with no ---@param |
missing-return |
fewer ---@return than the function's widest return arity |
param-name-mismatch / param-arity-mismatch / return-arity-mismatch |
annotations disagree with the signature |
duplicate-param |
two ---@param lines name the same parameter |
self-param |
---@param self on a : method |
missing-class |
a class-shaped table with no ---@class |
missing-field |
a data field assigned on a class with no ---@field |
missing-inherit |
runtime inheritance not declared as ---@class T : Base |
vague-table / vague-function |
bare table / function |
disallowed-any / disallowed-unknown |
any / unknown anywhere |
unresolved-type |
a type name declared nowhere in the index |
unresolved-module / untyped-module |
require that resolves to nothing, or to a C module |
duplicate-class |
the same class name declared in two files |
unused-ignore |
a -- typer: ignore comment that silences nothing |
A class-shaped table is one that defines : methods, assigns a table to
__index, is used as a metatable, or has fields assigned on self.
__index counts only when a table is on the right. A table there is
inheritance — instances fall back to a base — but a function is a
computed-lookup metamethod that makes the table a proxy over values generated on
demand. A proxy has no fields to declare and is not a class:
---@type string[]
local generated = {}
setmetatable(generated, {
__index = function(_, key) return tostring(key) end,
})An existing ---@type also satisfies missing-class outright: the rule exists
to force the literal-or-class question, and you have answered it. What you wrote
is still checked for vagueness, so ---@type table does not get through.
Methods are exempt from ---@field: a function T:foo() with its own
---@param/---@return already describes itself, and a duplicate ---@field
would only drift.
Parameters named self, _, or _a / _b / … are exempt from
missing-param, matching lua-language-server: both of the diagnostics that
would otherwise demand an annotation there — incomplete-signature-doc and
missing-doc-param — hard-skip exactly those names.
---@param callback fun(count: integer): nil
Editor.count_async = function(_, callback) -- `_` is a discarded `self`
callback(20)
endAnnotating one is still supported and still checked: ---@param _ string types
the call site like any other parameter. ---@param _ any is accepted too — on a
value the body throws away there is no real type to state, and no pass-through
generic to reach for. Only the bare form is excused; ---@param _ table<string, any> still has to say what it holds.
---@param binds by name, not by position, so two ---@param _ lines both
land on the first _ and the second parameter silently stays undocumented.
typer reports that as duplicate-param (LuaLS: duplicate-doc-param); the fix
is distinct names, which is what _a / _b are for.
For the stricter reading — every parameter annotated, placeholders included — turn on the opt-in code:
severity = { ["missing-param-placeholder"] = "error" }local M = {} with nothing but function M.f() end below it is a namespace,
and the literal-or-class question has no consumer for it: there is no data whose
shape a reader needs, and every member is a function that annotates itself. What
an annotation adds is a nameable type, which is worth something only if someone
writes the name — and mostly nobody does. In typer's own source, 19 of 26 module
class names appear in no type position anywhere.
So it reports as namespace-decl, separately from table-decl, and ships
off. Tables that really are opaque still report:
local M = {} -- namespace-decl (off): every member is a function
function M.run() end
local state = {} -- table-decl (error): holds data of unknown shape
state.count = 0Want your modules named anyway? One line — typer's own .typer.lua has it:
severity = { ["namespace-decl"] = "error" }The type namespace is flat and ambient, exactly like LuaLS: a ---@class
declared anywhere in the index is usable everywhere, with no require needed.
Declaring a class in one file and using it in another is fully supported, and a
file may consist of nothing but annotations:
-- types/http.lua
---@meta
---@class http.Response
---@field status integer
---@field headers table<string, string>
---@field body string---@meta marks a file as definitions-only: indexed, never reported on.
---@field children Node[] is complete the moment Node resolves to a
---@class. typer does not walk into Node's fields from there — an incomplete
class is reported once, at its own definition. That is what makes recursive
types (Node.children: Node[]) work.
Only files you pass on the command line are reported on. Everything else is read
for its declarations and stays silent — mypy's follow_imports = silent.
| Role | From | Indexed | Reported |
|---|---|---|---|
| checked | the command line | yes | yes |
| workspace | source_roots |
yes | no |
| library | reached via require |
yes | no |
| stub | stub_paths, bundled stdlib, any ---@meta |
yes | no |
First hit wins:
--stub-path/stub_paths- bundled stdlib stubs for the target version
source_rootslua_path$LUA_PATH,$LUA_PATH_5_4… (;;expands to the interpreter default)package.pathof the running interpreter, unless--no-inherit-pathworkspace.libraryfrom.luarc.json— how Neovim projects getvim.*
Stubs deliberately outrank real source: a hand-written ---@meta file is the
escape hatch for a dependency you cannot annotate. LUA_CPATH is searched only
to detect C modules — typer cannot read annotations out of a .so.
.luarc.json entries have $VAR, ${VAR} and ~ expanded, resolve relative
to the .luarc.json itself, and are scanned eagerly — they are the user
declaring "these are my type sources", and nothing in a project necessarily
requires them.
Some globals are created by the embedding program and assigned by no Lua file at
all — Neovim's vim is the canonical case. In a ---@meta file, a field
assignment declares the global:
---@meta
vim.NIL = ... -- declares `vim`Meta files only: in ordinary code the same shape would let a typo declare itself.
Deliberately rejected, not overlooked. A baseline file lets a project declare its existing defects acceptable and stop looking at them, which defeats the point. The only suppression is per-site and visible in the source. Three forms, by what they cover:
-- typer: ignore -- the next statement, whole
-- typer: ignore-next-line -- the next line of code only
-- typer: ignore-file -- every line in the fileEach narrows to a code list when given one — bracketed or bare, comma- or space-separated:
-- typer: ignore[disallowed-any]
-- typer: ignore[missing-param, vague-table]
-- typer: ignore-next-line[table-decl]
-- typer: ignore-file[unresolved-module]
-- typer: ignore missing-param vague-tableWith no list, the comment silences every code. ignore also works trailing,
where it covers the statement it shares a line with:
local opts = {} -- typer: ignore[table-decl]Most of what typer reports is anchored on a tag, not on the statement —
---@return any is a disallowed-any at the tag's own line. So directives work
next to tags, in either of two spellings:
---@param name string
-- typer: ignore-next-line[disallowed-any]
---@return any # The previous value.
local function save_global(name) end
---@class Deserialized
---@field display string
---@field value any The original data. -- typer: ignore[disallowed-any]Inside a doc block, ignore-next-line covers exactly the next annotation, and a
directive trailing a tag covers exactly that tag's line. Neither severs the
block: the surrounding --- run stays intact and the statement keeps every
annotation. (A plain -- line that is not a directive does sever it, which is
why an ordinary comment cannot be used this way.)
Only a real annotation line is scanned for a trailing directive, so a ---
description that merely mentions -- typer: ignore stays documentation.
A directive above the whole block works too, and reaches down over the block it skipped:
-- typer: ignore[disallowed-any]
---@param opts table<string, integer>
---@return any
local function loose(opts) endPlain comments, not doc comments — a ---@typer-ignore tag would show up as an
unknown tag to lua-language-server itself.
An ignore that silences nothing is reported as unused-ignore, at the comment,
with the advice to delete it:
lua/app/client.lua:12:1: error: [unused-ignore] `-- typer: ignore` suppresses nothing: no 'vague-table' diagnostic here
A misspelling is caught the same way, since a code typer does not know can never match anything:
lua/app/client.lua:31:1: error: [unused-ignore] `-- typer: ignore` suppresses nothing: 'table-declaration' is not a typer diagnostic code
This is what keeps the escape hatch from becoming the baseline file above.
Nothing else prunes it: a suppression written for a defect that has since been
fixed is invisible until something goes looking, and then it is indistinguishable
from one that is still load-bearing. unused-ignore is on by default, and the
answer to one is to remove the comment.
overrides and fail_on below are not baselines. They name paths and
severities, never individual defects, so nothing goes stale and nothing is
hidden: every diagnostic is still printed, every run, in full. They separate
what is reported from what fails the build — a baseline stops reporting.
.typer.lua (or .typer.json), searched upward from the working directory:
return {
source_roots = { "lua", "plugin" },
stub_paths = { "types" },
lua_path = { "deps/?.lua", "deps/?/init.lua" },
lua_version = "jit",
follow_requires = "index", -- index (default) | check | skip
ignore_missing = { "ffi", "socket.*" },
exclude = { "**/spec/**", "**/*_spec.lua" },
severity = { ["optional-param"] = "hint" },
-- Hold library code to a higher standard than tests. Later blocks win, so a
-- narrower one carves an exception out of a broader one above it.
overrides = {
{ paths = { "spec/**" }, severity = { ["*"] = "hint" } },
{ paths = { "spec/critical_spec.lua" }, severity = { ["missing-param"] = "error" } },
},
fail_on = "error", -- report everything; fail CI only on errors
require_scalar_types = false,
strict_globals = true,
require_method_fields = false,
undefined_globals = true,
}typer [options] <path>...
--json JSON output instead of vimgrep
--config <file> default: .typer.lua / .typer.json, searched upward
--severity <code>=<level> error | warning | hint | off
--fail-on <level> exit 1 only at or above this severity
(error | warning | hint; default hint, i.e. anything)
--no-suppress ignore `-- typer: ignore` comments
--stdin-filename <path> read source from stdin, report as <path>
--lua-version <5.1|jit|5.2|5.3|5.4>
--lua-path <path> prepend to the search path (repeatable)
--stub-path <path> prepend to the stub path (repeatable)
--no-inherit-path don't inherit the interpreter's package.path
--follow-requires <mode> index | check | skip
--no-cache
typer daemon start|stop|status|check
Exit codes: 0 clean, 1 diagnostics at or above --fail-on, 2 tool error.
--fail-on never suppresses output — everything found is always printed.
Output matches vim's default errorformat (%f:%l:%c:%m), so :cexpr,
nvim-lint and null-ls consume it with no custom parser.
Measured on Neovim's runtime/lua — 201 files, 5.1 MB:
| Path | When | Measured |
|---|---|---|
| cold, no cache | CI on a fresh clone | 2.9 s (LuaJIT), 3.7 s (PUC 5.1) |
| daemon, warm | editor, on save | 84–155 ms |
Parser throughput is ~15 MB/s lex and ~4 MB/s lex+parse on LuaJIT, validated against a 44 MB / 5,574-file corpus with zero failures.
The cold path is CI's, and CI does not care. The editor path is the daemon's.
Installing luafilesystem improves the warm path further by replacing a
whole-tree re-read with one stat per file.
typer daemon start &
typer daemon check lua/app/client.lualua tests/run.lua # fixture suite
lua tests/run.lua classes # filter by name
lua tests/corpus.lua <dir>... # parser validation + throughput
typer lua/ # typer checks itself -- must exit 0typer passes its own strictest check with zero diagnostics, and CI enforces it on every supported interpreter. That is the point of having no baseline mode: the only way to a clean run is to write the annotations.
Fixtures live in tests/fixtures/<phase>/<name>/ as input.lua +
expected.txt, or a directory with .typer.lua + TARGETS for multi-file
resolution cases.
MIT.