PEP 705: Read-only TypedDict items

PEP 705 proposes supporting read-only items in TypedDicts, allowing functions that access but do not modify data in TypedDicts (e.g. service APIs) to be annotated to accept a wide range of compatible types.

The pyright playground has support for this extension, if anyone wants to experiment; instructions for using it can be found here.

Feedback appreciated!

5 Likes

Looks great! Some feedback:

  • PEP readers who are not typing experts may appreciate adding a paragraph like the following to the end of §“Abstract”, so that they don’t need to read further: (I added a similar paragraph in PEP 655 based on feedback received then.)

This PEP makes no Python grammar changes. Correct usage of read-only keys of TypedDicts is intended to be enforced only by static type checkers and need not be enforced by Python itself at runtime.

  • §“Motivation” is quite clear. :+1: §“Updating nested dicts” in particular is not something I considered when thinking about ReadOnly[] before.

  • I’d recommend adding a section to §“Specification” explaining how get_type_hints() treats ReadOnly[], similar to the callouts in PEP 655. For completeness you might consider mentioning the altered behavior of get_origin(), and get_args() as well.

    • I’d recommend that get_type_hints() strip out ReadOnly[] qualifiers by default unless include_extras=True.
  • Nit: Suggest adding the following changes in bold:

Suggested changes to the typing module documentation, in line with current practice:

  • Nit: In “Individual items can be excluded from mutate operations […]” the phrase “mutate operations” feels both overly technical and vague to me. Suggested wordsmithing to be less technical:

Individual items can be prevented from being changed by marking then as ReadOnly, allowing them only to be read.

  • Nit: For “pyright 1.1.332 fully implements this proposal.” suggest linking to the changelog or commit from pyright that indicates that it added ReadOnly support.

  • Nit: In the sentence “It would be reasonable for someone familiar with frozen, […]” it’s not clear what frozen is referring to. There are no other uses of the word “frozen” in the PEP. Did you mean readonly instead?

  • There are multiple locations in this PEP that include a Never-typed TypedDict item. (See example below.) It’s not clear to me what that even means. I suppose I don’t have specific feedback on this construction other than I find it confusing.

class B(TypedDict):
  foo: NotRequired[Never]
  • In §“Rejected alternatives” maybe it would be worth talking about why the existing Final[] qualifier couldn’t be used in place of introducing a new ReadOnly[] qualifier? I think this was discussed before but I don’t remember what was said.

  • (I did not review the formal rules in §“Type Consistency”, due to lack of energy/time)

3 Likes

Thank you for the feedback! I will look to get this all improved/clarified in the PEP. In the meantime, to some of the questions you asked:

  • Never is typing.Never. Nothing will ever match this type, so if you use it to specify a value in a dictionary, that value must be absent.
  • frozen is a parameter on dataclasses, also used to similar effect in third party libraries
  • I mention why these aren’t “final” in the opening of the rationale section. (I will still add it to the rejected alternatives section!)

@davidfstr Thanks again for the feedback! PR to address it has now merged.

typing.ReadOnly is now available in typing-extensions 4.9.0-rc1 (thanks @Jelle!)

2 Likes

I’m in favor of this PEP.

Pure functions motivation

The “Pure functions” motivation section contains a very specific problem this solves, but I think the premier motivation is: code which shouldn’t mutate, should not be able to mutate. This is similar to the Principle of Least Authority from security, but I’m thinking about it from a maintainability angle: if a function takes Mapping I’m much more relaxed about it than if it takes dict. And ReadOnly brings this feature to TypedDict.

From my experience in TypeScript, my code is littered with readonly everywhere, and I often wish readonly was the default and a mutable keyword was used instead, but obviously that’s not possible here.

Final rejected alternative

In the Reusing the Final annotation rejected alternative section, the PEP says “[Final] is documented as preventing redefinition in subclasses […] This does not fit with the intended use of ReadOnly”. Just to make sure I understand, can you explain this a bit more or give an example?

I’m not sure if “subclasses” refers to actual overriding (i.e. subclassing the TypedDict) or to structural subtyping.

TypedDict Literals

TypeScript has a feature where a dict (“object” in TS) literal is inferred as the TypedDict type, e.g.

const foo = {"a": 1};
# Type of foo is `{"a": number}`

Unfortunately, neither mypy nor pyright do this currently (even if the variable is Final), but I think it should be possible as an improvement.

Such dynamically-inferred TypedDict creates a problem that there is no way to specify ReadOnly. In TypeScript, they fixed this with a new syntax:

const foo = {"a": 1} as const;
# Type of foo is `{readonly "a": 1}`

