diff --git a/docs/source/error_code_list.rst b/docs/source/error_code_list.rst
index 2739894dd3ec..73171131bc8d 100644
--- a/docs/source/error_code_list.rst
+++ b/docs/source/error_code_list.rst
@@ -1217,6 +1217,30 @@ If the code being checked is not syntactically valid, mypy issues a
 syntax error. Most, but not all, syntax errors are *blocking errors*:
 they can't be ignored with a ``# type: ignore`` comment.
 
+.. _code-typeddict-readonly-mutated:
+
+ReadOnly key of a TypedDict is mutated [typeddict-readonly-mutated]
+-------------------------------------------------------------------
+
+Consider this example:
+
+.. code-block:: python
+
+    from datetime import datetime
+    from typing import TypedDict
+    from typing_extensions import ReadOnly
+
+    class User(TypedDict):
+        username: ReadOnly[str]
+        last_active: datetime
+
+    user: User = {'username': 'foobar', 'last_active': datetime.now()}
+    user['last_active'] = datetime.now()  # ok
+    user['username'] = 'other'  # error: ReadOnly TypedDict key "key" TypedDict is mutated  [typeddict-readonly-mutated]
+
+`PEP 705 <https://peps.python.org/pep-0705>`_ specifies
+how ``ReadOnly`` special form works for ``TypedDict`` objects.
+
 .. _code-misc:
 
 Miscellaneous checks [misc]
diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py
index 55c42335744d..98e6eb6a7fc3 100644
--- a/mypy/checkexpr.py
+++ b/mypy/checkexpr.py
@@ -986,6 +986,10 @@ def check_typeddict_call_with_kwargs(
         always_present_keys: set[str],
     ) -> Type:
         actual_keys = kwargs.keys()
+        if callee.to_be_mutated:
+            assigned_readonly_keys = actual_keys & callee.readonly_keys
+            if assigned_readonly_keys:
+                self.msg.readonly_keys_mutated(assigned_readonly_keys, context=context)
         if not (
             callee.required_keys <= always_present_keys and actual_keys <= callee.items.keys()
         ):
@@ -4349,7 +4353,7 @@ def visit_index_with_type(
             else:
                 return self.nonliteral_tuple_index_helper(left_type, index)
         elif isinstance(left_type, TypedDictType):
-            return self.visit_typeddict_index_expr(left_type, e.index)
+            return self.visit_typeddict_index_expr(left_type, e.index)[0]
         elif isinstance(left_type, FunctionLike) and left_type.is_type_obj():
             if left_type.type_object().is_enum:
                 return self.visit_enum_index_expr(left_type.type_object(), e.index, e)
@@ -4530,7 +4534,7 @@ def union_tuple_fallback_item(self, left_type: TupleType) -> Type:
 
     def visit_typeddict_index_expr(
         self, td_type: TypedDictType, index: Expression, setitem: bool = False
-    ) -> Type:
+    ) -> tuple[Type, set[str]]:
         if isinstance(index, StrExpr):
             key_names = [index.value]
         else:
