A Decorator that turns Function Annotations into Type Checks
This is a very very poor implementation.


Firstly the code has little care for nested datatypes, and makes them a pain to add in a future version.  

To add support for this all that needs to be done is change the, per type, control flow to use recursion. The option to use standard loops rather than recursion is available too, however that will likely make the code harder to read.
The code just looks very error prone. Whilst RegEx has it's place in some limited scenarios, this is not then. The following snippet is almost never a good idea.

is_optional: bool = "Optional" in str(annotation)
if is_optional:
    annotation = re.search("Optional\[(.*)\]", str(annotation)).group(1)


This is the kind of nonsense I would expect of a junior that just learnt what RegEx is.
Whilst 'proper' low level typing inspection isn't that readable, mangling it with strings makes the code harder to read for people used to interacting with typing.

Also, when performing low level changes on core Python libraries read the PEPs that accompany them. They're their for a reason. For instance, PEP 484 states how to remove the need for using strings.


  The string literal should contain a valid Python expression (i.e., compile(lit, '', 'eval') should be a valid code object) and it should evaluate without errors once the module has been fully loaded. The local and global namespace in which it is evaluated should be the same namespaces in which default arguments to the same function would be evaluated.


Furthermore, reading the PEP is not required, as the typing documentation shows a function that does this out of the box.


  typing.get_type_hints(obj[, globals[, locals]])
  Return a dictionary containing type hints for a function, method, module or class object.



Whilst I can understand that hacking a solution may be the most fun way to implement something. It's how I learnt the typing library. You should at least read the documentation of the module. Python's documentation is very high-quality, and ignoring it leads to poor code.

Here's a naive implementation:

import typing
import inspect


def is_type(instance, type_info):
    if type_info == typing.Any:
        return True
    if hasattr(type_info, '__origin__'):
        if type_info.__origin__ in {typing.Union, typing.Optional}:
            return any(is_type(instance, arg) for arg in type_info.__args__)
    return isinstance(instance, type_info)


def enforce_types(func):
    types_info = typing.get_type_hints(func)
    signature = inspect.signature(func)

    def inner(*args, **kwargs):
        sig = signature.bind(*args, **kwargs)
        sig.apply_defaults()
        for name, value in sig.arguments.items():
            if not is_type(value, types_info.get(name, typing.Any)):
                raise TypeError(
                    '{name} is not of the correct type'
                    .format(name=name)
                )
        return func(*args, **kwargs)

    return inner