Skip to main content

Right Types, Wrong Code: Surprising Bugs A Type Checker Catches

· 4 min read

A type checker, as its name suggests, catches type mismatches: things like passing a str to a function that expects an int. But to understand your code's types, a type checker also has to understand its structure: control flow, scoping, class hierarchies, and more. This lets it detect a surprisingly wide range of issues that have nothing to do with int vs. str.

Here are five real categories of bugs that Pyrefly catches, none of which are straightforward type mismatches.

1. The Silent Coroutine

This code runs without error but silently fails to send a notification.

async def send_notification(user_id: int, message: str) -> None: ...

async def handle_request(user_id: int) -> None:
send_notification(user_id, "Request received") # bug!
process_request(user_id)

(See the code in the sandbox.)

Spot the bug? send_notification is an async function, and calling it without await creates a coroutine object that is immediately discarded. The notification never sends. Python won't raise an exception — you'll just get a RuntimeWarning that's easy to miss in logs.

Pyrefly flags this as an unused-coroutine: the result of an async function call was neither awaited nor assigned to a variable. The fix is simple:

await send_notification(user_id, "Request received")

2. The Forgotten Call

This code attempts to check whether a user is authorized before performing an action.

def is_authorized(user: User) -> bool:
return user.role in ADMIN_ROLES

def handle_admin_request(user: User) -> Response:
if is_authorized: # bug!
return perform_action()
return Response(403)

(sandbox)

However, the action is always performed regardless of authorization. is_authorized (without parentheses) is a reference to the function object, which is always truthy. What was meant was is_authorized(user).

Pyrefly catches this as a redundant-condition: it knows that is_authorized is a function, and a function is always truthy, so the condition is redundant.

3. The Breaking Rename

class BaseCache:
def get(self, key: str, default: object = None) -> object:
...

class RedisCache(BaseCache):
def get(self, key: str, fallback: object = None) -> object: ... # bug!

(sandbox)

This looks harmless. RedisCache.get has the same types as BaseCache.get, just a different parameter name. But any caller using cache.get("x", default=None) will break when the cache is a RedisCache, because RedisCache.get doesn't have a parameter named default.

Pyrefly reports this as bad-override-param-name: a subclass renamed a parameter that callers might be passing by keyword. This is a violation of the Liskov Substitution Principle because you can't safely substitute a RedisCache where a BaseCache is expected.

It's easy to dismiss this as unlikely, but it happens a lot in practice. Someone inherits from a base class, doesn't look at the parameter names carefully, and chooses a name that makes more sense for their implementation. The bug only surfaces when someone passes the argument by name, which might be rare enough that tests don't cover it.

4. The Missing Case

from enum import Enum

class Color(Enum):
RED = "red"
GREEN = "green"
BLUE = "blue"

def to_hex(color: Color):
match color:
case Color.RED:
return "#ff0000"
case Color.GREEN:
return "#00ff00"
# forgot BLUE!

(sandbox)

Pyrefly warns about this with non-exhaustive-match: the match statement doesn't cover all members of Color and has no wildcard default. If someone passes Color.BLUE, Python falls through the match without entering any case, and the function implicitly returns None, which may then cause a confusing error somewhere downstream.

5. The Misleading Comparison

This code attempts to check whether a user is an admin.

class User: ...
class Admin(User): ...

def check(user: User) -> None:
if user is Admin: # bug!
print("Welcome, admin!")

(sandbox)

This checks whether user is the same object as the Admin class, not whether user is an instance of Admin. What was almost certainly meant was isinstance(user, Admin). Pyrefly flags this as an unnecessary-comparison: using is to compare a value against a type is almost always a mistake.

Why a Type Checker?

You might wonder: shouldn't a linter catch these, rather than a type checker? Without an understanding of the types flowing through your program, a linter can see that you wrote if f:, but it might not be able to tell that f is a function, especially if f is imported. Pyrefly's type analysis allows it to report diagnostics that non-type-aware linters cannot detect with confidence.

To see the full list of error kinds Pyrefly supports and their severity levels, check out our error kinds documentation. And if there's a bug pattern you wish we'd catch, let us know on GitHub or Discord.