@@ -4553,17 +4557,17 @@ def visit_typeddict_index_expr(
                     key_names.append(key_type.value)
                 else:
                     self.msg.typeddict_key_must_be_string_literal(td_type, index)
-                    return AnyType(TypeOfAny.from_error)
+                    return AnyType(TypeOfAny.from_error), set()
 
         value_types = []
         for key_name in key_names:
             value_type = td_type.items.get(key_name)
             if value_type is None:
                 self.msg.typeddict_key_not_found(td_type, key_name, index, setitem)
-                return AnyType(TypeOfAny.from_error)
+                return AnyType(TypeOfAny.from_error), set()
             else:
                 value_types.append(value_type)
-        return make_simplified_union(value_types)
+        return make_simplified_union(value_types), set(key_names)
 
     def visit_enum_index_expr(
         self, enum_type: TypeInfo, index: Expression, context: Context
diff --git a/mypy/checkmember.py b/mypy/checkmember.py
index 0f117f5475ed..8f99f96e2dd5 100644
--- a/mypy/checkmember.py
+++ b/mypy/checkmember.py
@@ -1185,9 +1185,12 @@ def analyze_typeddict_access(
         if isinstance(mx.context, IndexExpr):
             # Since we can get this during `a['key'] = ...`
             # it is safe to assume that the context is `IndexExpr`.
-            item_type = mx.chk.expr_checker.visit_typeddict_index_expr(
+            item_type, key_names = mx.chk.expr_checker.visit_typeddict_index_expr(
                 typ, mx.context.index, setitem=True
             )
+            assigned_readonly_keys = typ.readonly_keys & key_names
+            if assigned_readonly_keys:
+                mx.msg.readonly_keys_mutated(assigned_readonly_keys, context=mx.context)
         else:
             # It can also be `a.__setitem__(...)` direct call.
             # In this case `item_type` can be `Any`,
diff --git a/mypy/checkpattern.py b/mypy/checkpattern.py
index cb3577ce2f6e..fa23dfb5f453 100644
--- a/mypy/checkpattern.py
+++ b/mypy/checkpattern.py
@@ -498,7 +498,7 @@ def get_mapping_item_type(
             with self.msg.filter_errors() as local_errors:
                 result: Type | None = self.chk.expr_checker.visit_typeddict_index_expr(
                     mapping_type, key
-                )
+                )[0]
                 has_local_errors = local_errors.has_new_errors()
             # If we can't determine the type statically fall back to treating it as a normal
             # mapping
diff --git a/mypy/copytype.py b/mypy/copytype.py
index 465f06566f54..ecb1a89759b6 100644
--- a/mypy/copytype.py
+++ b/mypy/copytype.py
@@ -107,7 +107,9 @@ def visit_tuple_type(self, t: TupleType) -> ProperType:
         return self.copy_common(t, TupleType(t.items, t.partial_fallback, implicit=t.implicit))
 
     def visit_typeddict_type(self, t: TypedDictType) -> ProperType:
-        return self.copy_common(t, TypedDictType(t.items, t.required_keys, t.fallback))
+        return self.copy_common(
+            t, TypedDictType(t.items, t.required_keys, t.readonly_keys, t.fallback)
+        )
 
     def visit_literal_type(self, t: LiteralType) -> ProperType:
         return self.copy_common(t, LiteralType(value=t.value, fallback=t.fallback))
diff --git a/mypy/errorcodes.py b/mypy/errorcodes.py
index ad061b161af1..a170b5d4d65a 100644
--- a/mypy/errorcodes.py
+++ b/mypy/errorcodes.py
@@ -185,6 +185,9 @@ def __hash__(self) -> int:
 ANNOTATION_UNCHECKED = ErrorCode(
     "annotation-unchecked", "Notify about type annotations in unchecked functions", "General"
 )
+TYPEDDICT_READONLY_MUTATED = ErrorCode(
+    "typeddict-readonly-mutated", "TypedDict's ReadOnly key is mutated", "General"
+)
 POSSIBLY_UNDEFINED: Final[ErrorCode] = ErrorCode(
     "possibly-undefined",
     "Warn about variables that are defined only in some execution paths",
diff --git a/mypy/exprtotype.py b/mypy/exprtotype.py
index c7df851668be..506194a4b285 100644
--- a/mypy/exprtotype.py
+++ b/mypy/exprtotype.py
@@ -244,7 +244,7 @@ def expr_to_unanalyzed_type(
                 value, options, allow_new_syntax, expr
             )
         result = TypedDictType(
-            items, set(), Instance(MISSING_FALLBACK, ()), expr.line, expr.column
+            items, set(), set(), Instance(MISSING_FALLBACK, ()), expr.line, expr.column
         )
         result.extra_items_from = extra_items_from
         return result
diff --git a/mypy/fastparse.py b/mypy/fastparse.py
index 18858b0fa0b8..bbbe2184738c 100644
--- a/mypy/fastparse.py
+++ b/mypy/fastparse.py
@@ -2130,7 +2130,7 @@ def visit_Dict(self, n: ast3.Dict) -> Type:
                     continue
                 return self.invalid_type(n)
             items[item_name.value] = self.visit(value)
-        result = TypedDictType(items, set(), _dummy_fallback, n.lineno, n.col_offset)
+        result = TypedDictType(items, set(), set(), _dummy_fallback, n.lineno, n.col_offset)
         result.extra_items_from = extra_items_from
         return result
 
diff --git a/mypy/join.py b/mypy/join.py
index 5284be7dd2a1..865dd073d081 100644
--- a/mypy/join.py
+++ b/mypy/join.py
@@ -631,10 +631,13 @@ def visit_typeddict_type(self, t: TypedDictType) -> ProperType:
                 )
             }
             fallback = self.s.create_anonymous_fallback()
+            all_keys = set(items.keys())
             # We need to filter by items.keys() since some required keys present in both t and
             # self.s might be missing from the join if the types are incompatible.
-            required_keys = set(items.keys()) & t.required_keys & self.s.required_keys
-            return TypedDictType(items, required_keys, fallback)
+            required_keys = all_keys & t.required_keys & self.s.required_keys
+            # If one type has a key as readonly, we mark it as readonly for both:
+            readonly_keys = (t.readonly_keys | t.readonly_keys) & all_keys
+            return TypedDictType(items, required_keys, readonly_keys, fallback)
         elif isinstance(self.s, Instance):
             return join_types(self.s, t.fallback)
         else:
diff --git a/mypy/meet.py b/mypy/meet.py
index 91abf43c0877..9f5c2d72a8cb 100644
--- a/mypy/meet.py
+++ b/mypy/meet.py
@@ -1017,7 +1017,8 @@ def visit_typeddict_type(self, t: TypedDictType) -> ProperType:
             items = dict(item_list)
             fallback = self.s.create_anonymous_fallback()
             required_keys = t.required_keys | self.s.required_keys
-            return TypedDictType(items, required_keys, fallback)
+            readonly_keys = t.readonly_keys | self.s.readonly_keys
+            return TypedDictType(items, required_keys, readonly_keys, fallback)
         elif isinstance(self.s, Instance) and is_subtype(t, self.s):
             return t
         else:
@@ -1139,6 +1140,9 @@ def typed_dict_mapping_overlap(
       - TypedDict(x=str, y=str, total=False) doesn't overlap with Dict[str, int]
       - TypedDict(x=int, y=str, total=False) overlaps with Dict[str, str]
 
+    * A TypedDict with at least one ReadOnly[] key does not overlap
+      with Dict or MutableMapping, because they assume mutable data.
+
     As usual empty, dictionaries lie in a gray area. In general, List[str] and List[str]
     are considered non-overlapping despite empty list belongs to both. However, List[int]
     and List[Never] are considered overlapping.
@@ -1159,6 +1163,12 @@ def typed_dict_mapping_overlap(
         assert isinstance(right, TypedDictType)
         typed, other = right, left
 
+    mutable_mapping = next(
+        (base for base in other.type.mro if base.fullname == "typing.MutableMapping"), None
+    )
+    if mutable_mapping is not None and typed.readonly_keys:
+        return False
+
     mapping = next(base for base in other.type.mro if base.fullname == "typing.Mapping")
     other = map_instance_to_supertype(other, mapping)
     key_type, value_type = get_proper_types(other.args)
diff --git a/mypy/messages.py b/mypy/messages.py
index 6567d9d96d0b..adf150eab50a 100644
--- a/mypy/messages.py
+++ b/mypy/messages.py
@@ -926,6 +926,17 @@ def invalid_index_type(
             code=code,
         )
 
+    def readonly_keys_mutated(self, keys: set[str], context: Context) -> None:
+        if len(keys) == 1:
+            suffix = "is"
+        else:
+            suffix = "are"
+        self.fail(
+            "ReadOnly {} TypedDict {} mutated".format(format_key_list(sorted(keys)), suffix),
+            code=codes.TYPEDDICT_READONLY_MUTATED,
+            context=context,
+        )
+
     def too_few_arguments(
         self, callee: CallableType, context: Context, argument_names: Sequence[str | None] | None
     ) -> None:
@@ -2613,10 +2624,13 @@ def format_literal_value(typ: LiteralType) -> str:
             return format(typ.fallback)
         items = []
         for item_name, item_type in typ.items.items():
-            modifier = "" if item_name in typ.required_keys else "?"
+            modifier = ""
+            if item_name not in typ.required_keys:
+                modifier += "?"
+            if item_name in typ.readonly_keys:
+                modifier += "="
             items.append(f"{item_name!r}{modifier}: {format(item_type)}")
-        s = f"TypedDict({{{', '.join(items)}}})"
-        return s
+        return f"TypedDict({{{', '.join(items)}}})"
     elif isinstance(typ, LiteralType):
         return f"Literal[{format_literal_value(typ)}]"
     elif isinstance(typ, UnionType):
diff --git a/mypy/plugins/default.py b/mypy/plugins/default.py
index 5139b9b82289..73c5742614ee 100644
--- a/mypy/plugins/default.py
+++ b/mypy/plugins/default.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 from functools import partial
-from typing import Callable
+from typing import Callable, Final
 
 import mypy.errorcodes as codes
 from mypy import message_registry
@@ -372,6 +372,10 @@ def typed_dict_setdefault_callback(ctx: MethodContext) -> Type:
             )
             return AnyType(TypeOfAny.from_error)
 
+        assigned_readonly_keys = ctx.type.readonly_keys & set(keys)
+        if assigned_readonly_keys:
+            ctx.api.msg.readonly_keys_mutated(assigned_readonly_keys, context=ctx.context)
+
         default_type = ctx.arg_types[1][0]
 
         value_types = []
@@ -415,13 +419,16 @@ def typed_dict_delitem_callback(ctx: MethodContext) -> Type:
             return AnyType(TypeOfAny.from_error)
 
         for key in keys:
-            if key in ctx.type.required_keys:
+            if key in ctx.type.required_keys or key in ctx.type.readonly_keys:
                 ctx.api.msg.typeddict_key_cannot_be_deleted(ctx.type, key, ctx.context)
             elif key not in ctx.type.items:
                 ctx.api.msg.typeddict_key_not_found(ctx.type, key, ctx.context)
     return ctx.default_return_type
 
 
+_TP_DICT_MUTATING_METHODS: Final = frozenset({"update of TypedDict", "__ior__ of TypedDict"})
+
+
 def typed_dict_update_signature_callback(ctx: MethodSigContext) -> CallableType:
     """Try to infer a better signature type for methods that update `TypedDict`.
 
@@ -436,10 +443,19 @@ def typed_dict_update_signature_callback(ctx: MethodSigContext) -> CallableType:
         arg_type = arg_type.as_anonymous()
         arg_type = arg_type.copy_modified(required_keys=set())
         if ctx.args and ctx.args[0]:
-            with ctx.api.msg.filter_errors():
+            if signature.name in _TP_DICT_MUTATING_METHODS:
+                # If we want to mutate this object in place, we need to set this flag,
+                # it will trigger an extra check in TypedDict's checker.
+                arg_type.to_be_mutated = True
+            with ctx.api.msg.filter_errors(
+                filter_errors=lambda name, info: info.code != codes.TYPEDDICT_READONLY_MUTATED,
+                save_filtered_errors=True,
+            ):
                 inferred = get_proper_type(
                     ctx.api.get_expression_type(ctx.args[0][0], type_context=arg_type)
                 )
+            if arg_type.to_be_mutated:
+                arg_type.to_be_mutated = False  # Done!
             possible_tds = []
             if isinstance(inferred, TypedDictType):
                 possible_tds = [inferred]
diff --git a/mypy/plugins/proper_plugin.py b/mypy/plugins/proper_plugin.py
index a1fd05272b65..f51685c80afa 100644
--- a/mypy/plugins/proper_plugin.py
+++ b/mypy/plugins/proper_plugin.py
@@ -106,6 +106,7 @@ def is_special_target(right: ProperType) -> bool:
             "mypy.types.ErasedType",
             "mypy.types.DeletedType",
             "mypy.types.RequiredType",
+            "mypy.types.ReadOnlyType",
         ):
             # Special case: these are not valid targets for a type alias and thus safe.
             # TODO: introduce a SyntheticType base to simplify this?
diff --git a/mypy/semanal.py b/mypy/semanal.py
index 0b654d6b145f..27abf2c1dc4c 100644
--- a/mypy/semanal.py
+++ b/mypy/semanal.py
@@ -7169,7 +7169,7 @@ def type_analyzer(
         allow_tuple_literal: bool = False,
         allow_unbound_tvars: bool = False,
         allow_placeholder: bool = False,
-        allow_required: bool = False,
+        allow_typed_dict_special_forms: bool = False,
         allow_param_spec_literals: bool = False,
         allow_unpack: bool = False,
         report_invalid_types: bool = True,
@@ -7188,7 +7188,7 @@ def type_analyzer(
             allow_tuple_literal=allow_tuple_literal,
             report_invalid_types=report_invalid_types,
             allow_placeholder=allow_placeholder,
-            allow_required=allow_required,
+            allow_typed_dict_special_forms=allow_typed_dict_special_forms,
             allow_param_spec_literals=allow_param_spec_literals,
             allow_unpack=allow_unpack,
             prohibit_self_type=prohibit_self_type,
@@ -7211,7 +7211,7 @@ def anal_type(
         allow_tuple_literal: bool = False,
         allow_unbound_tvars: bool = False,
         allow_placeholder: bool = False,
-        allow_required: bool = False,
+        allow_typed_dict_special_forms: bool = False,
         allow_param_spec_literals: bool = False,
         allow_unpack: bool = False,
         report_invalid_types: bool = True,
@@ -7246,7 +7246,7 @@ def anal_type(
             allow_unbound_tvars=allow_unbound_tvars,
             allow_tuple_literal=allow_tuple_literal,
             allow_placeholder=allow_placeholder,
-            allow_required=allow_required,
+            allow_typed_dict_special_forms=allow_typed_dict_special_forms,
             allow_param_spec_literals=allow_param_spec_literals,
             allow_unpack=allow_unpack,
             report_invalid_types=report_invalid_types,
diff --git a/mypy/semanal_shared.py b/mypy/semanal_shared.py
index db19f074911f..cb0bdebab724 100644
--- a/mypy/semanal_shared.py
+++ b/mypy/semanal_shared.py
@@ -181,7 +181,7 @@ def anal_type(
         tvar_scope: TypeVarLikeScope | None = None,
         allow_tuple_literal: bool = False,
         allow_unbound_tvars: bool = False,
-        allow_required: bool = False,
+        allow_typed_dict_special_forms: bool = False,
         allow_placeholder: bool = False,
         report_invalid_types: bool = True,
         prohibit_self_type: str | None = None,
diff --git a/mypy/semanal_typeddict.py b/mypy/semanal_typeddict.py
index 832530df55e5..d081898bf010 100644
--- a/mypy/semanal_typeddict.py
+++ b/mypy/semanal_typeddict.py
@@ -43,6 +43,7 @@
 from mypy.types import (
     TPDICT_NAMES,
     AnyType,
+    ReadOnlyType,
     RequiredType,
     Type,
     TypedDictType,
@@ -102,13 +103,15 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N
             and defn.base_type_exprs[0].fullname in TPDICT_NAMES
         ):
             # Building a new TypedDict
-            fields, types, statements, required_keys = self.analyze_typeddict_classdef_fields(defn)
+            fields, types, statements, required_keys, readonly_keys = (
+                self.analyze_typeddict_classdef_fields(defn)
+            )
             if fields is None:
                 return True, None  # Defer
             if self.api.is_func_scope() and "@" not in defn.name:
                 defn.name += "@" + str(defn.line)
             info = self.build_typeddict_typeinfo(
-                defn.name, fields, types, required_keys, defn.line, existing_info
+                defn.name, fields, types, required_keys, readonly_keys, defn.line, existing_info
             )
             defn.analyzed = TypedDictExpr(info)
             defn.analyzed.line = defn.line
@@ -154,10 +157,13 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N
         keys: list[str] = []
         types = []
         required_keys = set()
+        readonly_keys = set()
         # Iterate over bases in reverse order so that leftmost base class' keys take precedence
         for base in reversed(typeddict_bases):
-            self.add_keys_and_types_from_base(base, keys, types, required_keys, defn)
-        (new_keys, new_types, new_statements, new_required_keys) = (
+            self.add_keys_and_types_from_base(
+                base, keys, types, required_keys, readonly_keys, defn
+            )
+        (new_keys, new_types, new_statements, new_required_keys, new_readonly_keys) = (
             self.analyze_typeddict_classdef_fields(defn, keys)
         )
         if new_keys is None:
@@ -165,8 +171,9 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N
         keys.extend(new_keys)
         types.extend(new_types)
         required_keys.update(new_required_keys)
+        readonly_keys.update(new_readonly_keys)
         info = self.build_typeddict_typeinfo(
-            defn.name, keys, types, required_keys, defn.line, existing_info
+            defn.name, keys, types, required_keys, readonly_keys, defn.line, existing_info
         )
         defn.analyzed = TypedDictExpr(info)
         defn.analyzed.line = defn.line
@@ -180,6 +187,7 @@ def add_keys_and_types_from_base(
         keys: list[str],
         types: list[Type],
         required_keys: set[str],
+        readonly_keys: set[str],
         ctx: Context,
     ) -> None:
         base_args: list[Type] = []
@@ -221,6 +229,7 @@ def add_keys_and_types_from_base(
         keys.extend(valid_items.keys())
         types.extend(valid_items.values())
         required_keys.update(base_typed_dict.required_keys)
+        readonly_keys.update(base_typed_dict.readonly_keys)
 
     def analyze_base_args(self, base: IndexExpr, ctx: Context) -> list[Type] | None:
         """Analyze arguments of base type expressions as types.
@@ -241,7 +250,9 @@ def analyze_base_args(self, base: IndexExpr, ctx: Context) -> list[Type] | None:
                 self.fail("Invalid TypedDict type argument", ctx)
                 return None
             analyzed = self.api.anal_type(
-                type, allow_required=True, allow_placeholder=not self.api.is_func_scope()
+                type,
+                allow_typed_dict_special_forms=True,
+                allow_placeholder=not self.api.is_func_scope(),
             )
             if analyzed is None:
                 return None
@@ -270,7 +281,7 @@ def map_items_to_base(
 
     def analyze_typeddict_classdef_fields(
         self, defn: ClassDef, oldfields: list[str] | None = None
-    ) -> tuple[list[str] | None, list[Type], list[Statement], set[str]]:
+    ) -> tuple[list[str] | None, list[Type], list[Statement], set[str], set[str]]:
         """Analyze fields defined in a TypedDict class definition.
 
         This doesn't consider inherited fields (if any). Also consider totality,
@@ -316,17 +327,15 @@ def analyze_typeddict_classdef_fields(
                 else:
                     analyzed = self.api.anal_type(
                         stmt.unanalyzed_type,
-                        allow_required=True,
+                        allow_typed_dict_special_forms=True,
                         allow_placeholder=not self.api.is_func_scope(),
                         prohibit_self_type="TypedDict item type",
                     )
                     if analyzed is None:
-                        return None, [], [], set()  # Need to defer
+                        return None, [], [], set(), set()  # Need to defer
                     types.append(analyzed)
                     if not has_placeholder(analyzed):
-                        stmt.type = (
-                            analyzed.item if isinstance(analyzed, RequiredType) else analyzed
-                        )
+                        stmt.type = self.extract_meta_info(analyzed, stmt)[0]
                 # ...despite possible minor failures that allow further analysis.
                 if stmt.type is None or hasattr(stmt, "new_syntax") and not stmt.new_syntax:
                     self.fail(TPDICT_CLASS_ERROR, stmt)
@@ -342,17 +351,49 @@ def analyze_typeddict_classdef_fields(
                 if key == "total":
                     continue
                 self.msg.unexpected_keyword_argument_for_function(for_function, key, defn)
-        required_keys = {
-            field
-            for (field, t) in zip(fields, types)
-            if (total or (isinstance(t, RequiredType) and t.required))
-            and not (isinstance(t, RequiredType) and not t.required)
-        }
-        types = [  # unwrap Required[T] to just T
-            t.item if isinstance(t, RequiredType) else t for t in types
-        ]
-
-        return fields, types, statements, required_keys
+
+        res_types = []
+        readonly_keys = set()
+        required_keys = set()
+        for field, t in zip(fields, types):
+            typ, required, readonly = self.extract_meta_info(t)
+            res_types.append(typ)
+            if (total or required is True) and required is not False:
+                required_keys.add(field)
+            if readonly:
+                readonly_keys.add(field)
+
+        return fields, res_types, statements, required_keys, readonly_keys
+
+    def extract_meta_info(
+        self, typ: Type, context: Context | None = None
+    ) -> tuple[Type, bool | None, bool]:
+        """Unwrap all metadata types."""
+        is_required = None  # default, no modification
+        readonly = False  # by default all is mutable
+
+        seen_required = False
+        seen_readonly = False
+        while isinstance(typ, (RequiredType, ReadOnlyType)):
+            if isinstance(typ, RequiredType):
+                if context is not None and seen_required:
+                    self.fail(
+                        '"{}" type cannot be nested'.format(
+                            "Required[]" if typ.required else "NotRequired[]"
+                        ),
+                        context,
+                        code=codes.VALID_TYPE,
+                    )
+                is_required = typ.required
+                seen_required = True
+                typ = typ.item
+            if isinstance(typ, ReadOnlyType):
+                if context is not None and seen_readonly:
+                    self.fail('"ReadOnly[]" type cannot be nested', context, code=codes.VALID_TYPE)
+                readonly = True
+                seen_readonly = True
+                typ = typ.item
+        return typ, is_required, readonly
 
     def check_typeddict(
         self, node: Expression, var_name: str | None, is_func_scope: bool
@@ -391,7 +432,7 @@ def check_typeddict(
                     name += "@" + str(call.line)
             else:
                 name = var_name = "TypedDict@" + str(call.line)
-            info = self.build_typeddict_typeinfo(name, [], [], set(), call.line, None)
+            info = self.build_typeddict_typeinfo(name, [], [], set(), set(), call.line, None)
         else:
             if var_name is not None and name != var_name:
                 self.fail(
@@ -410,8 +451,11 @@ def check_typeddict(
                 if (total or (isinstance(t, RequiredType) and t.required))
                 and not (isinstance(t, RequiredType) and not t.required)
             }
-            types = [  # unwrap Required[T] to just T
-                t.item if isinstance(t, RequiredType) else t for t in types
+            readonly_keys = {
+                field for (field, t) in zip(items, types) if isinstance(t, ReadOnlyType)
+            }
+            types = [  # unwrap Required[T] or ReadOnly[T] to just T
+                t.item if isinstance(t, (RequiredType, ReadOnlyType)) else t for t in types
             ]
 
             # Perform various validations after unwrapping.
@@ -428,7 +472,7 @@ def check_typeddict(
             if isinstance(node.analyzed, TypedDictExpr):
                 existing_info = node.analyzed.info
             info = self.build_typeddict_typeinfo(
-                name, items, types, required_keys, call.line, existing_info
+                name, items, types, required_keys, readonly_keys, call.line, existing_info
             )
             info.line = node.line
         # Store generated TypeInfo under both names, see semanal_namedtuple for more details.
@@ -514,7 +558,7 @@ def parse_typeddict_fields_with_types(
                 return [], [], False
             analyzed = self.api.anal_type(
                 type,
-                allow_required=True,
+                allow_typed_dict_special_forms=True,
                 allow_placeholder=not self.api.is_func_scope(),
                 prohibit_self_type="TypedDict item type",
             )
@@ -535,6 +579,7 @@ def build_typeddict_typeinfo(
         items: list[str],
         types: list[Type],
         required_keys: set[str],
+        readonly_keys: set[str],
         line: int,
         existing_info: TypeInfo | None,
     ) -> TypeInfo:
@@ -546,7 +591,9 @@ def build_typeddict_typeinfo(
         )
         assert fallback is not None
         info = existing_info or self.api.basic_new_typeinfo(name, fallback, line)
-        typeddict_type = TypedDictType(dict(zip(items, types)), required_keys, fallback)
+        typeddict_type = TypedDictType(
+            dict(zip(items, types)), required_keys, readonly_keys, fallback
+        )
         if info.special_alias and has_placeholder(info.special_alias.target):
             self.api.process_placeholder(
                 None, "TypedDict item", info, force_progress=typeddict_type != info.typeddict_type
diff --git a/mypy/server/astdiff.py b/mypy/server/astdiff.py
index f8a874005adb..fc868d288b4d 100644
--- a/mypy/server/astdiff.py
+++ b/mypy/server/astdiff.py
@@ -475,7 +475,8 @@ def visit_tuple_type(self, typ: TupleType) -> SnapshotItem:
     def visit_typeddict_type(self, typ: TypedDictType) -> SnapshotItem:
         items = tuple((key, snapshot_type(item_type)) for key, item_type in typ.items.items())
         required = tuple(sorted(typ.required_keys))
-        return ("TypedDictType", items, required)
+        readonly = tuple(sorted(typ.readonly_keys))
+        return ("TypedDictType", items, required, readonly)
 
     def visit_literal_type(self, typ: LiteralType) -> SnapshotItem:
         return ("LiteralType", snapshot_type(typ.fallback), typ.value)
diff --git a/mypy/subtypes.py b/mypy/subtypes.py
index 608d098791a9..3b775a06bd6e 100644
--- a/mypy/subtypes.py
+++ b/mypy/subtypes.py
@@ -914,6 +914,17 @@ def visit_typeddict_type(self, left: TypedDictType) -> bool:
                 #       lands so here we are anticipating that change.
                 if (name in left.required_keys) != (name in right.required_keys):
                     return False
+                # Readonly fields check:
+                #
+                # A = TypedDict('A', {'x': ReadOnly[int]})
+                # B = TypedDict('A', {'x': int})
+                # def reset_x(b: B) -> None:
+                #     b['x'] = 0
+                #
+                # So, `A` cannot be a subtype of `B`, while `B` can be a subtype of `A`,
+                # because you can use `B` everywhere you use `A`, but not the other way around.
+                if name in left.readonly_keys and name not in right.readonly_keys:
+                    return False
             # (NOTE: Fallbacks don't matter.)
             return True
         else:
diff --git a/mypy/type_visitor.py b/mypy/type_visitor.py
index 38e4c5ba0d01..8aac7e5c2bbd 100644
--- a/mypy/type_visitor.py
+++ b/mypy/type_visitor.py
@@ -276,6 +276,7 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type:
         result = TypedDictType(
             items,
             t.required_keys,
+            t.readonly_keys,
             # TODO: This appears to be unsafe.
             cast(Any, t.fallback.accept(self)),
             t.line,
diff --git a/mypy/typeanal.py b/mypy/typeanal.py
index 6c94390c23dc..0a6b7689136e 100644
--- a/mypy/typeanal.py
+++ b/mypy/typeanal.py
@@ -83,6 +83,7 @@
     PlaceholderType,
     ProperType,
     RawExpressionType,
+    ReadOnlyType,
     RequiredType,
     SyntheticTypeVisitor,
     TrivialSyntheticTypeTranslator,
@@ -219,7 +220,7 @@ def __init__(
         allow_tuple_literal: bool = False,
         allow_unbound_tvars: bool = False,
         allow_placeholder: bool = False,
-        allow_required: bool = False,
+        allow_typed_dict_special_forms: bool = False,
         allow_param_spec_literals: bool = False,
         allow_unpack: bool = False,
         report_invalid_types: bool = True,
@@ -253,7 +254,7 @@ def __init__(
         # If false, record incomplete ref if we generate PlaceholderType.
         self.allow_placeholder = allow_placeholder
         # Are we in a context where Required[] is allowed?
-        self.allow_required = allow_required
+        self.allow_typed_dict_special_forms = allow_typed_dict_special_forms
         # Are we in a context where ParamSpec literals are allowed?
         self.allow_param_spec_literals = allow_param_spec_literals
         # Are we in context where literal "..." specifically is allowed?
@@ -684,7 +685,7 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ
                 return AnyType(TypeOfAny.from_error)
             return self.anal_type(t.args[0])
         elif fullname in ("typing_extensions.Required", "typing.Required"):
-            if not self.allow_required:
+            if not self.allow_typed_dict_special_forms:
                 self.fail(
                     "Required[] can be only used in a TypedDict definition",
                     t,
@@ -696,9 +697,11 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ
                     "Required[] must have exactly one type argument", t, code=codes.VALID_TYPE
                 )
                 return AnyType(TypeOfAny.from_error)
-            return RequiredType(self.anal_type(t.args[0]), required=True)
+            return RequiredType(
+                self.anal_type(t.args[0], allow_typed_dict_special_forms=True), required=True
+            )
         elif fullname in ("typing_extensions.NotRequired", "typing.NotRequired"):
-            if not self.allow_required:
+            if not self.allow_typed_dict_special_forms:
                 self.fail(
                     "NotRequired[] can be only used in a TypedDict definition",
                     t,
@@ -710,7 +713,23 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ
                     "NotRequired[] must have exactly one type argument", t, code=codes.VALID_TYPE
                 )
                 return AnyType(TypeOfAny.from_error)
-            return RequiredType(self.anal_type(t.args[0]), required=False)
+            return RequiredType(
+                self.anal_type(t.args[0], allow_typed_dict_special_forms=True), required=False
+            )
+        elif fullname in ("typing_extensions.ReadOnly", "typing.ReadOnly"):
+            if not self.allow_typed_dict_special_forms:
+                self.fail(
+                    "ReadOnly[] can be only used in a TypedDict definition",
+                    t,
+                    code=codes.VALID_TYPE,
+                )
+                return AnyType(TypeOfAny.from_error)
+            if len(t.args) != 1:
+                self.fail(
+                    '"ReadOnly[]" must have exactly one type argument', t, code=codes.VALID_TYPE
+                )
+                return AnyType(TypeOfAny.from_error)
+            return ReadOnlyType(self.anal_type(t.args[0], allow_typed_dict_special_forms=True))
         elif (
             self.anal_type_guard_arg(t, fullname) is not None
             or self.anal_type_is_arg(t, fullname) is not None
@@ -1223,9 +1242,11 @@ def visit_tuple_type(self, t: TupleType) -> Type:
 
     def visit_typeddict_type(self, t: TypedDictType) -> Type:
         req_keys = set()
+        readonly_keys = set()
         items = {}
         for item_name, item_type in t.items.items():
-            analyzed = self.anal_type(item_type, allow_required=True)
+            # TODO: rework
+            analyzed = self.anal_type(item_type, allow_typed_dict_special_forms=True)
             if isinstance(analyzed, RequiredType):
                 if analyzed.required:
                     req_keys.add(item_name)
@@ -1233,6 +1254,9 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type:
             else:
                 # Keys are required by default.
                 req_keys.add(item_name)
+            if isinstance(analyzed, ReadOnlyType):
+                readonly_keys.add(item_name)
+                analyzed = analyzed.item
             items[item_name] = analyzed
         if t.fallback.type is MISSING_FALLBACK:  # anonymous/inline TypedDict
             if INLINE_TYPEDDICT not in self.options.enable_incomplete_feature:
@@ -1257,10 +1281,12 @@ def visit_typeddict_type(self, t: TypedDictType) -> Type:
                     items[sub_item_name] = sub_item_type
                     if sub_item_name in p_analyzed.required_keys:
                         req_keys.add(sub_item_name)
+                    if sub_item_name in p_analyzed.readonly_keys:
+                        readonly_keys.add(sub_item_name)
         else:
             required_keys = t.required_keys
             fallback = t.fallback
-        return TypedDictType(items, required_keys, fallback, t.line, t.column)
+        return TypedDictType(items, required_keys, readonly_keys, fallback, t.line, t.column)
 
     def visit_raw_expression_type(self, t: RawExpressionType) -> Type:
         # We should never see a bare Literal. We synthesize these raw literals
@@ -1811,12 +1837,12 @@ def anal_type(
         allow_param_spec: bool = False,
         allow_unpack: bool = False,
         allow_ellipsis: bool = False,
-        allow_required: bool = False,
+        allow_typed_dict_special_forms: bool = False,
     ) -> Type:
         if nested:
             self.nesting_level += 1
-        old_allow_required = self.allow_required
-        self.allow_required = allow_required
+        old_allow_typed_dict_special_forms = self.allow_typed_dict_special_forms
+        self.allow_typed_dict_special_forms = allow_typed_dict_special_forms
         old_allow_ellipsis = self.allow_ellipsis
         self.allow_ellipsis = allow_ellipsis
         old_allow_unpack = self.allow_unpack
@@ -1826,7 +1852,7 @@ def anal_type(
         finally:
             if nested:
                 self.nesting_level -= 1
-            self.allow_required = old_allow_required
+            self.allow_typed_dict_special_forms = old_allow_typed_dict_special_forms
             self.allow_ellipsis = old_allow_ellipsis
             self.allow_unpack = old_allow_unpack
         if (
diff --git a/mypy/types.py b/mypy/types.py
index b1e57b2f6a86..dff7e2c0c829 100644
--- a/mypy/types.py
+++ b/mypy/types.py
@@ -476,6 +476,20 @@ def accept(self, visitor: TypeVisitor[T]) -> T:
         return self.item.accept(visitor)
 
 
+class ReadOnlyType(Type):
+    """ReadOnly[T] Only usable at top-level of a TypedDict definition."""
+
+    def __init__(self, item: Type) -> None:
+        super().__init__(line=item.line, column=item.column)
+        self.item = item
+
+    def __repr__(self) -> str:
+        return f"ReadOnly[{self.item}]"
+
+    def accept(self, visitor: TypeVisitor[T]) -> T:
+        return self.item.accept(visitor)
+
+
 class ProperType(Type):
     """Not a type alias.
 
@@ -2554,17 +2568,28 @@ class TypedDictType(ProperType):
     TODO: The fallback structure is perhaps overly complicated.
     """
 
-    __slots__ = ("items", "required_keys", "fallback", "extra_items_from")
+    __slots__ = (
+        "items",
+        "required_keys",
+        "readonly_keys",
+        "fallback",
+        "extra_items_from",
+        "to_be_mutated",
+    )
 
     items: dict[str, Type]  # item_name -> item_type
     required_keys: set[str]
+    readonly_keys: set[str]
     fallback: Instance
+
     extra_items_from: list[ProperType]  # only used during semantic analysis
+    to_be_mutated: bool  # only used in a plugin for `.update`, `|=`, etc
 
     def __init__(
         self,
         items: dict[str, Type],
         required_keys: set[str],
+        readonly_keys: set[str],
         fallback: Instance,
         line: int = -1,
         column: int = -1,
@@ -2572,16 +2597,25 @@ def __init__(
         super().__init__(line, column)
         self.items = items
         self.required_keys = required_keys
+        self.readonly_keys = readonly_keys
         self.fallback = fallback
         self.can_be_true = len(self.items) > 0
         self.can_be_false = len(self.required_keys) == 0
         self.extra_items_from = []
+        self.to_be_mutated = False
 
     def accept(self, visitor: TypeVisitor[T]) -> T:
         return visitor.visit_typeddict_type(self)
 
     def __hash__(self) -> int:
-        return hash((frozenset(self.items.items()), self.fallback, frozenset(self.required_keys)))
+        return hash(
+            (
+                frozenset(self.items.items()),
+                self.fallback,
+                frozenset(self.required_keys),
+                frozenset(self.readonly_keys),
+            )
+        )
 
     def __eq__(self, other: object) -> bool:
         if not isinstance(other, TypedDictType):
@@ -2596,6 +2630,7 @@ def __eq__(self, other: object) -> bool:
             )
             and self.fallback == other.fallback
             and self.required_keys == other.required_keys
+            and self.readonly_keys == other.readonly_keys
         )
 
     def serialize(self) -> JsonDict:
@@ -2603,6 +2638,7 @@ def serialize(self) -> JsonDict:
             ".class": "TypedDictType",
             "items": [[n, t.serialize()] for (n, t) in self.items.items()],
             "required_keys": sorted(self.required_keys),
+            "readonly_keys": sorted(self.readonly_keys),
             "fallback": self.fallback.serialize(),
         }
 
@@ -2612,6 +2648,7 @@ def deserialize(cls, data: JsonDict) -> TypedDictType:
         return TypedDictType(
             {n: deserialize_type(t) for (n, t) in data["items"]},
             set(data["required_keys"]),
+            set(data["readonly_keys"]),
             Instance.deserialize(data["fallback"]),
         )
 
@@ -2635,6 +2672,7 @@ def copy_modified(
         item_types: list[Type] | None = None,
         item_names: list[str] | None = None,
         required_keys: set[str] | None = None,
+        readonly_keys: set[str] | None = None,
     ) -> TypedDictType:
         if fallback is None:
             fallback = self.fallback
@@ -2644,10 +2682,12 @@ def copy_modified(
             items = dict(zip(self.items, item_types))
         if required_keys is None:
             required_keys = self.required_keys
+        if readonly_keys is None:
+            readonly_keys = self.readonly_keys
         if item_names is not None:
             items = {k: v for (k, v) in items.items() if k in item_names}
             required_keys &= set(item_names)
-        return TypedDictType(items, required_keys, fallback, self.line, self.column)
+        return TypedDictType(items, required_keys, readonly_keys, fallback, self.line, self.column)
 
     def create_anonymous_fallback(self) -> Instance:
         anonymous = self.as_anonymous()
@@ -3421,10 +3461,12 @@ def visit_tuple_type(self, t: TupleType) -> str:
 
     def visit_typeddict_type(self, t: TypedDictType) -> str:
         def item_str(name: str, typ: str) -> str:
-            if name in t.required_keys:
-                return f"{name!r}: {typ}"
-            else:
-                return f"{name!r}?: {typ}"
+            modifier = ""
+            if name not in t.required_keys:
+                modifier += "?"
+            if name in t.readonly_keys:
+                modifier += "="
+            return f"{name!r}{modifier}: {typ}"
 
         s = (
             "{"
diff --git a/test-data/unit/check-typeddict.test b/test-data/unit/check-typeddict.test
index dc1929751977..e1797421636e 100644
--- a/test-data/unit/check-typeddict.test
+++ b/test-data/unit/check-typeddict.test
@@ -2384,10 +2384,12 @@ class ForceDeferredEval: pass
 
 [case testTypedDictRequiredUnimportedAny]
 # flags: --disallow-any-unimported
-from typing import NotRequired, TypedDict
+from typing import NotRequired, TypedDict, ReadOnly
 from nonexistent import Foo  # type: ignore[import-not-found]
 class Bar(TypedDict):
     foo: NotRequired[Foo]  # E: Type of variable becomes "Any" due to an unfollowed import
+    bar: ReadOnly[Foo]  # E: Type of variable becomes "Any" due to an unfollowed import
+    baz: NotRequired[ReadOnly[Foo]]  # E: Type of variable becomes "Any" due to an unfollowed import
 [typing fixtures/typing-typeddict.pyi]
 
 -- Required[]
@@ -3631,6 +3633,16 @@ y = {"one": 1}  # E: Expected TypedDict keys ("one", "other") but found only key
 [builtins fixtures/dict.pyi]
 [typing fixtures/typing-typeddict.pyi]
 
+[case testTypedDictInlineReadOnly]
+# flags: --enable-incomplete-feature=InlineTypedDict
+from typing import ReadOnly
+
+x: {"one": int, "other": ReadOnly[int]}
+x["one"] = 1  # ok
+x["other"] = 1  # E: ReadOnly TypedDict key "other" TypedDict is mutated
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
 [case testTypedDictInlineNestedSchema]
 # flags: --enable-incomplete-feature=InlineTypedDict
 def nested() -> {"one": str, "other": {"a": int, "b": int}}:
@@ -3652,3 +3664,327 @@ x: {"a": int, **X[str], "b": int}
 reveal_type(x)  # N: Revealed type is "TypedDict({'a': builtins.int, 'b': builtins.int, 'item': builtins.str})"
 [builtins fixtures/dict.pyi]
 [typing fixtures/typing-full.pyi]
+
+
+# ReadOnly
+# See: https://peps.python.org/pep-0705
+
+[case testTypedDictReadOnly]
+# flags: --show-error-codes
+from typing import ReadOnly, TypedDict
+
+class TP(TypedDict):
+    one: int
+    other: ReadOnly[str]
+
+x: TP
+reveal_type(x["one"])   # N: Revealed type is "builtins.int"
+reveal_type(x["other"]) # N: Revealed type is "builtins.str"
+x["one"] = 1  # ok
+x["other"] = "a"  # E: ReadOnly TypedDict key "other" TypedDict is mutated  [typeddict-readonly-mutated]
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictReadOnlyCreation]
+from typing import ReadOnly, TypedDict
+
+class TD(TypedDict):
+    x: ReadOnly[int]
+    y: int
+
+# Ok:
+x = TD({"x": 1, "y": 2})
+y = TD(x=1, y=2)
+z: TD = {"x": 1, "y": 2}
+
+# Error:
+x2 = TD({"x": "a", "y": 2})  # E: Incompatible types (expression has type "str", TypedDict item "x" has type "int")
+y2 = TD(x="a", y=2)          # E: Incompatible types (expression has type "str", TypedDict item "x" has type "int")
+z2: TD = {"x": "a", "y": 2}  # E: Incompatible types (expression has type "str", TypedDict item "x" has type "int")
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictReadOnlyDel]
+from typing import ReadOnly, TypedDict, NotRequired
+
+class TP(TypedDict):
+    required_key: ReadOnly[str]
+    optional_key: ReadOnly[NotRequired[str]]
+
+x: TP
+del x["required_key"]  # E: Key "required_key" of TypedDict "TP" cannot be deleted
+del x["optional_key"]  # E: Key "optional_key" of TypedDict "TP" cannot be deleted
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictReadOnlyMutateMethods]
+from typing import ReadOnly, TypedDict
+
+class TP(TypedDict):
+    key: ReadOnly[str]
+    other: ReadOnly[int]
+    mutable: bool
+
+x: TP
+reveal_type(x.pop("key"))  # E: Key "key" of TypedDict "TP" cannot be deleted \
+                           # N: Revealed type is "builtins.str"
+
+x.update({"key": "abc", "other": 1, "mutable": True})  # E: ReadOnly TypedDict keys ("key", "other") TypedDict are mutated
+x.setdefault("key", "abc")  # E: ReadOnly TypedDict key "key" TypedDict is mutated
+x.setdefault("other", 1)  # E: ReadOnly TypedDict key "other" TypedDict is mutated
+x.setdefault("mutable", False)  # ok
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictFromTypingExtensionsReadOnlyMutateMethods]
+from typing_extensions import ReadOnly, TypedDict
+
+class TP(TypedDict):
+    key: ReadOnly[str]
+
+x: TP
+x.update({"key": "abc"})  # E: ReadOnly TypedDict key "key" TypedDict is mutated
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictFromMypyExtensionsReadOnlyMutateMethods]
+from mypy_extensions import TypedDict
+from typing_extensions import ReadOnly
+
+class TP(TypedDict):
+    key: ReadOnly[str]
+
+x: TP
+x.update({"key": "abc"})  # E: ReadOnly TypedDict key "key" TypedDict is mutated
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictReadOnlyMutate__ior__Statements]
+from typing_extensions import ReadOnly, TypedDict
+
+class TP(TypedDict):
+    key: ReadOnly[str]
+    other: ReadOnly[int]
+    mutable: bool
+
+x: TP
+x |= {"mutable": True}  # ok
+x |= {"key": "a"}  # E: ReadOnly TypedDict key "key" TypedDict is mutated
+x |= {"key": "a", "other": 1, "mutable": True}  # E: ReadOnly TypedDict keys ("key", "other") TypedDict are mutated
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict-iror.pyi]
+
+[case testTypedDictReadOnlyMutate__or__Statements]
+from typing_extensions import ReadOnly, TypedDict
+
+class TP(TypedDict):
+    key: ReadOnly[str]
+    other: ReadOnly[int]
+    mutable: bool
+
+x: TP
+# These are new objects, not mutation:
+x = x | {"mutable": True}
+x = x | {"key": "a"}
+x = x | {"key": "a", "other": 1, "mutable": True}
+y1 = x | {"mutable": True}
+y2 = x | {"key": "a"}
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict-iror.pyi]
+
+[case testTypedDictReadOnlyMutateWithOtherDicts]
+from typing import ReadOnly, TypedDict, Dict
+
+class TP(TypedDict):
+    key: ReadOnly[str]
+    mutable: bool
+
+class Mutable(TypedDict):
+    mutable: bool
+
+class Regular(TypedDict):
+    key: str
+
+m: Mutable
+r: Regular
+d: Dict[str, object]
+
+# Creating new objects is ok:
+tp: TP = {**r, **m}
+tp1: TP = {**tp, **m}
+tp2: TP = {**r, **m}
+tp3: TP = {**tp, **r}
+tp4: TP = {**tp, **d}  # E: Unsupported type "Dict[str, object]" for ** expansion in TypedDict
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictGenericReadOnly]
+from typing import ReadOnly, TypedDict, TypeVar, Generic
+
+T = TypeVar('T')
+
+class TP(TypedDict, Generic[T]):
+    key: ReadOnly[T]
+
+x: TP[int]
+reveal_type(x["key"])   # N: Revealed type is "builtins.int"
+x["key"] = 1  # E: ReadOnly TypedDict key "key" TypedDict is mutated
+x["key"] = "a"  # E: ReadOnly TypedDict key "key" TypedDict is mutated \
+                # E: Value of "key" has incompatible type "str"; expected "int"
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictReadOnlyOtherTypedDict]
+from typing import ReadOnly, TypedDict
+
+class First(TypedDict):
+    field: int
+
+class TP(TypedDict):
+    key: ReadOnly[First]
+
+x: TP
+reveal_type(x["key"]["field"])   # N: Revealed type is "builtins.int"
+x["key"]["field"] = 1  # ok
+x["key"] = {"field": 2}  # E: ReadOnly TypedDict key "key" TypedDict is mutated
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictReadOnlyInheritance]
+from typing import ReadOnly, TypedDict
+
+class Base(TypedDict):
+    a: ReadOnly[str]
+
+class Child(Base):
+    b: ReadOnly[int]
+
+base: Base
+reveal_type(base["a"])   # N: Revealed type is "builtins.str"
+base["a"] = "x"  # E: ReadOnly TypedDict key "a" TypedDict is mutated
+base["b"]  # E: TypedDict "Base" has no key "b"
+
+child: Child
+reveal_type(child["a"])   # N: Revealed type is "builtins.str"
+reveal_type(child["b"])   # N: Revealed type is "builtins.int"
+child["a"] = "x"  # E: ReadOnly TypedDict key "a" TypedDict is mutated
+child["b"] = 1  # E: ReadOnly TypedDict key "b" TypedDict is mutated
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictReadOnlySubtyping]
+from typing import ReadOnly, TypedDict
+
+class A(TypedDict):
+    key: ReadOnly[str]
+
+class B(TypedDict):
+    key: str
+
+a: A
+b: B
+
+def accepts_A(d: A): ...
+def accepts_B(d: B): ...
+
+accepts_A(a)
+accepts_A(b)
+accepts_B(a)  # E: Argument 1 to "accepts_B" has incompatible type "A"; expected "B"
+accepts_B(b)
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictReadOnlyCall]
+from typing import ReadOnly, TypedDict
+
+TP = TypedDict("TP", {"one": int, "other": ReadOnly[str]})
+
+x: TP
+reveal_type(x["one"])   # N: Revealed type is "builtins.int"
+reveal_type(x["other"]) # N: Revealed type is "builtins.str"
+x["one"] = 1  # ok
+x["other"] = "a"  # E: ReadOnly TypedDict key "other" TypedDict is mutated
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictReadOnlyABCSubtypes]
+from typing import ReadOnly, TypedDict, Mapping, Dict, MutableMapping
+
+class TP(TypedDict):
+    one: int
+    other: ReadOnly[int]
+
+def accepts_mapping(m: Mapping[str, object]): ...
+def accepts_mutable_mapping(mm: MutableMapping[str, object]): ...
+def accepts_dict(d: Dict[str, object]): ...
+
+x: TP
+accepts_mapping(x)
+accepts_mutable_mapping(x)  # E: Argument 1 to "accepts_mutable_mapping" has incompatible type "TP"; expected "MutableMapping[str, object]"
+accepts_dict(x)  # E: Argument 1 to "accepts_dict" has incompatible type "TP"; expected "Dict[str, object]"
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictReadOnlyAndNotRequired]
+from typing import ReadOnly, TypedDict, NotRequired
+
+class TP(TypedDict):
+    one: ReadOnly[NotRequired[int]]
+    two: NotRequired[ReadOnly[str]]
+
+x: TP
+reveal_type(x)  # N: Revealed type is "TypedDict('__main__.TP', {'one'?=: builtins.int, 'two'?=: builtins.str})"
+reveal_type(x.get("one"))  # N: Revealed type is "Union[builtins.int, None]"
+reveal_type(x.get("two"))  # N: Revealed type is "Union[builtins.str, None]"
+x["one"] = 1  # E: ReadOnly TypedDict key "one" TypedDict is mutated
+x["two"] = "a"  # E: ReadOnly TypedDict key "two" TypedDict is mutated
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testMeetOfTypedDictsWithReadOnly]
+from typing import TypeVar, Callable, TypedDict, ReadOnly
+XY = TypedDict('XY', {'x': ReadOnly[int], 'y': int})
+YZ = TypedDict('YZ', {'y': int, 'z': ReadOnly[int]})
+T = TypeVar('T')
+def f(x: Callable[[T, T], None]) -> T: pass
+def g(x: XY, y: YZ) -> None: pass
+reveal_type(f(g))  # N: Revealed type is "TypedDict({'x'=: builtins.int, 'y': builtins.int, 'z'=: builtins.int})"
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testTypedDictReadOnlyUnpack]
+from typing_extensions import TypedDict, Unpack, ReadOnly
+
+class TD(TypedDict):
+    x: ReadOnly[int]
+    y: str
+
+def func(**kwargs: Unpack[TD]):
+    kwargs["x"] = 1  # E: ReadOnly TypedDict key "x" TypedDict is mutated
+    kwargs["y" ] = "a"
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
+
+[case testIncorrectTypedDictSpecialFormsUsage]
+from typing import ReadOnly, TypedDict, NotRequired, Required
+
+x: ReadOnly[int]     # E: ReadOnly[] can be only used in a TypedDict definition
+y: Required[int]     # E: Required[] can be only used in a TypedDict definition
+z: NotRequired[int]  # E: NotRequired[] can be only used in a TypedDict definition
+
+class TP(TypedDict):
+    a: ReadOnly[ReadOnly[int]]              # E: "ReadOnly[]" type cannot be nested
+    b: ReadOnly[NotRequired[ReadOnly[str]]] # E: "ReadOnly[]" type cannot be nested
+    c: NotRequired[Required[int]]           # E: "Required[]" type cannot be nested
+    d: Required[NotRequired[int]]           # E: "NotRequired[]" type cannot be nested
+    e: Required[ReadOnly[NotRequired[int]]] # E: "NotRequired[]" type cannot be nested
+    f: ReadOnly[ReadOnly[ReadOnly[int]]]    # E: "ReadOnly[]" type cannot be nested
+    g: Required[Required[int]]              # E: "Required[]" type cannot be nested
+    h: NotRequired[NotRequired[int]]        # E: "NotRequired[]" type cannot be nested
+
+    j: NotRequired[ReadOnly[Required[ReadOnly[int]]]]  # E: "Required[]" type cannot be nested \
+                                                       # E: "ReadOnly[]" type cannot be nested
+
+    k: ReadOnly  # E: "ReadOnly[]" must have exactly one type argument
+[builtins fixtures/dict.pyi]
+[typing fixtures/typing-typeddict.pyi]
diff --git a/test-data/unit/fine-grained.test b/test-data/unit/fine-grained.test
index 2ad31311a402..faedd890922d 100644
--- a/test-data/unit/fine-grained.test
+++ b/test-data/unit/fine-grained.test
@@ -3705,6 +3705,34 @@ def foo() -> None:
 ==
 b.py:4: error: Incompatible types in assignment (expression has type "str", variable has type "int")
 
+[case testTypedDictUpdateReadOnly]
+import b
+[file a.py]
+from typing_extensions import TypedDict, ReadOnly
+Point = TypedDict('Point', {'x': int, 'y': int})
+p = Point(x=1, y=2)
+[file a.py.2]
+from typing_extensions import TypedDict, ReadOnly
+class Point(TypedDict):
+    x: int
+    y: ReadOnly[int]
+p = Point(x=1, y=2)
+[file a.py.3]
+from typing_extensions import TypedDict, ReadOnly
+Point = TypedDict('Point', {'x': ReadOnly[int], 'y': int})
+p = Point(x=1, y=2)
+[file b.py]
+from a import Point
+def foo(x: Point) -> None:
+    x['x'] = 1
+    x['y'] = 2
+[builtins fixtures/dict.pyi]
+[out]
+==
+b.py:4: error: ReadOnly TypedDict key "y" TypedDict is mutated
+==
+b.py:3: error: ReadOnly TypedDict key "x" TypedDict is mutated
+
 [case testBasicAliasUpdate]
 import b
 [file a.py]
diff --git a/test-data/unit/fixtures/typing-typeddict.pyi b/test-data/unit/fixtures/typing-typeddict.pyi
index d136ac4ab8be..7e9c642cf261 100644
--- a/test-data/unit/fixtures/typing-typeddict.pyi
+++ b/test-data/unit/fixtures/typing-typeddict.pyi
@@ -26,6 +26,7 @@ TypedDict = 0
 NoReturn = 0
 Required = 0
 NotRequired = 0
+ReadOnly = 0
 Self = 0
 
 T = TypeVar('T')
@@ -59,6 +60,10 @@ class Mapping(Iterable[T], Generic[T, T_co], metaclass=ABCMeta):
     def __len__(self) -> int: ...
     def __contains__(self, arg: object) -> int: pass
 
+class MutableMapping(Mapping[T, T_co], Generic[T, T_co], metaclass=ABCMeta):
+    # Other methods are not used in tests.
+    def clear(self) -> None: ...
+
 # Fallback type for all typed dicts (does not exist at runtime).
 class _TypedDict(Mapping[str, object]):
     # Needed to make this class non-abstract. It is explicitly declared abstract in
diff --git a/test-data/unit/lib-stub/typing_extensions.pyi b/test-data/unit/lib-stub/typing_extensions.pyi
index b5bfc1ab3f20..d9d7067efe0f 100644
--- a/test-data/unit/lib-stub/typing_extensions.pyi
+++ b/test-data/unit/lib-stub/typing_extensions.pyi
@@ -41,6 +41,7 @@ TypeVarTuple: _SpecialForm
 Unpack: _SpecialForm
 Required: _SpecialForm
 NotRequired: _SpecialForm
+ReadOnly: _SpecialForm
 
 @final
 class TypeAliasType: