Repository: ipython/traitlets (language: python)

Issue:
Allow type checkers to infer trait types
We are currently encoding typing information for traits without integrating with the Python static typing system. This change is for allowing traitlets users to get static typing "for free" by using our trait types.

Ideally, the typing system would allow for default types, such that `TraitType == TraitType[t.Any, t.Any]`, and `TraitType[U] == TraitType[U, U]`, but for now we have to be verbose for this to work.

This is currently in a draft state, as some of the typings are still not accurately reflecting all the information we have. Potential improvements:
- [ ] The Union type should correctly surface its types as the typings union of those of its components
- [ ] Make the `Optional` part depend on allow_none (is it possible?).
- [ ] Improve types of class based traits
- [ ] Can we make the coercing variants not need to re-inherit `TraitType` ?
- [ ] Make Enum and UseEnum types better
- [ ] etc.

cc @maartenbreddels 


Discussion / hints:

Fix patch:
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 5adeaf82..528130cb 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -22,22 +22,13 @@
 HERE = osp.abspath(osp.dirname(__file__))
 ROOT = osp.dirname(osp.dirname(HERE))
 
+from traitlets import version_info
+
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
 # sys.path.insert(0, os.path.abspath('.'))
 
-# We load the ipython release info into a dict by explicit execution
-_release = {}  # type:ignore
-exec(  # noqa
-    compile(
-        open(osp.join(ROOT, "traitlets/_version.py")).read(),
-        "../../traitlets/_version.py",
-        "exec",
-    ),
-    _release,
-)
-
 # -- General configuration ------------------------------------------------
 
 # If your documentation needs a minimal Sphinx version, state it here.
@@ -64,7 +55,7 @@
 source_suffix = ".rst"
 
 # Add dev disclaimer.
-if _release["version_info"][-1] == "dev":
+if version_info[-1] == "dev":
     rst_prolog = """
     .. note::
 
@@ -89,9 +80,9 @@
 # built documents.
 #
 # The short X.Y version.
-version = ".".join(map(str, _release["version_info"][:2]))
+version = ".".join(map(str, version_info[:2]))
 # The full version, including alpha/beta/rc tags.
-release = _release["__version__"]
+release = "__version__"
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff --git a/examples/docs/from_string.py b/examples/docs/from_string.py
index eb38cb78..a8b7304b 100755
--- a/examples/docs/from_string.py
+++ b/examples/docs/from_string.py
@@ -25,7 +25,7 @@ class App(Application):
     )
 
     def start(self):
-        print(f"key={self.key}")
+        print(f"key={self.key.decode('utf8')}")
 
 
 if __name__ == "__main__":
diff --git a/examples/myapp.py b/examples/myapp.py
index bd04be7d..009f9209 100755
--- a/examples/myapp.py
+++ b/examples/myapp.py
@@ -94,6 +94,7 @@ def start(self):
         print("app.config:")
         print(self.config)
         print("try running with --help-all to see all available flags")
+        assert self.log is not None
         self.log.debug("Debug Message")
         self.log.info("Info Message")
         self.log.warning("Warning Message")
diff --git a/pyproject.toml b/pyproject.toml
index 13c0b7db..8c2df586 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,9 +20,10 @@ classifiers = [
 urls = {Homepage = "https://github.com/ipython/traitlets"}
 requires-python = ">=3.7"
 dynamic = ["version"]
+dependencies = ["typing_extensions>=4.0.1"]
 
 [project.optional-dependencies]
-test = ["pytest", "pytest-mock", "pre-commit", "argcomplete>=2.0"]
+test = ["pytest>=7.0,<7.2", "pytest-mock", "pre-commit", "argcomplete>=2.0", "pytest-mypy-testing", "mypy @ git+https://github.com/python/mypy.git@cb1d1a0baba37f35268cb605b7345726f257f960#egg=mypy"]
 docs = [
     "myst-parser",
     "pydata-sphinx-theme",
@@ -32,6 +33,9 @@ docs = [
 [tool.hatch.version]
 path = "traitlets/_version.py"
 
+[tool.hatch.metadata]
+allow-direct-references = true
+
 [tool.hatch.envs.docs]
 features = ["docs"]
 [tool.hatch.envs.docs.scripts]
@@ -52,7 +56,7 @@ nowarn = "test -W default {args}"
 
 [tool.hatch.envs.typing]
 features = ["test"]
-dependencies = ["mypy>=0.990"]
+dependencies = ["mypy @ git+https://github.com/python/mypy.git@cb1d1a0baba37f35268cb605b7345726f257f960#egg=mypy"]
 [tool.hatch.envs.typing.scripts]
 test = "mypy --install-types --non-interactive {args:.}"
 
@@ -89,7 +93,7 @@ warn_unused_configs = true
 warn_redundant_casts = true
 warn_return_any = true
 warn_unused_ignores = true
-exclude = ["examples/docs/configs"]
+exclude = ["examples/docs/configs", "traitlets/tests/test_typing.py"]
 
 [tool.pytest.ini_options]
 addopts = "--durations=10 -ra --showlocals --doctest-modules --color yes --ignore examples/docs/configs"
diff --git a/traitlets/config/application.py b/traitlets/config/application.py
index af8fe536..6da29feb 100644
--- a/traitlets/config/application.py
+++ b/traitlets/config/application.py
@@ -149,21 +149,29 @@ class Application(SingletonConfigurable):
 
     # The name of the application, will usually match the name of the command
     # line application
-    name: t.Union[str, Unicode] = Unicode("application")
+    name: t.Union[str, Unicode[str, t.Union[str, bytes]]] = Unicode("application")
 
     # The description of the application that is printed at the beginning
     # of the help.
-    description: t.Union[str, Unicode] = Unicode("This is an application.")
+    description: t.Union[str, Unicode[str, t.Union[str, bytes]]] = Unicode(
+        "This is an application."
+    )
     # default section descriptions
-    option_description: t.Union[str, Unicode] = Unicode(option_description)
-    keyvalue_description: t.Union[str, Unicode] = Unicode(keyvalue_description)
-    subcommand_description: t.Union[str, Unicode] = Unicode(subcommand_description)
+    option_description: t.Union[str, Unicode[str, t.Union[str, bytes]]] = Unicode(
+        option_description
+    )
+    keyvalue_description: t.Union[str, Unicode[str, t.Union[str, bytes]]] = Unicode(
+        keyvalue_description
+    )
+    subcommand_description: t.Union[str, Unicode[str, t.Union[str, bytes]]] = Unicode(
+        subcommand_description
+    )
 
     python_config_loader_class = PyFileConfigLoader
     json_config_loader_class = JSONFileConfigLoader
 
     # The usage and example string that goes at the end of the help string.
-    examples: t.Union[str, Unicode] = Unicode()
+    examples: t.Union[str, Unicode[str, t.Union[str, bytes]]] = Unicode()
 
     # A sequence of Configurable subclasses whose config=True attributes will
     # be exposed at the command line.
@@ -190,18 +198,18 @@ def _classes_inc_parents(self, classes=None):
                     yield parent
 
     # The version string of this application.
-    version: t.Union[str, Unicode] = Unicode("0.0")
+    version: t.Union[str, Unicode[str, t.Union[str, bytes]]] = Unicode("0.0")
 
     # the argv used to initialize the application
     argv: t.Union[t.List[str], List] = List()
 
     # Whether failing to load config files should prevent startup
-    raise_config_file_errors: t.Union[bool, Bool] = Bool(
+    raise_config_file_errors: t.Union[bool, Bool[bool, t.Union[bool, int]]] = Bool(
         TRAITLETS_APPLICATION_RAISE_CONFIG_FILE_ERROR
     )
 
     # The log level for the application
-    log_level: t.Union[str, int, Enum] = Enum(
+    log_level: t.Union[str, int, Enum[t.Any, t.Any]] = Enum(
         (0, 10, 20, 30, 40, 50, "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"),
         default_value=logging.WARN,
         help="Set the log level by value or name.",
@@ -209,11 +217,11 @@ def _classes_inc_parents(self, classes=None):
 
     _log_formatter_cls = LevelFormatter
 
-    log_datefmt: t.Union[str, Unicode] = Unicode(
+    log_datefmt: t.Union[str, Unicode[str, t.Union[str, bytes]]] = Unicode(
         "%Y-%m-%d %H:%M:%S", help="The date format used by logging formatters for %(asctime)s"
     ).tag(config=True)
 
-    log_format: t.Union[str, Unicode] = Unicode(
+    log_format: t.Union[str, Unicode[str, t.Union[str, bytes]]] = Unicode(
         "[%(name)s]%(highlevel)s %(message)s",
         help="The Logging format template",
     ).tag(config=True)
@@ -420,11 +428,11 @@ def _log_default(self):
 
     _loaded_config_files = List()
 
-    show_config: t.Union[bool, Bool] = Bool(
+    show_config: t.Union[bool, Bool[bool, t.Union[bool, int]]] = Bool(
         help="Instead of starting the Application, dump configuration to stdout"
     ).tag(config=True)
 
-    show_config_json: t.Union[bool, Bool] = Bool(
+    show_config_json: t.Union[bool, Bool[bool, t.Union[bool, int]]] = Bool(
         help="Instead of starting the Application, dump configuration to stdout (as JSON)"
     ).tag(config=True)
 
@@ -436,7 +444,7 @@ def _show_config_json_changed(self, change):
     def _show_config_changed(self, change):
         if change.new:
             self._save_start = self.start
-            self.start = self.start_show_config  # type:ignore[method-assign]
+            self.start = self.start_show_config  # type:ignore[assignment]
 
     def __init__(self, **kwargs):
         SingletonConfigurable.__init__(self, **kwargs)
diff --git a/traitlets/config/configurable.py b/traitlets/config/configurable.py
index c3b2ed16..1bfa0457 100644
--- a/traitlets/config/configurable.py
+++ b/traitlets/config/configurable.py
@@ -182,6 +182,7 @@ def _load_config(self, cfg, section_names=None, traits=None):
                     from difflib import get_close_matches
 
                     if isinstance(self, LoggingConfigurable):
+                        assert self.log is not None
                         warn = self.log.warning
                     else:
 
@@ -462,6 +463,7 @@ def _validate_log(self, proposal):
     @default("log")
     def _log_default(self):
         if isinstance(self.parent, LoggingConfigurable):
+            assert self.parent is not None
             return self.parent.log
         from traitlets import log
 
diff --git a/traitlets/config/loader.py b/traitlets/config/loader.py
index a9136dc3..1c6b8e8c 100644
--- a/traitlets/config/loader.py
+++ b/traitlets/config/loader.py
@@ -96,9 +96,9 @@ class LazyConfigValue(HasTraits):
     _value = None
 
     # list methods
-    _extend = List()
-    _prepend = List()
-    _inserts = List()
+    _extend: List = List()
+    _prepend: List = List()
+    _inserts: List = List()
 
     def append(self, obj):
         """Append an item to a List"""
diff --git a/traitlets/traitlets.py b/traitlets/traitlets.py
index e5376738..22354f03 100644
--- a/traitlets/traitlets.py
+++ b/traitlets/traitlets.py
@@ -474,23 +474,36 @@ def instance_init(self, obj):
         pass
 
 
-class TraitType(BaseDescriptor):
+G = t.TypeVar("G")
+S = t.TypeVar("S")
+T = t.TypeVar("T")
+
+
+# Self from typing extension doesn't work well with mypy https://github.com/python/mypy/pull/14041
+# see https://peps.python.org/pep-0673/#use-in-generic-classes
+# Self = t.TypeVar("Self", bound="TraitType[Any, Any]")
+from typing_extensions import Literal, Self
+
+
+# We use a type for the getter (G) and setter (G) because we allow
+# for traits to cast (for instance CInt will use G=int, S=t.Any)
+class TraitType(BaseDescriptor, t.Generic[G, S]):
     """A base class for all trait types."""
 
     metadata: t.Dict[str, t.Any] = {}
-    allow_none = False
-    read_only = False
-    info_text = "any value"
+    allow_none: bool = False
+    read_only: bool = False
+    info_text: str = "any value"
     default_value: t.Optional[t.Any] = Undefined
 
     def __init__(
-        self,
-        default_value=Undefined,
-        allow_none=False,
-        read_only=None,
-        help=None,
-        config=None,
-        **kwargs,
+        self: "TraitType[G, S]",
+        default_value: t.Any = Undefined,
+        allow_none: bool = False,
+        read_only: t.Optional[bool] = None,
+        help: t.Optional[str] = None,
+        config: t.Any = None,
+        **kwargs: t.Any,
     ):
         """Declare a traitlet.
 
