3

I need to type-hint that a value is either some TypedDict or a completely empty dict. The TypedDict itself already exists and is non-trivial, and it's an all-or-nothing situation – so modifying the TypedDict to have optional keys is not sufficient.
Both of these are used in a context where its important whether I have one or the other (because they are handled differently) and in a context where any dict with the key-value types of the TypedDict is acceptable (e.g. serialising to JSON).

How can I type-hint the "completely empty dict" part?

The expectation is that this a) signals that no specific key can be read or written but b) still be read in any place an arbitrary-size Mapping of specific key-value types is expected.

Since I already use TypedDict, defining an empty TypedDict seems natural. However, neither MyPy nor PyRight meaningfully accept this: the resulting type cannot actually be used even as a trivial Mapping.

from typing import TypedDict, Mapping

class EmptyDict(TypedDict): pass
def foo(bar: Mapping[str, float]) -> None:...

foo(EmptyDict())

MyPy rejects this with:

error: Argument 1 to "foo" has incompatible type "EmptyDict"; expected "Mapping[str, float]"  [arg-type]

PyRight rejects this with:

Argument of type "EmptyDict" cannot be assigned to parameter "bar" of type "Mapping[str, float]" in function "foo"
  "EmptyDict" is not assignable to "Mapping[str, float]"
    Type parameter "_VT_co@Mapping" is covariant, but "object" is not a subtype of "float"
      "object" is not assignable to "float"  (reportArgumentType)
13
  • Not sure, but probably not possible, why not using None instead? Commented Sep 15 at 13:52
  • 1
    That is the correct type for "an empty dict": mypy-play.net/?mypy=latest&python=3.12. But why would you expect it to be a valid Mapping[str, float] (which implies a float value for any string key, whereas an empty dictionary is a key error for every key)? Commented Sep 15 at 14:09
  • 1
    typing.python.org/en/latest/spec/… - note "For the purpose of this rule, a TypedDict that does not have extra_items= or closed= set is considered to have an item with a value of type ReadOnly[object]" Commented Sep 15 at 14:56
  • 1
    @MisterMiyagi answer by MT0 seems to cover that, do you consider it sufficient? I don't think I have anything to add to it (other than the link to spec and a direct quote, perhaps). Commented Sep 16 at 0:49
  • 1
    @STerliakov Yes, as far as I can tell the answer does cover what you wrote in the comment. Commented Sep 16 at 3:49

3 Answers 3

3

PEP728 proposes the extra_items and closed arguments for the TypedDict class constructor:

For a TypedDict type that specifies extra_items, during construction, the value type of each unknown item is expected to be non-required and assignable to the extra_items argument.

Using extra_items=Never or closed=True means that there can be no other keys in the TypeDict other than the known ones.

There is a reference implementation supporting this syntax in PyRight 1.1.386 and in typing-extensions 4.10.0.

Then:

from typing import Mapping, TypedDict

class EmptyDict(TypedDict, closed=True): ...

def foo(bar: Mapping[str, float]):...

def add(
    value1: EmptyDict,
    value2: Mapping[str, float],
):
    foo(value1)
    foo(value2)

add({}, {"a": 1.23})
add(EmptyDict(), {})
foo(EmptyDict())
foo({"B": 3.141})

Passes (PyRight fiddle).

Unfortunately, MyPy does not (yet) appear to support PEP728.

Sign up to request clarification or add additional context in comments.

Comments

0

signals that no specific key can be read or written

That sounds like dict[Never, Never]. However, this type is perhaps more draconian than you might wish. It means that the dict can never have any key read or written to it, not just that it currently has no keys. The following demonstrates it passing with an empty dict that can be coerced to dict[Never, Never], and failing with an empty dict whose type cannot be coerced to dict[Never, Never].

from typing import TypedDict, Never

class SomeTypedDict:
    foo: str

def bar(x: SomeTypedDict | dict[Never, Never]) -> None: ...

bar({})              # type checks just fine
bar({'foo': 'bar'})  # also type checks just fine

my_dict: dict[str, str] = {}
bar(my_dict)         # error!
# Argument 1 to "bar" has incompatible type "dict[str, str]";
#     expected "SomeTypedDict | dict[Never, Never]"

This raises question of what use is a dict that can never have any keys read or written to it. I suspect this is not what you are after, but rather you want to specify a dict, that at the point of the function call, has no keys. This is outside the scope of static code analysis. You might be able to use type guards to help assist the type checker, but this can make the code more verbose. For example:

from typing import Any, TypeIs

def is_empty(mapping: dict[Any, Any]) -> TypeIs[dict[Never, Never]]:
    return not mapping

my_dict: dict[str, str] = {}
if is_empty(my_dict):
    bar(my_dict)  # type checks okay
    my_dict['foo'] = 'bar'  # error! (for the duration of this branch)
else:
    # type is not dict[Never, Bever] -- useful for excluding members of a union
    # eg.
    val: int | str = ''
    if not isinstance(val, str):
        # (int | str) - str => int
        # ergo val must be of type int during this branch
        ...

2 Comments

Could you please clarify why you suspect I don’t want what I have explicitly requested? I would like to improve the description if necessary.
This does not work with passing a dict[Never,Never] to def foo(bar: Mapping[str, float]):... PyRight fiddle. You have to explicitly add dict[Never,Never] as a union type to the argument (as you do in the answer - but without it it won't match an arbitrary mapping).
-4

The usual way to define a union of two types is like this:

def foo(bar: Type1 | Type2): ...

Here, it seems to me that you want Mapping[str, float] to be Type1, so then EmptyDict is Type2.

from typing import TypedDict, Mapping

class MyDict(TypedDict):  # I guess this is what your regular dict looks like
    x: float
    y: float
    weight: float

class EmptyDict(TypedDict): pass

def foo(bar: Mapping[str, float] | EmptyDict) -> None:
    ...

foo(EmptyDict())
foo(MyDict(x=1, y=2, weight=70))

But it's quite customary to use None as a place-holder for a non-existent object. So consider using the following:

def foo(bar: Mapping[str, float] | None) -> None:
    if bar:
        ...

foo(None)

6 Comments

I already know how unions work. Why do you dedicate more than half of the answer showing unions with a type that doesn’t do what I need?
I didn't understand what you need. I still don't. In my opinion, my answer works perfectly, even though it's very easy. I obviously didn't understand something in the question, but I don't know what.
I was very explicit that EmptyDict doesn’t work. Please let me know what is difficult about understanding that so I can improve the question. I was also very explicit about needing an empty dict, i.e. not None. Please let me know what is difficult about understanding that so I can improve the question.
The example code uses EmptyDict and generates an error. It looks like all you need is to eliminate the error. To make it look less like this, edit the example so it produces no errors and add a comment that explains why you don't want to use EmptyDict. If what you want to do is impossible, people should come up with closest feasible alternative; that requires understanding why you want what you want.
That the example code produces an error is its entire point.
I don't need an alternative. I need to type hint an empty dict. If that is not possible, saying so is a perfectly viable answer.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.