avatarMarcin Kozak

Summarize

PYTHON PROGRAMMING

Python Type Hinting with Literal

More powerful than it seems: use typing.Literal to create literal types

typing.Literal creates a type with selected choices. Photo by Caleb Jones on Unsplash

I’ll admit it: I wasn’t always a fan of typing.Literal, a form of creating literal types in Python. In fact, I not only undervalued literal types, but I completely ignored them, refusing to use them at all. For some reason, which remains unclear to me even today, I couldn’t find much practical value in literal types.

How wrong I was. I was blind to the power of this simple tool, and my code suffered as a result. If you’ve been ignoring literal types like I did, I urge you to read this article. I hope to convince you that despite its simplicity, typing.Literal can be a very useful tool in your Python coding arsenal.

Even if you’ve already recognized the value of literal types, don’t stop reading. While we won’t delve into all the intricacies of typing.Literal, this article will provide a more comprehensive introduction than the official Python documentation, without getting as bogged down in details as PEP 586.

Literal types are so straightforward that they can make code clearer and more readable than code without them. This simplicity is their both a strength and a weakness of typing.Literal, as it doesn’t offer any additional functionalities. However, I’ll show you how to implement additional functionality yourself.

The goal of this article is to introduce typing.Literal and discuss its value in Python coding. Along the way, we’ll explore when to use typing.Literal — and, just as importantly, when not to.

Literal types

Literal types were introduced to the Python typing system by PEP 586. This PEP provides a comprehensive exploration of the proposal behind literal types, serving as a rich source of information on the subject. In contrast, the official documentation for the typing.Literal type is intentionally concise, reflecting its straightforward nature. This article bridges the gap between these two resources, providing fundamental information about literal types while also delving into details that I consider crucial for the use cases discussed.

As explained in PEP 586, literal types are particularly useful in scenarios where APIs return different types based on the value of an argument. I would broaden this statement by saying that literal types allow for the creation of a type that encompasses specific values, not necessarily all of the same type. This does not preclude the possibility of all values having the same type.

Literal types provide a remarkably simple approach to defining and utilizing a type with specific values as the only possible values. This simplicity far surpasses any alternative methods. While it’s true that you can achieve the same outcome using other methods, these alternatives often come with more complex implementations and potentially richer functionality. For instance, creating your own type (class) requires careful consideration of both design and implementation — something you can ignore altogether when creating a literal type instead.

Employing typing.Literal invariably presents a simpler solution, often significantly simpler, but at the expense of reduced functionality. Therefore, before making a decision, it’s essential to carefully weigh the advantages and disadvantages of both approaches. This article can assist you in making an informed choice.

Acceptable types in literals

To create a typing.Literal type, you can use the following values:

  • a literal value of int, bool, str or bytes
  • an enum value
  • None

Such types as float or instances of a custom (non-enum) class are unacceptable.

Literal types: Use cases

We’ll now explore several use cases where I consider literal types to be an excellent choice, often the best option. We’ll also examine situations where alternative solutions may be more suitable. Each use case assumes the need for a type that accepts only specific values, not necessarily of the same type. typing.Literal does not create empty types, so Literal[] is not valid. It can, however, create literal types with a single value.

The use cases discussed below do not constitute an exhaustive list of scenarios. Instead, they serve as examples, and some may overlap. This non-exclusive list aims to showcase the range of opportunities that typing.Literal offers and to enhance understanding of this intriguing and valuable tool.

Example 1: One value only

As previously mentioned, you can employ a literal type when a variable accepts only a single value. While this might seem counterintuitive at first glance, the typing.Literal documentation provides a relevant example:

def validate_simple(data: Any) -> Literal[True]:
    ...

This function is designed for data validation and always returns True. In other words, if the validation fails, the function raises an error; otherwise, it returns True.

Theoretically, a type signature with a return value of the bool type, as shown below, would be acceptable to static checkers:

def validate_simple(data: Any) -> bool:
    ...

However, the function never returns False, making this type hint misleading and inaccurate. Using bool implies that the function can, depending on the situation, return either of the two Boolean values. When a function consistently returns only one of these values and never the other, using bool is misleading.

This is precisely where a literal type comes into play. Not only does it satisfy static checkers, but it also provides valuable information to users.

Example 2: In a need of a static type

When runtime type checking is not required, static types often provide the most effective solution. Therefore, if you need a type that accepts one or more specific values and your primary goal is to inform static checkers, creating the corresponding literal type is an excellent approach.

Example 3: A number of strings

This use case encompasses a range of strings, such as modes, products, or colors. Here are some examples:

Colors = Literal["white", "black", "grey"]
Grey = Literal["grey", "gray", "shades of grey", "shades of gray"]
Mode = Literal["read", "write", "append"]

As you can see, literal types in this use case can hold two or more strings. Importantly, using Literal does not allow us to establish relationships between the individual values. For instance, we could create the following literal type:

Days = Literal[
    "Monday", "Tuesday", "Wednesday",
    "Thursday", "Friday", "Saturday", "Sunday"
]

Does the order in which the values are provided matters? Before Python 3.9.1, it did:

Before Python 3.9.1, the order of values in a literal type mattered. Image by author

but ever since it doesn’t:

As of Python 3.9.1, the order of values in a literal type doesn’t matter. Image by author

Consequently, what matters are the possible choices, not their relationships. If utilizing the order of values is essential, consider employing a different type, not a literal one. One solution is to leverage an enumeration type, utilizing the enum.Enum class; we’ll delve into this concept soon, in a dedicated article.

A word of caution: Python 3.11 and newer introduce typing.LiteralString. This constitutes a distinct tool, as unlike typing.Literal, it serves as a type itself, not a tool for creating types. In this article, we’re exploring the creation of literal types, and I wouldn’t want to introduce confusion with this slightly different yet related tool. If you’re interested in learning more, visit the Appendix at the end of the article. However, let’s set this topic aside for now. The key takeaway is that typing.LiteralString is not a substitute for typing.Literal for strings.

typing.LiteralString is not a replacement for typing.Literal for strings.

Example 4: Multiple values of the same type

This example extends the previous one to encompass a broader range of data types. Just as we employed literal types for strings, we can apply them to most other data types as well. Here are some examples:

Literal[1, 5, 22] # integers
Literal["1", "5", "22"] # strings

As mentioned above, you can use a literal value of int, bool, str or bytes, an enum value and None.

Example 5: Combining values of various types

This represents the most general form of a literal type. You can combine objects of any type, and it will function correctly. This bears some resemblance to using the typing.Union type, but unlike the typical Union use case, we are combining objects rather than types.

Note the difference: A common Union use case might look like this:

Union[int, str]

while a literal type combining objects of int and str types could be as follows:

Tens = Literal[10, "10", "ten"]

Here are some other examples:

Positives = Literal[True, 1, "true", "yes"]
Negatives = Literal[False, 0, "false", "no"]
YesOrNo = Literal[Positives, Negatives]

You can create the following type: Literal[True, False, None]. It’s similar to the OptionalBool type described here:

The OptionalBool type described in the above article is far more complex than the corresponding one based on Literal, the latter being both easier to use and understand but also having significantly poorer functionality. The next three examples from the code block above are also interesting. They show that you can create combinations of two (or more, for that matter) literal types. Here, YesOrNo is a literal type that joins two other literal types, that is, Positives and Negatives:

Joining two literal types in Python 3.9.1 and newer. Imagine by author

Do remember, however, that this wouldn’t work the same way before Python 3.9.1 (we saw it before, where we discussed the order of literals in type definition):

Joining two literal types before Python 3.9.1. Imagine by author

Example 6: Runtime membership checking

In the preceding examples, we focused exclusively on static applications of literal types. However, this does not preclude their use during runtime, even though this deviates from the intended purpose of Python type hints. Here, I’ll demonstrate that you can perform runtime membership checks for literal types when the need arises. In other words, you can verify whether a given value belongs to the set of possible choices for a literal type.

Frankly, I believe this single capability elevates typing.Literal to a much more powerful tool. While it strays from the conventional usage of literal types (static code checking), it isn’t a hack. It’s a legitimate function of the typing module: typing.get_args().

An example will best illustrate this concept. First, let’s define a literal type:

from typing import Any, get_args, Literal, Optional

Tens = Literal[10, "10", "ten"]

The Tens type encompasses various representations of the number 10. Now, let’s define a function that validates whether an object has the type of Tens:

def is_ten(obj: Any) -> Optional[Tens]:
    if obj in get_args(Tens):
        return obj
    return None

A few remarks about the function:

  • It accepts any object and returns Optional[Tens], indicating that if obj is a valid member of Tens, the function will return it; otherwise, it will return None. This is why typing.Optional is used (see this article).
  • The check is performed using the typing.get_args() function. For a literal type, it returns all its possible values.
  • Here’s where it gets interesting. From a dynamic perspective, the last line of the function (return None) is redundant, as an absent None return is implicitly interpreted as a None return. However, mypy does not accept implicit None returns, as illustrated in the image below.