@@ -605,9 +618,9 @@ def init_default_value(self, obj):
         obj._trait_values[self.name] = value
         return value
 
-    def get(self, obj, cls=None):
+    def get(self, obj: "HasTraits", cls: t.Any = None) -> t.Optional[G]:
         try:
-            value = obj._trait_values[self.name]
+            value = obj._trait_values[self.name]  # type: ignore
         except KeyError:
             # Check for a dynamic initializer.
             default = obj.trait_defaults(self.name)
@@ -627,7 +640,7 @@ def get(self, obj, cls=None):
                 value = self._validate(obj, default)
             finally:
                 obj._cross_validation_lock = _cross_validation_lock
-            obj._trait_values[self.name] = value
+            obj._trait_values[self.name] = value  # type: ignore
             obj._notify_observers(
                 Bunch(
                     name=self.name,
@@ -636,14 +649,60 @@ def get(self, obj, cls=None):
                     type="default",
                 )
             )
-            return value
+            return value  # type: ignore
         except Exception as e:
             # This should never be reached.
             raise TraitError("Unexpected error in TraitType: default value not set properly") from e
         else:
-            return value
+            return value  # type: ignore
+
+    if t.TYPE_CHECKING:
+        # This gives ok type information, but not specific enough (e.g. it will)
+        # always be a TraitType, not a subclass, like Bool.
+        @t.overload
+        def __new__(  # type: ignore[misc]
+            cls,
+            default_value: t.Union[S, Sentinel] = Undefined,
+            allow_none: Literal[False] = ...,
+            read_only: t.Optional[bool] = None,
+            help: t.Optional[str] = None,
+            config: t.Any = None,
+            **kwargs: t.Any,
+        ) -> "TraitType[G, S]":
+            ...
+
+        @t.overload
+        def __new__(
+            cls,
+            default_value: t.Union[S, None, Sentinel] = Undefined,
+            allow_none: Literal[True] = ...,
+            read_only: t.Optional[bool] = None,
+            help: t.Optional[str] = None,
+            config: t.Any = None,
+            **kwargs: t.Any,
+        ) -> "TraitType[t.Optional[G], S]":
+            ...
+
+        def __new__(  # type: ignore[no-untyped-def, misc]
+            cls,
+            default_value: t.Union[S, None, Sentinel] = Undefined,
+            allow_none: Literal[True, False] = False,
+            read_only=None,
+            help=None,
+            config=None,
+            **kwargs,
+        ) -> t.Union["TraitType[t.Optional[G], S]", "TraitType[G, S]"]:
+            ...
+
+    @t.overload
+    def __get__(self, obj: None, cls: t.Type[t.Any]) -> Self:
+        ...
+
+    @t.overload
+    def __get__(self, obj: t.Any, cls: t.Type[t.Any]) -> G:
+        ...
 
