Fields That Refuse Bad Values

The Course
•
September 19, 2026
•
7 min read

A descriptor that validates is worth little on its own; a family of them, sharing one hook, is a library feature. This lesson designs that family, and the design questions are more interesting than the code: when to reject, what to say, and what “an integer” means.

One hook, many fields

The base class owns the descriptor protocol. Subclasses override one method:

class Field:
    def __set__(self, instance, value):
        instance.__dict__[self.slot] = self.check(value)

    def check(self, value):
        """Return the value to store, or raise ValueError."""
        if value is None and not self.null:
            raise ValueError(f"{self.name} may not be None")
        return value

check returns the value rather than just approving it, which lets a field normalise as well as validate — strip whitespace, coerce a date string, round a decimal. Every subclass calls super().check(value) first, so the None rule lives in one place.

Reject at assignment, not at save

post.views = -1 could raise now, or save() could collect all the problems later. This library raises now, because the traceback then points at the line that caused it. Deferred validation gives better error reports — every problem at once, which a web form wants — at the cost of a stack trace pointing at save(). Both are defensible; what isn’t is choosing by accident.

Two fields

TextField and IntegerField Python
class Field:
    def __init__(self, *, null=False):
        self.null = null
        self.name = "<unnamed>"

    def __set_name__(self, owner, name):
        self.name = name
        self.slot = f"_{name}"

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        try:
            return instance.__dict__[self.slot]
        except KeyError:
            raise AttributeError(f"{type(instance).__name__}.{self.name} is not set") from None

    def __set__(self, instance, value):
        instance.__dict__[self.slot] = self.check(value)

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


class TextField(Field):
    def __init__(self, *, max_length=None, **kwargs):
        super().__init__(**kwargs)
        self.max_length = max_length

    def check(self, value):
        value = super().check(value)
        if value is None:
            return None
        if not isinstance(value, str):
            raise ValueError(f"{self.name} must be a str, not {type(value).__name__}")
        if self.max_length is not None and len(value) > self.max_length:
            raise ValueError(f"{self.name} is longer than {self.max_length} characters")
        return value


class Post:
    title = TextField(max_length=10)
    subtitle = TextField(null=True)


post = Post()
post.title = "Hello"
post.subtitle = None
print("stored:", post.title, "|", post.subtitle)

for bad in [42, "a much longer title than allowed"]:
    try:
        post.title = bad
    except ValueError as error:
        print("ValueError:", error)

try:
    post.title = None
except ValueError as error:
    print("ValueError:", error)

print("the good value survived:", post.title)

The last line is the point of the whole design: three rejected assignments, and the object still holds "Hello". A validator that mutates before it checks leaves callers with objects that are half-updated after a caught exception.

An error message is API

Compare two messages for the same mistake:

ValueError: invalid value
ValueError: views must be an int, not str

The second names the field, the rule and what arrived. A library’s exception text is read far more often than its documentation, usually by someone who is already annoyed. Three habits pay for themselves:

  • Name the attribute (self.name, which is why __set_name__ exists).
  • Say what was expected and what came, using type(value).__name__ rather than the value, which may be enormous or secret.
  • Keep it one line, lowercase-ish, no trailing full stop — matching the standard library, so it reads the same in a traceback.

bool is an int

Python’s numeric tower has a sharp edge for validators: True is an integer.

The bool trap Python
print("isinstance(True, int):", isinstance(True, int))
print("True == 1:", True == 1)
print("sum of bools:", sum([True, True, False]))


def check_naive(value):
    if not isinstance(value, int):
        raise ValueError("must be an int")
    return value


def check_strict(value):
    if isinstance(value, bool) or not isinstance(value, int):
        raise ValueError(f"must be an int, not {type(value).__name__}")
    return value


for check in (check_naive, check_strict):
    try:
        print(check.__name__, "accepted", check(True))
    except ValueError as error:
        print(check.__name__, "rejected True:", error)

