Half-baked idea: `@inline_guards`

TypeGuard isn’t my favourite feature. It feels boilerplate-y to me. It puts responsibility for soundness with the user (similar to cast, but with less cultural awareness of that fact).

My ideal solution for narrowing is a small fantasy I call @inline_guards. You put this decorator on top of your utility functions, wave a magic wand, and type checkers inline the implications for control flow (sort of like pyright’s ability to alias conditional expressions, but on steroids). Such a thing could potentially be a really nice replacement for the proposed TypeAssert. (And if you continue waving the magic wand, maybe you can get better semantics when method calls assign instance variables)

This theoretical construct is my bar for how usable a type guard construct could be. It’s low boilerplate — you don’t have to re-encode the same information that’s in your main code into your type annotation code. There is a safety net — if you mess something up, the type checker will not narrow incorrectly.

There are several problems with this idea, but a reason I never floated it when TypeGuard was being discussed is that @inline_guards simply doesn’t work for the “non-strict” narrowing that PEP 647 lets you do.

However, it is much more relevant to the strict narrowing semantics PEP 724 tries to enable, so I’m now thinking of it again. Many of the cases I saw in PEP 724: Stricter Type Guards - #97 by hauntsaninja would be more conveniently expressed with @inline_guards. In particular, the functions I noticed where unsoundness was possible under 724 semantics would be sound if the functions were decorated with @inline_guards but would still perform the narrowing you’d like.

Of course, there are several problems with this half-baked idea:

  • The feature would pose problems for ensuring type checker consistency, because narrowing behaviour between different type checkers currently diverges a fair amount (although in most cases of divergence I think the desirable behaviour is pretty clear). This divergence previously did not affect public API symbols, but would with @inline_guards (although it seems like TypeGuard hasn’t yet found a huge amount of use in public APIs).
  • For some advanced cases, you’d have to use if TYPE_CHECKING to be able to get good strict narrowing with @inline_guards. This might be a good thing, since it’s usually pretty obvious to users that they need to fulfill the promises they are making to the type checker when using if TYPE_CHECKING than it is with -> TypeGuard[XYZ], but it certainly cedes ergonomic benefits
  • You’d need to define the behaviour allowed quite carefully, e.g. all recursion would need to be disallowed
  • If you wanted to use the feature in stubs, you’d need to have a non-trivial body
  • …and many more

Like any good ideas thread, I don’t know where I’m going while typing this up :slight_smile: / and this was always going to be the kind of idea that sounds nice in theory but where devils lie in the details.

(These ramblings are mine alone, not the Typing Council’s. This is not a coherent proposal, so doesn’t really affect my views on PEP 724)

7 Likes

I’ve also always felt uneasy about type guards for this reason.

Can you show an example? I’m not fully following the explanation.

Here’s an example. In Black, we have this function:

def _is_ipython_magic(node: ast.expr) -> TypeGuard[ast.Attribute]:
    """Check if attribute is IPython magic."""
    return (
        isinstance(node, ast.Attribute)
        and isinstance(node.value, ast.Call)
        and isinstance(node.value.func, ast.Name)
        and node.value.func.id == "get_ipython"
    )

(Incidentally, this would be unsafe under PEP 724.)

So you can use it like this:

e: ast.expr
if _is_ipython_magic(e):
    assert_type(e, ast.Attribute)

With Shantanu’s proposal, you’d instead be able to write:

@inline_guards
def _is_ipython_magic(node: ast.expr) -> bool:
    """Check if attribute is IPython magic."""
    return (
        isinstance(node, ast.Attribute)
        and isinstance(node.value, ast.Call)
        and isinstance(node.value.func, ast.Name)
        and node.value.func.id == "get_ipython"
    )

And now you can still use the function like a TypeGuard, but the rest of the narrowing also applies:

e: ast.expr
if  _is_ipython_magic(e):
    assert_type(e, ast.Attribute)
    assert_type(e.value.func, ast.Name)
3 Likes