Extend The typing.Final type qualifier to support function arguments

Hey All!

I recently encountered a bug in my python project caused because I mistakenly re-assigned a function argument a new value. Example:

def buggy(x:int):
    ...
    x = 33333
    ...

Ideally, I’d like to explicitly tell type checkers that the function’s argument shall not be reassigned in function’s context:

def buggy(x:typing.Final[int]):
    ...
    # Type checkers shall alert on that
    x = 333333
    ...

Happy to hear any feedback regarding this suggestion.

Cheers!

3 Likes

I like the idea of this, but I’d personally prefer to see PEP 705 (read-only attributes in TypedDict - PEP 705 – TypedDict: Read-only items | peps.python.org) extended so that the attribute is ReadOnly instead of Final as I think it is clearer about the intent and mirrors conventions in other languages such as typescript.

It makes sense to me to use Final[] on a function parameter variable to prevent it from being reassigned.

@NI1993 have you checked whether this syntax already works with your favorite typechecker?

@davidfstr it does not work in Mypy nor Pyright:

#!/usr/bin/env python
from typing import Final


def main(name: Final[str]) -> None:
    name = "World"
    print(f"Hello, {name}")


if __name__ == "__main__":
    name = "Kevin"
    main(name=name)
❯ pyright example.py
/Users/kkirsche/Desktop/final-example/example.py
  /Users/kkirsche/Desktop/final-example/example.py:5:16 - error: "Final" is not allowed in this context
1 error, 0 warnings, 0 informations
❯ mypy example.py
example.py:5: error: Final can be only used as an outermost qualifier in a variable annotation  [valid-type]

MyPy:
In particular, Final can’t be used in annotations for function arguments
https://mypy.readthedocs.io/en/stable/final_attrs.html#details-of-using-final

1 Like

Seems like this is leaking implementation details of the function being annotated. To the user looking at the function signature and documentation, the fact that a parameter is Final from changes internally isn’t relevant.

5 Likes

Seems like this is leaking implementation details of the function being annotated.

Function signatures already have some implementation-specific components:

  • The names of function parameters before the / when using positional-only parameters are excluded from the API, since they can’t be used by callers.

I think Final[] would be useful on local variable definitions in general, including function parameters.

I agree with this, Final is only relevant for the callee, not for the caller, so I think it would be little weird to read this in the function header. I think it would make more sense if you allowed a top-level type modifier in the implementation without raising an error about it having been redeclared:

def foo(x: int) -> None:
    x: Final[int]
    x = 5  # type error

But I also think this only solves part of the problem, because we also have mutable objects. Traditionally we use protocols like Mapping instead of dict in function parameters, both to be more permissive in what we accept, but also as a promise that we won’t modify the object that has been passed in.

But it might be nice to have a type modifier like Immutable which lets you accept an immutable reference to a nominal type, i.e. calling any method on an object that isn’t marked with something like @idempotent would trigger a type error on an Immutable reference to that object[1]. Here I think Immutable would actually be useful documentation in a function signature, compared to Final which is only really a self-guard for the implementation.


  1. with generics it would also allow for the variance on the type parameters to change, e.g. from invariant to covariant, since the interface would be reduced to the methods marked with @idempotent ↩︎

The names of parameters are relevant to the user because they are (hopefully) descriptive of what the argument is, and also match up with further documentation about each parameter.

Perhaps a function decorator which will declare that functions arguments can be set inside the function / read only / final? :thinking:

This sounds more like a linting rule than type checker functionality.

Pylint and ruff both support a rule called redefined-argument-from-local that appears to do what you want.

I don’t think it makes sense to ask users to add type qualifiers (whether it’s Final, ReadOnly or something similar) to every parameter annotation in every function just to prevent this class of bug.

5 Likes

great I’ll try it out. Thanks!

There is a relevant feature suggestion in Github issues for Mypy: Allow `Final` in type arguments to avoid shadowing arguments - disallow reassignment of function parameters · Issue #11076 · python/mypy · GitHub