views = True would be stored as 1 and silently reported as one view. Whether to refuse it is a judgement call — but make it deliberately, and write the test either way, because the naive check gives you the permissive answer without asking.

Don't reach for isinstance first

Type checks belong in a validator, where the whole job is to police types. In ordinary code, calling a method and letting it fail is usually better than isinstance, because it accepts anything that behaves correctly. Day 3 makes that idea precise with protocols.

Exercise

A ChoiceField

Add a field that accepts only one of a fixed set of values — the field a status column wants. The tests define it; note the last one, which is about the library’s own developer experience rather than its runtime behaviour.

import unittest


class Field:
    def __init__(self, *, null=False):
        self.null = null
        self.name = "<unnamed>"

    def __set_name__(self, owner, name):
        self.name = name
        self.slot = f"_{name}"

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

    def __set__(self, instance, value):
        instance.__dict__[self.slot] = self.check(value)

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


class ChoiceField(Field):
    """TODO: accept only the given choices, and refuse an empty set of them."""

    def __init__(self, *, choices=(), **kwargs):
        super().__init__(**kwargs)
        self.choices = tuple(choices)


class Post:
    status = ChoiceField(choices=("draft", "published"))


class Choices(unittest.TestCase):
    def test_accepts_a_choice(self):
        post = Post()
        post.status = "published"
        self.assertEqual(post.status, "published")

    def test_rejects_anything_else(self):
        post = Post()
        with self.assertRaises(ValueError):
            post.status = "deleted"

    def test_the_message_lists_the_choices(self):
        post = Post()
        with self.assertRaises(ValueError) as caught:
            post.status = "deleted"
        message = str(caught.exception)
        self.assertIn("status", message)
        self.assertIn("draft", message)
        self.assertIn("published", message)

    def test_none_is_still_refused(self):
        post = Post()
        with self.assertRaises(ValueError):
            post.status = None

    def test_empty_choices_is_refused_when_the_field_is_defined(self):
        """A field that can never accept anything is a mistake in the model,
        and the library should say so at import time, not at first use."""
        with self.assertRaises(ValueError):
            ChoiceField(choices=())


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

Two checks, at two different times. When the field is defined — in __init__ — an empty set of choices is a mistake in the model, so refuse it there. When a value is assigned, check runs: let the parent’s check handle None first, then refuse anything not in self.choices, with a message naming the field and listing the choices.

Hint 2: a first step

Override check and call super().check(value) first, so the None rule still applies. Then compare against self.choices. The empty-choices test needs a line in __init__, not in check.

Show one solution
class ChoiceField(Field):
    def __init__(self, *, choices, **kwargs):
        super().__init__(**kwargs)
        if not choices:
            raise ValueError("choices may not be empty")
        self.choices = tuple(choices)

    def check(self, value):
        value = super().check(value)
        if value is None:
            return None
        if value not in self.choices:
            allowed = ", ".join(repr(choice) for choice in self.choices)
            raise ValueError(f"{self.name} must be one of {allowed}, not {value!r}")
        return value

choices is keyword-only and required — ChoiceField() with no choices is a TypeError from Python itself, and an empty sequence is the ValueError the last test asks for. Both fire while the class body is being executed, so the model’s own author hears about it at import time.

tuple(choices) copies, so a caller who passes a list and mutates it later can’t change what the field accepts. That’s the same instinct as yesterday’s list(filters), applied to configuration instead of data.

Here the value is included in the message, unlike the type errors above. It’s a short string from a small set, and knowing which invalid status arrived is the whole diagnostic.

What you learned

  • A check hook that returns the value gives one place for validation and normalisation, with super().check() handling what’s common.
  • Validate before storing, so a rejected assignment changes nothing.
  • Error messages are part of the API: name the field, the rule and the type.
  • bool passes isinstance(x, int), and a numeric field should decide on purpose whether that’s acceptable.
Last updated on September 19, 2026

Was this article helpful?

Your response is saved on this device.