[PEP Proposal] Assure keyword

Proposal for assure: A Built-in Keyword for Simplified Non-None Type Validation

Problem Statement

When functions return values of type Optional (e.g., int | None), developers must add boilerplate to ensure the returned value is non-None before usage. This often involves custom TypeGuards, inline checks, or assertions, which:

  • Reduce readability by adding repetitive code.
  • Increase error-prone manual handling of None values.
  • Force developers to use constructs like assert, which are unsuitable for production environments.

Proposed Solution

Introduce the assure keyword, designed to validate that a value is non-None, narrow its type, and raise an exception if the value is None. For example:

def fetch_data() -> int | None:
    ...

result = assure fetch_data()
# `result` is guaranteed to be `int` at this point, or an exception is raised.

How It Works

  1. Runtime Validation: If the return value is None, assure raises an exception (ValueError or a new specific exception type).
  2. Type Narrowing: The type of the value is narrowed to exclude None, leveraging Python’s type hints and static analysis tools.

Advantages

  • Improved Readability: Removes boilerplate and provides a declarative syntax for non-None validation.
  • Safety in Production: Unlike assert, assure always validates at runtime, ensuring robust error handling.
  • Alignment with Typing Standards: Works seamlessly with Python’s type system and enhances static analysis capabilities.

Implementation in Python

A simple implementation to emulate the assure keyword:

from typing import TypeVar, NoReturn

T = TypeVar("T")

def assure(value: T | None) -> T:
    if value is None:
        raise ValueError("Expected a non-None value, got None.")
    return value

Usage:

def fetch_data() -> int | None:
    return None  # Example returning None

result = assure(fetch_data())  # Raises ValueError

Underlying C-Level Implementation

A possible implementation for CPython, demonstrating how assure could be built into Python’s runtime (in pseudocode):

PyObject* assure(PyObject* obj) {
    if (obj == Py_None) {
        PyErr_SetString(PyExc_ValueError, "Expected a non-None value, got None.");
        return NULL;
    }
    Py_INCREF(obj);  // Increment reference count for the returned value
    return obj;
}

How It Works:

  1. The assure function is added to CPython’s parser as a new keyword.
  2. It checks if the provided PyObject* is Py_None and raises a ValueError if true.
  3. Otherwise, it increments the reference count and returns the value, ensuring it is safe to use.

Community Impact

  1. Tooling Updates: Linters and type-checking tools like mypy will require updates to support the new keyword, which is standard for new PEPs.
  2. Backward Compatibility: assure does not conflict with existing syntax, ensuring no disruption to current codebases.

Would love feedback from the community on this proposal, including additional use cases, potential challenges, or other considerations.

1 Like

Doesn’t it conflict for example with this?

name = assure(re.search(r"gitlab.com/(.*)", repo.url)).group(1)

Does the feature really need a keyword? Maybe a function with the same name is sufficient?
It should be in typing module but if we agree that the usage is super common the function could be moved into builtins.

5 Likes

It shouldn’t as the import would simply shadow the keyword, thus code shouldn’t break

All of our projects, the projects of my colleagues, our partners and more, are laden with TypeGuards for None checks, for possible None-returning functions. I feel it is a very common, in many libraries, that functions return None (potentially). This would reduce a lot of boilerplate.

Of course, it could be just a function as well in typing, but the issue is that my proposal doesn’t just address type narrowing, but also a strict check for presenceo of non-None value, which doesn’t align with the purpose of the typing library to my knowledge, since typing modifications shouldn’t raise.

No, that would be completely new behavior. keywords can not be overwritten.

The typing library has a few things with runtime effects already (and e.g. incorrect usage of parameters does raise at runtime).

assure would be a perfectly fine function to add to typing (and typing_extensions first). Making this a keyword is a completely losing battle that will not be successful. The best you can hope for is this being moved to builtins at some point as a function.

That’s a reasonable point and you’re right, I forgot you cannot shadow keywords. As with the typing module, I wasn’t aware of some of those functions that raise on runtime.

Thus, it does make sense to add this to typing_extensions and typing and perhaps builtins down the line.

Thank you for the feedback.

If it will be a function, I would like better descriptive name than assure.
The function guards from passing None to API that doesn’t accept it.
I fully support it, both my job and open sourced code are full of assert x is not None statements.
Maybe we can name the function not_none()?
I like short names, assert_is_not_none() is too long word for typing.

3 Likes

Makes sense, and you’re right. non_none() sounds reasonable. I think require() and need() are also reasonable and more descriptive than assure() due to a more narrower linguistic oncept of what require and need represent.

Though, I can also image that assure() could be used more broadly not just check for non-None values, but to in general validate that the returned value is of a certain type, though this would be significantly more complicated.

not_none(val: object) for simply checking non-None return and narrowing down type, and assure(val: object, T: type) to fully restrict type to T out of possible hinted types, and throw if it isn’t that type.

So, assure(object, type) is a synonym for very common assert isinstance(obj, type) pattern, right?
It could be useful, but for the proposal successfulness I suggest restricting the scope down to the minimal. Describe a step, implement it, go to the next step.

1 Like

Pretty much, yes, with the key difference that it would be purposefully intended for runtime… and there’s also the consideration of issubclass() handling to consider here.

But yes, I agree, this was me getting ahead of myself. I will definitely restrict the proposal to not_none() / require()/need()` for now.

Thanks for the valuable feedback!

2 Likes

For a version that does type-narrowing-with-runtime-type-checking, given we already have cast, then checked_cast seems like a natural evolution in the naming. (raising TypeError rather than ValueError).

The typing module already provides assert_type, with two key differences being it doesn’t default to None and it doesn’t raise at runtime

How is

try:
    result = assure fetch_data()
except AssureError:
    # something

any better than

result = fetch_data()
if result is not None:
    # something

(of course, you cannot just throw exception unhandled, can you?)

8 Likes

I don’t particularly like this, you either have something that is unconditionally not None, and should use an assert which can be optimized out, or you should inline if var is not None to not incur the cost of a function call. Not everything is better re-used. I also don’t think this should be a special-cased function, which without type negation being user-expressible, would be the only way to do this.

With type negation (which I am assuming will require intersections for multiple theory reasons):

def non_null(val: T) -> T & ~None:
    if val is None:
        raise TypeError(
            f"Expected non-None value, got a value of type {type(val):!r}"
        )
    return val

...
val = non_null(func_that_needs_checking())

Note that this approach is safer than using TypeIs or cast.


For contrast, and places that should keep using assert:

An example where an API may be typed as returning SomeType | None, but the caller can be sure it isn’t None in some cases:

row = cursor.execute(
    """
    INSERT INTO some_table(u_id, x) values (?, ?)
    ON CONFLICT (u_id) DO UPDATE SET x=x +excluded.x
    RETURNING x
    """,
    (u_id, x),
).fetchone()
assert row is not None, "sqlite UPSERT + RETURNING" 

While normally fetchone may return None for no results, this query is guaranteed to have a result if it doesn’t error, and the type system can’t tell that that is the case. The assert is safe here, it is invariantly true barring an issue with the backing db, SQL driver, or python, things which most developers are willing to assume once it hits production, this check won’t be needed and should be optimized out.

5 Likes

Yeah, I proposed safe_cast here. It’s a similar idea as @slobodaapl’s but generalized to types other than NoneType.

If we add assure to typing, I’d name it ensure_non_none or something like that. It should definitely be a function over a keyword since it fits in the function paradigm.

2 Likes