I don’t think this should have an impact on the PEP but I thought it’s interesting to mention.

The problem is that the Final annotation PEP, PEP-591, only discusses traditional classes, not structural subtypes. The Protocol and TypedDict types are not mentioned at all, though they were introduced in the same version of Python.

PEP-544 draws a distinction between explicit subclasses and implicit subtypes, and then goes on to drop the words “explicit” and “implicit”, using subclass to refer to what is in the MRO, and subtype to refer to what is structurally compatible. However, that doesn’t necessarily imply that PEP-591 must be only talking about explicit subclasses, since it doesn’t seem to be considering structural subtyping at all. (And sadly, PEP-544 doesn’t mention the Final annotation at all either.)

I took the view in PEP-705 that subclassing for structural types should be considered equivalent to asking the type-checker to validate structural subtyping (though I’ll note that this isn’t entirely coherent since Protocols also get implementation inheritance). This has come up explicitly in Required/NotRequired and inheritance · python/typing · Discussion #1516 · GitHub, and pyright’s main maintainer seems to be taking this view as well, at least for TypedDicts. This allows us to infer what Final should mean for structural typing: it should prevent redefinition in structural subtypes.

If that line of reasoning is not convincing, we can take a look at what type-checkers currently think of this ambiguity, in the context of Protocols; let’s try some code:

class A(Protocol):
  x: Final[int | None]

class B(Protocol):
  x: int

def b_to_a(b: B) -> A:
  return b

Passing this to mypy, we get:

error: Protocol member cannot be final

On the other hand, passing it to pyright, we get:

Expression of type "B" cannot be assigned to return type "A"
    "B" is incompatible with protocol "A"
      "x" is marked Final in protocol

So either Final shouldn’t be used in a structural type at all (mypy’s view), or it should not be removable in a structural subtype (pyright’s view).

This is not compatible with how we want ReadOnly to behave; the equivalent example should be legal in any type-checker:

class A(TypedDict):
  x: ReadOnly[int | None]

class B(TypedDict):
  x: int

def b_to_a(b: B) -> A:
  return b

This makes sense: a Final property should never change once set. That’s the intent of the annotation. But we can’t (and don’t want to) prevent a ReadOnly item changing in a TypedDict instance; we only want to prevent it being changed through this structural type definition, because it has incomplete information about what the actual type constraints on the item are, so cannot safely make a change.

I hope this has made things clearer and not muddier!

Thanks! To restate my understanding: Final has a specific meaning of the item being set once and not changing ever. This is important for example if Final is ever to be used for thread safety checking like it is in Java. ReadOnly however is more of a restriction on the “view” of the dict, so that we can remove some limitations on TypedDict sub-typing that mutability makes unsound. ReadOnly has no reason to require total “finality” of the dict instance item.

Speaking of threading, it might be useful to make it clear in the PEP (as a non-normative note) that ReadOnly does not imply that x can not be mutated by another concurrent thread.

1 Like

I have the following text at the start of the Rationale section:

This does not mean the items are immutable: a reference to the underlying dictionary could still exist with a different but compatible type in which those items have mutator operations.

I could add text like “This could be used, for instance, to update the item in a concurrent thread” if this helps cement the idea.

2 Likes

Overall I like this PEP and I look forward to being able to declare TypedDict as read-only. A few pieces of feedback:

  • The runtime implementation in typing-extensions throws an error if a mutable key overrides a read-only one in a base class. I think I put that in because PEP 705 at the time specified this behavior, but the current version doesn’t seem to say this explicitly. I would like to drop this runtime check, as it’s generally better for the runtime to be permissive. For example, users may want to type ignore this particular error in some cases, or future extensions of TypedDict may come up that interfere with this check.
  • I was sad to see that the readonly=True class argument is no longer in the PEP. I would personally want to default to declaring TypedDicts as read-only when possible, so I’d like to make that as easy as possible. The current iteration of the PEP would require repeating ReadOnly[] on every item, which is verbose.
  • Relatedly, there is a lack of symmetry with the feature set available for stating whether keys are required. In that area, we can mark an individual key as Required or NotRequired and set the default for the whole TypedDict (total=False). But for marking a TypedDict read-only, the only option is to use ReadOnly[] to mark an individual key. For more flexibility, we could add a read_only=True class argument and a Mutable[] qualifier to mark individual keys as mutable in a TypedDict that defaults to read-only.

I’ll be out for the next few weeks, but wanted to get these ideas into the discussion.

3 Likes

The other way up I think? But I am happy for this to be left to type-checkers either way.

1 Like

