diff --git a/djblets/util/properties.py b/djblets/util/properties.py
index 2013c6787e0ea9576865f8b3567d209ae65b044d..9dc5e929c6acbc6dfbfcc58d39ccb8a5420a1cb6 100644
--- a/djblets/util/properties.py
+++ b/djblets/util/properties.py
@@ -1,20 +1,66 @@
 """Specialized descriptors/properties for classes."""
 
+from __future__ import annotations
+
 import warnings
+from typing import (Any, Callable, Generic, Optional, Sequence, Tuple, Type,
+                    Union, cast, overload)
+
+from typing_extensions import Self, TypeAlias, TypeVar
+
+from djblets.deprecation import (RemovedInDjblets50Warning,
+                                 deprecate_non_keyword_only_args)
+
+
+# NOTE: When mypy supports PEP 696, we can give many of these defaults.
+#       Setters can be based on the preceding getters as specific in
+#       Generic[...]. This will simplify usage for callers in the default
+#       case.
+_StoredT = TypeVar('_StoredT')
+_GetT = TypeVar('_GetT')
+_SetT = TypeVar('_SetT')
+
+_AliasPropertySetT = TypeVar('_AliasPropertySetT')
+_AliasPropertyStoredT: TypeAlias = Any
+
+_TypedPropertyGetT = TypeVar('_TypedPropertyGetT')
+_TypedPropertyValidTypesParamT: TypeAlias = Union[Type[_SetT],
+                                                  Sequence[Type[_SetT]]]
 
 
-class BaseProperty(object):
+class BaseProperty(Generic[_StoredT]):
     """Base class for a custom property for a class.
 
     This is an optional base class that provides handy utilities that
     properties may need. For instance, being able to determine the name of
     the property's attribute on a class.
+
+    Version Changed:
+        3.3:
+        This now supports generics for typing, taking the type of the stored
+        content.
     """
 
-    def get_attr_name(self, instance):
+    ######################
+    # Instance variables #
+    ######################
+
+    #: The name of the owning attribute.
+    #:
+    #: Version Added:
+    #:     3.3
+    attr_name: str
+
+    def get_attr_name(
+        self,
+        instance: object,
+    ) -> str:
         """Return the name of this property's attribute.
 
-        The value will be computed only once per property instance.
+        Deprecated:
+            3.3:
+            This has been replaced with :py:attr:`attr_name`, and will be
+            removed in Djblets 5.0.
 
         Args:
             instance (object):
@@ -24,17 +70,34 @@ class BaseProperty(object):
             str:
             The name of this property on the instance.
         """
-        if not hasattr(self, '_attr_name'):
-            cls = type(instance)
-            self._attr_name = get_descriptor_attr_name(self, cls)
+        RemovedInDjblets50Warning.warn(
+            '%s.get_attr_name() is deprecated. Please access the `attr_name` '
+            'attribute instead. This will be removed in Djblets 5.0.')
+
+        return self.attr_name
 
-            assert self._attr_name is not None, (
-                'Could not find the attribute for %r on %r' % (self, cls))
+    def __set_name__(
+        self,
+        owner: type,
+        name: str,
+    ) -> None:
+        """Handle setting the attribute name for this property.
+
+        Version Added:
+            3.3
+
+        Args:
+            owner (type, unused):
+                The class that owns the property.
 
-        return self._attr_name
+            name (str):
+                The attribute name for this property.
+        """
+        self.attr_name = name
 
 
-class AliasProperty(BaseProperty):
+class AliasProperty(Generic[_GetT, _AliasPropertySetT],
+                    BaseProperty[_AliasPropertyStoredT]):
     """A property that aliases to another property or attribute.
 
     Alias properties are used to automatically retrieve from another property
@@ -45,16 +108,62 @@ class AliasProperty(BaseProperty):
 
     Alias properties can optionally emit a deprecation warning on use, in order
     to help in the process of migrating legacy code.
+
+    Version Changed:
+        3.3:
+        This now supports generics for typing, taking the types to return on
+        access, and types that can be set.
+
+    Example:
+        .. code-block:: python
+
+           class MyClass:
+               new_prop: str
+               old_prop: AliasProperty[int, str] = AliasProperty[int, str](
+                   'new_prop',
+                   convert_to_func=str,
+                   convert_from_func=int)
+
+        Note that the explicit type declaration is important. Without it,
+        type checkers may allow constructors to override the type.
     """
 