-    def __get__(self, obj, cls=None):
+    def __get__(self, obj: t.Union["HasTraits", None], cls: t.Type[t.Any]) -> t.Union[Self, G]:
         """Get the value of the trait by self.name for the instance.
 
         Default values are instantiated when :meth:`HasTraits.__new__`
@@ -654,7 +713,7 @@ def __get__(self, obj, cls=None):
         if obj is None:
             return self
         else:
-            return self.get(obj, cls)
+            return t.cast(G, self.get(obj, cls))  # the G should encode the Optional
 
     def set(self, obj, value):
         new_value = self._validate(obj, value)
@@ -674,7 +733,7 @@ def set(self, obj, value):
             # comparison above returns something other than True/False
             obj._notify_trait(self.name, old_value, new_value)
 
-    def __set__(self, obj, value):
+    def __set__(self, obj: "HasTraits", value: S) -> None:
         """Set the value of the trait by self.name for the instance.
 
         Values pass through a validation stage where errors are raised when
@@ -819,7 +878,7 @@ def set_metadata(self, key, value):
         warn("Deprecated in traitlets 4.1, " + msg, DeprecationWarning, stacklevel=2)
         self.metadata[key] = value
 
-    def tag(self, **metadata):
+    def tag(self, **metadata: t.Any) -> "Self":
         """Sets metadata and returns self.
 
         This allows convenient metadata tagging when initializing the trait, such as:
@@ -1291,7 +1350,7 @@ def __init__(self, *args, **kwargs):
             def ignore(*_ignore_args):
                 pass
 
-            self.notify_change = ignore  # type:ignore[method-assign]
+            self.notify_change = ignore  # type:ignore[assignment]
             self._cross_validation_lock = True
             changes = {}
             for key, value in kwargs.items():
@@ -1422,7 +1481,7 @@ def hold(change):
             try:
                 # Replace notify_change with `hold`, caching and compressing
                 # notifications, disable cross validation and yield.
-                self.notify_change = hold  # type:ignore[method-assign]
+                self.notify_change = hold  # type:ignore[assignment]
                 self._cross_validation_lock = True
                 yield
                 # Cross validate final values when context is released.
@@ -1432,7 +1491,7 @@ def hold(change):
                     self.set_trait(name, value)
             except TraitError as e:
                 # Roll back in case of TraitError during final cross validation.
-                self.notify_change = lambda x: None  # type:ignore[method-assign]
+                self.notify_change = lambda x: None  # type:ignore[assignment]
                 for name, changes in cache.items():
                     for change in changes[::-1]:
                         # TODO: Separate in a rollback function per notification type.
@@ -1937,7 +1996,7 @@ def trait_events(cls, name=None):
 # -----------------------------------------------------------------------------
 
 
-class ClassBasedTraitType(TraitType):
+class ClassBasedTraitType(TraitType[G, S]):
     """
     A trait with error reporting and string -> type resolution for Type,
     Instance and This.
@@ -1950,10 +2009,64 @@ def _resolve_string(self, string):
         return import_item(string)
 
 
-class Type(ClassBasedTraitType):
+class Type(ClassBasedTraitType[G, S]):
     """A trait whose value must be a subclass of a specified class."""
 