This came up in the discussion for creating a Readable type modifier: Need a way to type-hint attributes that is compatible with duck-typing - #19 by randolf-scholz

While for a TypedDict the distinction between a Readable and ReadOnly key barely matters, the distinction becomes more important when applied to a Protocol and nominal types.

Since according to the inheritance rules outlined in PEP 705 we are allowed to override a ReadOnly key with a writeable key it might be better to call the modifier Readable to ensure future consistency, if/when we want to extend the type modifier to work with additional types.

That being said, I do think the name ReadOnly better conveys the promise that this key will never be written to inside a function that uses it, so I understand why it is a more attractive name for the modifier, but it would also be a shame if we reject a future PEP that defines a Readable modifier that is distinct from ReadOnly based on the fact that this PEP was accepted with ReadOnly meaning Readable.

There is some precedent for the distinction between Readable and ReadOnly with the Buffer protocol placeholders in typeshed, where we have ReadableBuffer and ReadOnlyBuffer. These were introduced as part of an earlier version of PEP 688, which initially aimed to include mutability as part of the Buffer protocol.

1 Like

I don’t agree with this; I think the distinction is relevant even for structural subtyping.

I’m happy to consider this. I’d like to do it on the basis of what is the best name for what you are calling “Readable”, though, not whether there’s a distinction based on TypedDict vs Protocol vs base class.

Would love to hear what other people prefer of the two suggestions.

This isn’t much of a precedent, is there anything else?

These aliases are used consistently across typeshed so I think at least as far as naming goes, this is a well established precedent. Whether or not the type system currently supports distinguishing between them is irrelevant for the naming discussion.

You can make the argument that the buffer terminology does not translate to attribute/key access, but I would disagree, I think it translates very well. I won’t deny however that it’s easy to use ReadOnly when you actually meant to use Readable. I have made that mistake before myself with the Buffer protocols and have to sometimes catch myself. Although this will be less of an issue when the type checker actually starts complaining about inconsistent use of ReadbleBuffer vs ReadOnlyBuffer.

I can’t really think of a better name than Readable meaning “this attribute/key is readable” without placing any further restrictions. The main confusion in naming occurs once you look at it from a receiving-perspective rather than a giving-perspective. Because on the receiving end you don’t care whether the key/value is Readable or ReadOnly, in either case a read is the only explicitly allowed operation, so it is in a sense read only, with the subtle distinction that with the former you are forbidden, because you weren’t told that you are allowed to write and in the latter you are explicitly forbidden from writing.

I’m not sure there’s a name that is completely unambiguous both from a receiving and giving perspective.

I would normally refer to what you’re calling “readonly” as “immutable”, which is a lot clearer of a distinction. I may be missing nuance though?

The problem with Immutable is that it’s ambiguous, does it mean we create an immutable reference to the value, so the value itself can never be modified (i.e. any attribute or method access that would modify the object’s internal state is forbidden), or does it mean it’s invalid to change where the reference points to? When I hear immutable in the context of Python I think of the former, not the latter.

In short Immutable is a modifier on the wrapped type, ReadOnly/Readable are modifiers on the type containing the attribute/key. You can’t use them for both.

If we ever wanted to introduce mutability of objects into the type system I think we would regret having already used that name. ReadOnly is actually closer to Final than Immutable in my book.

I think ReadOnly is the right term to use here and in the future. Readable doesn’t convey the right meaning. Let’s stick with ReadOnly.

6 Likes

If the more lax definition of ReadOnly was the only type modifier we ever wanted then I would agree, even if the term doesn’t completely match what it says on the tin, we usually care and think more about types from a receiving position and there ReadOnly is a better descriptor, but if we ever wanted to be able to express the more strict meaning as well we now don’t really have a good term for that.

Although I guess we could always try to go with what PEP 724 proposed initially for TypeGuard and use StrictReadOnly [1] to mean it’s only compatible if the attribute/key is read-only for both types.


  1. or StrictlyReadOnly if we feel strongly about proper grammar ↩︎

I had a second look at this, and it seems to me now that in my comment here, it may be more appropriate to translate foo: ReadOnly[T] to __setattr__(self, name: Literal["foo"], val: Never) -> None instead of __setattr__(self, name: Literal["foo"], val: T) -> Never.

This is compatible with the intended usage here (applying to __setitem__ instead of __setattr__) and allows for contravariant overriding so that subclasses can choose to make a ReadOnly mutable.

I think if one wants to forbid writing for subtypes entirely, then Final[ReadOnly[T]] would be the correct thing to do.

edit: removed a bit where I was confused which interpretation of “ReadOnly” you meant…

Why not just Final[T]?

1 Like