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.
from typing import Final
def main(name: Final[str]) -> None:
name = "World"
if __name__ == "__main__":
name = "Kevin"
❯ pyright 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]
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.
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 = 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. 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.
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↩︎
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.
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.
def foo(a: Mutable[int], b: int): ...
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 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.
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 ↩︎
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
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.