Repository: ipython/traitlets
Pull request #818 (2022-12-20 09:16:51)

feat: trait typing
Took https://github.com/ipython/traitlets/pull/788 and https://github.com/vidartf/traitlets/pull/1 did some fixes for py < 3.10 and squashed it to make rebasing to master easier.

Closes #788 



maartenbreddels: Failures are expected due to not being on master (where this would be green).

Also, would require a bit more work to get all the other types supported.

As @rmorshea said in https://github.com/ipython/traitlets/pull/788#issuecomment-1291216995 there is no way but to copy-paste the `__new__` overloads and modify them for each trait. I have to say that even with more advanced typing, I don't see us avoid this, since each trait is a bit different.
blink1073: I'd say partial support is a good start, we can follow up with the rest.
maartenbreddels: I'd like to add support to a few more, and see if we can do without the `Self` and simply add some more copy-pasting.
maartenbreddels: My guess is that the windows failures are solved by our good friend @vidartf who is always ahead of what we need for windows: https://github.com/davidfritzsche/pytest-mypy-testing/pull/34
Is that correct @vidartf ? Does the error in CI look familiar to you?
maartenbreddels: cc @rmorshea 

Btw, I've moved away from overloading `__new__`, but we are now overloading `__init__`, by annotating `self`.

```python
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] = ...,
        **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] = ...,
        **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"))
        super().__init__(default_value=default_value, allow_none=allow_none, **kwargs)
```

This looks way less scary than what we suggested in #788
rmorshea: > I've moved away from overloading `__new__`

That makes sense to me. Users won't really care whether the descriptor is a `Float` or `Bool`, they'll just be concerned with whether the descriptor casts to and from the appropriate types when the set/get attributes (which the generic `TraitType` is fully capable of indicating). External libraries that interface with `TraitType` descriptors directly may care, but that's less common and you can convince MyPy what the narrowed type is with an `assert` check or `cast()` when you really need to.
maartenbreddels: > > I've moved away from overloading `__new__`
> 
> That makes sense to me. Users won't really care whether the descriptor is a `Float` or `Bool`

Overloading `__init__` this way gives you all the advantages of overloading `__new__`, except it looks more clean. So it will give you information if its either `Float` or `Bool`. 

I don't think the `__init__(self : Foo[Bar], ..` overload trick is known that well actually, I found it when looking for some issues with overloading `__new__`.
rmorshea: > the `__init__(self : Foo[Bar], ...)` overload trick...

Wow! I didn't even catch what was going on there. That's quite interesting. In my sample I never actually implemented a subclass so I didn't really foresee all the problems that would result from overriding `__new__` in the way I did. Having fooled around with it for a bit, my idea of copy-pasting `__new__` into subclasses does not work. For anyone else who's curious here's a [playground link](https://mypy-play.net/?mypy=latest&python=3.11&gist=09a7f574a8ec5d77cb83cdb675d5545b) that shows off this `__init__` trick in isolation.