Mypy does not accept an implicit None return. Screenshots from Visual Studio Code. Image by author

According to the mypy documentation, you can disable strict None checking using the --no-strict-optional command-line option. Think twice if you want to use this option. I prefer to always explicitly declare whether a particular type accepts None or not. Disabling strict checking means that any type is assumed to accept None, which can lead to unexpected behavior and make code more difficult to understand and maintain. While I am not a great fan of very thorough type hints, using the --no-strict-optional flag is in my eyes an oversimplification, because None is too important a sentinel value to ignore it just like that.

If you do need to disable strict checking in specific situations, remember that when you do so but someone else doesn’t, they may encounter many static errors throughout the code. Maintaining consistent type checking settings throughout a codebase is a good general practice.

Literals versus enumerations

While reading the previous section, did you notice that some literal types resemble enumerations types? Indeed, they do share some similarities, but literal types lack the natural order of values inherent in enumerations.

Compare these two type definitions:

from typing import Literal
from enum import Enum

ColorsL = Literal["white", "black", "grey"]

class ColorsE(Enum):
    WHITE = "white"
    BLACK = "black"
    GREY = "grey"

If you primarily noticed the difference in syntax, be aware that you can also define enumeration types using static factory methods:

ColorsE2 = Enum("ColorsE2", ["WHITE", "BLACK", "GREY"])
ColorsE3 = Enum("ColorsE3", "WHITE BLACK GREY")

So, the definition syntax isn’t the key distinction between literal types and enumerations. Firstly, literal types are static types with minor dynamic functionality, while enumeration types offer both static and dynamic capabilities, making them more versatile. If you require more than what literal types provide, enumerations are likely the better choice.

This article doesn’t delve into the intricacies of Python enumerations. However, the following table compares the two tools. Before proceeding, analyze the table and observe that typing.literal offers a subset of enum.Enum's features.

Comparison of enum.Enum and typing.Literal. Image by author

Despite their versatility, literal types excel in simplicity, brevity, and readability. While Python enumerations are also straightforward and readable, literal types offer an even higher level of clarity and conciseness.

Conclusion

The central message of this article is that typing.Literal and literal types are powerful tools that offer more capabilities than one might initially assume. Their simplicity conceals their depth and versatility. As I mentioned at the beginning of the article, I had underestimated the value of this tool for quite some time. However, today I recognize it — and literal types in general — as a powerful yet straightforward mechanism for enhancing Python code conciseness while maintaining static correctness.

In fact, using other type hints to express the same concept as a literal type can lead to confusion, even if static checkers don’t raise any errors. When all you need is a static type to be checked by static checkers, typing.Literal should be your go-to choice. Its usage is straightforward and doesn’t require excessive code: just the type definition, which typically takes one or more lines depending on the number of literals included in the type.

For scenarios requiring more advanced dynamic functionality, enumerations may be a better fit. They provide an additional layer of safety at runtime by preventing invalid value assignments. Literal types, on the other hand, do not offer this inherent safeguard, although it can be implemented as demonstrated with the is_ten() function above. However, this safeguard would need to be applied every time a user provides a value of this type.

In essence, remember about literal types and typing.Literal. Incorporate them into your Python code to achieve simplicity and readability. I’d say that in Python, typing.Literal achieves one of the highest usefulness-to-complexity ratios, making it simultaneously highly useful and remarkably simple.

Appendix 1

typing.LiteralString

Python 3.11 and newer introduced the typing.LiteralString type. Despite its name, it is not a direct replacement for typing.Literal for strings. To avoid unnecessary confusion, let’s not delve into this type in detail here. Instead, let’s briefly outline the fundamental aspects of this type.

Unlike typing.Literal, which serves as a mechanism for creating literal types, typing.LiteralString is a type itself. It can be used to specify that a variable should hold a literal string, as demonstrated in the following example:

from typing import LiteralString

def foo(s: LiteralString) -> None
    ...

Note what the documentation says:

Any string literal is compatible with LiteralString, as is another LiteralString. However, an object typed as just str is not.

And

LiteralString is useful for sensitive APIs where arbitrary user-generated strings could generate problems. For example, the two cases above that generate type checker errors could be vulnerable to an SQL injection attack.

This brief overview should suffice for our current discussion. If you’re interested in exploring this type further, refer to PEP 675, which introduced this literal type.

Appendix 2

Defining literal types using iterables

Warning: This section presents a hack that does not work statically. So, if your only aim is to create a static type, do not use this hack. It’s rather an interesting piece of information than something to be used in production code.