-    def __init__(self, prop_name, convert_to_func=None, convert_from_func=None,
-                 deprecated=False, deprecation_warning=DeprecationWarning):
+    ######################
+    # Instance variables #
+    ######################
+
+    #: Whether to emit a deprecation warning on access.
+    deprecated: bool
+
+    #: The type of class to use for the deprecation warning.
+    deprecation_warning: Type[DeprecationWarning]
+
+    #: The name of the property or attribute to read from and write to
+    prop_name: str
+
+    @deprecate_non_keyword_only_args(RemovedInDjblets50Warning)
+    def __init__(
+        self,
+        prop_name: str,
+        *,
+        convert_to_func: Optional[Callable[[_AliasPropertySetT],
+                                           _AliasPropertyStoredT]] = None,
+        convert_from_func: Optional[Callable[[_AliasPropertyStoredT],
+                                             _GetT]] = None,
+        deprecated: bool = False,
+        deprecation_warning: Type[DeprecationWarning] = DeprecationWarning,
+    ) -> None:
         """Initialize the property.
 
+        Version Changed:
+            3.3:
+            All arguments but ``prop_name`` must be provided as keyword
+            arguments. This will be enforced in Djblets 5.0.
+
         Args:
             prop_name (str):
                 The name of the property or attribute to read from and write
-                it.
+                to.
 
             convert_to_func (callable, optional):
                 An optional function to call on a value before setting it on
@@ -82,7 +191,11 @@ class AliasProperty(BaseProperty):
         self._convert_to_func = convert_to_func
         self._convert_from_func = convert_from_func
 
-    def __set__(self, instance, value):
+    def __set__(
+        self,
+        instance: object,
+        value: _AliasPropertySetT,
+    ) -> None:
         """Set a value on the property.
 
         This will convert the value (if ``convert_to_func`` was provided
@@ -100,7 +213,7 @@ class AliasProperty(BaseProperty):
         if self.deprecated:
             cls_name = type(instance).__name__
             warnings.warn('%s.%s is deprecated. Please set %s.%s instead.'
-                          % (cls_name, self.get_attr_name(instance),
+                          % (cls_name, self.attr_name,
                              cls_name, self.prop_name),
                           self.deprecation_warning,
                           stacklevel=2)
@@ -110,7 +223,27 @@ class AliasProperty(BaseProperty):
 
         setattr(instance, self.prop_name, value)
 
-    def __get__(self, instance, owner):
+    @overload
+    def __get__(
+        self,
+        instance: None,
+        owner: Type[object],
+    ) -> Self:
+        ...
+
+    @overload
+    def __get__(
+        self,
+        instance: object,
+        owner: Type[object],
+    ) -> _GetT:
+        ...
+
+    def __get__(
+        self,
+        instance: object,
+        owner: Type[object],
+    ) -> Union[Self, _GetT]:
         """Return the value of the property.
 
         This will retrieve the value from the aliased property, converting
@@ -130,10 +263,13 @@ class AliasProperty(BaseProperty):
             object:
             The property value.
         """
+        if instance is None:
+            return self
+
         if self.deprecated:
             cls_name = type(instance).__name__
             warnings.warn('%s.%s is deprecated. Please access %s.%s instead.'
-                          % (cls_name, self.get_attr_name(instance),
+                          % (cls_name, self.attr_name,
                              cls_name, self.prop_name),
                           self.deprecation_warning,
                           stacklevel=2)
@@ -146,17 +282,68 @@ class AliasProperty(BaseProperty):
         return value
 
 
-class TypedProperty(BaseProperty):
+class TypedProperty(Generic[_TypedPropertyGetT, _SetT],
+                    BaseProperty[_TypedPropertyGetT]):
     """A property that enforces type safety.
 
     This property will ensure that only values that are compatible with a
     given type can be set. This ensures type safety and helps catch errors
     early.
+
+    Version Changed:
+        3.3:
+        This now supports generics for typing, taking the types to return on
+        access, and types that can be set.
+
+    Example:
+        .. code-block:: python
+
+           class MyClass:
+               optional_prop: TypedProperty[Optional[str], Optional[str]] = \
+                   TypedProperty(str)
+               required_to_set_prop: TypedProperty[Optional[int], int] = \
+                   TypedProperty(int,
+                                 default=None
+                                 allow_none=False)
+               never_none_prop: TypedProperty[int, int] = \
+                   TypedProperty(int,
+                                 default=42,
+                                 allow_none=False)
+
+        Note that the explicit type declaration is important. Without it,
+        type checkers may allow constructors to override the type.
     """
 
-    def __init__(self, valid_types, default=None, allow_none=True):
+    ######################
+    # Instance variables #
+    ######################
+
+    #: Whether a ``None`` value is allowed to be set.
+    allow_none: bool
+
+    #: The default value for the property if one is not set.
+    default: Optional[_TypedPropertyGetT]
+
+    #: The types that are valid for this property.
+    #:
+    #: New values are checked against this at runtime.
+    valid_types: Tuple[Type[_SetT], ...]
+
+    @deprecate_non_keyword_only_args(RemovedInDjblets50Warning)
+    def __init__(
+        self,
+        valid_types: _TypedPropertyValidTypesParamT,
+        *,
+        default: Optional[_TypedPropertyGetT] = None,
+        allow_none: bool = True,
+    ) -> None:
         """Initialize the property.
 
+        Version Changed:
+            3.3:
+            All arguments but ``prop_name`` must be provided as keyword
+            arguments. This will be enforced in Djblets 5.0.
+
         Args:
             valid_types (list of type):
                 The types of values that are permitted to be set.
@@ -167,11 +354,21 @@ class TypedProperty(BaseProperty):
             allow_none (bool, optional):
                 Whether ``None`` values are allowed to be set.
         """
-        self.valid_types = valid_types
+        if isinstance(valid_types, tuple):
+            self.valid_types = valid_types
+        elif isinstance(valid_types, Sequence):
+            self.valid_types = tuple(valid_types)
+        else:
+            self.valid_types = (valid_types,)
+
         self.default = default
         self.allow_none = allow_none
 
-    def __set__(self, instance, value):
+    def __set__(
+        self,
+        instance: object,
+        value: _SetT,
+    ) -> None:
         """Set a value on the property.
 
         This will check if the value is of a valid type, and then set it on
@@ -195,10 +392,29 @@ class TypedProperty(BaseProperty):
             raise TypeError('%s (%r) is not a valid type for this property.'
                             % (type(value).__name__, value))
 
-        attr_name = self.get_attr_name(instance)
-        instance.__dict__['_%s_typed' % attr_name] = value
-
-    def __get__(self, instance, owner):
+        instance.__dict__['_%s_typed' % self.attr_name] = value
+
+    @overload
+    def __get__(
+        self,
+        instance: None,
+        owner: Type[object],
+    ) -> Self:
+        ...
+
+    @overload
+    def __get__(
+        self,
+        instance: object,
+        owner: Type[object],
+    ) -> _TypedPropertyGetT:
+        ...
+
+    def __get__(
+        self,
+        instance: object,
+        owner: Type[object],
+    ) -> Union[Self, _TypedPropertyGetT]:
         """Return the value of the property.
 
         Args:
@@ -212,8 +428,12 @@ class TypedProperty(BaseProperty):
             object:
             The property value.
         """
-        attr_name = self.get_attr_name(instance)
-        return instance.__dict__.get('_%s_typed' % attr_name, self.default)
+        if instance is None:
+            return self
+
+        return cast(
+            _TypedPropertyGetT,
+            instance.__dict__.get('_%s_typed' % self.attr_name, self.default))
 
 
 def get_descriptor_attr_name(descriptor, cls):
@@ -223,6 +443,11 @@ def get_descriptor_attr_name(descriptor, cls):
     property, and returning its attribute name. This is primarily intended
     to help with providing better error messages.
 
+    Deprecated:
+        3.3:
+        This will be removed in Djblets 5.0. Callers should define
+        a ``__set_name__()`` method on the descriptor instead.
+
     Args:
         descriptor (object):
             The instance of the property/descriptor. For a proper value to
@@ -235,6 +460,11 @@ def get_descriptor_attr_name(descriptor, cls):
         str:
         The name of the property/descriptor.
     """
+    RemovedInDjblets50Warning.warn(
+        'djblets.util.properties.get_descriptor_attr_name() is deprecated. '
+        'Please define a __set_name__() on your descriptor instead. This '
+        'will be removed in Djblets 5.0.')
+
     for attr_name, attr_value in cls.__dict__.items():
         if attr_value is descriptor:
             return attr_name
diff --git a/djblets/util/tests/test_properties.py b/djblets/util/tests/test_properties.py
index 4f95ee6aa89a75c62c63391f1d21d1bf131ccf08..36f9089cc9ff607744769491b086911633205a86 100644
--- a/djblets/util/tests/test_properties.py
+++ b/djblets/util/tests/test_properties.py
@@ -1,5 +1,11 @@
 """Unit tests for djblets.util.properties."""
 
+from __future__ import annotations
+
+from typing import Optional, Union
+
+from typing_extensions import assert_type
+
 from djblets.testing.testcases import TestCase
 from djblets.util.properties import AliasProperty, TypedProperty
 
@@ -11,10 +17,10 @@ class MyDeprecationWarning(DeprecationWarning):
 class AliasPropertyTests(TestCase):
     """Unit tests for djblets.util.properties.AliasProperty."""
 
-    def test_set(self):
+    def test_set(self) -> None:
         """Testing AliasProperty.__set__"""
-        class MyObject(object):
-            prop = AliasProperty('other_prop')
+        class MyObject:
+            prop: AliasProperty[int, int] = AliasProperty('other_prop')
             other_prop = 100
 
         obj = MyObject()
@@ -24,15 +30,17 @@ class AliasPropertyTests(TestCase):
 
         self.assertEqual(obj.other_prop, 42)
 
-    def test_set_with_deprecated(self):
+    def test_set_with_deprecated(self) -> None:
         """Testing AliasProperty.__set__ with deprecated=True"""
-        class MyObject(object):
-            prop = AliasProperty('other_prop',
-                                 deprecated=True,
-                                 deprecation_warning=MyDeprecationWarning)
+        class MyObject:
+            prop: AliasProperty[int, int] = AliasProperty[int, int](
+                'other_prop',
+                deprecated=True,
+                deprecation_warning=MyDeprecationWarning)
             other_prop = 100
 
         obj = MyObject()
+        assert_type(obj.prop, int)
 
         expected_message = (
             'MyObject.prop is deprecated. Please set MyObject.other_prop '
@@ -44,54 +52,65 @@ class AliasPropertyTests(TestCase):
 
         self.assertEqual(obj.other_prop, 42)
 
-    def test_set_with_convert_to_func(self):
+    def test_set_with_convert_to_func(self) -> None:
         """Testing AliasProperty.__set__ with convert_to_func"""
-        class MyObject(object):
-            prop = AliasProperty('other_prop',
-                                 convert_to_func=str)
+        class MyObject:
+            prop: AliasProperty[str, int] = AliasProperty[str, int](
+                'other_prop',
+                convert_to_func=str)
             other_prop = '100'
 
         obj = MyObject()
 
         with self.assertNoWarnings():
-            obj.prop = 42
+            # Avoid a mismatch in type checkers. Some would see 42 as `int`
+            # and some would see `Literal[42]`.
+            obj.prop = int(42)
 
         self.assertEqual(obj.other_prop, '42')
+        self.assertEqual(obj.prop, '42')
 
-    def test_set_with_convert_to_func_and_none(self):
+    def test_set_with_convert_to_func_and_none(self) -> None:
         """Testing AliasProperty.__set__ with convert_to_func and value=None"""
-        class MyObject(object):
-            prop = AliasProperty('other_prop',
-                                 convert_to_func=str)
-            other_prop = 100
+        class MyObject:
+            prop: AliasProperty[Optional[int], Optional[int]] = \
+                AliasProperty[Optional[int], Optional[int]](
+                    'other_prop',
+                    convert_to_func=str)
+            other_prop = '100'
 
         obj = MyObject()
+        assert_type(obj.prop, Optional[int])
 
         with self.assertNoWarnings():
             obj.prop = None
 
+        assert_type(obj.prop, None)
         self.assertIsNone(obj.other_prop)
 
-    def test_get(self):
+    def test_get(self) -> None:
         """Testing AliasProperty.__get__"""
-        class MyObject(object):
-            prop = AliasProperty('other_prop')
+        class MyObject:
+            prop: AliasProperty[int, int] = AliasProperty('other_prop')
             other_prop = 100
 
         obj = MyObject()
+        assert_type(obj.prop, int)
 
         with self.assertNoWarnings():
             self.assertEqual(obj.prop, 100)
 
-    def test_get_with_deprecated(self):
+    def test_get_with_deprecated(self) -> None:
         """Testing AliasProperty.__get__ with deprecated=True"""
-        class MyObject(object):
-            prop = AliasProperty('other_prop',
-                                 deprecated=True,
-                                 deprecation_warning=MyDeprecationWarning)
+        class MyObject:
+            prop: AliasProperty[int, int] = AliasProperty(
+                'other_prop',
+                deprecated=True,
+                deprecation_warning=MyDeprecationWarning)
             other_prop = 100
 
         obj = MyObject()
+        assert_type(obj.prop, int)
 
         expected_message = (
             'MyObject.prop is deprecated. Please access MyObject.other_prop '
@@ -101,27 +120,32 @@ class AliasPropertyTests(TestCase):
         with self.assertWarns(MyDeprecationWarning, expected_message):
             self.assertEqual(obj.prop, 100)
 
-    def test_get_with_convert_from_func(self):
+    def test_get_with_convert_from_func(self) -> None:
         """Testing AliasProperty.__get__ with convert_from_func"""
-        class MyObject(object):
-            prop = AliasProperty('other_prop',
-                                 convert_from_func=str)
+        class MyObject:
+            prop: AliasProperty[str, int] = AliasProperty[str, int](
+                'other_prop',
+                convert_from_func=str)
             other_prop = 100
 
         obj = MyObject()
+        assert_type(obj.prop, str)
 
         with self.assertNoWarnings():
             self.assertEqual(obj.prop, '100')
 
-    def test_get_with_convert_from_func_and_none(self):
+    def test_get_with_convert_from_func_and_none(self) -> None:
         """Testing AliasProperty.__get__ with convert_from_func and value=None
         """
-        class MyObject(object):
-            prop = AliasProperty('other_prop',
-                                 convert_to_func=str)
+        class MyObject:
+            prop: AliasProperty[Optional[str], Optional[str]] = \
+                AliasProperty[Optional[str], Optional[str]](
+                    'other_prop',
+                    convert_to_func=str)
             other_prop = None
 
         obj = MyObject()
+        assert_type(obj.prop, Optional[str])
 
         with self.assertNoWarnings():
             self.assertIsNone(obj.prop)
@@ -130,12 +154,14 @@ class AliasPropertyTests(TestCase):
 class TypedPropertyTests(TestCase):
     """Unit tests for djblets.util.properties.TypedProperty."""
 
-    def test_with_valid_type(self):
+    def test_with_valid_type(self) -> None:
         """Testing TypedProperty with valid type"""
-        class MyObject(object):
-            prop = TypedProperty((int, bool))
+        class MyObject:
+            prop: TypedProperty[Union[int, bool],
+                                Union[int, bool]] = TypedProperty((int, bool))
 
         obj = MyObject()
+        assert_type(obj.prop, Union[int, bool])
 
         obj.prop = 100
         self.assertEqual(obj.prop, 100)
@@ -143,44 +169,57 @@ class TypedPropertyTests(TestCase):
         obj.prop = True
         self.assertIs(obj.prop, True)
 
-    def test_with_invalid_type(self):
+    def test_with_invalid_type(self) -> None:
         """Testing TypedProperty with invalid type"""
-        class MyObject(object):
-            prop = TypedProperty((str,))
+        class MyObject:
+            prop: TypedProperty[str, str] = TypedProperty((str,))
 
         obj = MyObject()
+        assert_type(obj.prop, str)
 
         expected_message = 'int (100) is not a valid type for this property.'
 
         with self.assertRaisesMessage(TypeError, expected_message):
-            obj.prop = 100
+            obj.prop = 100  # type: ignore
 
-    def test_set_with_none_and_allow_none_true(self):
+    def test_set_with_none_and_allow_none_true(self) -> None:
         """Testing TypedProperty.__set__ with None value and allow_none=True"""
-        class MyObject(object):
-            prop = TypedProperty((int, bool), allow_none=True)
+        class MyObject:
+            prop: TypedProperty[Optional[Union[int, bool]],
+                                Optional[Union[int, bool]]] = \
+                TypedProperty((int, bool), allow_none=True)
 
         obj = MyObject()
+        assert_type(obj.prop, Optional[Union[int, bool]])
+
         obj.prop = None
+        assert_type(obj.prop, None)
         self.assertIsNone(obj.prop)
 
-    def test_set_with_none_and_allow_none_false(self):
+    def test_set_with_none_and_allow_none_false(self) -> None:
         """Testing TypedProperty.__set__ with None value and allow_none=False
         """
-        class MyObject(object):
-            prop = TypedProperty((int, bool), allow_none=False)
+        class MyObject:
+            prop: TypedProperty[Union[int, bool],
+                                Union[int, bool]] = \
+                TypedProperty((int, bool), allow_none=False)
 
         obj = MyObject()
+        assert_type(obj.prop, Union[int, bool])
 
         expected_message = 'None is not a valid value for this property.'
 
         with self.assertRaisesMessage(TypeError, expected_message):
-            obj.prop = None
+            obj.prop = None  # type: ignore
 
-    def test_get_with_default(self):
+    def test_get_with_default(self) -> None:
         """Testing TypedProperty.__get__ with default value"""
-        class MyObject(object):
-            prop = TypedProperty((int, bool), default=100)
+        class MyObject:
+            prop: TypedProperty[Union[int, bool],
+                                Union[int, bool]] = \
+                TypedProperty((int, bool), default=100)
 
         obj = MyObject()
+
+        assert_type(obj.prop, Union[int, bool])
         self.assertEqual(obj.prop, 100)
