Typing a Descriptor

The Course
•
September 19, 2026
•
9 min read

Warm-up: from Day 2

From memory first.

1. Predict all three lines. Loud has a __get__ and no __set__.

Warm-up: predict Python
class Loud:
    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return f"descriptor {self.name}"

class Post:
    title = Loud()

post = Post()
print(post.title)
post.__dict__["title"] = "instance"
print(post.title)
print(type(Post.title).__name__)
Check your prediction
descriptor title
instance
Loud

With no __set__, Loud is a non-data descriptor, so an entry in the instance’s dict wins over it. Give it a __set__ and the descriptor would win instead. From Descriptors: Attributes With Behaviour.

2. Fix. A rejected assignment should leave the old value in place: this should print 5.

Warm-up: fix Python
class Positive:
    def __set_name__(self, owner, name):
        self.slot = "_" + name

    def __get__(self, instance, owner=None):
        return instance.__dict__[self.slot]

    def __set__(self, instance, value):
        instance.__dict__[self.slot] = value
        if value < 0:
            raise ValueError("may not be negative")

class Counter:
    views = Positive()

c = Counter()
c.views = 5
try:
    c.views = -1
except ValueError:
    pass
print(c.views)
Check your fix
class Positive:
    def __set_name__(self, owner, name):
        self.slot = "_" + name

    def __get__(self, instance, owner=None):
        return instance.__dict__[self.slot]

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError("may not be negative")
        instance.__dict__[self.slot] = value

class Counter:
    views = Positive()

c = Counter()
c.views = 5
try:
    c.views = -1
except ValueError:
    pass
print(c.views)
5

Check first, store second. Stored first, the bad value is already in place when the error is raised — and a caller who catches the error holds a broken object.

3. Explain. No code: what does __init_subclass__ let the library catch that a check in __init__ couldn’t?

Check your explanation

Mistakes in a model’s definition — no fields, two models claiming one table — caught when the class statement runs, which is at import time. A check in __init__ would wait until someone first creates an instance, possibly in production, possibly never. From init_subclass and What Happens at class.

Today

Your fields validate at run time. A type checker knows none of it: to mypy, post.views is whatever Field.__get__ says it returns, and so far that’s nothing useful.

Today’s job is to make the annotations carry the same information the descriptors already have — and, by the end of the day, to be precise about what they still can’t.

This day needs Python on your own machine

A type checker is a separate program, and it can’t run in the browser. The playgrounds here run the runtime behaviour; for the checking part, download the day’s files and run mypy --strict --python-version 3.14 . locally. pip install "mypy==2.3.*" is the only setup, and the files need Python 3.13+ for warnings.deprecated. Every mypy output quoted in these lessons is from mypy 2.3 targeting 3.14 on the day’s reference files; a different checker or target may word the diagnostics differently.

A field that knows what it holds

Make Field generic in the type of its value, and each concrete field fills the parameter in:

class Field[T]:
    def __set__(self, instance: object, value: T) -> None: ...

class TextField(Field[str]): ...
class IntegerField(Field[int]): ...

class Field[T] is the syntax added in Python 3.12; the older T = TypeVar("T") and Generic[T] mean the same thing. A subclass that stays generic re-declares the parameter — class Nullable[T](Field[T]) — which is easy to forget, and the error (NameError: name 'T' is not defined) arrives at import time.

Two ways to read a field, two overloads

Post.views gives the field object; post.views gives an integer. One method, two answers, and @overload is how you say so:

    @overload
    def __get__(self, instance: None, owner: type | None = None) -> Self: ...
    @overload
    def __get__(self, instance: object, owner: type | None = None) -> T: ...

    def __get__(self, instance: object | None, owner: type | None = None) -> Self | T:
        ...

The two stubs are what callers see; the third — the implementation — is invisible to them and must be compatible with both. Self rather than Field matters for subclasses: Post.views is an IntegerField, not a Field, so Post.views.minimum type-checks.

Overloads are a compile-time thing Python
from typing import Self, overload


class Field[T]:
    @overload
    def __get__(self, instance: None, owner: type | None = None) -> Self: ...
    @overload
    def __get__(self, instance: object, owner: type | None = None) -> T: ...

    def __get__(self, instance: object | None, owner: type | None = None) -> Self | T:
        if instance is None:
            return self
        return instance.__dict__["_" + self.name]

    def __set_name__(self, owner: type, name: str) -> None:
        self.name = name

    def __set__(self, instance: object, value: T) -> None:
        instance.__dict__["_" + self.name] = value


class IntegerField(Field[int]):
    pass


class Post:
    views = IntegerField()


post = Post()
post.views = 3
print("value:        ", post.views)
print("through class:", Post.views)
print("overloads at run time:", Field.__get__.__name__, "— just one function")

At run time there is exactly one __get__: the final definition is what runs, and the overload stubs are never called. (They aren’t quite gone — Python records them, and typing.get_overloads(f) hands them back for tools to inspect — but they contribute no behaviour.) Everything the overloads say is for the checker, which is the deal typing makes everywhere: no behaviour, only claims that can be checked.