If you are not familiar with typing.Literal, Literal[] might resemble indexing, and Literal[1, 2, 3] might appear similar to a list. As a result, you might be tempted to use a list comprehension, as shown here:

>>> OneToTen = Literal[i for i in range(1, 11)]
  File "<stdin>", line 1
    OneToTen = Literal[i for i in range(1, 11)]
                         ^^^
SyntaxError: invalid syntax

The error message indicates that this is not valid syntax. This is because typing.Literal is not meant to be used as a list comprehension. Instead, it is used to specify particular values the type accepts.

But look here:

>>> OneToTen = Literal[[i for i in range(1, 11)]]

No error? So, we’re fine, aren’t we?

No, we aren’t. Look at what OneToTen is:

>>> OneToTen
typing.Literal[[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]
>>> get_args(OneToTen)
([1, 2, 3, 4, 5, 6, 7, 8, 9, 10],)

As you can see, this definition worked but not in the way we intended. OneToTen is a literal type with only one value: a list of integers from 1 to 10. Not only is a list not an acceptable literal type, this is also not quite what we were hoping for!

But don’t worry, we’re not done here. There’s a trick that will help us achieve the desired outcome. We can access the possible values of a literal type in two ways. One method, which we’ve already seen in action, is the get_args() function. Another method is to use the .__args__ attributeof the type:

>>> get_args(OneToTen)
([1, 2, 3, 4, 5, 6, 7, 8, 9, 10],)
>>> OneToTen.__args__
([1, 2, 3, 4, 5, 6, 7, 8, 9, 10],)
>>> get_args(OneToTen) == OneToTen.__args__
True

While get_args() allows us to get a literal type’s values, we can leverage the .__args__ attribute to update the type. Look:

>>> OneToTen.__args__ = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> OneToTen
typing.Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Ha! This is the trick I mentioned above. We can call it the .__args__ trick.

Above, I used a list, but it doesn’t matter what type of iterable you’ll use:

>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True
>>> OneToTen.__args__ = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True
>>> OneToTen.__args__ = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True

I assigned a list literal to OneToTen.__args__, but you can do the same in any other way, like using a list comprehension or another comprehension:

>>> OneToTen.__args__ = [i for i in range(1, 11)]
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True
>>> OneToTen.__args__ = list(range(1, 11))
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True
>>> OneToTen.__args__ = {i for i in range(1, 11)}
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True

You do have to be careful, however, as not always will Literal behave in a predictable way. For instance, it will work like above with range() but won’t work with a generator expression:

>>> OneToTen.__args__ = range(1, 11)
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
True
>>> OneToTen.__args__ = (i for i in range(1, 11))
>>> OneToTen == Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
False
>>> OneToTen.__args__ 
<generator object <genexpr> at 0x7f...>

Actually, while experimenting with generator expressions used with Literal, I noticed that it did work several times… I don’t know why: normally it doesn’t work that way, so out of say two dozen times I tried it, it worked only 2 or 3 times. That’s something I’m worried about as I hate situations in which a programming language behaves in an unpredictable way — even if in a hack.

Having troubles believing this? Look at this screenshot from Python 3.11:

Unpredictable behavior of typing.Literal.__args__ used with generator expressions. Screenshot from Python 3.11. Image by author

Just so you know, A was not used before, but OneToTen was — on the other hand, this should not change a thing. Besides, the next time I tried this, this time for a new name, B, it didn’t work:

A different behavior of typing.Literal.__args__ with a generator expression than before. Screenshot from Python 3.11. Image by author

Hence, unless you’re ready to accept unpredictable behavior of Python, don’t use typing.Literal with generator expressions before this issue is solved. But there’s nothing to worry about, as generator expressions are typically used to overcome memory issues — and creating a literal type doesn’t seem like something that should lead to such problems. Hence, instead of using a generator to create a literal type, you can make a list out of it and use it.

As mentioned at the beginning of this section, you should avoid using the .__args__ hack. It will work dynamically, but mypy will not accept it. It’s good to know this, as it extends your knowledge of typing type hints, but it’s not something you should use in production code.

Thanks for reading. If you enjoyed this article, you may also enjoy other articles I wrote; you will see them here. And if you want to join Medium, please use my referral link below:

If you enjoyed this article, consider trying out the AI service I recommend. It provides the same performance and functions to ChatGPT Plus(GPT-4) but more cost-effective, at just $6/month (Special offer for $1/month). Click here to try ZAI.chat.

Python
Programming
Data Science
Python Programming
Deep Dives
Recommended from ReadMedium