[strictness]
[strictness]no-implicit-any = trueunused-import = "warn"exhaustive-match = "error"methods-in-class-body = "warn"nullable-use = "error"require-with = "warn"blocking-in-async = "warn"stub-check = "error"unintrospectable-dependency = "warn"suggest-gather = truesuggest-perf = truesuggest-parallel = trueauto-memoise = falseauto-gather = falseauto-parallel = falseauto-parallel-reductions = falseparallel-min-size = 64parallel-backend = "threads"pgo-memoise = falsepgo-min-calls = 100allow-secret-comptime = false| Key | Type | Default | Description |
|---|---|---|---|
no-implicit-any | bool | true | Reserved — surfaces today as the tyc::missing_annotation diagnostic on un-annotated parameters and return types. The flag is parsed for forward compatibility; toggling it does not currently relax the check. |
unused-import | "error" | "warn" | "off" | "warn" | Severity for unused imports (default "warn" since v0.8.0). The LSP offers a “Remove unused import” quick-fix. |
exhaustive-match | "error" | "warn" | "off" | "error" | Severity for non-exhaustive match over sealed unions. |
nullable-use | "error" | "warn" | "off" | "error" | Severity for the attribute-rooted form of tyc::nullable_use — dereferencing a possibly-None field (self.db.host where db: Db?). Introduced at "warn" in v1.0.0-alpha.7 because the check had never run before; an error by default since the 2026-09-30 review. Set "warn" to relax it during a migration. The bare-name form is always an error and is not governed by this knob. |
methods-in-class-body | "error" | "warn" | "off" | "warn" | Severity for tyc::method_in_class_body — Rule 4: methods live in impl Name:, not the class body. Default "warn" matches every other nudge diagnostic; promote to "error" once a codebase has finished migrating off in-body methods. "off" suppresses it entirely. |
require-with | "error" | "warn" | "off" | "warn" | Severity for tyc::resource_not_managed — a call to a known resource-returning function (open, socket.socket, sqlite3.connect, tempfile.*) bound to a variable outside a with statement. "error" promotes it once a codebase has paid down the migration; "off" drops it. |
blocking-in-async | "error" | "warn" | "off" | "warn" | Severity for tyc::blocking_in_async — a direct call to a known-blocking stdlib function (time.sleep, requests.get, subprocess.run, socket.recv, …) inside an async def. "error" promotes it for codebases that have finished moving to async-aware libraries; "off" suppresses it. |
stub-check | "error" | "warn" | "off" | "error" | Severity for tyc::stub_mismatch from tyc check --stubs — a .dty stub whose surface diverges from its sibling .ty / .py implementation. "error" (default) fails the check; "warn" surfaces drift without blocking CI; "off" drops the findings. |
unintrospectable-dependency | "warn" | "error" | "off" | "warn" | Severity when a declared dependency is imported but cannot be introspected (no reachable .venv / python3, not installed, or no introspectable signatures). "warn" surfaces it; "error" CI-gates; "off" restores the old silent skip. |
suggest-gather | bool | true | Emit the advice-level tyc::gather_opportunity hint when 2+ adjacent independent awaited calls could be wrapped in a gather: block. Never rewrites, never blocks a build. |
suggest-perf | bool | true | Emit the advice-level tyc::perf_* micro-optimisation family (six lints) plus tyc::lazy_import_opportunity. Never rewrites, never blocks a build. |
suggest-parallel | bool | true | Emit the advice-level tyc::parallel_opportunity / tyc::shared_mut_across_tasks hints. Both additionally require [python] free-threaded = true to fire. Never rewrites, never blocks a build. |
auto-memoise | bool | false | Insert a bounded, typed @functools.lru_cache on every function the analyser proves pure and cache-safe. Off by default — caches extend lifetimes invisibly. |
auto-gather | bool | false | Fold straight-line independent await runs into asyncio.TaskGroup, but only for @gatherable-decorated callees (same-module or imported from another project module). |
auto-parallel | bool | false | Rewrite pure list comprehensions over large iterables to a ThreadPoolExecutor.map (needs free-threaded). |
auto-parallel-reductions | bool | false | Parallelise int-only accumulator loops (for x in xs: total += f(x) with mut total: int and a pure f) the same way auto-parallel handles comprehensions. Requires auto-parallel = true. On its own it does nothing, so tyc check / tyc build warn when auto-parallel resolves to off (it is on with [optimise] level = 1 or tyc build -O); the config is still accepted. float accumulators are never rewritten — reordering IEEE-754 addition changes the result — and the iterable must be provably bounded and effect-free to materialise (a container literal, a container-annotated name, or a builtin range(...) call). |
parallel-min-size | int | 64 | Minimum iterable size for auto-parallel to fire. When the size can’t be statically inferred, treated as zero. |
parallel-backend | "threads" | "interpreters" | "threads" | Execution backend for auto-parallel / auto-parallel-reductions rewrites. "interpreters" tries a PEP 734 InterpreterPoolExecutor (3.14+) first, falling back transparently to the thread pool on an older runtime or an unshareable mapped callable. |
pgo-memoise | bool | false | Promote hot pure, cache-safe functions to a bounded, typed @functools.lru_cache based on typhon-profile.json from a prior tyc profile run. |
pgo-min-calls | int | 100 | Threshold for pgo-memoise. |
allow-secret-comptime | bool | false | When true, silences the tyc::contains_secret_literal warning that flags hardcoded secrets or environment variables mapped at build time. |
no-implicit-any
The flagship strictness rule. Today it is enforced indirectly: every parameter and return type must carry an annotation (Rule 1), so the most common path to an implicit Any — an un-annotated parameter — is rejected by tyc::missing_annotation:
def fetch(url): # ❌ tyc::missing_annotation ...Inside an unsafe: block, type checking is relaxed and Any flows freely. We strongly recommend leaving the rule on; toggling it false is reserved for a future relaxation pass and has no observable effect today.
unused-import
Three settings:
"warn"(default since v0.8.0) — flagged but doesn’t fail CI."error"— fails CI on unused imports."off"— no check.
The LSP includes a “Remove unused import” code action that fires on these diagnostics.
exhaustive-match
Drives the exhaustiveness check on match over sealed unions. Default "error" — keep it.
Lower to "warn" only during big refactors, and raise it back before merging.
methods-in-class-body
Severity for tyc::method_in_class_body (Rule 4: methods belong in impl Name:, not the class body). Defaults to "warn" so existing codebases can adopt Typhon’s impl-style without breaking CI overnight.
class User: id: int
def display(self) -> str: # ⚠️ tyc::method_in_class_body return f"user {self.id}"Three settings:
"warn"(default) — the diagnostic fires as a warning; the type checker still accepts the method, so the build succeeds."error"— promotes the diagnostic to a hard error. Set this once your codebase has finished migrating toimpl Name:blocks."off"— suppresses the diagnostic entirely. Useful during the transition.
The fix is the same regardless of severity: move the def into an impl ClassName: block at the same scope. Multiple impl blocks for one class are merged at desugar time, so the split is cosmetic.
unintrospectable-dependency
A declared dependency that’s imported but can’t be introspected — no reachable .venv / python3, the package isn’t installed, or it exposes no introspectable signatures — now surfaces a diagnostic instead of silently skipping that library’s third-party checks.
Three settings:
"warn"(default) — the dependency’s signature checks are skipped, but you’re told so. This catches the common “checks silently disappeared because the venv was missing” failure mode."error"— CI-gates: an unintrospectable dependency fails the build. Use this when you want a guarantee that every declared dependency was actually checked."off"— restores the old silent behaviour.
Clear the warning by making the dependency introspectable — install it (uv sync) or ship a .dty stub for it.
require-with
Severity for tyc::resource_not_managed: a bare assignment of open(...), socket.socket(...), sqlite3.connect(...), or a tempfile.* factory that is not wrapped in a with statement leaves cleanup to the garbage collector.
let f = open("data.txt") # ⚠️ tyc::resource_not_managed"warn"(default) — visible without breaking CI."error"— promote once the codebase has migrated towith(or an explicittry/finally)."off"— drop the diagnostic.
Bodies of @contextmanager / @asynccontextmanager factories are exempt — acquiring the resource is their job. So is a handle stored on an object (self.fh = open(p)), closed in a later try / finally: f.close(), handed to ExitStack.enter_context(...) / contextlib.closing(...), or returned. A handle used and dropped inline — open(p).read(), json.load(open(p)) — is flagged.
blocking-in-async
Severity for tyc::blocking_in_async: a direct call to a known-blocking stdlib function (time.sleep, requests.get / post / …, urllib.request.urlopen, subprocess.run / call / check_output, input, socket.recv) inside an async def stalls the event loop. Import aliases are followed (from time import sleep, import time as t, import subprocess as sp).
async def poll() -> None: time.sleep(1) # ⚠️ tyc::blocking_in_async — use `await asyncio.sleep(1)`"warn"(default) — surfaces the issue without breaking CI."error"— promote once the codebase has moved to async-aware libraries (httpxforrequests,asyncio.sleepfortime.sleep,asyncio.to_thread(...)around the rest)."off"— suppress it.
Calls inside an unsafe: block are not reported.
stub-check
Severity for tyc::stub_mismatch, produced by tyc check --stubs when a .dty stub’s surface (missing-in-implementation / missing-in-stub / signature mismatch) diverges from its sibling .ty or .py implementation.
"error"(default) — stub drift fails the check; right for projects that version their stubs alongside the code."warn"— drift is reported but does not break CI; useful mid-migration."off"— stubs are still parsed and type-checked, but mismatches are dropped.
suggest-gather
On by default. Controls tyc::gather_opportunity, an advice-level (Hint) lint that flags runs of two-or-more adjacent independent awaited calls inside an async def and suggests wrapping them in an explicit gather: block to run them concurrently.
It is purely advisory: it never rewrites your code and never blocks a build — unlike auto-gather, which performs the rewrite (and only for @gatherable callees). Set false to silence the hint project-wide:
[strictness]suggest-gather = falsesuggest-perf
On by default. Controls the tyc::perf_* family — six advice-level (Hint) lints that flag common hot-loop anti-patterns: a linear in scan of a loop-invariant list (perf_membership_in_loop), list.insert(0, …) / list.pop(0) in a loop (perf_list_shift_in_loop), quadratic str += accumulation (perf_str_concat_in_loop), re-sorting loop-invariant data every iteration (perf_sort_in_loop), sorted(...)[0] instead of min(...) (perf_sorted_first), and x in d.keys() instead of x in d (perf_keys_membership) — plus tyc::lazy_import_opportunity, which nudges a module-level import used only inside function bodies toward lazy import.
Every lint in the family fires only on unambiguous local evidence (annotated receiver types, statically-provable loop-invariance). Like suggest-gather, it never rewrites and never blocks a build:
[strictness]suggest-perf = falsesuggest-parallel
On by default. Controls two advice-level lints that only fire on a free-threaded target ([python] free-threaded = true — silent otherwise): tyc::parallel_opportunity (a comprehension or int accumulator loop that could be parallelised if auto-parallel / auto-parallel-reductions were on, or a float accumulator that’s ineligible only because of its type) and tyc::shared_mut_across_tasks (a go-spawned same-module function that writes a global or module-level mut binding — a data race under real concurrency).
[strictness]suggest-parallel = falseauto-memoise
Off by default. When on, every function the analyser can prove pure — with parameters that are sound cache keys (no float, callables or mutable classes) and a deeply immutable return type, and not recursive — gets @functools.lru_cache(maxsize=1024, typed=True). A plain @pure is held to the same bar. The runtime impact:
- Cached arguments stay alive forever (or until the cache is cleared).
- Cached return values stay alive forever.
- Process-local — different processes don’t share.
These tradeoffs aren’t free. Opt in deliberately, ideally after profiling.
auto-gather
Folds runs of two-or-more consecutive independent name = await callee(...) statements inside an async def into an asyncio.TaskGroup. Every callee must carry an explicit @gatherable decorator. As of v0.14.2 the rewrite folds runs whose callees are @gatherable async functions imported from another project module too, not just same-module callees as before. Undecorated async functions and third-party imported callees are still left alone, and the @gatherable decorator remains required on every callee regardless of where it is defined.
A callee name the enclosing function rebinds (a parameter, a local, a nested def, an import) is never folded — it refers to a different callable — nor is a name rebound at module level after its definition. Runs lexically inside a try or with body also stay sequential. A folded run raises the failing call’s own exception rather than the TaskGroup’s ExceptionGroup — the earliest failing task in source order, as the sequential awaits would — so an except ValueError in any caller still catches it. Only top-level functions count: a @gatherable method never makes a same-named module function eligible.
There is no diagnostic when a callee lacks @gatherable — the awaits just stay sequential. If you forget to decorate, you’ll see no parallelism and no error. Run tyc trace or read the emitted Python to confirm whether a particular run got rewritten. (For a non-rewriting nudge toward gather:, see suggest-gather.)
Default false. Explicit gather: blocks are unaffected by this setting.
auto-parallel
When true, pure list comprehensions whose element is a pure call are rewritten at build time to a ThreadPoolExecutor.map. Combine with [python] free-threaded = true to release the GIL across workers; on stock CPython the rewrite still runs but the GIL serialises.
The iterable must be bounded and effect-free to materialise — a list / tuple / set display, a builtin range(...), or a name annotated list / tuple / set / frozenset in scope — because the parallel map reads the whole iterable before mapping a single element; a generator would otherwise run ahead of the element that raises. A parameter or local that shadows a pure function’s name (def apply(double: Callable[...])) is never treated as that function.
parallel-min-size is the minimum statically-detectable iterable length. Default 64. When the iterable size cannot be inferred, the threshold is treated as zero — users opting in accept that contract.
auto-parallel-reductions
Off by default. Requires auto-parallel = true. Extends the auto-parallel rewrite to integer accumulator loops: for x in xs: total += f(x) with a plain int accumulator (mut total: int) and a pure f lowers to total += sum(map_pure(lambda x: f(x), xs)). Integer addition is exact and associative/commutative, so summing partial results in any order gives an identical answer.
The iterable must additionally be provably bounded and effect-free to materialise, because map_pure runs list(ITER) before evaluating a single element: a list/tuple/set literal, a bare name annotated list[...] / tuple[...] / set[...] / frozenset[...] in the loop’s scope, or a direct builtin range(...) call. Anything else — an unannotated name, a function/method call result, a generator — is never rewritten: an unbounded iterator would hang where the sequential loop raises on its first element, and a stateful iterator’s side effects would all run where the loop stopped early. Note that parallelising a range loop materialises the range — an inherent cost of the map-based design.
Each element must also provably be an exact int / bool, not just the accumulator: the loop target over a range(...) or an int-element container, int-annotated names, integer literals, operators other than /, comparisons, and calls to functions declared -> int. An element typed Any (a json.loads(...) result) may be a float, and 1 + 1e16 - 1e16 summed in a different order prints a different answer.
parallel-backend
Default "threads". The execution backend baked into the generated typhon_runtime/parallel.py for auto-parallel / auto-parallel-reductions rewrites:
"threads"— aconcurrent.futures.ThreadPoolExecutor(order-preserving; escapes the GIL on a free-threaded build, serialises but stays correct on stock CPython)."interpreters"— first tries a PEP 734concurrent.futures.InterpreterPoolExecutor(Python 3.14+), falling back transparently to the thread pool onImportError/AttributeError(older runtimes) or when the mapped callable can’t be pickled across the interpreter boundary — the generated helper probespickle.dumps(fn)before creating a pool, so an unshareable callable (whose pickling raisesPicklingError/AttributeError, not justTypeError) falls back cleanly while exceptions raised by the callable still propagate normally. Order is preserved on every fallback path. Note that the lambdas the auto-parallel rewrites emit never pickle, so rewritten call sites always run on the thread pool under this backend today; the interpreters pool benefits hand-writtenmap_purecalls passing top-level named functions.
Any other value is rejected at config load.
pgo-memoise
When true, tyc build reads typhon-profile.json (from a prior tyc profile run) and promotes every provably pure, cache-safe function whose observed call count meets pgo-min-calls to a bounded, typed @functools.lru_cache, even if the user did not write @memo. Complements auto-memoise (which caches every pure function regardless of profile data).
Missing profile file is not an error — PGO is best-effort: nothing is promoted and nothing is reported.
The round trip has three moving parts worth knowing. tyc profile only produces an instrumented build — you run it yourself, from the project root, because the profile is written to the process’s working directory (or TYPHON_PROFILE_OUT) while tyc build reads typhon-profile.json from the directory holding typhon.toml. Only top-level def / async def are instrumented (not methods or nested functions), and only functions that pass the purity analysis are candidates. Profile keys are matched per module (users.find_user promotes find_user in src/users.ty only); the entry module records as __main__.<fn>, which is accepted alongside main.<fn> for src/main.ty, so the default layout round-trips out of the box. See tyc profile.
allow-secret-comptime
Off by default. When true, this knob silences the tyc::contains_secret_literal diagnostic.
That diagnostic flags hardcoded credentials (like let API_KEY = "sk-live-…") and secrets mapped at build time with comptime let API_KEY: str = env("API_KEY"). While comptime substitution is useful, mapping secrets at build time embeds the literal secret directly into the compiled .py output, exposing it to anyone who can read the build artifacts.
It fires when the binding name — or, for a comptime let, the key of any env("…") it reads — names a credential, and the value is a string that could be one:
- Clear credential names (
API_KEY,DB_PASSWORD,GITHUB_TOKEN,AWS_SECRET_ACCESS_KEY,SECRET_KEY_BASE) warn on any string except a placeholder ("","xxxx","<your token>","${TOKEN}"). - Ambiguous names (
KEY,STRIPE_KEY,PWD,DATABASE_DSN,SESSION_COOKIE) warn only on a credential-shaped value: a known token prefix (ghp_,sk-,AKIA,xoxb-, …), a PEM private key, a URL with an embedded password, or a long high-entropy run of letters and digits. - Descriptive names never warn: a metadata word after the credential word (
TOKEN_LIMIT,PASSWORD_MIN_LENGTH,CREDENTIALS_PATH,AUTHORIZATION_URL,API_KEY_HEADER,AWS_ACCESS_KEY_ID), a counting word (MAX_TOKENS), or a non-secret qualifier (PRIMARY_KEY,SORT_KEY,PUBLIC_KEY). Nor does a non-string value (comptime let TOKEN_LIMIT: int = …).
So comptime let DEPLOY_CFG: str = env("AWS_SECRET_ACCESS_KEY") warns even though DEPLOY_CFG is not secret-shaped.
Setting this to true disables the warning, which is useful when the literal is intentionally public or part of a test suite, allowing you to document and silence expected findings.
Always-on advisory lints
A handful of advisory lints fire at warn level without a dedicated [strictness] knob — they ride the project’s global severity rather than being individually configurable. They’re listed here so you know they exist; the full semantics live in the diagnostics catalog.
| Lint | Default | Flags |
|---|---|---|
tyc::mutable_default_param | warn | A mutable default argument value (def f(x=[])). |
tyc::is_literal_comparison | warn | is / is not against a literal (use ==). |
tyc::incompatible_override | warn | A method override whose signature doesn’t match the base. |
tyc::loop_closure_capture | warn | A closure capturing a loop variable by reference. |
Where next
tyc check— sees[strictness]directly.@pureand@memo— the purity check used for memoisation.async,await,gather,go—@gatherableforauto-gather.- Recommended Presets — opinionated starting points.