Self for anything that returns the receiver

    def save(self, connection: Connection) -> Self: ...

    @classmethod
    def get(cls, connection: Connection, id: int) -> Self: ...

Annotating these -> "Model" would be wrong in a way that only shows up downstream: Post.get(...) would be a Model, and .title an error. Self means “whatever class this was called on”, which is what the code actually does via cls(...).

Verified on the day’s files, these two lines check clean:

saved: Post = post.save(connection)
loaded: Post = Post.get(connection, 1)

Where cast is honest

Field.check is declared -> T, and the base implementation returns the value it was handed, which the checker only knows as Any:

    def check(self, value: Any) -> T:
        if value is None and not self.null:
            raise ValueError(f"{self.name} may not be None")
        return cast(T, value)

cast generates no code and checks nothing: it’s a note to the checker saying trust me here. That makes it a small, deliberate hole, and the reason this one is defensible is that the base class genuinely can’t prove the claim — only a subclass knows what T is, and each subclass’s check does the isinstance test that makes it true.

The alternative to a cast is usually a # type: ignore[...] comment, and between the two prefer the cast: it says what you’re claiming, and it can’t accidentally silence a second, unrelated error on the same line.

Keep ignores narrow

# type: ignore silences everything on that line; # type: ignore[no-any-return] silences one error code. Libraries should always use the narrow form — the broad one hides the errors you introduce later.

Exercise

Type a cached property

Once computes a value the first time it’s read and remembers it. Add the annotations that make the commented lines below type-check the way the comments say — the tests here only check the run-time behaviour, so the real exercise is running mypy --strict on it yourself.

import unittest
from typing import Any, Callable, Self, overload


class Once:
    """A non-data descriptor: computed once, then shadowed by the instance dict.

    TODO: make this generic in the value the function returns, so that
    `report.total` is an `int` to a checker, and `Report.total` is the
    descriptor itself.
    """

    def __init__(self, function):
        self.function = function
        self.name = function.__name__

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        value = self.function(instance)
        instance.__dict__[self.name] = value       # no __set__, so this wins next time
        return value


class Report:
    calls = 0

    @Once
    def total(self) -> int:
        Report.calls += 1
        return 40 + 2


class Cached(unittest.TestCase):
    def test_computes_once_and_remembers(self):
        report = Report()
        Report.calls = 0
        self.assertEqual(report.total, 42)
        self.assertEqual(report.total, 42)
        self.assertEqual(Report.calls, 1)

    def test_each_instance_computes_its_own(self):
        Report.calls = 0
        first, second = Report(), Report()
        first.total, second.total
        self.assertEqual(Report.calls, 2)

    def test_through_the_class_you_get_the_descriptor(self):
        self.assertIsInstance(Report.total, Once)


unittest.TextTestRunner(verbosity=2).run(
    unittest.defaultTestLoader.loadTestsFromTestCase(Cached)
)
Hint 1: the idea

Make Once generic in the type the wrapped function returns — Once[T] — and let the constructor’s annotation tie it together: it takes a function from an instance to a T. Then give __get__ the same two overloads as Field.__get__ above: through the class you get the descriptor, through an instance you get a T.

Hint 2: a first step

Start with class Once[T]: and annotate __init__ so function is a Callable[[Any], T]. Then copy the two overload stubs from Two ways to read a field and adapt them. The playground’s tests only check behaviour; the type check happens when you run mypy --strict locally.

Show one solution
class Once[T]:
    def __init__(self, function: Callable[[Any], T]) -> None:
        self.function = function
        self.name = function.__name__

    def __set_name__(self, owner: type, name: str) -> None:
        self.name = name

    @overload
    def __get__(self, instance: None, owner: type | None = None) -> Self: ...
    @overload
    def __get__(self, instance: object, owner: type | None = None) -> T: ...

    def __get__(self, instance: object | None, owner: type | None = None) -> Self | T:
        if instance is None:
            return self
        value = self.function(instance)
        instance.__dict__[self.name] = value
        return value

Callable[[Any], T] is what ties it together: the decorated method takes an instance and returns a T, so @Once over a method returning int produces an Once[int], and the second overload reports int.

[Any] for the instance parameter rather than a second type variable is a deliberate simplification. Being precise there means making Once generic in the owner too, which buys nothing here: nothing in the class reads the instance’s type.

Note that this descriptor defines no __set__, so it’s a non-data descriptor — which is exactly why writing the value into instance.__dict__ under the same name works as a cache. A field, which does define __set__, has to store under _name instead, because the descriptor always wins.

What you learned

  • class Field[T] makes a descriptor generic, and each concrete field fixes what T is.
  • Two @overload stubs describe class-versus-instance access; the implementation is invisible to callers and must satisfy both.
  • Self is the right return type for save and get, and the reason Post.get(...) is a Post.
  • cast is a claim, not a conversion — acceptable where only a subclass can prove the type, and always narrower than a bare type: ignore.
Last updated on September 19, 2026

Was this article helpful?

Your response is saved on this device.