Calculating a Collatz Sequence
I think the handling of the cache could be improved. You add to the cache in _calc, then check the cache in collatz. Why not just let _calc handle the cache? That way collatz doesn't need to know anything about how _calc is getting its results. I'd change it to something like:

def _calc2(number: int) -&gt; int:
    if number in collatz_cache:  # Let it do the lookup
        return collatz_cache[number]

    if number % 2 == 0:
        next_number = number // 2
    else:
        next_number = number * 3 + 1

    collatz_cache[number] = next_number
    return next_number

def collatz2(number: int) -&gt; List[int]:
    if number &lt; 1:
        raise ValueError(f"Collatz sequence not defined for {type(number).__name__}({number})")

    results = [number]

    new_number = number
    while new_number != 1:
        new_number = _calc2(new_number)
        results.append(new_number)

    return results


I also don't think collatz really needs to do an isinstance check on number. You've said via type-hints that it only accepts integers. If you're going to go down that road, arguably every function should check what it's arguments are. I suppose protecting against a float being passed may be worth it since that will silently mess with the results, but you can only hand-hold the user so much.

I'll note though that your manual memoization isn't necessary. functools has a decorator for this:

from functools import lru_cache

@lru_cache(maxsize=int(1e6))  # This could probably be smaller
def _calc3(number: int) -&gt; int:
    if number % 2 == 0:
        next_number = number // 2
    else:
        next_number = number * 3 + 1

    return next_number


Using a dirty timeit timing using seeded random numbers, I found that all three versions perform nearly identically for me:

from timeit import timeit
from random import randint, seed

timeit(lambda: collatz(randint(5, 100)),
       setup=lambda: seed(12345),
       number=int(1e6))

# All take between 7 seconds for me


I used random numbers so the cache is actually getting fully tested instead of just returning the same numbers over and over. The random numbers are seeded, so the results should be reliable.

I'll note, you could also write your own bare-bones memoize decorator as well:

def memoize(f):
    cache = {}

    def wrapper(*args):
        if args in cache:
            return cache[args]

        else:
            result = f(*args)
            cache[args] = result
            return result

    return wrapper

@memoize
def tester(n, m):
    print("Called with", n, m)
    return n + m

&gt;&gt;&gt;&gt; tester(1, 3)
Called with 1 3
4

&gt;&gt;&gt; tester(1, 3)
4




I'll note too though, this could be made into a generator if you wanted to generalize it:

from typing import Generator

def collatz3(number: int) -&gt; Generator[int, None, None]:
    yield number  # To be consistent with the other functions

    new_number = number
    while new_number != 1:
        new_number = _calc3(new_number)
        yield new_number


This may prove to be more memory efficient for longer sequences, depending on how it's used. Now the whole sequence isn't required to be held in memory all at once.