from typing import Tuple
from typing import Optional


def broadcast_shapes(shape1: Tuple[int, ...], shape2: Tuple[int, ...]) -> Optional[Tuple[int, ...]]:
    """Broadcast two shapes into a single shape.

    We are mimicing the exact semantics of broadcasting in numpy.
    You can learn more about it here: https://numpy.org/doc/stable/user/theory.broadcasting.html

    Args:
        shape1 (Tuple[int, ...]): first shape to broadcast
        shape2 (Tuple[int, ...]): second shape to broadcast

    Returns:
        Optional[Tuple[int, ...]]: None if the shapes are not broadcastable else broadcasted shape
    """

    result = []
    for size1, size2 in zip(shape1[::-1], shape2[::-1]):
        if size1 != size2 and size1 != 1 and size2 != 1 and size1 != 0 and size2 != 0:
            return None

        if size1 == 0 or size2 == 0:
            result.append(0)
        else:
            result.append(max(size1, size2))

    if len(result) < len(shape1):
        for i in reversed(range(len(shape1) - len(result))):
            result.append(shape1[i])
    elif len(result) < len(shape2):
        for i in reversed(range(len(shape2) - len(result))):
            result.append(shape2[i])

    return tuple(reversed(result))