Skip to content

[strictness]

[strictness]
no-implicit-any = true
unused-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 = true
suggest-perf = true
suggest-parallel = true
auto-memoise = false
auto-gather = false
auto-parallel = false
auto-parallel-reductions = false
parallel-min-size = 64
parallel-backend = "threads"
pgo-memoise = false
pgo-min-calls = 100
allow-secret-comptime = false
KeyTypeDefaultDescription
no-implicit-anybooltrueReserved — 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-gatherbooltrueEmit 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-perfbooltrueEmit the advice-level tyc::perf_* micro-optimisation family (six lints) plus tyc::lazy_import_opportunity. Never rewrites, never blocks a build.
suggest-parallelbooltrueEmit 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-memoiseboolfalseInsert a bounded, typed @functools.lru_cache on every function the analyser proves pure and cache-safe. Off by default — caches extend lifetimes invisibly.
auto-gatherboolfalseFold straight-line independent await runs into asyncio.TaskGroup, but only for @gatherable-decorated callees (same-module or imported from another project module).
auto-parallelboolfalseRewrite pure list comprehensions over large iterables to a ThreadPoolExecutor.map (needs free-threaded).
auto-parallel-reductionsboolfalseParallelise 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-sizeint64Minimum 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-memoiseboolfalsePromote 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-callsint100Threshold for pgo-memoise.
allow-secret-comptimeboolfalseWhen 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 to impl 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 to with (or an explicit try / 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 (httpx for requests, asyncio.sleep for time.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 = false

suggest-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 = false

suggest-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 = false

auto-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" — a concurrent.futures.ThreadPoolExecutor (order-preserving; escapes the GIL on a free-threaded build, serialises but stays correct on stock CPython).
  • "interpreters" — first tries a PEP 734 concurrent.futures.InterpreterPoolExecutor (Python 3.14+), falling back transparently to the thread pool on ImportError / AttributeError (older runtimes) or when the mapped callable can’t be pickled across the interpreter boundary — the generated helper probes pickle.dumps(fn) before creating a pool, so an unshareable callable (whose pickling raises PicklingError / AttributeError, not just TypeError) 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-written map_pure calls 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.

LintDefaultFlags
tyc::mutable_default_paramwarnA mutable default argument value (def f(x=[])).
tyc::is_literal_comparisonwarnis / is not against a literal (use ==).
tyc::incompatible_overridewarnA method override whose signature doesn’t match the base.
tyc::loop_closure_capturewarnA closure capturing a loop variable by reference.

Where next