Taskless, ty, and the Type-Shaped Gap

On September 24, Charlie Marsh posted a thread about redundant-condition, a new check in ty that flags a condition that is always true or always false. Charlie called it "exceptional work from Alex Waygood", and it's a great piece of static analysis.
ty can now detect conditions that are always-true or always-false. The classic example: forgetting to call a function in an if-statement. These aren't type errors, but they're (very) often mistakes. We found and fixed multiple bugs like this internally!
The first example is a bug most Python developers have written at least once:
def condition(): ...
if condition: passelse: print("Unreachable!")condition is a function nobody called. A function object is always truthy, so the else branch never runs.
The rest of the thread goes much further. ty flags a generator expression used as a condition. It flags if self.x: when self.x is annotated tuple[int] and asks whether you meant tuple[int, ...]. It knows an Enum with no __bool__ is always truthy, and that if not x: can never pass when x is a TypedDict with a required key, even when that key comes from a parent class. With the strict variant on, which is opt-in because it fires more often, it tells you an elif isinstance(x, str) is the only thing x: int | str can still be.
Every one of those depends on knowing what a name refers to. Is this a function? Does this class define __bool__? Where is this TypedDict's parent, and which of its keys are required? Answering those on real code without burying people in false positives is hard. ty's default check skips if True: and while 1:, because those are almost always on purpose.
Reading the thread, we wanted to know: how much of ty can a generic tool like Taskless cover?
Taskless runs ast-grep and Vale rules, plus runtime rules written in TypeScript, so a team can write down the rules its codebase and its agents keep breaking, in any language. Taskless knows nothing about Python's type system. So we rebuilt each check from the thread as a Taskless rule and ran it next to ty, pylint and ruff, on the thread's examples and on six real codebases, to find out how far it gets and where it should hand off. Everything is public in taskless/blog-taskless-ty, including a notes/ directory with what we tried and what broke.
Syntax trees cover 13 of 15
scripts/compare.sh runs the tools over the thread's examples, plus negative cases, and lines up what each one flags. ty is the reference.
Tool | Of ty's 15 |
|---|---|
ty | 15 |
Taskless | 13 |
pylint | 5 |
Neither Taskless nor pylint flags anything on these examples that ty doesn't. ruff doesn't have a check for this, so it isn't in the table.
If you're writing Python, you should probably be running ty. You can still cover a lot of ground with Taskless. The two lines it misses are the two that need real type inference. The other 13 can work with any static analysis tool.
How far a syntax rule reaches
Each check from the thread became one of six sg rules, which is ast-grep YAML with its own test file. Here's the core of the headline one:
id: uncalled-function-in-conditionlanguage: Pythonmessage: "`$F` is a function, so this condition is always true. Did you mean `$F()`?"rule: all: - kind: identifier pattern: $F - matches: in-condition - inside: kind: module stopBy: end has: any: - kind: function_definition has: { field: name, pattern: $F } - kind: decorated_definition has: { kind: function_definition, has: { field: name, pattern: $F } } # A parameter or local assignment with the same name shadows the module function. - not: inside: kind: function_definition stopBy: end any: - has: { field: parameters, has: { pattern: $F, stopBy: end } } - has: { field: body, has: { kind: assignment, has: { field: left, pattern: $F }, stopBy: end } }$F does most of the work. An ast-grep metavariable has to match the same text everywhere it appears, so this says "an identifier in a condition, and a def with that same name somewhere in this module." in-condition is a helper, copied into each rule, that covers if, elif, while, assert, the middle of a ternary, and anything reached through and, or, not or parentheses on the way there. The Enum, tuple and TypedDict rules use the same move to tie a parameter's annotation to a class defined in the file.
All six rules passed taskless verify and taskless test on the first draft. Then we ran them over django, flask, httpx, rich, black and pydantic, 4,111 Python files, and they flagged 23 lines. One of them was a line ty flags too.
Each fix is its own commit, with a new test case and the corpus lines it removed:
Commit | What was wrong | Hits after |
|---|---|---|
The six rules as first written | 23 | |
Any name inside a ternary matched, so black's | 17 | |
| 16 | |
Django has | 14 | |
The tuple rule flagged every | 2 |
Each fix has the marks of trying to build a type checker in a syntax tree tool. Once you're rebuilding a type checker inside of your rule engine, just run the type checker.
Of the two hits left, one is the same line ty flags, in black's formatter test data. The other is a false positive we kept: foo used on line 18 of a file whose def foo is on line 70. At that point foo isn't bound yet, which ty knows, and so does ruff's F821. tree-sitter based tools can express source order, but the rule would mean adding a manual list of clauses that grows with the codebase.
ty flagged 27 lines across the corpus, and our rules flag one of them. Most of the rest are cases the thread never showed, like string literals, ..., TypeVars and Django's empty-tuple defaults. That leaves 26 lines ty found and our rules didn't.
Three of those 26 are the thread's own kinds of bug: assert Field after an import in pydantic's tests, assert Foo.bar on a method, and Django's unannotated geom_param_pos = (0,). Each needs an import, an attribute or an inferred type resolved. ty walks every file in a Python project and knows what each name refers to. A tool built on syntax trees for every language has to rebuild that knowledge one rule at a time. It's the same reason we'd run Taskless alongside ESLint in a JavaScript project.
Following Bar(Foo) across files
The thread's TypedDict example is the one an sg rule can't reach:
class Foo(TypedDict): X: int
class Bar(Foo): Y: int
def check(x: Bar): if not x: print("empty dictionary")Bar never mentions TypedDict. ast-grep sees one file at a time and can't walk an inheritance chain.
Taskless runtime rules exist for evidence spread across files. A runtime rule is a directory with ast-grep captures and a check.ts. The captures do the cheap part across the whole repository: every parameter annotated with a plain class name and tested for truthiness, every class with a base list, every from module import .... check.ts gets all the matches at once, indexes the classes, resolves each name through the importing file's own imports, walks the bases up to TypedDict, and tracks which keys are required along the way.
When a name doesn't resolve to exactly one class, the check reports nothing. That covers a third-party base, a star import, or two classes with the same name. A false positive is more problematic than a miss, because it erodes developer trust in the tool. Over the corpus it reports nothing, the same as ty, so we planted a two-file TypedDict bug inside Django's tree to prove the zero meant something. A full check over Django caught it in about 5 seconds, and corpus/plant.py reproduces it.
Name resolution only goes so far. A type alias (Payload = Order), a TypedDict built with TypedDict("Name", {...}), or a class used as app.models.Order all resolve to nothing. ty handles those, because it has a type system and our check.ts needs a lookup table.
Running it also takes --dangerously-run-scripts. A runtime rule executes code on your machine, and Taskless only runs runtime rules its service has signed. A rule you wrote yourself is never signed, so the flag asks you to read check.ts before you run it.
What Taskless can't reach
Two of ty's checks need a full type system, and Taskless can't reach them.
gen = (x for x in xs) followed by if gen: means tracking what a variable holds from one line to the next. A rule can see the assignment and the if, but it can't follow the value between them in general. pylint catches the simplest case.
The isinstance exhaustiveness check needs ty to narrow int | str to str after the first branch, then see that the elif can't be false.
The 13 things Taskless catches are surface level issues. Definition order, aliases, imports through a package path, calls that return a TypedDict: each is one more thing a structural rule has to approximate and a type checker already knows. You can keep writing clauses, and a runtime rule can follow evidence across files.
When to add the specialized tool
A generic tool covered 13 of 15, and the last two are where a specialized one covers more. ty and ruff are specialized tools. ty understands Python's types well enough to answer the questions above with no extra rules. ruff runs over 900 built-in rules fast enough to run on every save. Neither one lets you write your own rules today. ruff's FAQ says it "does not yet support third-party plugins, though a plugin system is within-scope for the project" (ruff#283). pylint's using-constant-test gets 5 of the 15 without a type checker, and writing past that means a custom checker in Python against astroid.
Taskless covers different ground. It's where a team writes down the rules specific to its own codebase, in whatever language that codebase is in, and where its agents can be held to them. The same rule shape from this post works in any language ast-grep parses, and Vale rules run over prose with the same check.
So we'd run them together:
- Run ty, or another type checker, for anything that depends on types. That includes these checks, and narrowing, dataflow and inheritance generally.
- Run ruff for the large set of well-known Python mistakes it already knows.
- Use Taskless for your team's own rules, and when consistency is more important than depth.
Try it
You need uv and Node 22.22 or later.
git clone https://github.com/taskless/blog-taskless-tycd blog-taskless-tyuv syncnpm installscripts/compare.shThat prints the table above, plus a ruff column. uv run corpus/run.py runs the six codebases, and npm run test:rules runs the rules' own tests. The rules use the per-rule directory layout from Taskless 0.12, so copy any of them into your own .taskless/rules/ and run npx @taskless/cli check. The TypedDict runtime rule also needs --dangerously-run-scripts, after you've read its check.ts.