-    def __init__(self, default_value=Undefined, klass=None, **kwargs):
+    if t.TYPE_CHECKING:
+
+        @t.overload
+        def __init__(
+            self: "Type[object, object]",
+            default_value: t.Union[Sentinel, None, str] = ...,
+            klass: t.Union[None, str] = ...,
+            allow_none: Literal[False] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Optional[t.Any] = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        @t.overload
+        def __init__(
+            self: "Type[t.Optional[object], t.Optional[object]]",
+            default_value: t.Union[S, Sentinel, None, str] = ...,
+            klass: t.Union[None, str] = ...,
+            allow_none: Literal[True] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Optional[t.Any] = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        @t.overload
+        def __init__(
+            self: "Type[S, S]",
+            default_value: t.Union[S, Sentinel, str] = ...,
+            klass: t.Type[S] = ...,
+            allow_none: Literal[False] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Optional[t.Any] = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        @t.overload
+        def __init__(
+            self: "Type[t.Optional[S], t.Optional[S]]",
+            default_value: t.Union[S, Sentinel, None, str] = ...,
+            klass: t.Type[S] = ...,
+            allow_none: Literal[True] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Optional[t.Any] = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+    def __init__(self, default_value=Undefined, klass=None, allow_none=False, **kwargs):
         """Construct a Type trait
 
         A Type trait specifies that its values must be subclasses of
@@ -1995,7 +2108,7 @@ def __init__(self, default_value=Undefined, klass=None, **kwargs):
 
         self.klass = klass
 
-        super().__init__(new_default_value, **kwargs)
+        super().__init__(new_default_value, allow_none=allow_none, **kwargs)
 
     def validate(self, obj, value):
         """Validates that the value is a valid object instance."""
@@ -2046,7 +2159,7 @@ def default_value_repr(self):
             return repr(f"{value.__module__}.{value.__name__}")
 
 
-class Instance(ClassBasedTraitType):
+class Instance(ClassBasedTraitType[T, T]):
     """A trait whose value must be an instance of a specified class.
 
     The value can also be an instance of a subclass of the specified class.
@@ -2054,9 +2167,72 @@ class Instance(ClassBasedTraitType):
     Subclasses can declare default classes by overriding the klass attribute
     """
 
-    klass = None
+    klass: t.Union[str, t.Type[T], None] = None
+
+    if t.TYPE_CHECKING:
+
+        @t.overload
+        def __init__(
+            self: "Instance[T]",
+            klass: t.Type[T] = ...,
+            args: t.Optional[t.Tuple[t.Any, ...]] = ...,
+            kw: t.Optional[t.Dict[str, t.Any]] = ...,
+            allow_none: Literal[False] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            **kwargs: t.Any,
+        ) -> None:
+            ...
+
+        @t.overload
+        def __init__(
+            self: "Instance[t.Optional[T]]",
+            klass: t.Type[T] = ...,
+            args: t.Optional[t.Tuple[t.Any, ...]] = ...,
+            kw: t.Optional[t.Dict[str, t.Any]] = ...,
+            allow_none: Literal[True] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            **kwargs: t.Any,
+        ) -> None:
+            ...
+
+        @t.overload
+        def __init__(
+            self: "Instance[t.Any]",
+            klass: t.Union[str, None] = ...,
+            args: t.Optional[t.Tuple[t.Any, ...]] = ...,
+            kw: t.Optional[t.Dict[str, t.Any]] = ...,
+            allow_none: Literal[False] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            **kwargs: t.Any,
+        ) -> None:
+            ...
+
+        @t.overload
+        def __init__(
+            self: "Instance[t.Optional[t.Any]]",
+            klass: t.Union[str, None] = ...,
+            args: t.Optional[t.Tuple[t.Any, ...]] = ...,
+            kw: t.Optional[t.Dict[str, t.Any]] = ...,
+            allow_none: Literal[True] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            **kwargs: t.Any,
+        ) -> None:
+            ...
 
-    def __init__(self, klass=None, args=None, kw=None, **kwargs):
+    def __init__(
+        self,
+        klass: t.Union[str, t.Type[T], None] = None,
+        args: t.Optional[t.Tuple[t.Any, ...]] = None,
+        kw: t.Optional[t.Dict[str, t.Any]] = None,
+        allow_none: bool = False,
+        read_only: t.Optional[bool] = None,
+        help: t.Optional[str] = None,
+        **kwargs: t.Any,
+    ) -> None:
         """Construct an Instance trait.
 
         This trait allows values that are instances of a particular
@@ -2101,7 +2277,7 @@ class or its subclasses.  Our implementation is quite different
         self.default_args = args
         self.default_kwargs = kw
 
-        super().__init__(**kwargs)
+        super().__init__(allow_none=allow_none, **kwargs)
 
     def validate(self, obj, value):
         assert self.klass is not None
@@ -2157,7 +2333,7 @@ def _resolve_string(self, string):
         return import_item(".".join([modname, string]))
 
 
-class ForwardDeclaredType(ForwardDeclaredMixin, Type):
+class ForwardDeclaredType(ForwardDeclaredMixin, Type[G, S]):
     """
     Forward-declared version of Type.
     """
@@ -2165,7 +2341,7 @@ class ForwardDeclaredType(ForwardDeclaredMixin, Type):
     pass
 
 
-class ForwardDeclaredInstance(ForwardDeclaredMixin, Instance):
+class ForwardDeclaredInstance(ForwardDeclaredMixin, Instance[T]):
     """
     Forward-declared version of Instance.
     """
@@ -2173,7 +2349,7 @@ class ForwardDeclaredInstance(ForwardDeclaredMixin, Instance):
     pass
 
 
-class This(ClassBasedTraitType):
+class This(ClassBasedTraitType[t.Optional[T], t.Optional[T]]):
     """A trait for instances of the class containing this trait.
 
     Because how how and when class bodies are executed, the ``This``
@@ -2197,7 +2373,7 @@ def validate(self, obj, value):
             self.error(obj, value)
 
 
-class Union(TraitType):
+class Union(TraitType[t.Any, t.Any]):
     """A trait type representing a Union type."""
 
     def __init__(self, trait_types, **kwargs):
@@ -2286,9 +2462,73 @@ def from_string(self, s):
 # -----------------------------------------------------------------------------
 
 
-class Any(TraitType):
+class Any(TraitType[t.Optional[t.Any], t.Optional[t.Any]]):
     """A trait which allows any value."""
 
+    if t.TYPE_CHECKING:
+
+        @t.overload
+        def __init__(
+            self: "Any",
+            default_value: str = ...,
+            *,
+            allow_none: Literal[False],
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Optional[t.Any] = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        @t.overload
+        def __init__(
+            self: "Any",
+            default_value: str = ...,
+            *,
+            allow_none: Literal[True],
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Optional[t.Any] = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        @t.overload
+        def __init__(
+            self: "Any",
+            default_value: str = ...,
+            *,
+            allow_none: Literal[True, False] = ...,
+            help: t.Optional[str] = ...,
+            read_only: t.Optional[bool] = False,
+            config: t.Any = None,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        def __init__(
+            self: "Any",
+            default_value: str = ...,
+            *,
+            allow_none: t.Optional[bool] = False,
+            help: t.Optional[str] = "",
+            read_only: t.Optional[bool] = False,
+            config: t.Any = None,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        @t.overload
+        def __get__(self, obj: None, cls: t.Type[t.Any]) -> "Any":
+            ...
+
+        @t.overload
+        def __get__(self, obj: t.Any, cls: t.Type[t.Any]) -> t.Any:
+            ...
+
+        def __get__(self, obj: t.Union[t.Any, None], cls: t.Type[t.Any]) -> t.Union[t.Any, "Any"]:
+            ...
+
     default_value: t.Optional[t.Any] = None
     allow_none = True
     info_text = "any value"
@@ -2319,12 +2559,39 @@ def _validate_bounds(trait, obj, value):
     return value
 
 
-class Int(TraitType):
+# I = t.TypeVar('I', t.Optional[int], int)
+
+
+class Int(TraitType[G, S]):
     """An int trait."""
 
     default_value = 0
     info_text = "an int"
 
+    @t.overload
+    def __init__(
+        self: "Int[int, int]",
+        default_value: t.Union[int, Sentinel] = ...,
+        allow_none: Literal[False] = ...,
+        read_only: t.Optional[bool] = ...,
+        help: t.Optional[str] = ...,
+        config: t.Optional[t.Any] = ...,
+        **kwargs: t.Any,
+    ):
+        ...
+
+    @t.overload
+    def __init__(
+        self: "Int[t.Optional[int], t.Optional[int]]",
+        default_value: t.Union[int, Sentinel, None] = ...,
+        allow_none: Literal[True] = ...,
+        read_only: t.Optional[bool] = ...,
+        help: t.Optional[str] = ...,
+        config: t.Optional[t.Any] = ...,
+        **kwargs: t.Any,
+    ):
+        ...
+
     def __init__(self, default_value=Undefined, allow_none=False, **kwargs):
         self.min = kwargs.pop("min", None)
         self.max = kwargs.pop("max", None)
@@ -2344,9 +2611,38 @@ def subclass_init(self, cls):
         pass  # fully opt out of instance_init
 
 
-class CInt(Int):
+class CInt(Int[G, S]):
     """A casting version of the int trait."""
 
+    if t.TYPE_CHECKING:
+
+        @t.overload
+        def __init__(
+            self: "CInt[int, t.Any]",
+            default_value: t.Union[t.Any, Sentinel] = ...,
+            allow_none: Literal[False] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Optional[t.Any] = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        @t.overload
+        def __init__(
+            self: "CInt[t.Optional[int], t.Any]",
+            default_value: t.Union[t.Any, Sentinel, None] = ...,
+            allow_none: Literal[True] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Optional[t.Any] = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        def __init__(self, default_value=Undefined, allow_none=False, **kwargs):
+            ...
+
     def validate(self, obj, value):
         try:
             value = int(value)
@@ -2359,12 +2655,36 @@ def validate(self, obj, value):
 Integer = Int
 
 
-class Float(TraitType):
+class Float(TraitType[G, S]):
     """A float trait."""
 
     default_value = 0.0
     info_text = "a float"
 
+    @t.overload
+    def __init__(
+        self: "Float[float, t.Union[int, float]]",
+        default_value: t.Union[float, Sentinel] = ...,
+        allow_none: Literal[False] = ...,
+        read_only: t.Optional[bool] = ...,
+        help: t.Optional[str] = ...,
+        config: t.Optional[t.Any] = ...,
+        **kwargs: t.Any,
+    ):
+        ...
+
+    @t.overload
+    def __init__(
+        self: "Float[t.Optional[int], t.Union[int, float, None]]",
+        default_value: t.Union[float, Sentinel, None] = ...,
+        allow_none: Literal[True] = ...,
+        read_only: t.Optional[bool] = ...,
+        help: t.Optional[str] = ...,
+        config: t.Optional[t.Any] = ...,
+        **kwargs: t.Any,
+    ):
+        ...
+
     def __init__(self, default_value=Undefined, allow_none=False, **kwargs):
         self.min = kwargs.pop("min", -float("inf"))
         self.max = kwargs.pop("max", float("inf"))
@@ -2386,9 +2706,38 @@ def subclass_init(self, cls):
         pass  # fully opt out of instance_init
 
 
-class CFloat(Float):
+class CFloat(Float[G, S]):
     """A casting version of the float trait."""
 
+    if t.TYPE_CHECKING:
+
+        @t.overload
+        def __init__(
+            self: "CFloat[float, t.Any]",
+            default_value: t.Any = ...,
+            allow_none: Literal[False] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Optional[t.Any] = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        @t.overload
+        def __init__(
+            self: "CFloat[t.Optional[float], t.Any]",
+            default_value: t.Any = ...,
+            allow_none: Literal[True] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Optional[t.Any] = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        def __init__(self, default_value=Undefined, allow_none=False, **kwargs):
+            ...
+
     def validate(self, obj, value):
         try:
             value = float(value)
@@ -2397,7 +2746,7 @@ def validate(self, obj, value):
         return _validate_bounds(self, obj, value)
 
 
-class Complex(TraitType):
+class Complex(TraitType[complex, t.Union[complex, float, int]]):
     """A trait for complex numbers."""
 
     default_value = 0.0 + 0.0j
@@ -2419,7 +2768,7 @@ def subclass_init(self, cls):
         pass  # fully opt out of instance_init
 
 
-class CComplex(Complex):
+class CComplex(Complex, TraitType[complex, t.Any]):
     """A casting version of the complex number trait."""
 
     def validate(self, obj, value):
@@ -2432,7 +2781,7 @@ def validate(self, obj, value):
 # We should always be explicit about whether we're using bytes or unicode, both
 # for Python 3 conversion and for reliable unicode behaviour on Python 2. So
 # we don't have a Str type.
-class Bytes(TraitType):
+class Bytes(TraitType[bytes, bytes]):
     """A trait for byte strings."""
 
     default_value = b""
@@ -2465,7 +2814,7 @@ def subclass_init(self, cls):
         pass  # fully opt out of instance_init
 
 
-class CBytes(Bytes):
+class CBytes(Bytes, TraitType[bytes, t.Any]):
     """A casting version of the byte string trait."""
 
     def validate(self, obj, value):
@@ -2475,12 +2824,41 @@ def validate(self, obj, value):
             self.error(obj, value)
 
 
-class Unicode(TraitType):
+class Unicode(TraitType[G, S]):
     """A trait for unicode strings."""
 
     default_value = ""
     info_text = "a unicode string"
 
+    if t.TYPE_CHECKING:
+
+        @t.overload
+        def __init__(
+            self: "Unicode[str, t.Union[str, bytes]]",
+            default_value: t.Union[str, Sentinel] = ...,
+            allow_none: Literal[False] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Any = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        @t.overload
+        def __init__(
+            self: "Unicode[t.Optional[str], t.Union[str, bytes, None]]",
+            default_value: t.Union[str, Sentinel, None] = ...,
+            allow_none: Literal[True] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Any = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        def __init__(self, **kwargs):
+            ...
+
     def validate(self, obj, value):
         if isinstance(value, str):
             return value
@@ -2514,9 +2892,38 @@ def subclass_init(self, cls):
         pass  # fully opt out of instance_init
 
 
-class CUnicode(Unicode):
+class CUnicode(Unicode[G, S], TraitType[str, t.Any]):
     """A casting version of the unicode trait."""
 
+    if t.TYPE_CHECKING:
+
+        @t.overload
+        def __init__(
+            self: "CUnicode[str, t.Any]",
+            default_value: t.Union[str, Sentinel] = ...,
+            allow_none: Literal[False] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Any = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        @t.overload
+        def __init__(
+            self: "CUnicode[t.Optional[str], t.Any]",
+            default_value: t.Union[str, Sentinel, None] = ...,
+            allow_none: Literal[True] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Any = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        def __init__(self, **kwargs):
+            ...
+
     def validate(self, obj, value):
         try:
             return str(value)
@@ -2524,14 +2931,14 @@ def validate(self, obj, value):
             self.error(obj, value)
 
 
-class ObjectName(TraitType):
+class ObjectName(TraitType[str, str]):
     """A string holding a valid object name in this version of Python.
 
     This does not check that the name exists in any scope."""
 
     info_text = "a valid object identifier in Python"
 
-    coerce_str = staticmethod(lambda _, s: s)
+    coerce_str = staticmethod(lambda _, s: s)  # type:ignore[no-any-return]
 
     def validate(self, obj, value):
         value = self.coerce_str(obj, value)
@@ -2557,12 +2964,41 @@ def validate(self, obj, value):
         self.error(obj, value)
 
 
-class Bool(TraitType):
+class Bool(TraitType[G, S]):
     """A boolean (True, False) trait."""
 
     default_value = False
     info_text = "a boolean"
 
+    if t.TYPE_CHECKING:
+
+        @t.overload
+        def __init__(
+            self: "Bool[bool, t.Union[bool, int]]",
+            default_value: t.Union[bool, Sentinel] = ...,
+            allow_none: Literal[False] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Any = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        @t.overload
+        def __init__(
+            self: "Bool[t.Optional[bool], t.Union[bool, int, None]]",
+            default_value: t.Union[bool, Sentinel, None] = ...,
+            allow_none: Literal[True] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Any = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        def __init__(self, **kwargs):
+            ...
+
     def validate(self, obj, value):
         if isinstance(value, bool):
             return value
@@ -2595,9 +3031,38 @@ def argcompleter(self, **kwargs):
         return completions
 
 
-class CBool(Bool):
+class CBool(Bool[G, S]):
     """A casting version of the boolean trait."""
 
+    if t.TYPE_CHECKING:
+
+        @t.overload
+        def __init__(
+            self: "CBool[bool, t.Any]",
+            default_value: t.Union[bool, Sentinel] = ...,
+            allow_none: Literal[False] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Any = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        @t.overload
+        def __init__(
+            self: "CBool[t.Optional[bool], t.Any]",
+            default_value: t.Union[bool, Sentinel, None] = ...,
+            allow_none: Literal[True] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Any = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        def __init__(self, **kwargs):
+            ...
+
     def validate(self, obj, value):
         try:
             return bool(value)
@@ -2605,10 +3070,12 @@ def validate(self, obj, value):
             self.error(obj, value)
 
 
-class Enum(TraitType):
+class Enum(TraitType[G, S]):
     """An enum whose value must be in a given sequence."""
 
-    def __init__(self, values, default_value=Undefined, **kwargs):
+    def __init__(
+        self: "Enum[t.Any, t.Any]", values: t.Any, default_value: t.Any = Undefined, **kwargs: t.Any
+    ):
         self.values = values
         if kwargs.get("allow_none", False) and default_value is Undefined:
             default_value = None
@@ -2653,10 +3120,15 @@ def argcompleter(self, **kwargs):
         return [str(v) for v in self.values]
 
 
-class CaselessStrEnum(Enum):
+class CaselessStrEnum(Enum[G, S]):
     """An enum of strings where the case should be ignored."""
 
-    def __init__(self, values, default_value=Undefined, **kwargs):
+    def __init__(
+        self: "CaselessStrEnum[t.Any, t.Any]",
+        values: t.Any,
+        default_value: t.Any = Undefined,
+        **kwargs: t.Any,
+    ):
         super().__init__(values, default_value=default_value, **kwargs)
 
     def validate(self, obj, value):
@@ -2680,7 +3152,7 @@ def info_rst(self):
         return self._info(as_rst=True)
 
 
-class FuzzyEnum(Enum):
+class FuzzyEnum(Enum[G, S]):
     """An case-ignoring enum matching choices by unique prefixes/substrings."""
 
     case_sensitive = False
@@ -2688,12 +3160,12 @@ class FuzzyEnum(Enum):
     substring_matching = False
 
     def __init__(
-        self,
-        values,
-        default_value=Undefined,
-        case_sensitive=False,
-        substring_matching=False,
-        **kwargs,
+        self: "FuzzyEnum[t.Any, t.Any]",
+        values: t.Any,
+        default_value: t.Any = Undefined,
+        case_sensitive: bool = False,
+        substring_matching: bool = False,
+        **kwargs: t.Any,
     ):
         self.case_sensitive = case_sensitive
         self.substring_matching = substring_matching
@@ -2705,7 +3177,11 @@ def validate(self, obj, value):
 
         conv_func = (lambda c: c) if self.case_sensitive else lambda c: c.lower()
         substring_matching = self.substring_matching
-        match_func = (lambda v, c: v in c) if substring_matching else (lambda v, c: c.startswith(v))
+        match_func = (
+            (lambda v, c: v in c)
+            if substring_matching
+            else (lambda v, c: c.startswith(v))  # type:ignore[no-any-return]
+        )
         value = conv_func(value)
         choices = self.values
         matches = [match_func(value, conv_func(c)) for c in choices]
@@ -2730,18 +3206,58 @@ def info_rst(self):
         return self._info(as_rst=True)
 
 
-class Container(Instance):
+class Container(Instance[T]):
     """An instance of a container (list, set, etc.)
 
     To be subclassed by overriding klass.
     """
 
-    klass: t.Optional[t.Union[str, t.Type[t.Any]]] = None
+    klass: t.Optional[t.Type[T]] = None
     _cast_types: t.Any = ()
     _valid_defaults = SequenceTypes
     _trait = None
     _literal_from_string_pairs: t.Any = ("[]", "()")
 
+    @t.overload
+    def __init__(
+        self: "Container[T]",
+        kind: t.Type[T],
+        *,
+        allow_none: Literal[False],
+        read_only: t.Optional[bool] = ...,
+        help: t.Optional[str] = ...,
+        config: t.Optional[t.Any] = ...,
+        **kwargs: t.Any,
+    ):
+        ...
+
+    @t.overload
+    def __init__(
+        self: "Container[T | None]",
+        kind: t.Type[T],
+        *,
+        allow_none: Literal[True],
+        read_only: t.Optional[bool] = ...,
+        help: t.Optional[str] = ...,
+        config: t.Optional[t.Any] = ...,
+        **kwargs: t.Any,
+    ):
+        ...
+
+    @t.overload
+    def __init__(
+        self: "Container[T]",
+        kind: t.Type[T],
+        *,
+        help: str = ...,
+        read_only: bool = ...,
+        config: t.Any = ...,
+        trait: t.Any = ...,
+        default_value: t.Any = ...,
+        **kwargs: t.Any,
+    ):
+        ...
+
     def __init__(self, trait=None, default_value=Undefined, **kwargs):
         """Create a container trait type from a list, set, or tuple.
 
@@ -2817,7 +3333,7 @@ def __init__(self, trait=None, default_value=Undefined, **kwargs):
     def validate(self, obj, value):
         if isinstance(value, self._cast_types):
             assert self.klass is not None
-            value = self.klass(value)  # type:ignore[operator]
+            value = self.klass(value)  # type:ignore[call-arg]
         value = super().validate(obj, value)
         if value is None:
             return value
@@ -2838,7 +3354,7 @@ def validate_elements(self, obj, value):
             else:
                 validated.append(v)
         assert self.klass is not None
-        return self.klass(validated)  # type:ignore[operator]
+        return self.klass(validated)  # type:ignore[call-arg]
 
     def class_init(self, cls, name):
         if isinstance(self._trait, TraitType):
@@ -2889,18 +3405,18 @@ def from_string_list(self, s_list):
                     DeprecationWarning,
                     stacklevel=2,
                 )
-                return self.klass(literal_eval(r))  # type:ignore[operator]
+                return self.klass(literal_eval(r))  # type:ignore[call-arg]
         sig = inspect.signature(self.item_from_string)
         if "index" in sig.parameters:
             item_from_string = self.item_from_string
         else:
             # backward-compat: allow item_from_string to ignore index arg
             def item_from_string(s, index=None):
-                return self.item_from_string(s)  # noqa[E371]
+                return self.item_from_string(s)
 
-        return self.klass(
+        return self.klass(  # type:ignore[call-arg]
             [item_from_string(s, index=idx) for idx, s in enumerate(s_list)]
-        )  # type:ignore[operator]
+        )
 
     def item_from_string(self, s, index=None):
         """Cast a single item from a string
@@ -2913,7 +3429,7 @@ def item_from_string(self, s, index=None):
             return s
 
 
-class List(Container):
+class List(Container[t.List[t.Any]]):
     """An instance of a Python list."""
 
     klass = list
@@ -3031,7 +3547,7 @@ def default_value_repr(self):
         return "{" + list_repr[1:-1] + "}"
 
 
-class Tuple(Container):
+class Tuple(Container[t.Tuple[t.Any, ...]]):
     """An instance of a Python tuple."""
 
     klass = tuple
@@ -3160,7 +3676,7 @@ def subclass_init(self, cls):
         # to opt out of instance_init
 
 
-class Dict(Instance):
+class Dict(Instance[t.Dict[t.Any, t.Any]]):
     """An instance of a Python dict.
 
     One or more traits can be passed to the constructor
@@ -3431,7 +3947,7 @@ def item_from_string(self, s):
         return {key: value}
 
 
-class TCPAddress(TraitType):
+class TCPAddress(TraitType[G, S]):
     """A trait for an (ip, port) tuple.
 
     This allows for both IPv4 IP addresses as well as hostnames.
@@ -3440,6 +3956,46 @@ class TCPAddress(TraitType):
     default_value = ("127.0.0.1", 0)
     info_text = "an (ip, port) tuple"
 
+    if t.TYPE_CHECKING:
+
+        @t.overload
+        def __init__(
+            self: "TCPAddress[t.Tuple[str, int], t.Tuple[str, int]]",
+            default_value: t.Union[bool, Sentinel] = ...,
+            allow_none: Literal[False] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Any = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        @t.overload
+        def __init__(
+            self: "TCPAddress[t.Optional[t.Tuple[str, int]], t.Optional[t.Tuple[str, int]]]",
+            default_value: t.Union[bool, None, Sentinel] = ...,
+            allow_none: Literal[True] = ...,
+            read_only: t.Optional[bool] = ...,
+            help: t.Optional[str] = ...,
+            config: t.Any = ...,
+            **kwargs: t.Any,
+        ):
+            ...
+
+        def __init__(
+            self: t.Union[
+                "TCPAddress[t.Optional[t.Tuple[str, int]], t.Optional[t.Tuple[str, int]]]",
+                "TCPAddress[t.Tuple[str, int], t.Tuple[str, int]]",
+            ],
+            default_value: t.Union[bool, None, Sentinel] = Undefined,
+            allow_none: Literal[True, False] = False,
+            read_only: t.Optional[bool] = None,
+            help: t.Optional[str] = None,
+            config: t.Any = None,
+            **kwargs: t.Any,
+        ):
+            ...
+
     def validate(self, obj, value):
         if isinstance(value, tuple):
             if len(value) == 2:
@@ -3459,7 +4015,7 @@ def from_string(self, s):
         return (ip, port)
 
 
-class CRegExp(TraitType):
+class CRegExp(TraitType["re.Pattern[t.Any]", t.Union["re.Pattern[t.Any]", str]]):
     """A casting compiled regular expression trait.
 
     Accepts both strings and compiled regular expressions. The resulting
@@ -3474,7 +4030,7 @@ def validate(self, obj, value):
             self.error(obj, value)
 
 
-class UseEnum(TraitType):
+class UseEnum(TraitType[t.Any, t.Any]):
     """Use a Enum class as model for the data type description.
     Note that if no default-value is provided, the first enum-value is used
     as default-value.
@@ -3571,7 +4127,7 @@ def info_rst(self):
         return self._info(as_rst=True)
 
 
-class Callable(TraitType):
+class Callable(TraitType[t.Callable[..., t.Any], t.Callable[..., t.Any]]):
     """A trait which is callable.
 
     Notes


Test patch:
diff --git a/traitlets/tests/test_traitlets.py b/traitlets/tests/test_traitlets.py
index 3b41fe4b..62fa726f 100644
--- a/traitlets/tests/test_traitlets.py
+++ b/traitlets/tests/test_traitlets.py
@@ -112,7 +112,7 @@ class A(HasTraitsStub):
         self.assertEqual(a._notify_new, 10)
 
     def test_validate(self):
-        class MyTT(TraitType):
+        class MyTT(TraitType[int, int]):
             def validate(self, inst, value):
                 return -1
 
@@ -127,7 +127,7 @@ class A(HasTraitsStub):
         self.assertEqual(a.tt, -1)
 
     def test_default_validate(self):
-        class MyIntTT(TraitType):
+        class MyIntTT(TraitType[int, int]):
             def validate(self, obj, value):
                 if isinstance(value, int):
                     return value
@@ -154,7 +154,7 @@ class A(HasTraits):
 
     def test_error(self):
         class A(HasTraits):
-            tt = TraitType()
+            tt = TraitType[int, int]()
 
         a = A()
         self.assertRaises(TraitError, A.tt.error, a, 10)
@@ -270,14 +270,14 @@ def _default_x(self):
         self.assertEqual(a._trait_values, {"x": 11})
 
     def test_tag_metadata(self):
-        class MyIntTT(TraitType):
+        class MyIntTT(TraitType[int, int]):
             metadata = {"a": 1, "b": 2}
 
         a = MyIntTT(10).tag(b=3, c=4)
         self.assertEqual(a.metadata, {"a": 1, "b": 3, "c": 4})
 
     def test_metadata_localized_instance(self):
-        class MyIntTT(TraitType):
+        class MyIntTT(TraitType[int, int]):
             metadata = {"a": 1, "b": 2}
 
         a = MyIntTT(10)
@@ -325,7 +325,7 @@ class Foo(HasTraits):
         self.assertEqual(Foo().bar, {})
 
     def test_deprecated_metadata_access(self):
-        class MyIntTT(TraitType):
+        class MyIntTT(TraitType[int, int]):
             metadata = {"a": 1, "b": 2}
 
         a = MyIntTT(10)
@@ -394,12 +394,12 @@ class C(HasTraits):
 
     def test_this_class(self):
         class A(HasTraits):
-            t = This()
-            tt = This()
+            t = This["A"]()
+            tt = This["A"]()
 
         class B(A):
-            tt = This()
-            ttt = This()
+            tt = This["A"]()
+            ttt = This["A"]()
 
         self.assertEqual(A.t.this_class, A)
         self.assertEqual(B.t.this_class, A)
@@ -1094,7 +1094,7 @@ class Bar(Foo):
         class Bah:
             pass
 
-        class FooInstance(Instance):
+        class FooInstance(Instance[Foo]):
             klass = Foo
 
         class A(HasTraits):
@@ -1170,7 +1170,7 @@ class Foo:
 
         def inner():
             class A(HasTraits):
-                inst = Instance(Foo())
+                inst = Instance(Foo())  # type:ignore
 
         self.assertRaises(TraitError, inner)
 
@@ -1178,7 +1178,7 @@ class A(HasTraits):
 class TestThis(TestCase):
     def test_this_class(self):
         class Foo(HasTraits):
-            this = This()
+            this = This["Foo"]()
 
         f = Foo()
         self.assertEqual(f.this, None)
@@ -1189,7 +1189,7 @@ class Foo(HasTraits):
 
     def test_this_inst(self):
         class Foo(HasTraits):
-            this = This()
+            this = This["Foo"]()
 
         f = Foo()
         f.this = Foo()
@@ -1197,7 +1197,7 @@ class Foo(HasTraits):
 
     def test_subclass(self):
         class Foo(HasTraits):
-            t = This()
+            t = This["Foo"]()
 
         class Bar(Foo):
             pass
@@ -1211,7 +1211,7 @@ class Bar(Foo):
 
     def test_subclass_override(self):
         class Foo(HasTraits):
-            t = This()
+            t = This["Foo"]()
 
         class Bar(Foo):
             t = This()
@@ -2423,11 +2423,11 @@ def test_notification_order():
 # Traits for Forward Declaration Tests
 ###
 class ForwardDeclaredInstanceTrait(HasTraits):
-    value = ForwardDeclaredInstance("ForwardDeclaredBar", allow_none=True)
+    value = ForwardDeclaredInstance["ForwardDeclaredBar"]("ForwardDeclaredBar", allow_none=True)
 
 
 class ForwardDeclaredTypeTrait(HasTraits):
-    value = ForwardDeclaredType("ForwardDeclaredBar", allow_none=True)
+    value = ForwardDeclaredType[t.Any, t.Any]("ForwardDeclaredBar", allow_none=True)
 
 
 class ForwardDeclaredInstanceListTrait(HasTraits):
diff --git a/traitlets/tests/test_typing.py b/traitlets/tests/test_typing.py
new file mode 100644
index 00000000..adce8820
--- /dev/null
+++ b/traitlets/tests/test_typing.py
@@ -0,0 +1,172 @@
+import typing
+from typing import Optional
+
+import pytest
+
+from traitlets import Bool, CInt, HasTraits, Instance, Int, TCPAddress
+
+if not typing.TYPE_CHECKING:
+
+    def reveal_type(*args, **kwargs):
+        pass
+
+
+class Foo:
+    def __init__(self, c):
+        self.c = c
+
+
+@pytest.mark.mypy_testing
+def mypy_bool_typing():
+    class T(HasTraits):
+        b = Bool(True).tag(sync=True)
+        ob = Bool(None, allow_none=True).tag(sync=True)
+
+    t = T()
+    reveal_type(
+        Bool(True)  # R: traitlets.traitlets.Bool[builtins.bool, Union[builtins.bool, builtins.int]]
+    )
+    reveal_type(
+        Bool(  # R: traitlets.traitlets.Bool[builtins.bool, Union[builtins.bool, builtins.int]]
+            True
+        ).tag(sync=True)
+    )
+    reveal_type(
+        Bool(  # R: traitlets.traitlets.Bool[Union[builtins.bool, None], Union[builtins.bool, builtins.int, None]]
+            None, allow_none=True
+        )
+    )
+    reveal_type(
+        Bool(  # R: traitlets.traitlets.Bool[Union[builtins.bool, None], Union[builtins.bool, builtins.int, None]]
+            None, allow_none=True
+        ).tag(
+            sync=True
+        )
+    )
+    reveal_type(
+        T.b  # R: traitlets.traitlets.Bool[builtins.bool, Union[builtins.bool, builtins.int]]
+    )
+    reveal_type(t.b)  # R: builtins.bool
+    reveal_type(t.ob)  # R: Union[builtins.bool, None]
+    reveal_type(
+        T.b  # R: traitlets.traitlets.Bool[builtins.bool, Union[builtins.bool, builtins.int]]
+    )
+    reveal_type(
+        T.ob  # R: traitlets.traitlets.Bool[Union[builtins.bool, None], Union[builtins.bool, builtins.int, None]]
+    )
+    # we would expect this to be Optional[Union[bool, int]], but...
+    t.b = "foo"  # E: Incompatible types in assignment (expression has type "str", variable has type "Union[bool, int]")  [assignment]
+    t.b = None  # E: Incompatible types in assignment (expression has type "None", variable has type "Union[bool, int]")  [assignment]
+
+
+@pytest.mark.mypy_testing
+def mypy_int_typing():
+    class T(HasTraits):
+        i: Int[int, int] = Int(42).tag(sync=True)
+        oi: Int[Optional[int], Optional[int]] = Int(42, allow_none=True).tag(sync=True)
+
+    t = T()
+    reveal_type(Int(True))  # R: traitlets.traitlets.Int[builtins.int, builtins.int]
+    reveal_type(Int(True).tag(sync=True))  # R: traitlets.traitlets.Int[builtins.int, builtins.int]
+    reveal_type(
+        Int(  # R: traitlets.traitlets.Int[Union[builtins.int, None], Union[builtins.int, None]]
+            None, allow_none=True
+        )
+    )
+    reveal_type(
+        Int(  # R: traitlets.traitlets.Int[Union[builtins.int, None], Union[builtins.int, None]]
+            None, allow_none=True
+        ).tag(sync=True)
+    )
+    reveal_type(T.i)  # R: traitlets.traitlets.Int[builtins.int, builtins.int]
+    reveal_type(t.i)  # R: builtins.int
+    reveal_type(t.oi)  # R: Union[builtins.int, None]
+    reveal_type(T.i)  # R: traitlets.traitlets.Int[builtins.int, builtins.int]
+    reveal_type(
+        T.oi  # R: traitlets.traitlets.Int[Union[builtins.int, None], Union[builtins.int, None]]
+    )
+    t.i = "foo"  # E: Incompatible types in assignment (expression has type "str", variable has type "int")  [assignment]
+    t.i = None  # E: Incompatible types in assignment (expression has type "None", variable has type "int")  [assignment]
+    t.i = 1.2  # E: Incompatible types in assignment (expression has type "float", variable has type "int")  [assignment]
+
+
+@pytest.mark.mypy_testing
+def mypy_cint_typing():
+    class T(HasTraits):
+        i = CInt(42).tag(sync=True)
+        oi = CInt(42, allow_none=True).tag(sync=True)
+
+    t = T()
+    reveal_type(CInt(42))  # R: traitlets.traitlets.CInt[builtins.int, Any]
+    reveal_type(CInt(42).tag(sync=True))  # R: traitlets.traitlets.CInt[builtins.int, Any]
+    reveal_type(
+        CInt(None, allow_none=True)  # R: traitlets.traitlets.CInt[Union[builtins.int, None], Any]
+    )
+    reveal_type(
+        CInt(  # R: traitlets.traitlets.CInt[Union[builtins.int, None], Any]
+            None, allow_none=True
+        ).tag(sync=True)
+    )
+    reveal_type(T.i)  # R: traitlets.traitlets.CInt[builtins.int, Any]
+    reveal_type(t.i)  # R: builtins.int
+    reveal_type(t.oi)  # R: Union[builtins.int, None]
+    reveal_type(T.i)  # R: traitlets.traitlets.CInt[builtins.int, Any]
+    reveal_type(T.oi)  # R: traitlets.traitlets.CInt[Union[builtins.int, None], Any]
+
+
+@pytest.mark.mypy_testing
+def mypy_tcp_typing():
+    class T(HasTraits):
+        tcp = TCPAddress()
+        otcp = TCPAddress(None, allow_none=True)
+
+    t = T()
+    reveal_type(t.tcp)  # R: Tuple[builtins.str, builtins.int]
+    reveal_type(
+        T.tcp  # R: traitlets.traitlets.TCPAddress[Tuple[builtins.str, builtins.int], Tuple[builtins.str, builtins.int]]
+    )
+    reveal_type(
+        T.tcp.tag(  # R:traitlets.traitlets.TCPAddress[Tuple[builtins.str, builtins.int], Tuple[builtins.str, builtins.int]]
+            sync=True
+        )
+    )
+    reveal_type(t.otcp)  # R: Union[Tuple[builtins.str, builtins.int], None]
+    reveal_type(
+        T.otcp  # R: traitlets.traitlets.TCPAddress[Union[Tuple[builtins.str, builtins.int], None], Union[Tuple[builtins.str, builtins.int], None]]
+    )
+    reveal_type(
+        T.otcp.tag(  # R: traitlets.traitlets.TCPAddress[Union[Tuple[builtins.str, builtins.int], None], Union[Tuple[builtins.str, builtins.int], None]]
+            sync=True
+        )
+    )
+    t.tcp = "foo"  # E: Incompatible types in assignment (expression has type "str", variable has type "Tuple[str, int]")  [assignment]
+    t.otcp = "foo"  # E: Incompatible types in assignment (expression has type "str", variable has type "Optional[Tuple[str, int]]")  [assignment]
+    t.tcp = None  # E: Incompatible types in assignment (expression has type "None", variable has type "Tuple[str, int]")  [assignment]
+
+
+@pytest.mark.mypy_testing
+def mypy_instance_typing():
+    class T(HasTraits):
+        inst = Instance(Foo)
+        oinst = Instance(Foo, allow_none=True)
+        oinst_string = Instance("Foo", allow_none=True)
+
+    t = T()
+    reveal_type(t.inst)  # R: traitlets.tests.test_typing.Foo
+    reveal_type(T.inst)  # R: traitlets.traitlets.Instance[traitlets.tests.test_typing.Foo]
+    reveal_type(
+        T.inst.tag(sync=True)  # R: traitlets.traitlets.Instance[traitlets.tests.test_typing.Foo]
+    )
+    reveal_type(t.oinst)  # R: Union[traitlets.tests.test_typing.Foo, None]
+    reveal_type(t.oinst_string)  # R: Union[Any, None]
+    reveal_type(
+        T.oinst  # R: traitlets.traitlets.Instance[Union[traitlets.tests.test_typing.Foo, None]]
+    )
+    reveal_type(
+        T.oinst.tag(  # R: traitlets.traitlets.Instance[Union[traitlets.tests.test_typing.Foo, None]]
+            sync=True
+        )
+    )
+    t.inst = "foo"  # E: Incompatible types in assignment (expression has type "str", variable has type "Foo")  [assignment]
+    t.oinst = "foo"  # E: Incompatible types in assignment (expression has type "str", variable has type "Optional[Foo]")  [assignment]
+    t.inst = None  # E: Incompatible types in assignment (expression has type "None", variable has type "Foo")  [assignment]