63

I have a function which validates its argument to accept only values from a given list of valid options. Typing-wise, I reflect this behavior using a Literal type alias, like so:

from typing import Literal


VALID_ARGUMENTS = ['foo', 'bar']

Argument = Literal['foo', 'bar']


def func(argument: 'Argument') -> None:
    if argument not in VALID_ARGUMENTS:
        raise ValueError(
            f'argument must be one of {VALID_ARGUMENTS}'
        )
    # ...

This is a violation of the DRY principle, because I have to rewrite the list of valid arguments in the definition of my Literal type, even if it is already stored in the variable VALID_ARGUMENTS. How can I create the Argument Literal type dynamically, given the VALID_ARGUMENTS variable?

The following things do not work:

from typing import Literal, Union, NewType


Argument = Literal[*VALID_ARGUMENTS]  # SyntaxError: invalid syntax

Argument = Literal[VALID_ARGUMENTS]  # Parameters to generic types must be types

Argument = Literal[Union[VALID_ARGUMENTS]]  # TypeError: Union[arg, ...]: each arg must be a type. Got ['foo', 'bar'].

Argument = NewType(
    'Argument',
    Union[
        Literal[valid_argument]
        for valid_argument in VALID_ARGUMENTS
    ]
)  # Expected type 'Type[_T]', got 'list' instead

So, how can it be done? Or can't it be done at all?

martineau
  • 119,623
  • 25
  • 170
  • 301
Jonathan Scholbach
  • 4,925
  • 3
  • 23
  • 44
  • 4
    You almost got it! `Literal` accept a tuple of types or literals. `ValidArgs = Literal[tuple(VALID_ARGUMENTS)]` will work. But as was mentioned already it defeats static type checkers. – kuza Dec 02 '20 at 19:43
  • *Of course* it defeats static type checkers. The goal here is a contradiction in terms. Creating the type *dynamically* means that it can't happen until the code runs. The entire point of a *static* type checker is that it performs its checks *before* the code runs. – Karl Knechtel Aug 23 '23 at 02:52

4 Answers4

61

Go the other way around, and build VALID_ARGUMENTS from Argument:

Argument = typing.Literal['foo', 'bar']
VALID_ARGUMENTS: typing.Tuple[Argument, ...] = typing.get_args(Argument)

It's possible at runtime to build Argument from VALID_ARGUMENTS, but doing so is incompatible with static analysis, which is the primary use case of type annotations. Building VALID_ARGUMENTS from Argument is the way to go.

I've used a tuple for VALID_ARGUMENTS here, but if for some reason you really prefer a list, you can get one:

VALID_ARGUMENTS: typing.List[Argument] = list(typing.get_args(Argument))
user2357112
  • 260,549
  • 28
  • 431
  • 505
  • 1
    What is the purpose of the ellipsis in`typing.Tuple[Argument, ...]`? – N4v Sep 28 '21 at 15:09
  • 9
    @N4v: That's the syntax for a homogeneous, arbitrary-length tuple. – user2357112 Sep 29 '21 at 00:46
  • This is a good way to go but you lose type information. If you do VALID_ARGUMENTS = "foo", "bar" by hand you get tuple[Literal["foo"], Literal["bar"]] instead of tuple[Argument, ...], so I think the best way is still rewriting everything by hand a second time – Fayeure Aug 17 '23 at 08:33
  • @Fayeure: Perhaps, but it's hard to come up with a reasonable use case where something should actually care about the extra type information. It'd have to be something that statically relies on the order of `VALID_ARGUMENTS`, and relying on the order at all, let alone statically, seems like a bad idea. – user2357112 Aug 17 '23 at 08:42
19

If anyone's still looking for a workaround for this:

typing.Literal[tuple(VALID_ARGUMENTS)]
Chris Goddard
  • 395
  • 2
  • 6
  • 13
    From the mypy documentation: "`Literal` types may contain one or more literal bools, ints, strs, bytes, and enum values. However, literal types cannot contain arbitrary expressions: types like `Literal[my_string.trim()]`, `Literal[x > 3]`, or `Literal[3j + 4]` are all illegal." So this is valid *python* syntax, but will not be understood by any type checker, which completely defies the point of adding type hints in the first place. https://mypy.readthedocs.io/en/stable/literal_types.html#limitations – Alex Waygood Sep 02 '21 at 21:10
  • 6
    Absolutely doens't defeat the purpose. Typing can be viewed as, in first instance, structured documentation. This absolutely solves that problem. – Marc Dec 07 '21 at 23:14
  • 1
    @alex-waygood there are frameworks that use type hints as well. For example, in order to semi-dynamically include a list of allowed values in your FastAPI swagger UI/documentation, this is pretty much the only way at the moment. It's not nice but it's better than hard coding the values in some cases. – miksus May 22 '22 at 16:38
  • 2
    I can't edit my comment, but sure, I agree that if you're using type hints for runtime purposes rather than for static typing, this can still be useful. I feel like it's fair enough to assume, however, in the context of a question regarding Python typing, that the author is looking for a solution that will please static type-checkers, unless otherwise stated. By far the most common use for type hints is for static type-checking. – Alex Waygood May 22 '22 at 17:46
  • @Marc I see what you mean, but I disagree. Static analysis is a very important part of typing. I don't think "in first instance" is accurate. Including hints that look like they'll provide static analysis, but don't, can lead to confusion – joel Oct 15 '22 at 13:20
1

Expanding on @user2357112's answer... it's possible to make variables for the individual strings of "foo" and "bar".

from __future__ import annotations
from typing import get_args, Literal, TypeAlias

T_foo = Literal['foo']
T_bar = Literal['bar']
T_valid_arguments: TypeAlias = T_foo | T_bar

FOO: T_foo = get_args(T_foo)[0]
BAR: T_bar = get_args(T_bar)[0]

VALID_ARGUMENTS = (FOO, BAR)


def func(argument: T_valid_arguments) -> None:
    if argument not in VALID_ARGUMENTS:
        raise ValueError(f"argument must be one of {VALID_ARGUMENTS}")


#mypy checks
func(FOO)  # OK
func('foo')  # OK
func('baz')  # error: Argument 1 to "func" has incompatible type "Literal['baz']"; expected "Literal['foo', 'bar']"  [arg-type]

reveal_type(FOO) # note: Revealed type is "Literal['foo']" 
reveal_type(BAR). # note: Revealed type is "Literal['bar']"
reveal_type(VALID_ARGUMENTS)  # note: Revealed type is "tuple[Literal['foo'], Literal['bar']]"

Though, it could be argued that using get_args in this case is overkill to avoid typing the string "foo" twice in code. (re: DRY vs WET) You could just as easily do the following with the same results.

from __future__ import annotations
from typing import Literal, TypeAlias

T_foo = Literal['foo']
T_bar = Literal['bar']
T_valid_arguments: TypeAlias = T_foo | T_bar

FOO: T_foo = 'foo'
BAR: T_bar = 'bar'

VALID_ARGUMENTS = (FOO, BAR)

As a word of caution with using Literal strings as annotations. Mypy will complain about this:

FOO = 'foo'

def func(argument: T_valid_arguments) -> None:
    ...

func(FOO) #  error: Argument 1 to "func" has incompatible type "str"; expected "Literal['foo', 'bar']"  [arg-type]

But the following is fine.

func('foo')  # OK
Marcel Wilson
  • 3,842
  • 1
  • 26
  • 55
-5

Here is the workaround for this. But don't know if it is a good solution.

VALID_ARGUMENTS = ['foo', 'bar']

Argument = Literal['1']

Argument.__args__ = tuple(VALID_ARGUMENTS)

print(Argument)
# typing.Literal['foo', 'bar']
  • 5
    Since this performs a *runtime modification* of the previously defined *static type*, it will plain not work. The *runtime* type will be ``Literal['foo', 'bar']``, but the *static* type actually used for verification is still ``Literal['1']``. – MisterMiyagi Dec 30 '20 at 19:32
  • @MisterMiyagi I use this solution with FastAPI + Pydantic. It works good for a field validation. I can only pass `foo` or `bar` values to my API with a POST request, but not a `1` value. – Nairum Feb 28 '21 at 09:02
  • @AlexeiMarinichenko That’s nice, but does not change that it will plain not work for its intended use. The question already shows two cases that also work at runtime but are considerably more robust than fiddling with implementation specific internals. – MisterMiyagi Feb 28 '21 at 09:40