That is also a feature I would like to have or an immutability-by-default typechecking mode, which is suggested in a different issue.

Just for completeness - this lint rule is only applied for specific cases

This is taken in account only for a handful of name binding operations,
such as for iteration, with statement assignment and exception handler assignment.
1 Like

I am new to Python typing discussion, but I feel this can be useful because it is sometimes really important to state that the function parameter is Immutable. In the same way, the difference between Sequence and MutableSequence is working.

What if we add the Mutable and Immutable classes to the typing?

From the perspective of type checkers like Mypy, I see it in the following way.

  1. Should be selected as a default option, mutable is the default (C++ as a reference, where all objects by default are mutable), and immutable is the default (similar to Rust). Depending on the default option, static type checkers should require explicit “Mutable” and “Immutable” types.

For example:

def foo(a: Mutable[int], b: int): ...

or

For example:

def foo(a: Immutable[int], b: int): ...

From this perspective, Mutable[dict] should be allowed by MutableMapping, etc.

I would love to have mutability modifiers. But it’s not as simple as you imagine, since it’s not trivial to statically analyze which methods are allowed on an Immutable reference, and completely impossible in pyi files, where you don’t have the implementation. Also Immutable would not do anything on an immutable type like int, so it’s different from what the OP asked for, which is reassignment, which would still be legal for an Immutable type, since it neither affects the original object nor its reference that was passed into the function.

So it probably should be the programmer’s job to explicitly mark the methods that are safe on an Immutable reference with something like a @idempotent decorator like I suggested above. This would essentially mean that Immutable strips away[1] all the methods that aren’t idempotent and turns all the attributes into ReadOnly[Immutable[...]] attributes. The type vars could then also technically recalculate their variance. E.g. Immutable[list[T]] should now be covariant in T, because all the unsafe operations have been stripped away.


  1. although to be safe it would still keep track of the unsafe methods, so you’re forbidden from calling them even if you do a subsequent hasattr check ↩︎

3 Likes

Yes, totally agree. I don’t think it is an easy feature to implement, but it is something really useful, especially if you want to guarantee no implisit updates inside the code.

Immutable[list[T]] should work in the same way as Sequence[T] does. Maybe even cast to this type. At list for build-in collection logic like this already present in typing libraries and mypy, but in general case it is a huge amount of work.

Even to create a proper specification of what exactly should be implemented

Isn’t an Immutable[list[T]] the same as a tuple[T, ...] in most situations? When would you want to go for the former?

1 Like

It’s closer to Sequence[T], which we are currently using in code.

Scenario: you don’t want implicit change of the object, passed to the function. Like modifying a dictionary, list, or set but creating a copy of that object instead.

You can find a similar approach used, for example, in pandas, where without inplace=True, you will always get a copy of the original object, not the original object itself.

I agree that the list is not the best example here, in case we already had ways to prevent behavior like this in the typing library.

While a Protocol is a pretty good workaround to not having something like Immutable and a good fit for Python’s common duck typing approach, it’s far from perfect.

For one you lose the original identity of the type, so if your implementation relies on being passed a dict and exactly a dict but without modifying it, then both Mapping and dict are inadequate, since Mapping is too loose and dict is too strict in terms of variance.

On top of that Mapping does not actually fully guarantee immutability, defaultdict e.g. can insert new keys on __getitem__, which is part of the Mapping protocol, so even inside a function that takes a Mapping a defaultdict could be modified, not in a particularly harmful way, mind you, but there may be more extreme examples out there where a Protocol is insufficient in modelling mutability and guaranteeing the kind of safety you would like to be able to guarantee.

Neither case is super common though, so I’m not sure it’s worth the extra complexity in the type system, but I certainly would like to play around with it, to see where we can benefit today, I think it might also make things a little easier for typing newbies, since writing a Protocol is certainly more advanced than just wrapping something in a type modifier.

1 Like