The main downside of all this is that `self` is always unbound, that is, if you're calling a method on the parent class that's parametrized somehow, you'll need to annotate `self: TheTraitType[TheType | None]`. You can see that in the MyPy playground. If you remove the `self` annotation, you'll get an error when calling `super().__init__`.
blink1073: Okay, so basically we need to wait for a new release of mypy, and then if there is not a release of https://github.com/davidfritzsche/pytest-mypy-testing/pull/34, skip the typing tests on Windows.
blink1073: `mypy` 1.0 has been released.  It seems to me that [pytest-mypy-testing](https://github.com/davidfritzsche/pytest-mypy-testing) isn't going to get a release.  I would vote against depending on it, and instead have a set of files that we run `mypy` directly against.
saulshanabrook: Not to get too off-topic here, but @blink1073 I am also looking at using [pytest-mypy-testing](https://github.com/davidfritzsche/pytest-mypy-testing) in a project but also am seeing that it is out of date.

I was thinking about trying instead to use MyPy's own unit testing framework, which is built on pytest, and was curious if you all had considered that as well.

* Pytest plugin: https://github.com/python/mypy/blob/5e2702fa753ed1625d5788dcb1b71ac4a50989fd/mypy/test/data.py
* Docs on test plugin: https://github.com/python/mypy/blob/master/test-data/unit/README.md

I wasn't sure if anyone was using this outside of MyPy though.
maartenbreddels: Interesting, I think it is tricky to get typing right without testing both successes and failures.
saulshanabrook: @maartenbreddels Yes I agree. I wasn't sure if you were implying that the mypy unit testing framework doesn't support testing failures as well, but here is a simple example of using it verify some things that pass and some that fail: https://github.com/python/mypy/blob/54635dec2379e2ac8b65b6ef07778015c69cfb6a/test-data/unit/check-basic.test#L93-L99
saulshanabrook: I just had a go at trying to use MyPy's builtin testing framework in my package, and it seems to work OK! Only requires one monkeypatch...

https://github.com/metadsl/egg-smol-python/pull/6
blink1073: I wonder if the `mypy` folks would support making the plugin public and not require the monkey-patch
blink1073: All green, let's gooo!

Patch:
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 5adeaf8..528130c 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -22,22 +22,13 @@ import sys
 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 @@ github_project_url = "https://github.com/ipython/traitlets"
 source_suffix = ".rst"
 
 # Add dev disclaimer.
-if _release["version_info"][-1] == "dev":
+if version_info[-1] == "dev":
     rst_prolog = """
     .. note::
 
@@ -89,9 +80,9 @@ author = "The IPython Development Team"
 # 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 eb38cb7..a8b7304 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 bd04be7..009f920 100755
--- a/examples/myapp.py
+++ b/examples/myapp.py
@@ -94,6 +94,7 @@ class MyApp(Application):
         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 13c0b7d..8c2df58 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 af8fe53..6da29fe 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 @@ class Application(SingletonConfigurable):
                     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 @@ class Application(SingletonConfigurable):
 
     _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 @@ class Application(SingletonConfigurable):
 
     _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 @@ class Application(SingletonConfigurable):
     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 c3b2ed1..1bfa045 100644
--- a/traitlets/config/configurable.py
+++ b/traitlets/config/configurable.py
@@ -182,6 +182,7 @@ class Configurable(HasTraits):
                     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 @@ class LoggingConfigurable(Configurable):
     @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 a9136dc..1c6b8e8 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 e537673..22354f0 100644
--- a/traitlets/traitlets.py
+++ b/traitlets/traitlets.py
@@ -474,23 +474,36 @@ class BaseDescriptor:
         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 @@ class TraitType(BaseDescriptor):
         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 @@ class TraitType(BaseDescriptor):
                 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 @@ class TraitType(BaseDescriptor):
                     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 @@ class TraitType(BaseDescriptor):
         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 @@ class TraitType(BaseDescriptor):
             # 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 @@ class TraitType(BaseDescriptor):
         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 @@ class HasTraits(HasDescriptors, metaclass=MetaHasTraits):
             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 @@ class HasTraits(HasDescriptors, metaclass=MetaHasTraits):
             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 @@ class HasTraits(HasDescriptors, metaclass=MetaHasTraits):
                     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 @@ class HasTraits(HasDescriptors, metaclass=MetaHasTraits):
 # -----------------------------------------------------------------------------
 
 
-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 @@ class ClassBasedTraitType(TraitType):
         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 @@ class Type(ClassBasedTraitType):
 
         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 @@ class Type(ClassBasedTraitType):
             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 Instance(ClassBasedTraitType):
         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 @@ class ForwardDeclaredMixin:
         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 @@ class This(ClassBasedTraitType):
             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 @@ class Union(TraitType):
 # -----------------------------------------------------------------------------
 
 
-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 @@ class Int(TraitType):
         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 @@ Long, CLong = Int, CInt
 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 @@ class Float(TraitType):
         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 @@ class CFloat(Float):
         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 @@ class Complex(TraitType):
         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 @@ class CComplex(Complex):
 # 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 @@ class Bytes(TraitType):
         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 @@ class CBytes(Bytes):
             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 @@ class Unicode(TraitType):
         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 @@ class CUnicode(Unicode):
             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 @@ class DottedObjectName(ObjectName):
         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 @@ class Bool(TraitType):
         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 @@ class CBool(Bool):
             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 @@ class Enum(TraitType):
         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 @@ class CaselessStrEnum(Enum):
         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 @@ class FuzzyEnum(Enum):
 
         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 @@ class FuzzyEnum(Enum):
         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 @@ class Container(Instance):
     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 @@ class Container(Instance):
             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 @@ class Container(Instance):
                     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 @@ class Container(Instance):
             return s
 
 
-class List(Container):
+class List(Container[t.List[t.Any]]):
     """An instance of a Python list."""
 
     klass = list
@@ -3031,7 +3547,7 @@ class Set(List):
         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 @@ class Tuple(Container):
         # 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 @@ class Dict(Instance):
         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 @@ class TCPAddress(TraitType):
         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 @@ class CRegExp(TraitType):
             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 @@ class UseEnum(TraitType):
         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 3b41fe4..62fa726 100644
--- a/traitlets/tests/test_traitlets.py
+++ b/traitlets/tests/test_traitlets.py
@@ -112,7 +112,7 @@ class TestTraitType(TestCase):
         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 TestTraitType(TestCase):
         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 TestTraitType(TestCase):
 
     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 @@ class TestTraitType(TestCase):
         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 TestTraitType(TestCase):
         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 TestHasDescriptorsMeta(TestCase):
 
     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 TestInstance(TestCase):
         class Bah:
             pass
 
-        class FooInstance(Instance):
+        class FooInstance(Instance[Foo]):
             klass = Foo
 
         class A(HasTraits):
@@ -1170,7 +1170,7 @@ class TestInstance(TestCase):
 
         def inner():
             class A(HasTraits):
-                inst = Instance(Foo())
+                inst = Instance(Foo())  # type:ignore
 
         self.assertRaises(TraitError, inner)
 
@@ -1178,7 +1178,7 @@ class TestInstance(TestCase):
 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 TestThis(TestCase):
 
     def test_this_inst(self):
         class Foo(HasTraits):
-            this = This()
+            this = This["Foo"]()
 
         f = Foo()
         f.this = Foo()
@@ -1197,7 +1197,7 @@ class TestThis(TestCase):
 
     def test_subclass(self):
         class Foo(HasTraits):
-            t = This()
+            t = This["Foo"]()
 
         class Bar(Foo):
             pass
@@ -1211,7 +1211,7 @@ class TestThis(TestCase):
 
     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 0000000..adce882
--- /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]