Suggestion: add a typing.safe_cast() function, which just does assert isinstance(x, T)

I’m regularly defining this function, which helps make my code shorter and easier to read. I think it may be a good idea to add something like this to the typing module:

def safe_cast[T](t: type[T], x: object) -> T:
    if not isinstance(x, t):
        raise TypeError(f'Expecting {x!r} to be of type {t!r}')
    return x

It is very useful when I want my code to type-check, and I want a quick failure if my assumptions aren’t satisfied. This comes a lot when parsing objects coming from json, and also in other cases where a function can return one of several types and I expect a specific one.

My current use case: I’m using pygit2, and this allows me to write:

commits.append(safe_cast(Commit, repo[commit_id]))

instead of:

tmp = repo[commit_id]
assert isinstance(tmp, Commit)
commits.append(tmp)

With the extra benefit of a better error message in case the assertion fails.

The easiest solution right now is to just use typing.cast, and I guess that that’s what most people do. But this contradicts “Errors should never pass silently”, from the Zen of Python. Or, in other words, I prefer to catch the error sooner rather than later.

Note: I deliberately suggest a simple function, that doesn’t handle generics. This is just meant as a shorthand for assert isinstance, not as a way to solve complex typing problems.

WDYT?

Edit: changed the name from assert_cast to safe_cast, I like it better. Thanks @tmk!

5 Likes

I’ve also found myself writing tools like this, but I think assert_cast is a strange name for this operation.

I’d expect that *cast performs some kind of coercion. I was going to provide trycast as an example, but it turns out that is non-coercing :man_facepalming:


Anyways, I call this ensure_type.
It accepts multiple types and you can pass in some context for the error message - such that it continues to be useful when deep in a call stack:

Show overloads
from typing import Any, overload

@overload
def ensure_type[T1](
    obj: Any, *valid_types: *tuple[type[T1]], name: str = "object", explain: str = ""
) -> T1: ...
@overload
def ensure_type[T1, T2](
    obj: Any, *valid_types: *tuple[type[T1], type[T2]], name: str = "object", explain: str = ""
) -> T1 | T2: ...
@overload
def ensure_type[T1, T2, T3](
    obj: Any,
    *valid_types: *tuple[type[T1], type[T2], type[T3]],
    name: str = "object",
    explain: str = "",
) -> T1 | T2 | T3: ...
@overload
def ensure_type[T1, T2, T3, T4](
    obj: Any,
    *valid_types: *tuple[type[T1], type[T2], type[T3], type[T4]],
    name: str = "object",
    explain: str = "",
) -> T1 | T2 | T3 | T4: ...


# NOTE: If you hit this overload, consider using a base class to simplify or write a type guard.
@overload
def ensure_type[T1, T2, T3, T4, T5](
    obj: Any,
    *valid_types: *tuple[type[T1], type[T2], type[T3], type[T4], type[T5], *tuple[type[Any], ...]],
    name: str = "object",
    explain: str = "",
) -> T1 | T2 | T3 | T4 | T5 | Any: ...
from typing import Any

def ensure_type(obj: Any, *valid_types: type[Any], name: str = "object", explain: str = "") -> Any:
    if not isinstance(obj, valid_types):
        tp_names = " | ".join(qualified_type_name(tp) for tp in valid_types)
        got = qualified_type_name(obj)
        msg = f"Expected {name!r} to be of type {tp_names!r}, got: {got!r}\n  {obj!r}"
        if explain:
            msg = f"{msg}\n{explain.lstrip()}"
        raise TypeError(msg)
    return obj
Show qualified_type_name
def qualified_type_name(obj: object | type[Any], /) -> str:
    tp = obj if isinstance(obj, type) else type(obj)
    module = tp.__module__ if tp.__module__ != "builtins" else ""
    return f"{module}.{tp.__name__}".lstrip(".")
>>> ensure_type("not an int", int, name="test")
Traceback (most recent call last):
TypeError: Expected 'test' to be of type 'int', got: 'str'
  'not an int'

This previous discussion thread called it safe_cast. So that’s another potential name.

One question is what to do with types like list[int]. Do you also check all the elements? EDIT: ah sorry, you actually addressed this in the OP

2 Likes

I do something like this for a libary that has a general “load” function that auto-detects what class should be returned, and you may want to accept some loadable files but not others:

def load_as_api[T: Serializable](path: os.PathLike, api: type[T]) -> T:
    if not isinstance(obj := load(path), api):
        raise TypeError(f'File loaded from {path} does not satisfy {api.__name__}')
    return obj

I doubt I would use a generic ensure_type or assert_cast very much, since there’s almost always a better error message worth baking into the check. For the pygit2 case, I would write something like:

def get_commit(repo: Repository, commit_id: str) -> Commit:
    if not isinstance(commit := repo[commit_id], Commit):
        raise TypeError(f"Object {commit_id} is a {commit.__class__.__name__}; expected Commit")
    return commit

I find myself writing these quite rarely, though. The return type depending on a non-trivial inspection of a caller-supplied string is not very common, in my experience.

1 Like

I would suggest the name checked_cast over safe_cast. It’s clearer that it is an actual runtime check that isn’t intended to be something optimized out. (and being clear it’s a runtime check helps people intuit why list[int] won’t be checked for them, and they should write a TypeIs function if needed)

There have also been some prior suggestions to change cast behavior to only allow casting to types that are “possible” with other known information, so picking a name that more closely matches behavior avoids potential mixups if future “better non runtime casts” ever come to be under a different name rather than by changing the existing behavior.

3 Likes

Thanks for the feedback!

I would like to allow safe_cast be called like cast, meaning that the type will be the first argument. I just checked, and isinstance(x, int | list) works fine with Python 3.14, so I don’t think there’s any need for special logic for union types.

I like the idea of having an optional error message. So I think allowing `safe_cast(Commit, repo[commit_id], “expecting object to be a commit”) is a good idea.

Thanks! I agree that it’s clearer than safe_cast. Thinking about it, I tend to think that chk_cast is better than both. I would like the name to be short, because it really isn’t part of the logic of the program (it’s just an assertion), so the shorter the better. chk is both more descriptive and shorter than safe.

Thanks for the feedback! I think that it just so happens that you don’t encounter my use case a lot.

I find myself writing pretty frequently code that I don’t mind if it fails if I made a wrong assumption, and I still want it to pass type checking. So adding many descriptive assertion functions isn’t something that I want to do. I usually just define this checking casting function and use it instead of typing.cast for simple types.

2 Likes

I assume you’ve seen https://typeguard.readthedocs.io/?

Thanks for the link. Actually I haven’t heard of it, but it has a quite different goal from what I propose. Verifying general types on runtime is a hard endeavor. I just propose a small function that would help make some code easier to debug, by adding simple runtime checks.