Repository: quora/pyanalyze
Pull request #723 (2024-02-24 07:23:49)

Add support for PEP 705 and 728


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index f7c9a40..a85063d 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,8 @@
 
 ## Unreleased
 
+- Add support for the `ReadOnly` type qualifier (PEP 705) and
+  for the `closed=True` TypedDict argument (PEP 728) (#723)
 - Fix some higher-order behavior of `TypeGuard` and `TypeIs` (#719)
 - Add support for `TypeIs` from PEP 742 (#718)
 - More PEP 695 support: generic classes and functions. Scoping rules
diff --git a/pyanalyze/annotated_types.py b/pyanalyze/annotated_types.py
index 45aaefe..3cfbfae 100644
--- a/pyanalyze/annotated_types.py
+++ b/pyanalyze/annotated_types.py
@@ -13,6 +13,7 @@ from pyanalyze.value import CanAssign, CanAssignContext, Value, flatten_values
 
 from .extensions import CustomCheck
 from .value import (
+    NO_RETURN_VALUE,
     AnnotatedValue,
     AnyValue,
     CanAssignError,
@@ -293,7 +294,7 @@ def _min_len_of_value(val: Value) -> Optional[int]:
     elif isinstance(val, DictIncompleteValue):
         return sum(pair.is_required and not pair.is_many for pair in val.kv_pairs)
     elif isinstance(val, TypedDictValue):
-        return sum(required for required, _ in val.items.values())
+        return sum(entry.required for entry in val.items.values())
     else:
         return None
 
@@ -314,6 +315,10 @@ def _max_len_of_value(val: Value) -> Optional[int]:
             if pair.is_required:
                 maximum += 1
         return maximum
+    elif isinstance(val, TypedDictValue):
+        if val.extra_keys is not NO_RETURN_VALUE:
+            # May have arbitrary number of extra keys
+            return None
+        return len(val.items)
     else:
-        # Always None for TypedDicts as TypedDicts may have arbitrary extra keys
         return None
diff --git a/pyanalyze/annotations.py b/pyanalyze/annotations.py
index 00ceec1..2c65fa1 100644
--- a/pyanalyze/annotations.py
+++ b/pyanalyze/annotations.py
@@ -50,7 +50,7 @@ from typing import (
 
 import qcore
 import typing_extensions
-from typing_extensions import ParamSpec, TypedDict, get_args, get_origin
+from typing_extensions import Literal, ParamSpec, TypedDict, get_args, get_origin
 
 from pyanalyze.annotated_types import get_annotated_types_extension
 
@@ -102,6 +102,7 @@ from .value import (
     SubclassValue,
     TypeAlias,
     TypeAliasValue,
+    TypedDictEntry,
     TypedDictValue,
     TypedValue,
     TypeGuardExtension,
@@ -432,20 +433,32 @@ def _type_from_runtime(
     # mypy_extensions.TypedDict
     elif is_instance_of_typing_name(val, "_TypedDictMeta"):
         required_keys = getattr(val, "__required_keys__", None)
+        readonly_keys = getattr(val, "__readonly_keys__", None)
         # 3.8's typing.TypedDict doesn't have __required_keys__. With
         # inheritance, this makes it apparently impossible to figure out which
         # keys are required at runtime.
         total = getattr(val, "__total__", True)
+        extra_keys = None
         if hasattr(val, "__extra_keys__"):
-            extra_keys = _type_from_runtime(val.__extra_keys__, ctx)
-        else:
-            extra_keys = None
+            extra_keys = _type_from_runtime(val.__extra_keys__, ctx, is_typeddict=True)
+        if hasattr(val, "__closed__") and val.__closed__:
+            extra_keys = _type_from_runtime(val.__extra_items__, ctx, is_typeddict=True)
+        extra_readonly = False
+        while isinstance(extra_keys, TypeQualifierValue):
+            if extra_keys.qualifier == "ReadOnly":
+                extra_readonly = True
+            else:
+                ctx.show_error(f"{extra_keys.qualifier} not allowed on extra_keys")
+            extra_keys = extra_keys.value
         return TypedDictValue(
             {
-                key: _get_typeddict_value(value, ctx, key, required_keys, total)
+                key: _get_typeddict_value(
+                    value, ctx, key, required_keys, total, readonly_keys
+                )
                 for key, value in val.__annotations__.items()
             },
             extra_keys=extra_keys,
+            extra_keys_readonly=extra_readonly,
         )
     elif val is InitVar:
         # On 3.6 and 3.7, InitVar[T] just returns InitVar at runtime, so we can't
@@ -624,15 +637,26 @@ def _get_typeddict_value(
     key: str,
     required_keys: Optional[Container[str]],
     total: bool,
-) -> Tuple[bool, Value]:
+    readonly_keys: Optional[Container[str]],
+) -> TypedDictEntry:
     val = _type_from_runtime(value, ctx, is_typeddict=True)
-    if isinstance(val, Pep655Value):
-        return (val.required, val.value)
     if required_keys is None:
         required = total
     else:
         required = key in required_keys
-    return required, val
+    if readonly_keys is None:
+        readonly = False
+    else:
+        readonly = key in readonly_keys
+    while isinstance(val, TypeQualifierValue):
+        if val.qualifier == "ReadOnly":
+            readonly = True
+        elif val.qualifier == "Required":
+            required = True
+        elif val.qualifier == "NotRequired":
+            required = False
+        val = val.value
+    return TypedDictEntry(required=required, readonly=readonly, typ=val)
 
 
 def _eval_forward_ref(
@@ -797,7 +821,9 @@ def _type_from_subscripted_value(
         if len(members) != 1:
             ctx.show_error("Required[] requires a single argument")
             return AnyValue(AnySource.error)
-        return Pep655Value(True, _type_from_value(members[0], ctx))
+        return TypeQualifierValue(
+            "Required", _type_from_value(members[0], ctx, is_typeddict=True)
+        )
     elif is_typing_name(root, "NotRequired"):
         if not is_typeddict:
             ctx.show_error("NotRequired[] used in unsupported context")
@@ -805,7 +831,19 @@ def _type_from_subscripted_value(
         if len(members) != 1:
             ctx.show_error("NotRequired[] requires a single argument")
             return AnyValue(AnySource.error)
-        return Pep655Value(False, _type_from_value(members[0], ctx))
+        return TypeQualifierValue(
+            "NotRequired", _type_from_value(members[0], ctx, is_typeddict=True)
+        )
+    elif is_typing_name(root, "ReadOnly"):
+        if not is_typeddict:
+            ctx.show_error("ReadOnly[] used in unsupported context")
+            return AnyValue(AnySource.error)
+        if len(members) != 1:
+            ctx.show_error("ReadOnly[] requires a single argument")
+            return AnyValue(AnySource.error)
+        return TypeQualifierValue(
+            "ReadOnly", _type_from_value(members[0], ctx, is_typeddict=True)
+        )
     elif is_typing_name(root, "Unpack"):
         if not allow_unpack:
             ctx.show_error("Unpack[] used in unsupported context")
@@ -919,8 +957,8 @@ class _SubscriptedValue(Value):
 
 
 @dataclass
-class Pep655Value(Value):
-    required: bool
+class TypeQualifierValue(Value):
+    qualifier: Literal["Required", "NotRequired", "ReadOnly"]
     value: Value
 
 
@@ -1210,7 +1248,9 @@ def _value_of_origin_args(
         if len(args) != 1:
             ctx.show_error("Required[] requires a single argument")
             return AnyValue(AnySource.error)
-        return Pep655Value(True, _type_from_runtime(args[0], ctx))
+        return TypeQualifierValue(
+            "Required", _type_from_runtime(args[0], ctx, is_typeddict=True)
+        )
     elif is_typing_name(origin, "NotRequired"):
         if not is_typeddict:
             ctx.show_error("NotRequired[] used in unsupported context")
@@ -1218,7 +1258,19 @@ def _value_of_origin_args(
         if len(args) != 1:
             ctx.show_error("NotRequired[] requires a single argument")
             return AnyValue(AnySource.error)
-        return Pep655Value(False, _type_from_runtime(args[0], ctx))
+        return TypeQualifierValue(
+            "NotRequired", _type_from_runtime(args[0], ctx, is_typeddict=True)
+        )
+    elif is_typing_name(origin, "ReadOnly"):
+        if not is_typeddict:
+            ctx.show_error("ReadOnly[] used in unsupported context")
+            return AnyValue(AnySource.error)
+        if len(args) != 1:
+            ctx.show_error("ReadOnly[] requires a single argument")
+            return AnyValue(AnySource.error)
+        return TypeQualifierValue(
+            "ReadOnly", _type_from_runtime(args[0], ctx, is_typeddict=True)
+        )
     elif is_typing_name(origin, "Unpack"):
         if not allow_unpack:
             ctx.show_error("Invalid usage of Unpack")
diff --git a/pyanalyze/arg_spec.py b/pyanalyze/arg_spec.py
index 8b0ad51..d8f6f41 100644
--- a/pyanalyze/arg_spec.py
+++ b/pyanalyze/arg_spec.py
@@ -83,6 +83,7 @@ from .value import (
     KVPair,
     NewTypeValue,
     SubclassValue,
+    TypedDictEntry,
     TypedDictValue,
     TypedValue,
     TypeVarValue,
@@ -218,6 +219,7 @@ class ClassesSafeToInstantiate(PyObjectSequenceOption[type]):
         Value,
         Extension,
         KVPair,
+        TypedDictEntry,
         asynq.ConstFuture,
         range,
         tuple,
@@ -732,10 +734,10 @@ class ArgSpecCache:
                     SigParameter(
                         key,
                         ParameterKind.KEYWORD_ONLY,
-                        default=None if required else KnownValue(...),
-                        annotation=value,
+                        default=None if entry.required else KnownValue(...),
+                        annotation=entry.typ,
                     )
-                    for key, (required, value) in td_type.items.items()
+                    for key, entry in td_type.items.items()
                 ]
                 if td_type.extra_keys is not None:
                     annotation = GenericValue(
diff --git a/pyanalyze/error_code.py b/pyanalyze/error_code.py
index 13dc065..e8356fd 100644
--- a/pyanalyze/error_code.py
+++ b/pyanalyze/error_code.py
@@ -109,6 +109,7 @@ class ErrorCode(enum.Enum):
     disallowed_import = 89
     typeis_must_be_subtype = 90
     invalid_typeguard = 91
+    readonly_typeddict = 92
 
 
 # Allow testing unannotated functions without too much fuss
@@ -245,6 +246,7 @@ ERROR_DESCRIPTION = {
         "TypeIs narrowed type must be a subtype of the input type"
     ),
     ErrorCode.invalid_typeguard: "Invalid use of TypeGuard or TypeIs",
+    ErrorCode.readonly_typeddict: "TypedDict is read-only",
 }
 
 
diff --git a/pyanalyze/implementation.py b/pyanalyze/implementation.py
index 6fd695c..6336f94 100644
--- a/pyanalyze/implementation.py
+++ b/pyanalyze/implementation.py
@@ -493,7 +493,43 @@ def _sequence_getitem_impl(ctx: CallContext) -> ImplReturn:
 def _typeddict_setitem(
     self_value: TypedDictValue, key: Value, value: Value, ctx: CallContext
 ) -> None:
-    if not isinstance(key, KnownValue) or not isinstance(key.val, str):
+    if not isinstance(key, KnownValue):
+        if not TypedValue(str).is_assignable(key, ctx.visitor):
+            ctx.show_error(
+                f"TypedDict key must be str, not {key}",
+                ErrorCode.invalid_typeddict_key,
+                arg="k",
+            )
+            return
+        if self_value.extra_keys is None or self_value.extra_keys is NO_RETURN_VALUE:
+            ctx.show_error(
+                f"Cannot set unknown key {key} in TypedDict {self_value}",
+                ErrorCode.invalid_typeddict_key,
+                arg="k",
+            )
+            return
+        for td_key, entry in self_value.items.items():
+            if not key.is_assignable(KnownValue(td_key), ctx.visitor):
+                continue
+            if entry.readonly:
+                ctx.show_error(
+                    f"Cannot set readonly key {key} in TypedDict {self_value}",
+                    ErrorCode.readonly_typeddict,
+                    arg="k",
+                )
+                return
+            can_assign = entry.typ.can_assign(value, ctx.visitor)
+            if isinstance(can_assign, CanAssignError):
+                ctx.show_error(
+                    f"Value for key {key} must be {entry.typ}, not {value}",
+                    ErrorCode.incompatible_argument,
+                    arg="v",
+                    detail=str(can_assign),
+                )
+                return
+        return
+
+    if not isinstance(key.val, str):
         ctx.show_error(
             f"TypedDict key must be a string literal (got {key})",
             ErrorCode.invalid_typeddict_key,
@@ -501,6 +537,13 @@ def _typeddict_setitem(
         )
         return
     if key.val not in self_value.items:
+        if self_value.extra_keys_readonly:
+            ctx.show_error(
+                f"Cannot set unknown key {key.val!r} in closed TypedDict {self_value}",
+                ErrorCode.readonly_typeddict,
+                arg="k",
+            )
+            return
         if self_value.extra_keys is None:
             ctx.show_error(
                 f"Key {key.val!r} does not exist in {self_value}",
@@ -511,7 +554,15 @@ def _typeddict_setitem(
         else:
             expected_type = self_value.extra_keys
     else:
-        _, expected_type = self_value.items[key.val]
+        entry = self_value.items[key.val]
+        if entry.readonly:
+            ctx.show_error(
+                f"Cannot set readonly key {key.val!r} in TypedDict {self_value}",
+                ErrorCode.readonly_typeddict,
+                arg="k",
+            )
+            return
+        expected_type = entry.typ
     tv_map = expected_type.can_assign(value, ctx.visitor)
     if isinstance(tv_map, CanAssignError):
         ctx.show_error(
@@ -572,25 +623,29 @@ def _dict_getitem_impl(ctx: CallContext) -> ImplReturn:
                 return AnyValue(AnySource.error)
             elif isinstance(key, KnownValue):
                 try:
-                    _, value = self_value.items[key.val]
-                    return value
+                    entry = self_value.items[key.val]
+                    return entry.typ
                 # probably KeyError, but catch anything in case it's an
                 # unhashable str subclass or something
                 except Exception:
-                    if self_value.extra_keys is None:
-                        ctx.show_error(
-                            f"Unknown TypedDict key {key}",
-                            ErrorCode.invalid_typeddict_key,
-                            arg="k",
-                        )
-                        return AnyValue(AnySource.error)
-            if self_value.extra_keys is not None:
+                    pass
+            if (
+                self_value.extra_keys is not None
+                and self_value.extra_keys is not NO_RETURN_VALUE
+            ):
                 return self_value.extra_keys
-            ctx.show_error(
-                f"TypedDict key must be a literal, not {key}",
-                ErrorCode.invalid_typeddict_key,
-                arg="k",
-            )
+            if isinstance(key, KnownValue):
+                ctx.show_error(
+                    f"Unknown TypedDict key {key.val!r}",
+                    ErrorCode.invalid_typeddict_key,
+                    arg="k",
+                )
+            else:
+                ctx.show_error(
+                    f"TypedDict key must be a literal, not {key}",
+                    ErrorCode.invalid_typeddict_key,
+                    arg="k",
+                )
             return AnyValue(AnySource.error)
         elif isinstance(self_value, DictIncompleteValue):
             val = self_value.get_value(key, ctx.visitor)
@@ -645,29 +700,33 @@ def _dict_get_impl(ctx: CallContext) -> ImplReturn:
                 return AnyValue(AnySource.error)
             elif isinstance(key, KnownValue):
                 try:
-                    required, value = self_value.items[key.val]
+                    entry = self_value.items[key.val]
                 # probably KeyError, but catch anything in case it's an
                 # unhashable str subclass or something
                 except Exception:
-                    if self_value.extra_keys is None:
-                        ctx.show_error(
-                            f"Unknown TypedDict key {key.val!r}",
-                            ErrorCode.invalid_typeddict_key,
-                            arg="k",
-                        )
-                        return AnyValue(AnySource.error)
+                    pass
                 else:
-                    if required:
-                        return value
+                    if entry.required:
+                        return entry.typ
                     else:
-                        return value | default
-            if self_value.extra_keys is not None:
+                        return entry.typ | default
+            if (
+                self_value.extra_keys is not None
+                and self_value.extra_keys is not NO_RETURN_VALUE
+            ):
                 return self_value.extra_keys | default
-            ctx.show_error(
-                f"TypedDict key must be a literal, not {key}",
-                ErrorCode.invalid_typeddict_key,
-                arg="k",
-            )
+            if isinstance(key, KnownValue):
+                ctx.show_error(
+                    f"Unknown TypedDict key {key.val!r}",
+                    ErrorCode.invalid_typeddict_key,
+                    arg="k",
+                )
+            else:
+                ctx.show_error(
+                    f"TypedDict key must be a literal, not {key}",
+                    ErrorCode.invalid_typeddict_key,
+                    arg="k",
+                )
             return AnyValue(AnySource.error)
         elif isinstance(self_value, DictIncompleteValue):
             val = self_value.get_value(key, ctx.visitor)
@@ -692,6 +751,88 @@ def _dict_get_impl(ctx: CallContext) -> ImplReturn:
     return flatten_unions(inner, ctx.vars["key"])
 
 
+def _dict_delitem_impl(ctx: CallContext) -> ImplReturn:
+    key = ctx.vars["key"]
+    varname = ctx.visitor.varname_for_self_constraint(ctx.node)
+    self_value = replace_known_sequence_value(ctx.vars["self"])
+
+    if not _check_dict_key_hashability(key, ctx, "key"):
+        return ImplReturn(AnyValue(AnySource.error))
+
+    if isinstance(self_value, TypedDictValue):
+        if not TypedValue(str).is_assignable(key, ctx.visitor):
+            ctx.show_error(
+                f"TypedDict key must be str, not {key}",
+                ErrorCode.invalid_typeddict_key,
+                arg="key",
+            )
+            return ImplReturn(AnyValue(AnySource.error))
+        elif isinstance(key, KnownValue):
+            try:
+                entry = self_value.items[key.val]
+            # probably KeyError, but catch anything in case it's an
+            # unhashable str subclass or something
+            except Exception:
+                pass
+            else:
+                if entry.required:
+                    ctx.show_error(
+                        f"Cannot delete required TypedDict key {key}",
+                        error_code=ErrorCode.incompatible_argument,
+                        arg="key",
+                    )
+                elif entry.readonly:
+                    ctx.show_error(
+                        f"Cannot delete readonly TypedDict key {key}",
+                        error_code=ErrorCode.readonly_typeddict,
+                        arg="key",
+                    )
+                return ImplReturn(KnownValue(None))
+        if self_value.extra_keys_readonly:
+            ctx.show_error(
+                f"Cannot delete unknown key {key} in closed TypedDict {self_value}",
+                ErrorCode.readonly_typeddict,
+                arg="key",
+            )
+        elif self_value.extra_keys is None or self_value.extra_keys is NO_RETURN_VALUE:
+            ctx.show_error(
+                f"Key {key} does not exist in TypedDict",
+                ErrorCode.invalid_typeddict_key,
+                arg="key",
+            )
+    elif isinstance(self_value, DictIncompleteValue):
+        existing_value = self_value.get_value(key, ctx.visitor)
+        is_present = existing_value is not UNINITIALIZED_VALUE
+        if varname is not None and isinstance(key, KnownValue):
+            new_value = DictIncompleteValue(
+                self_value.typ,
+                [pair for pair in self_value.kv_pairs if pair.key != key],
+            )
+            no_return_unless = Constraint(
+                varname, ConstraintType.is_value_object, True, new_value
+            )
+        else:
+            no_return_unless = NULL_CONSTRAINT
+        if not is_present:
+            ctx.show_error(
+                f"Key {key} does not exist in dictionary {self_value}",
+                error_code=ErrorCode.incompatible_argument,
+                arg="key",
+            )
+            return ImplReturn(KnownValue(None))
+        return ImplReturn(KnownValue(None), no_return_unless=no_return_unless)
+    elif isinstance(self_value, TypedValue):
+        key_type = self_value.get_generic_arg_for_type(dict, ctx.visitor, 0)
+        tv_map = key_type.can_assign(key, ctx.visitor)
+        if isinstance(tv_map, CanAssignError):
+            ctx.show_error(
+                f"Key {key} is not valid for {self_value}",
+                ErrorCode.incompatible_argument,
+                arg="key",
+            )
+    return ImplReturn(KnownValue(None))
+
+
 def _dict_pop_impl(ctx: CallContext) -> ImplReturn:
     key = ctx.vars["key"]
     default = ctx.vars["default"]
@@ -711,20 +852,38 @@ def _dict_pop_impl(ctx: CallContext) -> ImplReturn:
             return ImplReturn(AnyValue(AnySource.error))
         elif isinstance(key, KnownValue):
             try:
-                is_required, expected_type = self_value.items[key.val]
+                entry = self_value.items[key.val]
             # probably KeyError, but catch anything in case it's an
             # unhashable str subclass or something
             except Exception:
                 pass
             else:
-                if is_required:
+                if entry.required:
                     ctx.show_error(
                         f"Cannot pop required TypedDict key {key}",
                         error_code=ErrorCode.incompatible_argument,
                         arg="key",
                     )
-                return ImplReturn(_maybe_unite(expected_type, default))
-        if self_value.extra_keys is not None:
+                elif entry.readonly:
+                    ctx.show_error(
+                        f"Cannot pop readonly TypedDict key {key}",
+                        error_code=ErrorCode.readonly_typeddict,
+                        arg="key",
+                    )
+                return ImplReturn(_maybe_unite(entry.typ, default))
+        if self_value.extra_keys_readonly:
+            ctx.show_error(
+                f"Cannot pop unknown key {key} in closed TypedDict {self_value}",
+                ErrorCode.readonly_typeddict,
+                arg="key",
+            )
+            return ImplReturn(
+                _maybe_unite(self_value.extra_keys or TypedValue(object), default)
+            )
+        if (
+            self_value.extra_keys is not None
+            and self_value.extra_keys is not NO_RETURN_VALUE
+        ):
             return ImplReturn(_maybe_unite(self_value.extra_keys, default))
         ctx.show_error(
             f"Key {key} does not exist in TypedDict",
@@ -797,22 +956,31 @@ def _dict_setdefault_impl(ctx: CallContext) -> ImplReturn:
             return ImplReturn(AnyValue(AnySource.error))
         elif isinstance(key, KnownValue):
             try:
-                _, expected_type = self_value.items[key.val]
+                entry = self_value.items[key.val]
             # probably KeyError, but catch anything in case it's an
             # unhashable str subclass or something
             except Exception:
                 pass
             else:
-                tv_map = expected_type.can_assign(default, ctx.visitor)
+                if entry.readonly:
+                    ctx.show_error(
+                        f"Cannot setdefault readonly TypedDict key {key}",
+                        error_code=ErrorCode.readonly_typeddict,
+                        arg="key",
+                    )
+                tv_map = entry.typ.can_assign(default, ctx.visitor)
                 if isinstance(tv_map, CanAssignError):
                     ctx.show_error(
                         f"TypedDict key {key.val} expected value of type"
-                        f" {expected_type}, not {default}",
+                        f" {entry.typ}, not {default}",
                         ErrorCode.incompatible_argument,
                         arg="default",
                     )
-                return ImplReturn(expected_type)
-        if self_value.extra_keys is not None:
+                return ImplReturn(entry.typ)
+        if (
+            self_value.extra_keys is not None
+            and self_value.extra_keys is not NO_RETURN_VALUE
+        ):
             return ImplReturn(self_value.extra_keys | default)
         ctx.show_error(
             f"Key {key} does not exist in TypedDict",
@@ -1222,9 +1390,9 @@ def _str_format_impl(ctx: CallContext) -> Value:
             else:
                 return TypedValue(str)
     elif isinstance(kwargs_value, TypedDictValue):
-        for key, (required, value_value) in kwargs_value.items.items():
-            if required:
-                kwargs[key] = value_value
+        for key, entry in kwargs_value.items.items():
+            if entry.required:
+                kwargs[key] = entry.typ
     else:
         return TypedValue(str)
     template = self.val
@@ -1723,6 +1891,15 @@ def get_default_argspecs() -> Dict[object, Signature]:
             impl=_dict_pop_impl,
             return_annotation=AnyValue(AnySource.inference),
         ),
+        Signature.make(
+            [
+                SigParameter("self", _POS_ONLY, annotation=TypedValue(dict)),
+                SigParameter("key", _POS_ONLY),
+            ],
+            callable=dict.__delitem__,
+            impl=_dict_delitem_impl,
+            return_annotation=KnownValue(None),
+        ),
         Signature.make(
             [
                 SigParameter("self", _POS_ONLY, annotation=TypedValue(dict)),
diff --git a/pyanalyze/signature.py b/pyanalyze/signature.py
index 5ac6297..ffa30f6 100644
--- a/pyanalyze/signature.py
+++ b/pyanalyze/signature.py
@@ -89,6 +89,7 @@ from .value import (
     ParamSpecKwargsValue,
     SelfT,
     SequenceValue,
+    TypedDictEntry,
     TypedDictValue,
     TypedValue,
     TypeGuardExtension,
@@ -1027,7 +1028,9 @@ class Signature:
                 ) in actual_args.keywords.items():
                     if key in keywords_consumed:
                         continue
-                    items[key] = (definitely_provided, composite.value)
+                    items[key] = TypedDictEntry(
+                        composite.value, required=definitely_provided
+                    )
                 position = KWARGS
                 if actual_args.ellipsis:
                     star_kwargs_value = GenericValue(
@@ -1035,7 +1038,8 @@ class Signature:
                     )
                 elif actual_args.star_kwargs is not None:
                     value_value = unite_values(
-                        *(val for _, val in items.values()), actual_args.star_kwargs
+                        *(entry.typ for entry in items.values()),
+                        actual_args.star_kwargs,
                     )
                     star_kwargs_value = GenericValue(
                         dict, [TypedValue(str), value_value]
@@ -1819,12 +1823,12 @@ class Signature:
             elif param.kind is ParameterKind.VAR_KEYWORD and isinstance(
                 param.annotation, TypedDictValue
             ):
-                for name, (is_required, value) in param.annotation.items.items():
+                for name, entry in param.annotation.items.items():
                     param_dict[name] = SigParameter(
                         name,
                         ParameterKind.KEYWORD_ONLY,
-                        annotation=value,
-                        default=None if is_required else AnyValue(AnySource.marker),
+                        annotation=entry.typ,
+                        default=None if entry.required else AnyValue(AnySource.marker),
                     )
                     i += 1
                 if param.annotation.extra_keys is not None:
@@ -2129,7 +2133,9 @@ def _preprocess_kwargs_no_mvv(
     """
     value = replace_known_sequence_value(value)
     if isinstance(value, TypedDictValue):
-        return value.items, None
+        return {
+            key: (entry.required, entry.typ) for key, entry in value.items.items()
+        }, None
     elif isinstance(value, DictIncompleteValue):
         return _preprocess_kwargs_kv_pairs(value.kv_pairs, ctx)
     else:
@@ -2630,7 +2636,7 @@ def can_assign_var_keyword(
             return CanAssignError(
                 f"parameter {my_param.name!r} is not accepted by {kwargs_annotation}"
             )
-        their_annotation = kwargs_annotation.items[my_param.name][1]
+        their_annotation = kwargs_annotation.items[my_param.name].typ
         can_assign = their_annotation.can_assign(my_annotation, ctx)
         if isinstance(can_assign, CanAssignError):
             return CanAssignError(
diff --git a/pyanalyze/typeshed.py b/pyanalyze/typeshed.py
index 74bcb8e..9dc1dac 100644
--- a/pyanalyze/typeshed.py
+++ b/pyanalyze/typeshed.py
@@ -42,8 +42,8 @@ from .analysis_lib import is_positional_only_arg_name
 from .annotations import (
     Context,
     DecoratorValue,
-    Pep655Value,
     SyntheticEvaluator,
+    TypeQualifierValue,
     make_type_var_value,
     type_from_value,
     value_from_ast,
@@ -75,6 +75,7 @@ from .value import (
     KnownValue,
     SubclassValue,
     SyntheticModuleValue,
+    TypedDictEntry,
     TypedDictValue,
     TypedValue,
     TypeVarValue,
@@ -1100,6 +1101,7 @@ class TypeshedFinder:
         total = True
         if isinstance(info.ast, ast.ClassDef):
             for keyword in info.ast.keywords:
+                # TODO support PEP 728 here
                 if keyword.arg == "total":
                     val = self._parse_expr(keyword.value, module)
                     if isinstance(val, KnownValue) and isinstance(val.val, bool):
@@ -1123,11 +1125,18 @@ class TypeshedFinder:
         )
         return TypedDictValue(items)
 
-    def _make_td_value(self, field: Value, total: bool) -> Tuple[bool, Value]:
-        if isinstance(field, Pep655Value):
-            return (field.required, field.value)
-        else:
-            return (total, field)
+    def _make_td_value(self, field: Value, total: bool) -> TypedDictEntry:
+        readonly = False
+        required = total
+        while isinstance(field, TypeQualifierValue):
+            if field.qualifier == "ReadOnly":
+                readonly = True
+            elif field.qualifier == "Required":
+                required = True
+            elif field.qualifier == "NotRequired":
+                required = False
+            field = field.value
+        return TypedDictEntry(readonly=readonly, required=required, typ=field)
 
     def _value_from_info(
         self, info: typeshed_client.resolver.ResolvedName, module: str
diff --git a/pyanalyze/value.py b/pyanalyze/value.py
index 4b8a9df..0615f10 100644
--- a/pyanalyze/value.py
+++ b/pyanalyze/value.py
@@ -956,8 +956,8 @@ class GenericValue(TypedValue):
                     if isinstance(can_assign, CanAssignError):
                         return CanAssignError(f"In TypedDict key {key!r}", [can_assign])
             elif i == 1:
-                for key, (_, value) in other.items.items():
-                    can_assign = expected.can_assign(value, ctx)
+                for key, entry in other.items.items():
+                    can_assign = expected.can_assign(entry.typ, ctx)
                     if isinstance(can_assign, CanAssignError):
                         return CanAssignError(f"In TypedDict key {key!r}", [can_assign])
         elif isinstance(other, SequenceValue) and self.typ in {
@@ -1241,22 +1241,42 @@ class DictIncompleteValue(GenericValue):
         return unite_values(*possible_values)
 
 
+@dataclass(frozen=True)
+class TypedDictEntry:
+    typ: Value
+    required: bool = True
+    readonly: bool = False
+
+    def __str__(self) -> str:
+        val = str(self.typ)
+        if self.readonly:
+            val = f"Readonly[{val}]"
+        if not self.required:
+            val = f"NotRequired[{val}]"
+        return val
+
+
 @dataclass(init=False)
 class TypedDictValue(GenericValue):
     """Equivalent to ``typing.TypedDict``; a dictionary with a known set of string keys."""
 
-    items: Dict[str, Tuple[bool, Value]]
+    items: Dict[str, TypedDictEntry]
     """The items of the ``TypedDict``. Required items are represented as (True, value) and optional
     ones as (False, value)."""
     extra_keys: Optional[Value] = None
     """The type of unknown keys, if any."""
+    extra_keys_readonly: bool = False
+    """Whether the extra keys are readonly."""
 
     def __init__(
-        self, items: Dict[str, Tuple[bool, Value]], extra_keys: Optional[Value] = None
+        self,
+        items: Dict[str, TypedDictEntry],
+        extra_keys: Optional[Value] = None,
+        extra_keys_readonly: bool = False,
     ) -> None:
         value_types = []
         if items:
-            value_types += [val for _, val in items.values()]
+            value_types += [val.typ for val in items.values()]
         if extra_keys is not None:
             value_types.append(extra_keys)
         value_type = (
@@ -1269,97 +1289,214 @@ class TypedDictValue(GenericValue):
         super().__init__(dict, (key_type, value_type))
         self.items = items
         self.extra_keys = extra_keys
+        self.extra_keys_readonly = extra_keys_readonly
 
     def num_required_keys(self) -> int:
-        return sum(1 for required, _ in self.items.values() if required)
+        return sum(1 for entry in self.items.values() if entry.required)
 
     def all_keys_required(self) -> bool:
-        return all(required for required, _ in self.items.values())
+        return all(entry.required for entry in self.items.values())
 
     def can_assign(self, other: Value, ctx: CanAssignContext) -> CanAssign:
         if isinstance(other, DictIncompleteValue):
             bounds_maps = []
-            for key, (is_required, value) in self.items.items():
+            for key, entry in self.items.items():
                 their_value = other.get_value(KnownValue(key), ctx)
                 if their_value is UNINITIALIZED_VALUE:
-                    if is_required:
+                    if entry.required:
                         return CanAssignError(f"Key {key} is missing in {other}")
                     else:
                         continue
-                can_assign = value.can_assign(their_value, ctx)
+                can_assign = entry.typ.can_assign(their_value, ctx)
                 if isinstance(can_assign, CanAssignError):
                     return CanAssignError(
                         f"Types for key {key} are incompatible", children=[can_assign]
                     )
                 bounds_maps.append(can_assign)
+            for pair in other.kv_pairs:
+                for key_type in flatten_values(pair.key, unwrap_annotated=True):
+                    if isinstance(key_type, KnownValue):
+                        if not isinstance(key_type.val, str):
+                            return CanAssignError(f"Key {pair.key} is not a string")
+                        if key_type.val not in self.items:
+                            if self.extra_keys is NO_RETURN_VALUE:
+                                return CanAssignError(
+                                    f"Key {key_type.val!r} is not allowed in closed"
+                                    f" TypedDict {self}"
+                                )
+                            elif self.extra_keys is not None:
+                                can_assign = self.extra_keys.can_assign(pair.value, ctx)
+                                if isinstance(can_assign, CanAssignError):
+                                    return CanAssignError(
+                                        f"Type for extra key {pair.key} is"
+                                        " incompatible",
+                                        children=[can_assign],
+                                    )
+                                bounds_maps.append(can_assign)
+                    else:
+                        can_assign = TypedValue(str).can_assign(key_type, ctx)
+                        if isinstance(can_assign, CanAssignError):
+                            return CanAssignError(
+                                f"Type for key {pair.key} is not a string",
+                                children=[can_assign],
+                            )
+                        if self.extra_keys is NO_RETURN_VALUE:
+                            return CanAssignError(
+                                f"Key {pair.key} is not allowed in closed TypedDict"
+                                f" {self}"
+                            )
+                        elif self.extra_keys is not None:
+                            can_assign = self.extra_keys.can_assign(pair.value, ctx)
+                            if isinstance(can_assign, CanAssignError):
+                                return CanAssignError(
+                                    f"Type for extra key {pair.key} is incompatible",
+                                    children=[can_assign],
+                                )
+                            bounds_maps.append(can_assign)
             return unify_bounds_maps(bounds_maps)
         elif isinstance(other, TypedDictValue):
             bounds_maps = []
-            for key, (is_required, value) in self.items.items():
+            for key, entry in self.items.items():
                 if key not in other.items:
-                    if is_required:
-                        return CanAssignError(f"Key {key} is missing in {other}")
+                    if entry.required:
+                        return CanAssignError(
+                            f"Required key {key} is missing in {other}"
+                        )
+                    if not entry.readonly:
+                        # "other" may be a subclass of its TypedDict type that sets a different key
+                        return CanAssignError(
+                            f"Mutable key {key} is missing in {other}"
+                        )
+                    extra_keys_type = other.extra_keys or TypedValue(object)
+                    can_assign = entry.typ.can_assign(extra_keys_type, ctx)
+                    if isinstance(can_assign, CanAssignError):
+                        return CanAssignError(
+                            f"Type for key {key} is incompatible with extra keys type"
+                            f" {extra_keys_type}",
+                            children=[can_assign],
+                        )
                 else:
-                    can_assign = value.can_assign(other.items[key][1], ctx)
+                    their_entry = other.items[key]
+                    if entry.required and not their_entry.required:
+                        return CanAssignError(
+                            f"Required key {key} is non-required in {other}"
+                        )
+                    if (
+                        not entry.required
+                        and not entry.readonly
+                        and their_entry.required
+                    ):
+                        # This means we may del the key, but the other TypedDict does not
+                        # allow it
+                        return CanAssignError(
+                            f"Mutable key {key} is required in {other}"
+                        )
+                    if not entry.readonly and their_entry.readonly:
+                        return CanAssignError(
+                            f"Mutable key {key} is readonly in {other}"
+                        )
+
+                    can_assign = entry.typ.can_assign(their_entry.typ, ctx)
                     if isinstance(can_assign, CanAssignError):
                         return CanAssignError(
                             f"Types for key {key} are incompatible",
                             children=[can_assign],
                         )
                     bounds_maps.append(can_assign)
-            # TODO: What if only one of the two has extra keys?
-            if self.extra_keys is not None and other.extra_keys is not None:
-                can_assign = self.extra_keys.can_assign(other.extra_keys, ctx)
+                    if not entry.readonly:
+                        can_assign = their_entry.typ.can_assign(entry.typ, ctx)
+                        if isinstance(can_assign, CanAssignError):
+                            return CanAssignError(
+                                f"Types for mutable key {key} are incompatible",
+                                children=[can_assign],
+                            )
+                        bounds_maps.append(can_assign)
+            if not self.extra_keys_readonly and other.extra_keys_readonly:
+                return CanAssignError(f"Extra keys are readonly in {other}")
+            if self.extra_keys is not None:
+                their_extra_keys = other.extra_keys or TypedValue(object)
+                can_assign = self.extra_keys.can_assign(their_extra_keys, ctx)
                 if isinstance(can_assign, CanAssignError):
                     return CanAssignError(
                         "Types for extra keys are incompatible", children=[can_assign]
                     )
                 bounds_maps.append(can_assign)
+                if not self.extra_keys_readonly:
+                    can_assign = their_extra_keys.can_assign(self.extra_keys, ctx)
+                    if isinstance(can_assign, CanAssignError):
+                        return CanAssignError(
+                            "Types for mutable extra keys are incompatible",
+                            children=[can_assign],
+                        )
+                    bounds_maps.append(can_assign)
             return unify_bounds_maps(bounds_maps)
         elif isinstance(other, KnownValue) and isinstance(other.val, dict):
             bounds_maps = []
-            for key, (is_required, value) in self.items.items():
+            for key, entry in self.items.items():
                 if key not in other.val:
-                    if is_required:
+                    if entry.required:
                         return CanAssignError(f"Key {key} is missing in {other}")
                 else:
-                    can_assign = value.can_assign(KnownValue(other.val[key]), ctx)
+                    can_assign = entry.typ.can_assign(KnownValue(other.val[key]), ctx)
                     if isinstance(can_assign, CanAssignError):
                         return CanAssignError(
                             f"Types for key {key} are incompatible",
                             children=[can_assign],
                         )
                     bounds_maps.append(can_assign)
+            for key, value in other.val.items():
+                if key not in self.items:
+                    if self.extra_keys is NO_RETURN_VALUE:
+                        return CanAssignError(
+                            f"Key {key} is not allowed in closed TypedDict {self}"
+                        )
+                    elif self.extra_keys is not None:
+                        can_assign = self.extra_keys.can_assign(KnownValue(value), ctx)
+                        if isinstance(can_assign, CanAssignError):
+                            return CanAssignError(
+                                f"Type for extra key {key} is incompatible",
+                                children=[can_assign],
+                            )
+                        bounds_maps.append(can_assign)
             return unify_bounds_maps(bounds_maps)
         return super().can_assign(other, ctx)
 
     def substitute_typevars(self, typevars: TypeVarMap) -> "TypedDictValue":
         return TypedDictValue(
             {
-                key: (is_required, value.substitute_typevars(typevars))
-                for key, (is_required, value) in self.items.items()
+                key: TypedDictEntry(
+                    entry.typ.substitute_typevars(typevars),
+                    required=entry.required,
+                    readonly=entry.readonly,
+                )
+                for key, entry in self.items.items()
             },
             extra_keys=(
                 self.extra_keys.substitute_typevars(typevars)
                 if self.extra_keys is not None
                 else None
             ),
+            extra_keys_readonly=self.extra_keys_readonly,
         )
 
     def __str__(self) -> str:
-        items = [
-            f'"{key}": {value if required else "NotRequired[" + str(value) + "]"}'
-            for key, (required, value) in self.items.items()
-        ]
-        return "TypedDict({%s})" % ", ".join(items)
+        entries: List[Tuple[str, object]] = list(self.items.items())
+        if self.extra_keys is not None and self.extra_keys is not NO_RETURN_VALUE:
+            extra_typ = str(self.extra_keys)
+            if self.extra_keys_readonly:
+                extra_typ = f"ReadOnly[{extra_typ}]"
+            entries.append(("__extra_items__", extra_typ))
+        items = [f'"{key}": {entry}' for key, entry in entries]
+        closed = ", closed=True" if self.extra_keys is not None else ""
+        return f"TypedDict({{{', '.join(items)}}}{closed})"
 
     def __hash__(self) -> int:
         return hash(tuple(sorted(self.items)))
 
     def walk_values(self) -> Iterable["Value"]:
         yield self
-        for _, value in self.items.values():
-            yield from value.walk_values()
+        for entry in self.items.values():
+            yield from entry.typ.walk_values()
 
 
 @dataclass(unsafe_hash=True, init=False)
@@ -2533,12 +2670,12 @@ def concrete_values_from_iterable(
             return value.args[0]
         return members
     elif isinstance(value, TypedDictValue):
-        if value.extra_keys is None and all(
-            required for required, _ in value.items.items()
+        if value.extra_keys is NO_RETURN_VALUE and all(
+            entry.required for entry in value.items.values()
         ):
             return [KnownValue(key) for key in value.items]
         possibilities = [KnownValue(key) for key in value.items]
-        if value.extra_keys is not None:
+        if value.extra_keys is not NO_RETURN_VALUE:
             possibilities.append(TypedValue(str))
         return MultiValuedValue(possibilities)
     elif isinstance(value, DictIncompleteValue):
@@ -2622,8 +2759,8 @@ def kv_pairs_from_mapping(
         return value_val.kv_pairs
     elif isinstance(value_val, TypedDictValue):
         pairs = [
-            KVPair(KnownValue(key), value, is_required=required)
-            for key, (required, value) in value_val.items.items()
+            KVPair(KnownValue(key), entry.typ, is_required=entry.required)
+            for key, entry in value_val.items.items()
         ]
         if value_val.extra_keys is not None:
             pairs.append(


Test patch:
diff --git a/pyanalyze/test_annotations.py b/pyanalyze/test_annotations.py
index 2a33724..6c7dc05 100644
--- a/pyanalyze/test_annotations.py
+++ b/pyanalyze/test_annotations.py
@@ -15,6 +15,7 @@ from .value import (
     NewTypeValue,
     SequenceValue,
     SubclassValue,
+    TypedDictEntry,
     TypedDictValue,
     TypedValue,
     TypeVarValue,
@@ -1504,9 +1505,9 @@ class TestRequired(TestNameCheckVisitorBase):
                 td,
                 TypedDictValue(
                     {
-                        "a": (True, TypedValue(int)),
-                        "b": (True, TypedValue(str)),
-                        "c": (False, TypedValue(float)),
+                        "a": TypedDictEntry(TypedValue(int)),
+                        "b": TypedDictEntry(TypedValue(str)),
+                        "c": TypedDictEntry(TypedValue(float), required=False),
                     }
                 ),
             )
@@ -1521,9 +1522,9 @@ class TestRequired(TestNameCheckVisitorBase):
                 td,
                 TypedDictValue(
                     {
-                        "a": (False, TypedValue(int)),
-                        "b": (True, TypedValue(str)),
-                        "c": (False, TypedValue(float)),
+                        "a": TypedDictEntry(TypedValue(int), required=False),
+                        "b": TypedDictEntry(TypedValue(str)),
+                        "c": TypedDictEntry(TypedValue(float), required=False),
                     }
                 ),
             )
@@ -1538,9 +1539,9 @@ class TestRequired(TestNameCheckVisitorBase):
                 td,
                 TypedDictValue(
                     {
-                        "a": (True, TypedValue(int)),
-                        "b": (True, TypedValue(str)),
-                        "c": (False, TypedValue(float)),
+                        "a": TypedDictEntry(TypedValue(int)),
+                        "b": TypedDictEntry(TypedValue(str)),
+                        "c": TypedDictEntry(TypedValue(float), required=False),
                     }
                 ),
             )
@@ -1571,9 +1572,9 @@ class TestRequired(TestNameCheckVisitorBase):
                 KnownValue(None)
                 | TypedDictValue(
                     {
-                        "a": (True, TypedValue(int)),
-                        "b": (True, TypedValue(str)),
-                        "c": (False, TypedValue(float)),
+                        "a": TypedDictEntry(TypedValue(int)),
+                        "b": TypedDictEntry(TypedValue(str)),
+                        "c": TypedDictEntry(TypedValue(float), required=False),
                     }
                 ),
             )
@@ -1582,9 +1583,9 @@ class TestRequired(TestNameCheckVisitorBase):
                 KnownValue(None)
                 | TypedDictValue(
                     {
-                        "a": (True, TypedValue(int)),
-                        "b": (True, TypedValue(str)),
-                        "c": (False, TypedValue(float)),
+                        "a": TypedDictEntry(TypedValue(int)),
+                        "b": TypedDictEntry(TypedValue(str)),
+                        "c": TypedDictEntry(TypedValue(float), required=False),
                     }
                 ),
             )
@@ -1605,9 +1606,9 @@ class TestRequired(TestNameCheckVisitorBase):
                 td,
                 TypedDictValue(
                     {
-                        "a": (True, TypedValue(int)),
-                        "b": (True, TypedValue(str)),
-                        "c": (False, TypedValue(float)),
+                        "a": TypedDictEntry(TypedValue(int)),
+                        "b": TypedDictEntry(TypedValue(str)),
+                        "c": TypedDictEntry(TypedValue(float), required=False),
                     }
                 ),
             )
@@ -1622,9 +1623,9 @@ class TestRequired(TestNameCheckVisitorBase):
                 td,
                 TypedDictValue(
                     {
-                        "a": (False, TypedValue(int)),
-                        "b": (True, TypedValue(str)),
-                        "c": (False, TypedValue(float)),
+                        "a": TypedDictEntry(TypedValue(int), required=False),
+                        "b": TypedDictEntry(TypedValue(str)),
+                        "c": TypedDictEntry(TypedValue(float), required=False),
                     }
                 ),
             )
@@ -1639,9 +1640,9 @@ class TestRequired(TestNameCheckVisitorBase):
                 td,
                 TypedDictValue(
                     {
-                        "a": (True, TypedValue(int)),
-                        "b": (True, TypedValue(str)),
-                        "c": (False, TypedValue(float)),
+                        "a": TypedDictEntry(TypedValue(int)),
+                        "b": TypedDictEntry(TypedValue(str)),
+                        "c": TypedDictEntry(TypedValue(float), required=False),
                     }
                 ),
             )
diff --git a/pyanalyze/test_boolability.py b/pyanalyze/test_boolability.py
index 8a38b22..46e9e3b 100644
--- a/pyanalyze/test_boolability.py
+++ b/pyanalyze/test_boolability.py
@@ -14,6 +14,7 @@ from .value import (
     KnownValue,
     KVPair,
     SequenceValue,
+    TypedDictEntry,
     TypedDictValue,
     TypedValue,
     UnboundMethodValue,
@@ -44,10 +45,10 @@ def test_get_boolability() -> None:
 
     # Sequence/dict values
     assert Boolability.type_always_true == get_boolability(
-        TypedDictValue({"a": (True, TypedValue(int))})
+        TypedDictValue({"a": TypedDictEntry(TypedValue(int))})
     )
     assert Boolability.boolable == get_boolability(
-        TypedDictValue({"a": (False, TypedValue(int))})
+        TypedDictValue({"a": TypedDictEntry(TypedValue(int), required=False)})
     )
     assert Boolability.type_always_true == get_boolability(
         SequenceValue(tuple, [(False, KnownValue(1))])
diff --git a/pyanalyze/test_name_check_visitor.py b/pyanalyze/test_name_check_visitor.py
index f3151dc..baeefcb 100644
--- a/pyanalyze/test_name_check_visitor.py
+++ b/pyanalyze/test_name_check_visitor.py
@@ -44,6 +44,7 @@ from .value import (
     ReferencingValue,
     SequenceValue,
     SubclassValue,
+    TypedDictEntry,
     TypedDictValue,
     TypedValue,
     TypeVarValue,
@@ -132,6 +133,7 @@ def _make_module(code_str: str) -> types.ModuleType:
         CallableValue=CallableValue,
         DictIncompleteValue=DictIncompleteValue,
         KVPair=KVPair,
+        TypedDictEntry=TypedDictEntry,
         GenericValue=GenericValue,
         KnownValue=KnownValue,
         MultiValuedValue=MultiValuedValue,
diff --git a/pyanalyze/test_signature.py b/pyanalyze/test_signature.py
index 72a8d85..25f2b11 100644
--- a/pyanalyze/test_signature.py
+++ b/pyanalyze/test_signature.py
@@ -17,6 +17,7 @@ from .value import (
     GenericValue,
     KnownValue,
     SequenceValue,
+    TypedDictEntry,
     TypedDictValue,
     TypedValue,
 )
@@ -316,9 +317,9 @@ class TestCanAssign:
         self.can(three_ints_sig, Signature.make([dict_int]))
         good_td = TypedDictValue(
             {
-                "a": (True, TypedValue(int)),
-                "b": (True, TypedValue(int)),
-                "c": (True, TypedValue(int)),
+                "a": TypedDictEntry(TypedValue(int)),
+                "b": TypedDictEntry(TypedValue(int)),
+                "c": TypedDictEntry(TypedValue(int)),
             }
         )
         self.can(
@@ -326,7 +327,7 @@ class TestCanAssign:
             Signature.make([P("a", annotation=good_td, kind=K.VAR_KEYWORD)]),
         )
         smaller_td = TypedDictValue(
-            {"a": (True, TypedValue(int)), "b": (True, TypedValue(int))}
+            {"a": TypedDictEntry(TypedValue(int)), "b": TypedDictEntry(TypedValue(int))}
         )
         # This is still OK because TypedDicts are allowed to have extra keys.
         # TODO change to can
@@ -335,7 +336,7 @@ class TestCanAssign:
             Signature.make([P("a", annotation=smaller_td, kind=K.VAR_KEYWORD)]),
         )
         bad_td = TypedDictValue(
-            {"a": (True, TypedValue(str)), "b": (True, TypedValue(int))}
+            {"a": TypedDictEntry(TypedValue(str)), "b": TypedDictEntry(TypedValue(int))}
         )
         self.cannot(
             three_ints_sig,
@@ -1273,7 +1274,10 @@ class TestUnpack(TestNameCheckVisitorBase):
             assert_is_value(
                 kwargs,
                 TypedDictValue(
-                    {"a": (False, TypedValue(int)), "b": (True, TypedValue(str))}
+                    {
+                        "a": TypedDictEntry(TypedValue(int), required=False),
+                        "b": TypedDictEntry(TypedValue(str)),
+                    }
                 ),
             )
 
diff --git a/pyanalyze/test_typeddict.py b/pyanalyze/test_typeddict.py
index db71c9a..441e6d6 100644
--- a/pyanalyze/test_typeddict.py
+++ b/pyanalyze/test_typeddict.py
@@ -2,7 +2,7 @@
 from .implementation import assert_is_value
 from .test_name_check_visitor import TestNameCheckVisitorBase
 from .test_node_visitor import assert_passes
-from .value import AnySource, AnyValue, TypedDictValue, TypedValue
+from .value import AnySource, AnyValue, TypedDictEntry, TypedDictValue, TypedValue
 
 
 class TestExtraKeys(TestNameCheckVisitorBase):
@@ -21,7 +21,7 @@ class TestExtraKeys(TestNameCheckVisitorBase):
             assert_is_value(
                 x,
                 TypedDictValue(
-                    {"a": (True, TypedValue(str))}, extra_keys=TypedValue(int)
+                    {"a": TypedDictEntry(TypedValue(str))}, extra_keys=TypedValue(int)
                 ),
             )
 
@@ -79,7 +79,7 @@ class TestExtraKeys(TestNameCheckVisitorBase):
     def test_compatibility(self):
         from typing import Any, Dict
 
-        from typing_extensions import TypedDict
+        from typing_extensions import ReadOnly, TypedDict
 
         from pyanalyze.extensions import has_extra_keys
 
@@ -95,15 +95,27 @@ class TestExtraKeys(TestNameCheckVisitorBase):
         class TD3(TypedDict):
             a: str
 
+        @has_extra_keys(ReadOnly[int])
+        class TD4(TypedDict):
+            a: str
+
         def want_td(td: TD) -> None:
             pass
 
+        def want_td4(td: TD4) -> None:
+            pass
+
         def capybara(td: TD, td2: TD2, td3: TD3, anydict: Dict[str, Any]) -> None:
             want_td(td)
-            want_td(td2)
+            want_td(td2)  # E: incompatible_argument
             want_td(td3)  # E: incompatible_argument
             want_td(anydict)
 
+            want_td4(td)
+            want_td4(td2)
+            want_td4(td3)  # E: incompatible_argument
+            want_td4(anydict)
+
     @assert_passes()
     def test_iteration(self):
         from typing import Union
@@ -130,7 +142,7 @@ class TestExtraKeys(TestNameCheckVisitorBase):
                 assert_type(k, str)
                 assert_type(v, str)
             for k in td2:
-                assert_type(k, Literal["a"])
+                assert_type(k, Union[str, Literal["a"]])
 
 
 class TestTypedDict(TestNameCheckVisitorBase):
@@ -151,7 +163,10 @@ class TestTypedDict(TestNameCheckVisitorBase):
             assert_is_value(
                 cap,
                 TypedDictValue(
-                    {"x": (True, TypedValue(int)), "y": (True, TypedValue(str))}
+                    {
+                        "x": TypedDictEntry(TypedValue(int)),
+                        "y": TypedDictEntry(TypedValue(str)),
+                    }
                 ),
             )
             Capybara(x=1)  # E: incompatible_call
@@ -160,7 +175,10 @@ class TestTypedDict(TestNameCheckVisitorBase):
             assert_is_value(
                 maybe_cap,
                 TypedDictValue(
-                    {"x": (True, TypedValue(int)), "y": (False, TypedValue(str))}
+                    {
+                        "x": TypedDictEntry(TypedValue(int)),
+                        "y": TypedDictEntry(TypedValue(str), required=False),
+                    }
                 ),
             )
 
@@ -187,14 +205,20 @@ class TestTypedDict(TestNameCheckVisitorBase):
             assert_is_value(
                 x,
                 TypedDictValue(
-                    {"a": (True, TypedValue(int)), "b": (True, TypedValue(str))}
+                    {
+                        "a": TypedDictEntry(TypedValue(int)),
+                        "b": TypedDictEntry(TypedValue(str)),
+                    }
                 ),
             )
             assert_is_value(x["a"], TypedValue(int))
             assert_is_value(
                 y,
                 TypedDictValue(
-                    {"a": (True, TypedValue(int)), "b": (True, TypedValue(str))}
+                    {
+                        "a": TypedDictEntry(TypedValue(int)),
+                        "b": TypedDictEntry(TypedValue(str)),
+                    }
                 ),
             )
             assert_is_value(y["a"], TypedValue(int))
@@ -242,3 +266,108 @@ class TestTypedDict(TestNameCheckVisitorBase):
             f({"a": "a"})  # E: incompatible_argument
             g({"c": 1.0})
             g({})  # E: incompatible_argument
+
+
+class TestReadOnly(TestNameCheckVisitorBase):
+    @assert_passes()
+    def test_basic(self):
+        from typing import Any, Dict
+
+        from typing_extensions import NotRequired, ReadOnly, TypedDict
+
+        class TD(TypedDict):
+            a: ReadOnly[NotRequired[int]]
+            b: ReadOnly[str]
+
+        def capybara(td: TD, anydict: Dict[str, Any]) -> None:
+            td["a"] = 1  # E: readonly_typeddict
+            td["b"] = "a"  # E: readonly_typeddict
+            td.update(anydict)  # E: invalid_typeddict_key
+            td.setdefault("a", 1)  # E: readonly_typeddict
+            td.setdefault("b", "a")  # E: readonly_typeddict
+            td.pop("a")  # E: readonly_typeddict
+            td.pop("b")  # E: incompatible_argument
+            del td["a"]  # E: readonly_typeddict
+            del td["b"]  # E: incompatible_argument
+
+    @assert_passes()
+    def test_compatibility(self):
+        from typing import Any, Dict
+
+        from typing_extensions import ReadOnly, TypedDict
+
+        class TD(TypedDict):
+            a: int
+
+        class TD2(TypedDict):
+            a: ReadOnly[int]
+
+        class TD3(TypedDict):
+            a: bool
+
+        class TD4(TypedDict):
+            a: str
+
+        def want_td(td: TD) -> None:
+            pass
+
+        def want_td2(td: TD2) -> None:
+            pass
+
+        def capybara(
+            td: TD, td2: TD2, td3: TD3, td4: TD4, anydict: Dict[str, Any]
+        ) -> None:
+            want_td(td)
+            want_td(td2)  # E: incompatible_argument
+            want_td(td3)  # E: incompatible_argument
+            want_td(td4)  # E: incompatible_argument
+            want_td(anydict)
+
+            want_td2(td)
+            want_td2(td2)
+            want_td2(td3)
+            want_td2(td4)  # E: incompatible_argument
+            want_td2(anydict)
+
+
+class TestClosed(TestNameCheckVisitorBase):
+    @assert_passes()
+    def test_basic(self):
+        from typing import Any, Dict
+
+        from typing_extensions import NotRequired, TypedDict
+
+        class Closed(TypedDict, closed=True):
+            a: NotRequired[int]
+            b: str
+
+        class Open(TypedDict):
+            a: NotRequired[int]
+            b: str
+
+        def want_closed(td: Closed) -> None:
+            pass
+
+        def want_open(td: Open) -> None:
+            pass
+
+        def capybara(closed: Closed, open: Open, anydict: Dict[str, Any]) -> None:
+            closed["a"] = 1
+            closed["b"] = "a"
+            closed["a"] = "x"  # E: incompatible_argument
+
+            open["a"] = 1
+            open["b"] = "a"
+            open["a"] = "x"  # E: incompatible_argument
+
+            closed.update(anydict)  # E: invalid_typeddict_key
+            open.update(anydict)  # E: invalid_typeddict_key
+
+            x: Closed = {"a": 1, "b": "a", "c": "x"}  # E: incompatible_assignment
+            y: Open = {"a": 1, "b": "a", "c": "x"}
+
+            want_closed(closed)
+            want_closed(open)  # E: incompatible_argument
+
+            want_open(open)
+            want_open(closed)
diff --git a/pyanalyze/test_typeshed.py b/pyanalyze/test_typeshed.py
index 2a5bb9f..41e077a 100644
--- a/pyanalyze/test_typeshed.py
+++ b/pyanalyze/test_typeshed.py
@@ -38,6 +38,7 @@ from .value import (
     NewTypeValue,
     SequenceValue,
     SubclassValue,
+    TypedDictEntry,
     TypedDictValue,
     TypedValue,
     TypeVarValue,
@@ -181,18 +182,26 @@ class TestTypeshedClient(TestNameCheckVisitorBase):
 
 
 _EXPECTED_TYPED_DICTS = {
-    "TD1": TypedDictValue({"a": (True, TypedValue(int)), "b": (True, TypedValue(str))}),
+    "TD1": TypedDictValue(
+        {"a": TypedDictEntry(TypedValue(int)), "b": TypedDictEntry(TypedValue(str))}
+    ),
     "TD2": TypedDictValue(
-        {"a": (False, TypedValue(int)), "b": (False, TypedValue(str))}
+        {
+            "a": TypedDictEntry(TypedValue(int), required=False),
+            "b": TypedDictEntry(TypedValue(str), required=False),
+        }
     ),
     "PEP655": TypedDictValue(
-        {"a": (False, TypedValue(int)), "b": (True, TypedValue(str))}
+        {
+            "a": TypedDictEntry(TypedValue(int), required=False),
+            "b": TypedDictEntry(TypedValue(str)),
+        }
     ),
     "Inherited": TypedDictValue(
         {
-            "a": (True, TypedValue(int)),
-            "b": (True, TypedValue(str)),
-            "c": (True, TypedValue(float)),
+            "a": TypedDictEntry(TypedValue(int)),
+            "b": TypedDictEntry(TypedValue(str)),
+            "c": TypedDictEntry(TypedValue(float)),
         }
     ),
 }
diff --git a/pyanalyze/test_value.py b/pyanalyze/test_value.py
index 7f681e3..a9476a0 100644
--- a/pyanalyze/test_value.py
+++ b/pyanalyze/test_value.py
@@ -358,7 +358,10 @@ def test_variable_name_value() -> None:
 
 def test_typeddict_value() -> None:
     val = value.TypedDictValue(
-        {"a": (True, TypedValue(int)), "b": (True, TypedValue(str))}
+        {
+            "a": value.TypedDictEntry(TypedValue(int)),
+            "b": value.TypedDictEntry(TypedValue(str)),
+        }
     )
     # dict iteration order in some Python versions is not deterministic
     assert str(val) in [
@@ -384,26 +387,44 @@ def test_typeddict_value() -> None:
     assert_can_assign(
         val,
         value.TypedDictValue(
-            {"a": (True, KnownValue(1)), "b": (True, TypedValue(str))}
+            {
+                "a": value.TypedDictEntry(TypedValue(int)),
+                "b": value.TypedDictEntry(TypedValue(str)),
+                "c": value.TypedDictEntry(TypedValue(float)),
+            }
         ),
     )
-    assert_can_assign(
+    assert_cannot_assign(
         val,
         value.TypedDictValue(
             {
-                "a": (True, KnownValue(1)),
-                "b": (True, TypedValue(str)),
-                "c": (True, TypedValue(float)),
+                "a": value.TypedDictEntry(KnownValue(1)),
+                "b": value.TypedDictEntry(TypedValue(str)),
             }
         ),
     )
     assert_cannot_assign(
         val,
         value.TypedDictValue(
-            {"a": (True, KnownValue(1)), "b": (True, TypedValue(int))}
+            {
+                "a": value.TypedDictEntry(KnownValue(1)),
+                "b": value.TypedDictEntry(TypedValue(str)),
+                "c": value.TypedDictEntry(TypedValue(float)),
+            }
         ),
     )
-    assert_cannot_assign(val, value.TypedDictValue({"b": (True, TypedValue(str))}))
+    assert_cannot_assign(
+        val,
+        value.TypedDictValue(
+            {
+                "a": value.TypedDictEntry(KnownValue(1)),
+                "b": value.TypedDictEntry(TypedValue(int)),
+            }
+        ),
+    )
+    assert_cannot_assign(
+        val, value.TypedDictValue({"b": value.TypedDictEntry(TypedValue(str))})
+    )
 
     # DictIncompleteValue
     assert_can_assign(