Repository: beartype/plum (language: python)

Issue:
make @parametric work with classes with arbitrary metaclasses
I am experimenting with https://github.com/cgarciae/treeo and plum, and found out that since both inject meta classes they don't work together.

This PR makes plum work with classes which already have a meta class by injecting `CovariantMeta` as a superclass of the existing meta class, and by redefining `__call__` (essentially the constructor) to infer the parameters before calling into the existing class.

I'm surprised this was so easy to implement!
How to handle doc strings
How do doc strings work with several methods? It would be nice to put different doc strings in different methods. I don't think this works.

Is there a recommended way then ? Maybe put one big doc string in the first method?



Discussion / hints:

[![Coverage Status](https://coveralls.io/builds/44761020/badge)](https://coveralls.io/builds/44761020)

Coverage remained the same at 100.0% when pulling **a0df7ac5a61443dbcfb1438b4fa2d129e0f86b10 on PhilipVinc:pv/parametricmeta** into **b521badf3447d61fc06ddb1956ac9e658639e9ce on wesselb:master**.

Thank you, @PhilipVinc!! This really is excellent—a strict improvement. Merging this now and releasing a new version. :)
Actually, you've still marked this as a draft. The PR looks complete to me. Are you happy to have this merged?
Yeah, I think I wanted to add a test to check for what the `fix issue in __init_subclass__` commit fixed.
I found out about it in a very exotic case that arises when you combine `@parametric` with classes that auto-register themselves with jax, but it's a real issue of plum,.

I'll add a test soon
Hey @jlapeyre!

Currently, the docstring of the first definition of a function is used. Example:


```python
from plum import dispatch


@dispatch
def f(x: str):
    """Do something."""
    pass


@dispatch
def f(x: int):
    """Do more."""
    pass


print(f.__doc__)  # "Do something."
```

Ideally, all docstrings would be appended in some sane way. E.g., for the above example, `f.__doc__` would ideally be

```
f(x: str)

Do something.

—

f(x: int)

Do more.
```

I've tagged this issue as a feature request. I think this would be a nice improvement!

Fix patch:
diff --git a/.gitignore b/.gitignore
index 45cb0bf2..83973b97 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ plum/_version.py
 
 # Virtual environments
 venv
+.tox
 
 # Packaging
 *.egg-info
diff --git a/.isort.cfg b/.isort.cfg
new file mode 100644
index 00000000..fc12f633
--- /dev/null
+++ b/.isort.cfg
@@ -0,0 +1,3 @@
+[settings]
+profile = black
+src_paths = plum,tests
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9b472131..da9377c6 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,20 @@
+default_language_version:
+    python: python3.8
 repos:
   - repo: https://github.com/psf/black
     rev: 22.3.0
     hooks:
       - id: black
-        language_version: python3
+  - repo: https://github.com/pycqa/isort
+    rev: 5.12.0
+    hooks:
+      - id: isort
+        args: ["--profile", "black"]
+  - repo: https://github.com/pycqa/flake8
+    rev: 5.0.4
+    hooks:
+      - id: flake8
+        args: ["--max-line-length=88", "--extend-ignore=E203,F811"]
+        additional_dependencies:
+          - flake8-bugbear>=22.12
+          - flake8-noqa>=1.3
diff --git a/Makefile b/Makefile
index ee59cf85..7e468f26 100644
--- a/Makefile
+++ b/Makefile
@@ -2,56 +2,11 @@
 
 PACKAGE := plum
 
-docmake:
-	rm -rf docs/source
-	sphinx-apidoc -eMT -o docs/source/ $(PACKAGE)
-	rm docs/source/$(PACKAGE).rst
-	pandoc --from=markdown --to=rst --output=docs/readme.rst README.md
-	cd docs && make html
-
-docopen:
-	open docs/_build/html/index.html
-
-docinit:
-	$(eval BRANCH := $(shell git rev-parse --abbrev-ref HEAD))
-	git checkout -b gh-pages
-	git ls-tree HEAD \
-		| awk '$$4 !~ /\.nojekyll|docs|index\.html/ { print $$4 }' \
-		| xargs -I {} git rm -r {}
-	touch .nojekyll
-	echo '<meta http-equiv="refresh" content="0; url=./docs/_build/html/index.html" />' > index.html
-	git add .nojekyll index.html
-	git commit -m "Branch cleaned for docs"
-	git push origin gh-pages
-	git checkout $(BRANCH)
-
-docremove:
-	git branch -D gh-pages
-	git push origin --delete gh-pages
-
-docupdate: docmake
-	$(eval BRANCH := $(shell git rev-parse --abbrev-ref HEAD))
-	rm -rf docs/_build/html_new
-	mv docs/_build/html docs/_build/html_new
-	git checkout gh-pages
-	rm -rf docs/_build/html
-	mv docs/_build/html_new docs/_build/html
-	git add -f docs/_build/html
-	git commit -m "Update docs at $$(date +'%d %b %Y, %H:%M')"
-	git push origin gh-pages
-	git checkout $(BRANCH)
-
 install:
 	pip install -r requirements.txt
 
 test:
 	python setup.py --version
-	PRAGMA_VERSION=`python -c "import sys; print('.'.join(map(str, sys.version_info[:2])))"` \
-		pytest -v --cov=$(PACKAGE) --cov-report html:cover --cov-report term-missing
-
-clean:
-	rm -rf docs/_build docs/source docs/readme.rst
-	git rm --cached -r docs
-	git add docs/Makefile docs/conf.py docs/index.rst docs/api.rst
-	rm -rf .coverage cover
-	find . | grep '\(\.DS_Store\|\.pyc\)$$' | xargs rm
+	pre-commit run --all-files && sleep 0.2 && \
+		PRAGMA_VERSION=`python -c "import sys; print('.'.join(map(str, sys.version_info[:2])))"` \
+			pytest tests -v --cov=$(PACKAGE) --cov-report html:cover --cov-report term-missing
diff --git a/README.md b/README.md
index ff6557de..e5328d17 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@
 
 Everybody likes multiple dispatch, just like everybody likes plums.
 
+*Note:*
+Plum 2 is now powered by [Beartype](https://github.com/beartype/beartype)!
+If you notice any issues with the new release, please open an issue.
+
 # Installation
 
 Plum requires Python 3.7 or higher.
@@ -29,6 +33,7 @@ from numbers import Number
 
 from plum import dispatch
 
+
 @dispatch
 def f(x: str):
     return "This is a string!"
@@ -52,10 +57,10 @@ def f(x: Number):
 'This is an integer!'
 
 >>> f(1.0)
-"This is a number, but I don't know which type."
+'This is a number, but I don't know which type.'
 
 >>> f(object())
-NotFoundLookupError: For function "f", signature Signature(builtins.object) could not be resolved.
+NotFoundLookupError: For function `f`, `(<object object at 0x7fb528458190>,)` could not be resolved.
 ```
 
 This also works for multiple arguments, enabling some neat design patterns:
@@ -65,31 +70,32 @@ from numbers import Number, Real, Rational
 
 from plum import dispatch
 
+
 @dispatch
 def multiply(x: Number, y: Number):
-    return "fallback implementation of multiplication"
+    return "Performing fallback implementation of multiplication..."
 
 
 @dispatch
 def multiply(x: Real, y: Real):
-    return "specialised implementation for reals"
+    return "Performing specialised implementation for reals..."
 
 
 @dispatch
 def multiply(x: Rational, y: Rational):
-    return "specialised implementation for rationals"
+    return "Performing specialised implementation for rationals..."
 ```
 
 ```python
 >>> multiply(1, 1)
-'specialised implementation for rationals'
+'Performing specialised implementation for rationals...'
 
 >>> multiply(1.0, 1.0)
-'specialised implementation for reals'
+'Performing specialised implementation for reals...'
 
 >>> multiply(1j, 1j)
-'fallback implementation of multiplication'
+'Performing fallback implementation of multiplication...'
 
 >>> multiply(1, 1.0)  # For mixed types, it automatically chooses the right optimisation!
-'specialised implementation for reals'
+'Performing specialised implementation for reals...'
 ```
diff --git a/benchmark.py b/benchmark.py
index 957f7eae..58bf1625 100644
--- a/benchmark.py
+++ b/benchmark.py
@@ -1,6 +1,8 @@
+from typing import Tuple
+
 import numpy as np
 
-from plum import dispatch, Dispatcher, Tuple
+from plum import Dispatcher, dispatch
 from tests.util import benchmark
 
 
@@ -18,20 +20,13 @@ def g(x: str):
     pass
 
 
-dur_native = benchmark(f, (1,), n=1000000)
-dur_first_plum = benchmark(g, (1,), n=1)
-dur_plum = benchmark(g, (1,), n=1000000)
+dur_native = benchmark(f, (1,), n=1000, burn=10)
+dur_plum = benchmark(g, (1,), n=1000, burn=10)
+factor = int(np.round(dur_plum / dur_native))
 
 print("# Function Calls")
-print("Native call:     {:6.2f} us ({:.1f} x)".format(dur_native, 1))
-print(
-    "First plum call: {:6.2f} us ({:.1f} x)"
-    "".format(dur_first_plum, int(np.round(dur_first_plum / dur_native)))
-)
-print(
-    "Plum call:       {:6.2f} us ({:.1f} x)"
-    "".format(dur_plum, int(np.round(dur_plum / dur_native)))
-)
+print("Native call: {:6.2f} us ({:.1f} x)".format(dur_native, 1))
+print("Plum call:   {:6.2f} us ({:.1f} x)".format(dur_plum, factor))
 print()
 
 
@@ -49,20 +44,13 @@ def g2(x: Tuple[str]):
     pass
 
 
-dur_native = benchmark(f2, ((1,),), n=1000000)
-dur_first_plum = benchmark(g2, ((1,),), n=1)
-dur_plum = benchmark(g2, ((1,),), n=1000000)
+dur_native = benchmark(f2, ((1,),), n=1000, burn=10)
+dur_plum = benchmark(g2, ((1,),), n=1000, burn=10)
+factor = int(np.round(dur_plum / dur_native))
 
 print("# Parametric Function Calls")
-print("Native call:     {:6.2f} us ({:.1f} x)".format(dur_native, 1))
-print(
-    "First plum call: {:6.2f} us ({:.1f} x)"
-    "".format(dur_first_plum, int(np.round(dur_first_plum / dur_native)))
-)
-print(
-    "Plum call:       {:6.2f} us ({:.1f} x)"
-    "".format(dur_plum, int(np.round(dur_plum / dur_native)))
-)
+print("Native call: {:6.2f} us ({:.1f} x)".format(dur_native, 1))
+print("Plum call:   {:6.2f} us ({:.1f} x)".format(dur_plum, factor))
 print()
 
 
@@ -97,33 +85,19 @@ def go(self, x: str):
 a = A()
 b = B()
 
-dur_native = benchmark(a, (1,), n=1000000)
-dur_first_plum = benchmark(b, (1,), n=1)
-dur_plum = benchmark(b, (1,), n=1000000)
+dur_native = benchmark(a, (1,), n=1000, burn=10)
+dur_plum = benchmark(b, (1,), n=1000, burn=10)
+factor = int(np.round(dur_plum / dur_native))
 
 print("# Class Calls")
-print("Native call:     {:6.2f} us ({:.1f} x)".format(dur_native, 1))
-print(
-    "First plum call: {:6.2f} us ({:.1f} x)"
-    "".format(dur_first_plum, int(np.round(dur_first_plum / dur_native)))
-)
-print(
-    "Plum call:       {:6.2f} us ({:.1f} x)"
-    "".format(dur_plum, int(np.round(dur_plum / dur_native)))
-)
+print("Native call: {:6.2f} us ({:.1f} x)".format(dur_native, 1))
+print("Plum call:   {:6.2f} us ({:.1f} x)".format(dur_plum, factor))
 print()
 
-dur_native = benchmark(lambda x: a.go(x), (1,), n=1000000)
-dur_first_plum = benchmark(lambda x: b.go(x), (1,), n=1)
-dur_plum = benchmark(lambda x: b.go(x), (1,), n=1000000)
+dur_native = benchmark(lambda x: a.go(x), (1,), n=1000, burn=10)
+dur_plum = benchmark(lambda x: b.go(x), (1,), n=1000, burn=10)
+factor = int(np.round(dur_plum / dur_native))
 
 print("# Class Attribute Calls")
-print("Native call:     {:6.2f} us ({:.1f} x)".format(dur_native, 1))
-print(
-    "First plum call: {:6.2f} us ({:.1f} x)"
-    "".format(dur_first_plum, int(np.round(dur_first_plum / dur_native)))
-)
-print(
-    "Plum call:       {:6.2f} us ({:.1f} x)"
-    "".format(dur_plum, int(np.round(dur_plum / dur_native)))
-)
+print("Native call: {:6.2f} us ({:.1f} x)".format(dur_native, 1))
+print("Plum call:   {:6.2f} us ({:.1f} x)".format(dur_plum, factor))
diff --git a/docs/_config.yml b/docs/_config.yml
index bab04299..7f69cf5c 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -4,6 +4,11 @@ author: Wessel Bruinsma, Filippo Vicentini, and Ruan Comelli
 copyright: "2022"
 logo: logo.png
 
+# Enable definition lists.
+parse:
+  myst_enable_extensions:
+    - deflist
+
 # Force re-execution of notebooks on each build.
 # See https://jupyterbook.org/content/execute.html
 execute:
diff --git a/docs/_toc.yml b/docs/_toc.yml
index 7db9b763..d11bb6a8 100644
--- a/docs/_toc.yml
+++ b/docs/_toc.yml
@@ -6,11 +6,17 @@ root: intro
 chapters:
 - file: basic_usage
   sections:
+  - file: types
+  - file: dispatch
   - file: scope
   - file: classes
   - file: keyword_arguments
   - file: comparison
-  - file: type_system
-  - file: conversion_promotion
 - file: advanced_usage
+  sections:
+  - file: conversion_promotion
+  - file: precedence
+  - file: parametric
+  - file: union_aliases
+  - file: autoreload
 - file: api
diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md
index b18fbb14..ce8b0615 100644
--- a/docs/advanced_usage.md
+++ b/docs/advanced_usage.md
@@ -1,341 +1,4 @@
 # Advanced Usage
 
-## Abstract Function Definitions
-
-A function can be abstractly defined using `dispatch.abstract`.
-When a function is abstractly defined, the function is created, but no methods
-are defined.
-
-```python
-from plum import dispatch
-
-@dispatch.abstract
-def f(x):
-    pass
-```
-
-```python
->>> f
-<function <function f at 0x7f9f6820aea0> with 0 method(s)>
-
->>> @dispatch
-... def f(x: int):
-...     pass
-
->>> f
-<function <function f at 0x7f9f6820aea0> with 1 method(s)>
-```
-
-(method-precedence)=
-## Method Precedence
-
-The keyword argument `precedence` can be set to an integer value to specify
-precedence levels of methods, which are used to break ambiguity:
-
-```python
-from plum import dispatch
-
-class Element:
-    pass
-
-
-class ZeroElement(Element):
-    pass
-
-
-class SpecialisedElement(Element):
-    pass
-
-
-@dispatch
-def mul_no_precedence(a: ZeroElement, b: Element):
-    return "zero"
-
-
-@dispatch
-def mul_no_precedence(a: Element, b: SpecialisedElement):
-    return "specialised operation"
-    
-
-@dispatch(precedence=1)
-def mul(a: ZeroElement, b: Element):
-    return "zero"
-
-
-@dispatch
-def mul(a: Element, b: SpecialisedElement):
-    return "specialised operation"
-```
-
-```python
->>> zero = ZeroElement()
-
->>> specialised_element = SpecialisedElement()
-
->>> element = Element()
-
->>> mul(zero, element)
-'zero'
-
->>> mul(element, specialised_element)
-'specialised operation'
-
->>> mul_no_precedence(zero, specialised_element)
-AmbiguousLookupError: For function "mul_no_precedence", signature Signature(__main__.ZeroElement, __main__.SpecialisedElement) is ambiguous among the following:
-  Signature(__main__.ZeroElement, __main__.Element) (precedence: 0)
-  Signature(__main__.Element, __main__.SpecialisedElement) (precedence: 0)
-
->>> mul(zero, specialised_element)
-'zero'
-```
-
-The method precedences of all implementations of a function can be obtained
-with the attribute `precedences`:
-
-```python
->>> mul_no_precedence.precedences
-{Signature(__main__.ZeroElement, __main__.Element): 0,
- Signature(__main__.Element, __main__.SpecialisedElement): 0}
-
->>> mul.precedences
-{Signature(__main__.ZeroElement, __main__.Element): 1,
- Signature(__main__.Element, __main__.SpecialisedElement): 0}
-```
-
-## Parametric Classes
-
-The decorator `@parametric` can be used to create parametric classes:
-
-```python
-from plum import dispatch, parametric
-
-@parametric
-class A:
-    def __init__(self, x, *, y = 3):
-        self.x = x
-        self.y = y
-    
-    
-@dispatch
-def f(x: A):
-    return "fallback: x={}".format(x.x)
-    
-    
-@dispatch
-def f(x: A[int]):
-    return "int x={}".format(x.x)
-    
-    
-@dispatch
-def f(x: A[float]):
-    return "float x={}".format(x.x)
-```
-
-```python
->>> A
-__main__.A
-
->>> A[int]
-__main__.A[builtins.int]
-
->>> issubclass(A[int], A)
-True
-
->>> type(A(1)) == A[int]
-True
-
->>> A[int](1)
-<__main__.A[builtins.int] at 0x10c2bab70>
-
->>> f(A[int](1))
-'int x=1'
-
->>> f(A(1))
-'int x=1'
-
->>> f(A(1.0))
-'float x=1.0'
-
->>> f(A(1 + 1j))
-'fallback: x=1+1j'
-```
-
-**Note:** Calling `A[pars]` on parametrized type `A` instantiates the concrete
-type with parameters `pars`.
-If `A(args)` is called directly, the concrete type is first instantiated by
-taking the type of all positional arguments, and then an instance of the type
-is created.
-
-This behaviour can be customized by overriding the `@classmethod`
-`__infer_type_parameter__` of the parametric class.
-This method must return the type parameter or a tuple of type parameters.
-
-```python
-from plum import parametric
-
-@parametric
-class NTuple:
-    @classmethod
-    def __infer_type_parameter__(self, *args):
-        # Mimicks the type parameters of an `NTuple`.
-        T = type(args[0])
-        N = len(args)
-        return (N, T)
-
-    def __init__(self, *args):
-        # Check that the arguments satisfy the type specification.
-        T = type(self)._type_parameter[1]
-        assert all(isinstance(val, T) for val in args)
-        self.args = args
-```
-
-```python
->>> type(NTuple(1, 2, 3))
-__main__.NTuple[3, <class 'int'>]
-```
-
-## Hooking Into Type Inference
-
-With parametric classes, you can hook into Plum's type inference system to do cool
-things!
-Here's an example which introduces types for NumPy arrays of particular ranks:
-
-```python
-import numpy as np
-from plum import dispatch, parametric, type_of
-
-
-@parametric(runtime_type_of=True)
-class NPArray(np.ndarray):
-    """A type for NumPy arrays where the type parameter specifies the number of
-    dimensions."""
-
-
-@type_of.dispatch
-def type_of(x: np.ndarray):
-    # Hook into Plum's type inference system to produce an appropriate instance of
-    # `NPArray` for NumPy arrays.
-    return NPArray[x.ndim]
-
-
-@dispatch
-def f(x: NPArray[1]):
-    return "vector"
-
-
-@dispatch
-def f(x: NPArray[2]):
-    return "matrix"
-```
-
-```python
->>> f(np.random.randn(10))
-'vector'
-
->>> f(np.random.randn(10, 10))
-'matrix'
-
->>> f(np.random.randn(10, 10, 10))
-NotFoundLookupError: For function "f", signature Signature(__main__.NPArray[3]) could not be resolved.
-```
-
-## Add Multiple Methods
-
-`Dispatcher.multi` can be used to implement multiple methods at once:
-
-```python
-from typing import Union
-
-from plum import dispatch
-
-@dispatch.multi((int, int), (float, float))
-def add(x: Union[int, float], y: Union[int, float]):
-    return x + y
-```
-
-```python
->>> add(1, 1)
-2
-
->>> add(1.0, 1.0)
-2.0
-
->>> add(1, 1.0)
-NotFoundLookupError: For function "add", signature Signature(builtins.int, builtins.float) could not be resolved.
-```
-
-## Extend a Function From Another Package
-
-`Function.dispatch` can be used to extend a particular function from an external
-package:
-
-```python
-from package import f
-
-@f.dispatch
-def f(x: int):
-    return "new behaviour"
-```
-
-```python
->>> f(1.0)
-'old behaviour'
-
->>> f(1)
-'new behaviour'
-```
-
-## Directly Invoke a Method
-
-`Function.invoke` can be used to invoke a method given types of the arguments:
-
-```python
-from plum import dispatch
-
-@dispatch
-def f(x: int):
-    return "int"
-    
-    
-@dispatch
-def f(x: str):
-    return "str"
-```
-
-```python
->>> f(1)
-'int'
-
->>> f("1")
-'str'
-
->>> f.invoke(int)("1")
-'int'
-
->>> f.invoke(str)(1)
-'str'
-```
-
-## Support for IPython Autoreload
-
-Plum does not work out of the box with
-[IPython's autoreload extension](https://ipython.readthedocs.io/en/stable/config/extensions/autoreload.html),
-and if you reload a file where a class is defined, you will most likely break your dispatch table.
-
-However, experimental support for IPython's autoreload is included into Plum,
-but it is not enabled by default, as it overrides some internal methods of IPython.
-To activate it, either set the environment variable `PLUM_AUTORELOAD=1` **before** loading plum
-
-```bash
-export PLUM_AUTORELOAD=1
-```
-
-or manually call the `autoreload.activate` method in an interactive session.
-
-```python
-import plum
-
-plum.autoreload.activate()
-```
-
-If there are issues with autoreload, please open a bug report.
+You've now mastered the basics!
+Its time to explore more advanced ways to use Plum.
diff --git a/docs/api.rst b/docs/api.rst
index 95b75ba2..61ca0c58 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -9,13 +9,15 @@ Application Programming Interface
     :members:
 .. automodule:: plum.dispatcher
     :members:
+.. automodule:: plum.resolver
+    :members:
 .. automodule:: plum.parametric
     :members:
 .. automodule:: plum.promotion
     :members:
-.. automodule:: plum.autoreload
+.. automodule:: plum.alias
     :members:
-.. automodule:: plum.resolvable
+.. automodule:: plum.autoreload
     :members:
 .. automodule:: plum.util
     :members:
diff --git a/docs/autoreload.md b/docs/autoreload.md
new file mode 100644
index 00000000..41fefeb4
--- /dev/null
+++ b/docs/autoreload.md
@@ -0,0 +1,32 @@
+# Support for IPython Autoreload
+
+Plum does not work out of the box with
+[IPython's autoreload extension](https://ipython.readthedocs.io/en/stable/config/extensions/autoreload.html),
+and, if you reload a file where a class is defined,
+you will most likely break your dispatch table.
+
+Experimental support for IPython's autoreload is included into Plum,
+but it is not enabled by default, as it overrides some internal methods of IPython.
+To activate it, either set the environment variable `PLUM_AUTORELOAD=1` *before* loading plum
+
+```bash
+export PLUM_AUTORELOAD=1
+```
+
+or use `activate_autoreload`:
+
+```python
+>>> from plum import activate_autoreload
+
+>>> activate_autoreload()
+```
+
+If there are issues with autoreload, please open an issue.
+
+You can disable the feature with `deactivate_autoreload`:
+
+```python
+>>> from plum import deactivate_autoreload
+
+>>> deactivate_autoreload()
+```
diff --git a/docs/basic_usage.md b/docs/basic_usage.md
index 737f562d..992cb525 100644
--- a/docs/basic_usage.md
+++ b/docs/basic_usage.md
@@ -6,6 +6,7 @@ Multiple dispatch allows you to implement multiple *methods* for the same
 ```python
 from plum import dispatch
 
+
 @dispatch
 def f(x: str):
     return "This is a string!"
@@ -29,7 +30,7 @@ will be raised:
 
 ```python
 >>> f(1.0)
-NotFoundLookupError: For function "f", signature Signature(builtins.float) could not be resolved.
+NotFoundLookupError: For function `f`, `(1.0,)` could not be resolved.
 ```
 
 Instead of implementing a method for `float`s, let's implement a method for
@@ -38,6 +39,7 @@ all numbers:
 ```python
 from numbers import Number
 
+
 @dispatch
 def f(x: Number):
     return "This is a number!"
@@ -60,14 +62,10 @@ For a function `f`, all available methods can be obtained with `f.methods`:
 
 ```python
 >>> f.methods
-{Signature(builtins.str): (<function __main__.f(x:str)>, builtins.object),
- Signature(builtins.int): (<function __main__.f(x:int)>, builtins.object),
- Signature(numbers.Number): (<function __main__.f(x:numbers.Number)>,
-  builtins.object)}
+[Signature(str, implementation=<function f at 0x7f9f6014f160>),
+ Signature(int, implementation=<function f at 0x7f9f6029c280>),
+ Signature(numbers.Number, implementation=<function f at 0x7f9f801fdf70>)]
 ```
 
-In the values, the first element in the tuple is the implementation and the
-second element the return type.
-
 For an excellent and way more detailed overview of multiple dispatch, see the
 [manual of the Julia Language](https://docs.julialang.org/en/).
diff --git a/docs/classes.md b/docs/classes.md
index c58ff076..a7c4496b 100644
--- a/docs/classes.md
+++ b/docs/classes.md
@@ -5,6 +5,7 @@ You can use dispatch within classes:
 ```python
 from plum import dispatch
 
+
 class Real:
    @dispatch
    def __add__(self, other: int):
@@ -25,79 +26,38 @@ class Real:
 'float added'
 ```
 
-If you use other decorators, then `dispatch` must be the _outermost_ decorator:
 
-```python
-class Real:
-   @dispatch
-   @decorator
-   def __add__(self, other: int):
-      return "int added"
-```
+## Decorators
 
-## `@staticmethod`, `@classmethod`, and `@property.setter`
-
-In the case of `@staticmethod`, `@classmethod`, or `@property.setter`, the rules
-are different:
-
-1. The `@dispatch` decorator must be applied _before_ `@staticmethod`,
-   `@classmethod`, and `@property.setter`.
-   This means that `@dispatch` is then _not_ the outermost decorator.
-2. The class must have _at least one_ other method where `@dispatch` is the
-   outermost decorator.
-   If this is not the case, you will need to add a dummy method, as the
-   following example illustrates.
+You can use `@dispatch` with other decorators without any problem:
 
 ```python
 from plum import dispatch
 
+
 class MyClass:
     def __init__(self):
         self._name = None
-       
+
     @property
-    def property(self):
+    def name(self):
         return self._name
 
-    @property.setter
+    @name.setter
     @dispatch
-    def property(self, value: str):
+    def name(self, value: str):
         self._name = value
-      
-    @staticmethod
-    @dispatch
-    def f(x: int):
-        return x
-
-    @classmethod
-    @dispatch
-    def g(cls: type, x: float):
-        return x
-
-    @dispatch
-    def _(self):
-        # Dummy method that needs to be added whenever no method has
-        # `@dispatch` as the outermost decorator.
-        pass
 ```
 
-If you don't add the dummy method whenever it is required, you will run into
-a `ResolutionError`:
-
 ```python
-from plum import dispatch
+>>> a = MyClass()
 
-class MyClass:
-    @staticmethod
-    @dispatch
-    def f(x: int):
-        return x
-```
+>>> a.name = "1"  # OK
 
+>>> a.name = 1    # Not OK
+NotFoundLookupError: For function `name` of `__main__.MyClass`, `(<__main__.MyClass object at 0x7f8cb8813eb0>, 1)` could not be resolved.
 ```
->>> MyClass.f(1)
-ResolutionError: Promise `Promise()` was not kept.
-```
+
 
 (forward-references)=
 ## Forward References
@@ -107,10 +67,11 @@ Imagine the following design:
 ```python
 from plum import dispatch
 
+
 class Real:
     @dispatch
     def __add__(self, other: Real):
-        pass # Do something here. 
+        ...
 ```
 
 If we try to run this, we get the following error:
@@ -128,7 +89,7 @@ NameError                                 Traceback (most recent call last)
       3 class Real:
       4     @dispatch
 ----> 5     def __add__(self, other: Real):
-      6         pass # Do something here.
+      6         ...
 
 NameError: name 'Real' is not defined
 ```
@@ -140,15 +101,9 @@ To circumvent this issue, you can use a forward reference:
 ```python
 from plum import dispatch
 
+
 class Real:
     @dispatch
     def __add__(self, other: "Real"):
-        pass # Do something here. 
+        ...
 ```
-
-**Note:**
-A forward reference `"A"` will resolve to the _next defined_ class `A` _in
-which dispatch is used_.
-This works fine for self references.
-In is recommended to only use forward references for self references.
-For more advanced use cases of forward references, you can use `plum.type.PromisedType`.
diff --git a/docs/comparison.md b/docs/comparison.md
index 79fc17af..2cd51d83 100644
--- a/docs/comparison.md
+++ b/docs/comparison.md
@@ -1,50 +1,108 @@
-# Comparison with `multipledispatch`
+# Comparison with Other Multiple Dispatch Implementations
 
-As an alternative to Plum, there is
-[multipledispatch](https://github.com/mrocklin/multipledispatch), which also is a
-great solution.
-Plum was developed to provide a slightly more featureful implementation of multiple
-dispatch.
+There are a few really great alternatives to Plum, most notably
+[multipledispatch](https://github.com/mrocklin/multipledispatch)
+and [multimethod](https://github.com/coady/multimethod).
+Below we describe what sets Plum apart from other implementations.
 
-**Like `multipledispatch`, Plum's caching mechanism is optimised to minimise overhead.**
+## Powered by [Beartype](https://github.com/beartype/beartype)
+
+The arguably most appealing aspect of Plum is that it is powered by
+[Beartype](https://github.com/beartype/beartype),
+which means that all the heavy lifting around correctly handling
+type hints from `typing` is taken care of.
+Getting this right is _very_ difficult,
+and Plum has absolutely no intention to mess around with this;
+Plum very gladly lets Beartype handle the difficult stuff.
+
+## Design Goal: Mimic Julia's Type System
+
+A central goal of Plum is to stay close to Julia's type system.
+This is not necessarily the case for other packages.
+
+For example,
 
 ```python
-from multipledispatch import dispatch as dispatch_md
-from plum import dispatch as dispatch_plum
+from multipledispatch import dispatch
 
-@dispatch_md(int)
-def f_md(x):
-   return x
 
+@dispatch((object, int), int)
+def f(x, y):
+    return "first"
+    
 
-@dispatch_plum
-def f_plum(x: int):
-   return x
+@dispatch(int, object)
+def f(x, y):
+    return "second"
+```
 
+```python
+>>> f(1, 1)
+'first'
+```
 
-def f_native(x):
-    return x
+Because the union of `object` and `int` is `object`, `f(1, 1)` should raise an
+ambiguity error!
+For example, compare with Julia:
+
+```julia
+julia> f(x::Union{Any, Int}, y::Int) = "first"
+f (generic function with 1 method)
+
+julia> f(x::Int, y::Any) = "second"
+f (generic function with 2 methods)
+
+julia> f(3, 3)
+ERROR: MethodError: f(::Int64, ::Int64) is ambiguous. Candidates:
+  f(x, y::Int64) in Main at REPL[1]:1
+  f(x::Int64, y) in Main at REPL[2]:1
 ```
 
+Plum handles this correctly.
+
 ```python
->>> f_md(1); f_plum(1);  # Run once to populate cache.
+from typing import Union
 
->>> %timeit f_native(1)
-82.4 ns ± 0.162 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
+from plum import dispatch
 
->>> %timeit f_md(1)
-845 ns ± 77.1 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
 
->>> %timeit f_plum(1)
-404 ns ± 2.83 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
+@dispatch
+def f(x: Union[object, int], y: int):
+    return "first"
+
+
+@dispatch
+def f(x: int, y: object):
+    return "second"
+```
+
+```python
+>>> f(1, 1)
+AmbiguousLookupError: For function "f", signature Signature(builtins.int, builtins.int) is ambiguous among the following:
+  Signature(builtins.object, builtins.int) (precedence: 0)
+  Signature(builtins.int, builtins.object) (precedence: 0)
+```
+
+Just to sanity check that things are indeed working correctly:
+
+```python
+>>> f(1.0, 1)
+'first'
+
+>>> f(1, 1.0)
+'second'
 ```
 
-*Plum synergises with OOP.*
+## Careful Synergy With OOP
+
+Plum takes OOP very seriously.
+
 Consider the following snippet:
 
 ```python
 from multipledispatch import dispatch
 
+
 class A:
     def f(self, x):
         return "fallback"
@@ -66,20 +124,19 @@ class B(A):
 NotImplementedError: Could not find signature for f: <str>
 ```
 
-This behaviour might be undesirable: since `B.f` isn't matched, we could want `A.f`
-to be tried next.
-Plum supports this:
+Similarly,
 
 ```python
-from plum import dispatch
+from multimethod import multimethod
+
 
 class A:
     def f(self, x):
         return "fallback"
-
+        
 
 class B(A):
-    @dispatch
+    @multimethod
     def f(self, x: int):
         return x
 ```
@@ -91,86 +148,126 @@ class B(A):
 1
 
 >>> b.f("1")
-'fallback'
+DispatchError: ('f: 0 methods found', (<class '__main__.B'>, <class 'str'>), [])
 ```
 
-[**Plum supports forward references.**](forward-references)
+This behaviour is undesirable.
+Since `B.f` isn't matched, according to OOP principles, `A.f` should be tried next.
+Plum correctly implements this:
 
-[**Plum supports parametric types from `typing`.**](parametric-types)
+```python
+from plum import dispatch
 
-**Plum attempts to stay close to Julia's type system.**
-For example, `multipledispatch`'s union type is not a true union type:
 
-```python
-from multipledispatch import dispatch
+class A:
+    def f(self, x):
+        return "fallback"
 
-@dispatch((object, int), int)
-def f(x, y):
-    return "first"
-    
 
-@dispatch(int, object)
-def f(x, y):
-    return "second"
+class B(A):
+    @dispatch
+    def f(self, x: int):
+        return x
 ```
 
 ```python
->>> f(1, 1)
-'first'
+>>> b = B()
+
+>>> b.f(1)
+1
+
+>>> b.f("1")
+'fallback'
 ```
 
-Because the union of `object` and `int` is `object`, `f(1, 1)` should raise an
-ambiguity error!
-For example, compare with Julia:
+(comparison-parametric)=
+## Parametric Classes
+Plum provides `@parametric`, which you can use to make your wildest parametric type
+dreams come true.
+For example, you can use it to dispatch on NumPy array shapes and sizes without
+making concessions:
 
-```julia
-julia> f(x::Union{Any, Int}, y::Int) = "first"
-f (generic function with 1 method)
+```python
+from plum import dispatch, parametric
+from typing import Any, Optional, Tuple, Union
 
-julia> f(x::Int, y::Any) = "second"
-f (generic function with 2 methods)
+import numpy as np
 
-julia> f(3, 3)
-ERROR: MethodError: f(::Int64, ::Int64) is ambiguous. Candidates:
-  f(x, y::Int64) in Main at REPL[1]:1
-  f(x::Int64, y) in Main at REPL[2]:1
-```
 
-Plum does provide a true union type:
+class NDArrayMeta(type):
+    def __instancecheck__(self, x):
+        if self.concrete:
+            shape, dtype = self.type_parameter
+        else:
+            shape, dtype = None, None
+        return (
+            isinstance(x, np.ndarray)
+            and (shape is None or x.shape == shape)
+            and (dtype is None or x.dtype == dtype)
+        )
 
-```python
-from typing import Union
 
-from plum import dispatch
+@parametric
+class NDArray(np.ndarray, metaclass=NDArrayMeta):
+    @classmethod
+    @dispatch
+    def __init_type_parameter__(
+        cls,
+        shape: Optional[Tuple[int, ...]],
+        dtype: Optional[Any],
+    ):
+        """Validate the type parameter."""
+        return shape, dtype
+
+    @classmethod
+    @dispatch
+    def __le_type_parameter__(
+        cls,
+        left: Tuple[Optional[Tuple[int, ...]], Optional[Any]],
+        right: Tuple[Optional[Tuple[int, ...]], Optional[Any]],
+    ):
+        """Define an order on type parameters. That is, check whether
+        `left <= right` or not."""
+        shape_left, dtype_left = left
+        shape_right, dtype_right = right
+        return (
+            (shape_right is None or shape_left == shape_right)
+            and (dtype_right is None or dtype_left == dtype_right)
+        )
+
 
 @dispatch
-def f(x: Union[object, int], y: int):
-    return "first"
+def f(x: np.ndarray):
+    print("Any NP array!")
 
 
 @dispatch
-def f(x: int, y: object):
-    return "second"
-```
+def f(x: NDArray[(2, 2), None]):
+    print("A 2x2 array!")
 
-```python
->>> f(1, 1)
-AmbiguousLookupError: For function "f", signature Signature(builtins.int, builtins.int) is ambiguous among the following:
-  Signature(builtins.object, builtins.int) (precedence: 0)
-  Signature(builtins.int, builtins.object) (precedence: 0)
-```
 
-Just to sanity check that things are indeed working correctly:
+@dispatch
+def f(x: NDArray[None, int]):
+    print("An int array!")
 
-```python
->>> f(1.0, 1)
-'first'
 
->>> f(1, 1.0)
-'second'
+@dispatch
+def f(x: NDArray[(2, 2), int]):
+    print("A 2x2 int array!")
+
+
+f(np.ones((3, 3)))       # Any NP array!
+f(np.ones((3, 3), int))  # An int array!
+f(np.ones((2, 2)))       # A 2x2 array!
+f(np.ones((2, 2), int))  # A 2x2 int array!
 ```
 
-[**Plum implements method precedence.**](method-precedence)
-Method precedence can be a very powerful tool to simplify more complicated designs.
+## Feature Rich
+Plum implements numerous features nowhere else to be found:
 
-[**Plum provides generic `convert` and `promote` functions.**](conversion-promotion)
+* [method precedence](method-precedence),
+    a powerful tool to simplify more complicated designs;
+* [generic `convert` and `promote` functions](conversion-promotion);
+* [specialised](moduletype) [types](promisedtype) for niche use cases;
+* [union aliases](union-aliases); and
+* more!
diff --git a/docs/conversion_promotion.md b/docs/conversion_promotion.md
index 3d3b4154..1135357a 100644
--- a/docs/conversion_promotion.md
+++ b/docs/conversion_promotion.md
@@ -1,9 +1,48 @@
 (conversion-promotion)=
 # Conversion and Promotion
 
-## Conversion
+## Return Types
+
+When a return type is not matched, Plum will attempt to convert the result to the 
+right type.
+
+```python
+from typing import Union
+
+from plum import dispatch, add_conversion_method
+
+
+@dispatch
+def f(x: Union[int, str]) -> int:
+    return x
+```
+
+```python
+>>> f(1)
+1
+
+>>> f("1")
+TypeError: Cannot convert `1` to `int`.
+```
+
+Plum will usually not know how to perform this conversion.
+You can tell Plum how the conversion should be done with `add_conversion_method`:
+
+```python
+>>> add_conversion_method(type_from=str, type_to=int, f=int)
+
+>>> f("1")
+1
+```
+
+## Conversion With `convert`
+
+The function `convert` can be used to convert objects of one type to another.
+This is also what is used to perform the conversion in the case the return type
+does not match.
+
+Example:
 
-The function `convert` can be used to convert objects of one type to another:
 
 ```python
 from numbers import Number
@@ -22,7 +61,7 @@ class Rational:
 0.5
 
 >>> convert(Rational(1, 2), Number)
-TypeError: Cannot convert a "__main__.Rational" to a "numbers.Number".
+TypeError: Cannot convert `<__main__.Rational object at 0x7f88f8369310>` to `numbers.Number`.
 ```
 
 The `TypeError` indicates that `convert` does not know how to convert a
@@ -45,14 +84,14 @@ def rational_to_number(q):
 0.5
 ```
 
-Instead of the decorator `conversion_method`, one can also use
+As above, instead of the decorator `conversion_method`, one can also use
 `add_conversion_method`:
 
 
 ```python
-from plum import add_conversion_method
+>>> from plum import add_conversion_method
 
-add_conversion_method(type_from, type_to, conversion_function)
+>>> add_conversion_method(type_from, type_to, conversion_function)
 ```
 
 ## Promotion
@@ -62,6 +101,7 @@ The function `promote` can be used to promote objects to a common type:
 ```python
 from plum import dispatch, promote, add_promotion_rule, add_conversion_method
 
+
 @dispatch
 def add(x, y):
     return add(*promote(x, y))
@@ -85,12 +125,16 @@ def add(x: float, y: float):
 3.0
 
 >>> add(1, 2.0)
-TypeError: No promotion rule for "builtins.int" and "builtins.float".
+TypeError: No promotion rule for `int` and `float`.
+```
+
+You can add promotion rules with `add_promotion_rule`:
 
+```python
 >>> add_promotion_rule(int, float, float)
 
 >>> add(1, 2.0)
-TypeError: Cannot convert a "builtins.int" to a "builtins.float".
+TypeError: Cannot convert `1` to `float`.
 
 >>> add_conversion_method(type_from=int, type_to=float, f=float)
 
diff --git a/docs/dispatch.md b/docs/dispatch.md
new file mode 100644
index 00000000..b2f8ce26
--- /dev/null
+++ b/docs/dispatch.md
@@ -0,0 +1,140 @@
+# Ways to Dispatch
+
+You can add new methods to functions in a variety of ways.
+We list them all below.
+
+## Abstract Function Definitions
+
+A function can be abstractly defined using `dispatch.abstract`.
+When a function is abstractly defined, the function is created, but no methods
+are defined.
+
+Example usage:
+
+```python
+from numbers import Number
+
+from plum import dispatch
+
+
+@dispatch.abstract
+def f(x: Number, y: Number):
+    """Multiply two numbers."""
+
+
+@dispatch
+def f(x: float, y: float):
+    """Multiply two floats."""
+    return x * y
+
+
+@dispatch
+def f(x: int, y: int):
+    """Multiply two ints."""
+    return x * y
+```
+
+Then
+
+```python
+>>> f.methods  # No implementation for `Number`s!
+[Signature(float, float, implementation=<function f at 0x7f8c4015e9d0>),
+ Signature(int, int, implementation=<function f at 0x7f8c4015ea60>)]
+```
+
+and calling `help(f)` produces
+
+```
+Help on Function in module __main__:
+
+f(x: numbers.Number, y: numbers.Number)
+    Multiply two numbers.
+
+    This function has further implementations documented below.
+
+    f(x: float, y: float)
+        Multiply two floats.
+
+    f(x: int, y: int)
+        Multiply two ints.
+```
+
+## Extend a Function From Another Package
+
+`Function.dispatch` can be used to extend a particular function from an external
+package:
+
+```python
+from package import f
+
+
+@f.dispatch
+def f(x: int):
+    return "new behaviour"
+```
+
+```python
+>>> f(1.0)
+'old behaviour'
+
+>>> f(1)
+'new behaviour'
+```
+
+## Directly Invoke a Method
+
+`Function.invoke` can be used to invoke a method given types of the arguments:
+
+```python
+from plum import dispatch
+
+
+@dispatch
+def f(x: int):
+    return "int"
+    
+    
+@dispatch
+def f(x: str):
+    return "str"
+```
+
+```python
+>>> f(1)
+'int'
+
+>>> f("1")
+'str'
+
+>>> f.invoke(int)("1")
+'int'
+
+>>> f.invoke(str)(1)
+'str'
+```
+
+## Define Multiple Methods at Once
+
+`Dispatcher.multi` can be used to implement multiple methods at once:
+
+```python
+from typing import Union
+
+from plum import dispatch
+
+
+@dispatch.multi((int, int), (float, float))
+def add(x: Union[int, float], y: Union[int, float]):
+    return x + y
+```
+
+```python
+>>> add(1, 1)
+2
+
+>>> add(1.0, 1.0)
+2.0
+
+>>> add(1, 1.0)
+NotFoundLookupError: For function "add", signature Signature(builtins.int, builtins.float) could not be resolved.
+```
diff --git a/docs/intro.md b/docs/intro.md
index 0f484219..ddd1fd72 100644
--- a/docs/intro.md
+++ b/docs/intro.md
@@ -1,3 +1,4 @@
 # Plum
 
 Welcome to the package!
+Please click next to explore basic usage.
diff --git a/docs/keyword_arguments.md b/docs/keyword_arguments.md
index 0dac897f..522e891b 100644
--- a/docs/keyword_arguments.md
+++ b/docs/keyword_arguments.md
@@ -1,12 +1,46 @@
-# Keyword Arguments and Default Values
+# Keyword Arguments
+
+````{important}
+Before anything else, one thing must be stated very clearly:
+dispatch, as implemented by Plum, is based on the _positional_ arguments to a function.
+Keyword arguments are certainly supported, but they are not used in the decision making
+for which method to call.
+In particular, this means that _positional arguments without a default value must
+always be given as positional arguments_!
+
+Example:
+
+```python
+from plum import dispatch
+
+
+@dispatch
+def f(x: int):
+    return x
+```
+
+```python
+>>> f(1)    # OK
+1
+
+>>> f(x=1)  # Not OK
+NotFoundLookupError: For function `f`, `()` could not be resolved.
+```
+
+See [below](why) for why this is the case.
+````
+
+## Default Arguments
 
 Default arguments can be used.
 The type annotation must match the default value otherwise an error is thrown.
-As the example below illustrates, different default values can be used for different methods:
+As the example below illustrates, different default values can be used for
+different methods:
 
 ```python
 from plum import dispatch
 
+
 @dispatch
 def f(x: int, y: int = 3):
     return y
@@ -39,6 +73,7 @@ Example:
 ```python
 from plum import dispatch
 
+
 @dispatch
 def f(x, *, option="a"):
     return option
@@ -52,5 +87,55 @@ def f(x, *, option="a"):
 'b'
 
 >>> f(1, "b")  # This will not work, because `option` must be given as a keyword.
-NotFoundLookupError: For function "f", signature Signature(builtins.int, builtins.str) could not be resolved.
+NotFoundLookupError: For function `f`, `(1, 'b')` could not be resolved.
 ```
+
+(why)=
+## Why Doesn't Dispatch Fully Support Keyword Arguments?
+
+That's a very good question, and unfortunately I don't have a strong answer.
+The main reason is that it presents some new challenges.
+Is not entirely clear how
+this would work.
+For example, consider the following scenario:
+
+```python
+@dispatch
+def f(x: int, y: float):
+    ... # Method 1
+
+
+@dispatch
+def f(y: float, x: int):
+   ... # Method 2
+```
+
+Then calling `f(1, 1.0)` would call method 1 and calling `f(1.0, 1)` would call method 2.
+
+What might be confusing is what `f(x=1.0, y=1)` would do.
+For method 1, the call would be equivalent to `f(1.0, 1)`, which wouldn't match.
+For method 2, the call would be equivalent to `f(1, 1.0)`, which also wouldn't match.
+The strange thing is that the arguments are switched around between the two methods
+because the names don't line up.
+
+Perhaps this a poor example, but what's going wrong is the following.
+In Python, there are two ways to say that a particular argument should have a value:
+
+1. by _position_, or
+2. by _name_.
+
+Once you name things, the position becomes irrelevant.
+And once you position an argument, the name of the argument as written in the function
+doesn't matter.
+Hence, in some sense, it's an either-or situation where you have to choose to
+designate arguments by position _or_ designate arguments by name.
+
+Currently, the whole multiple dispatch system has been designed around
+arguments-designated-by-position (and type), which is, as argued above,
+somehow incongruent with designating arguments by name.
+Why?
+Once you name things, the position becomes irrelevant;
+and once you position something, the name of the argument becomes irrelevant.
+
+Therefore, if we were to support naming arguments,
+how precisely this would work would have to be spelled out in detail.
diff --git a/docs/parametric.md b/docs/parametric.md
new file mode 100644
index 00000000..45c923bb
--- /dev/null
+++ b/docs/parametric.md
@@ -0,0 +1,276 @@
+(parametric)=
+# Parametric Classes
+
+## Construction
+
+The decorator `@parametric` can be used to create parametric classes:
+
+```python
+from plum import dispatch, parametric
+
+
+@parametric
+class A:
+    def __init__(self, x):
+        self.x = x
+```
+
+You can create a version of `A` with a type parameter using `__getindex__`:
+
+```python
+>>> A
+__main__.A
+
+>>> A[int]
+__main__.A[builtins.int]
+```
+
+These types `A[int]` can be regarded as subclasses of `A`:
+
+```python
+>>> issubclass(A[int], A)
+True
+```
+
+We call `A[int]` a _concrete_ parametric type.
+It is concrete because `A[int]` can be instantiated into an object:
+
+```python
+>>> A[int](1)
+<__main__.A[int] at 0x7feb403d4d90>
+```
+
+When you don't instantiate a concrete parametric type, but try to instantiate `A`
+directly, the type parameter is automatically inferred from the argument:
+
+```python
+>>> A(1)
+<__main__.A[int] at 0x7feb6060e370>
+
+>>> A("1")
+<__main__.A[str] at 0x7feb801409d0>
+
+>>> A(1.0)
+ <__main__.A[float] at 0x7feb5034be50>
+```
+
+You can use parametric types to perform dispatch:
+
+```python
+@dispatch
+def f(x: A):
+    return "Just some A"
+    
+    
+@dispatch
+def f(x: A[int]):
+    return "A has an integer!"
+    
+    
+@dispatch
+def f(x: A[float]):
+    return "A has a float!"
+```
+
+Note that parametric types are *covariant*, which means
+that `A[T1]` is a subtype of `A[T2]` whenever `T1` is a subtype of `T2`:
+
+```python
+>>> from numbers import Number
+
+>>> issubclass(A[int], A[Number])
+True
+```
+
+For a concrete parametric type or an instance of a concrete parametric type, you
+can extract the type parameter with `type_parameter`:
+
+```python
+>>> from plum import type_parameter
+
+>>> type_parameter(A[int])
+int
+
+>>> type_parameter(A[int]())
+int
+```
+
+## Customisation
+
+You can customise precisely how type parameters are inferred and instantiated
+by overriding certain `@classmethod`s:
+
+| Class Method | What does it do? |
+| - | - |
+| `__init_type_parameter__` | Initialise the type parameter. |
+| `__infer_type_parameter__` | Infer the type parameter from the arguments. |
+| `__le_type_parameter__` | For a given `left` and `right`, check whether `Type[left]` is a subtype of `Type[Right]`. |
+
+How these methods work is best described with an example.
+See also `help(parametric)` for information.
+
+```python
+from plum import dispatch, parametric, type_parameter
+
+
+@parametric
+class NTuple:
+    def __init__(self, *args):
+        # Check that the arguments satisfy the type specification.
+        n, t = type_parameter(self)
+        if len(args) != n or any(not isinstance(arg, t) for arg in args):
+            raise ValueError("Incorrect arguments!")
+
+        self.args = args
+
+    @classmethod
+    @dispatch
+    def __init_type_parameter__(self, n: int, t: type):
+        """Check whether the type parameters are valid."""
+        # In this case, we use `@dispatch` to check the validity of the type parameter.
+        return n, t
+
+    @classmethod
+    def __infer_type_parameter__(self, *args):
+        """Inter the type parameter from the arguments."""
+        n = len(args)
+        # For simplicity, take the type of the first argument! We could do something
+        # more refined here.
+        t = type(args[0])  
+        return n, t
+
+    @classmethod
+    def __le_type_parameter__(self, left, right):
+        """Is `NTuple[left]` a subtype of `NTuple[right]`?"""
+        n_left, t_left = left
+        n_right, t_right = right
+        return n_left == n_right and issubclass(t_left, t_right)
+```
+
+`NTuple` automatically infers an appropriate type parameter with
+`__infer_type_parameter__`:
+
+```python
+>>> NTuple(10, 11, 12)
+<__main__.NTuple[3, int] at 0x7fa9d84ccd00>
+```
+
+It also validates any given type parameter using `__init_type_parameter__`:
+
+```python
+>>> NTuple[2, int]     # OK
+__main__.NTuple[2, int]
+
+>>> NTuple[2, "int"]   # Not OK
+NotFoundLookupError: For function `__init_type_parameter__` of `__main__.NTuple`, `(<class '__main__.NTuple'>, 2, 'int')` could not be resolved.
+
+>>> NTuple[None, int]  # Also not OK
+NotFoundLookupError: For function `__init_type_parameter__` of `__main__.NTuple`, `(<class '__main__.NTuple'>, None, <class 'int'>)` could not be resolved.
+```
+
+Given a valid type parameter, it validates the arguments:
+
+```python
+>>> NTuple[2, int](10, 11)
+<__main__.NTuple[2, int] at 0x7fa9780a7d30>
+
+>>> NTuple[2, int](10, 11, 12) 
+ValueError: Incorrect arguments!
+
+>>> NTuple[2, int](10, "11")
+ValueError: Incorrect arguments!
+```
+
+Finally, it implements the desired covariance:
+
+```python
+>>> from numbers import Number
+
+>>> issubclass(NTuple[2, int], NTuple[2, Number])
+True
+
+>>> issubclass(NTuple[2, int], NTuple[2, float]) 
+False
+
+>>> issubclass(NTuple[2, int], NTuple[3, int]) 
+False
+```
+
+## `Kind`
+Plum provides a convience parametric class `Kind` which you can use to quickly
+make a parametric wrapper object:
+
+```python
+>>> from plum import Kind
+
+>>> this = Kind["This"](1)
+
+>>> this
+<plum.parametric.Kind['This'] at 0x7fd6b861b520>
+
+>>> this.get()
+1
+
+>>> that = Kind["That"]("some", "args", "here")
+
+>>> that
+<plum.parametric.Kind['That'] at 0x7fd6b00d6850>
+
+>>> that.get()
+('some', 'args', 'here')
+```
+
+For example, you can use this in the following way:
+
+```python
+from plum import dispatch
+
+
+@dispatch
+def i_expect_this(this: Kind["this"]):
+    arg = this.get()
+    ...
+
+
+@dispatch
+def i_expect_that(that: Kind["that"]):
+    arg0, arg1, arg2 = that.get()
+    ...
+```
+
+## `Val`
+
+Plum provides a parametric class `Val` which you can use to bring information
+from the object domain to the type domain.
+
+Example:
+
+```python
+from plum import dispatch, Val
+
+
+@dispatch
+def algorithm(setting: Val["fast"], x):
+    return "Running fast!"
+
+
+@dispatch
+def algorithm(setting: Val["slow"], x):
+    return "Running slowly..."
+```
+
+```python
+>>> algorithm(Val("fast"), 1)
+'Running fast!'
+
+>>> algorithm(Val("slow"), 1)
+'Running slowly...'
+```
+
+`typing.Literal` fills a very similar purpose.
+You can use `Val` if `typing.Literal` is not available, e.g. on Python 3.7.
+
+
+## Example: `NDArray`
+See [here](comparison-parametric).
+
diff --git a/docs/precedence.md b/docs/precedence.md
new file mode 100644
index 00000000..b5d3f3fc
--- /dev/null
+++ b/docs/precedence.md
@@ -0,0 +1,66 @@
+(method-precedence)=
+# Method Precedence
+
+The keyword argument `precedence` can be set to an integer value to specify
+precedence levels of methods, which are used to break ambiguity.
+Method precendence is a powerful tool that can be used to simplify more complicated designs.
+
+Example:
+
+```python
+from plum import dispatch
+
+
+class Element:
+    pass
+
+
+class ZeroElement(Element):
+    pass
+
+
+class SpecialisedElement(Element):
+    pass
+
+
+@dispatch
+def mul_no_precedence(a: ZeroElement, b: Element):
+    return "zero"
+
+
+@dispatch
+def mul_no_precedence(a: Element, b: SpecialisedElement):
+    return "specialised operation"
+    
+
+@dispatch(precedence=1)
+def mul(a: ZeroElement, b: Element):
+    return "zero"
+
+
+@dispatch
+def mul(a: Element, b: SpecialisedElement):
+    return "specialised operation"
+```
+
+```python
+>>> zero = ZeroElement()
+
+>>> specialised_element = SpecialisedElement()
+
+>>> element = Element()
+
+>>> mul(zero, element)
+'zero'
+
+>>> mul(element, specialised_element)
+'specialised operation'
+
+>>> mul_no_precedence(zero, specialised_element)
+AmbiguousLookupError: For function `mul_no_precedence`, `(<__main__.ZeroElement object at 0x7feb80140d00>, <__main__.SpecialisedElement object at 0x7feb605abfd0>)` is ambiguous among the following:
+  Signature(__main__.ZeroElement, __main__.Element, implementation=<function mul_no_precedence at 0x7feb6066a700>) (precedence: 0)
+  Signature(__main__.Element, __main__.SpecialisedElement, implementation=<function mul_no_precedence at 0x7feb3000f670>) (precedence: 0)
+
+>>> mul(zero, specialised_element)
+'zero'
+```
diff --git a/docs/scope.md b/docs/scope.md
index 84d0d901..6c820b66 100644
--- a/docs/scope.md
+++ b/docs/scope.md
@@ -2,28 +2,30 @@
 
 Consider the following package design.
 
-**package/\_\_init\_\_.py**
+`package/__init__.py`:
 
 ```python
 import a
 import b
 ```
 
-**package/a.py**
+`package/a.py`:
 
 ```python
 from plum import dispatch
 
+
 @dispatch
 def f(x: int):
    return "int"
 ```
 
-**package/b.py**
+`package/b.py`:
 
 ```python
 from plum import dispatch
 
+
 @dispatch
 def f(x: float):
    return "float"
@@ -38,18 +40,34 @@ In a design like this, the methods for `f` recorded by `dispatch` are _global_:
 'float'
 ```
 
-This could be what you want, but it can also be undesirable, because it means that
-someone could accidentally overwrite your methods.
-To keep your functions private, you can create new dispatchers:
+This could be what you want, but it could also be undesirable.
+In addition, by using the global `@dispatch`, someone could accidentally overwrite
+your methods.
+
+`other_package/some_file.py`:
+```python
+from plum import dispatch
+
+
+# If another package happens to also define `f` for an `int`, then that overwrites
+# your method!
+
+@dispatch
+def f(x: int):
+    ...
+```
+
+To prevent this from happening and to keep your functions private, you can create new
+dispatchers.
 
-**package/\_\_init\_\_.py**
+`package/__init__.py`:
 
 ```python
 import a
 import b
 ```
 
-**package/a.py**
+`package/a.py`:
 
 ```python
 from plum import Dispatcher
@@ -62,7 +80,7 @@ def f(x: int):
    return "int"
 ```
 
-**package/b.py**
+`package/b.py`:
 
 ```python
 from plum import Dispatcher
@@ -83,12 +101,12 @@ def f(x: float):
 'int'
 
 >>> f(1.0)
-NotFoundLookupError: For function "f", signature Signature(builtins.float) could not be resolved.
+NotFoundLookupError: For function `f`, `(1.0,)` could not be resolved.
 
 >>> from package.b import f
 
 >>> f(1)
-NotFoundLookupError: For function "f", signature Signature(builtins.int) could not be resolved.
+NotFoundLookupError: For function `f`, `(1,)` could not be resolved.
 
 >>> f(1.0)
 'float'
diff --git a/docs/type_system.md b/docs/type_system.md
deleted file mode 100644
index 7914e52c..00000000
--- a/docs/type_system.md
+++ /dev/null
@@ -1,148 +0,0 @@
-# Type System
-
-## Union Types
-
-`typing.Union` can be used to instantiate union types:
-
-```python
-from typing import Union
-
-from plum import dispatch
-
-@dispatch
-def f(x):
-    return "fallback"
-
-
-@dispatch
-def f(x: Union[int, str]):
-    return "int or str"
-```
-
-```python
->>> f(1)
-'int or str'
-
->>> f("1")
-'int or str'
-
->>> f(1.0)
-'fallback'
-```
-
-(parametric-types)=
-## Parametric Types
-
-The parametric types `typing.Tuple`, `typing.List`, `typing.Dict`,
-`typing.Iterable`, and `typing.Sequence` can be used to dispatch on respectively
-tuples, lists,  dictionaries, iterables, and sequence with particular types of
-elements.
-Importantly, the type system is *covariant*, as opposed to Julia's type
-system, which is *invariant*.
-
-Example involving some parametric types:
-
-```python
-from typing import Union, Tuple, List, Dict
-
-from plum import dispatch
-
-@dispatch
-def f(x: Union[tuple, list]):
-    return "tuple or list"
-    
-    
-@dispatch
-def f(x: Tuple[int, int]):
-    return "tuple of two ints"
-    
-    
-@dispatch
-def f(x: List[int]):
-    return "list of int"
-
-
-@dispatch
-def f(x: Dict[int, str]):
-   return "dict of int to str"
-```
-
-```python
->>> f([1, 2])
-'list of int'
-
->>> f([1, "2"])
-'tuple or list'
-
->>> f((1, 2))
-'tuple of two ints'
-
->>> f((1, 2, 3))
-'tuple or list'
-
->>> f((1, "2"))
-'tuple or list'
-
->>> f({1: "2"})
-'dict of int to str'
-```
-
-**Note:** Although parametric types are supported, parametric types do incur a
-significant  performance hit, because the type of every element in a list or tuple
-must be checked.
-It is recommended to use parametric types only where absolutely necessary.
-
-## Variable Arguments
-
-A variable number of arguments can be used without any problem.
-
-```python
-from plum import dispatch
-
-@dispatch
-def f(x: int):
-    return "single argument"
-    
-
-@dispatch
-def f(x: int, *xs: int):
-    return "multiple arguments"
-```
-
-```python
->>> f(1)
-'single argument'
-
->>> f(1, 2)
-'multiple arguments'
-
->>> f(1, 2, 3)
-'multiple arguments'
-```
-
-## Return Types
-
-Return types can be used without any problem.
-
-```python
-from typing import Union
-
-from plum import dispatch, add_conversion_method
-
-@dispatch
-def f(x: Union[int, str]) -> int:
-    return x
-```
-
-```python
->>> f(1)
-1
-
->>> f("1")
-TypeError: Cannot convert a "builtins.str" to a "builtins.int".
-
->>> add_conversion_method(type_from=str, type_to=int, f=int)
-
->>> f("1")
-1
-```
diff --git a/docs/types.md b/docs/types.md
new file mode 100644
index 00000000..6b55ee68
--- /dev/null
+++ b/docs/types.md
@@ -0,0 +1,235 @@
+# Types
+
+Types of arguments of arguments and return types can be specified using type hints as
+usual.
+You can use anything from `typing`.
+Under the hood, Plum uses [Beartype](https://github.com/beartype/beartype), which means
+that all types and type hints supported by Beartype are also supported by Plum.
+Here are a few examples:
+
+```python
+from typing import Union, Optional, List, Dict
+
+from plum import dispatch
+
+
+@dispatch
+def f(x) -> str:
+    return "fallback"
+
+
+@dispatch
+def f(x: int, *xs: int) -> str:
+    return "one or more ints"
+
+
+@dispatch
+def f(x: Union[int, str]) -> str:
+    return "int or str"
+
+
+@dispatch
+def f(x: list) -> str:
+    return "list"
+
+
+@dispatch
+def f(x: List[int]) -> str:
+    return "list of int"
+
+
+@dispatch
+def f(x: Optional[dict]) -> Optional[str]:
+    return "dict or None"
+
+
+@dispatch
+def f(x: Dict[int, str]) -> str:
+   return "dict of int to str"
+```
+
+**Note:**
+Although parametric types such as `List[int]` and `Dict[int, str]` are fully
+supported, they do incur a performance penalty.
+For optimal performance, is recommended to use parametric types only where necessary.
+`Union` and `Optional` do not incur a performance penalty.
+
+The type system is *covariant*, as opposed to Julia's type
+system, which is *invariant*.
+For example, this means that `List[T1]` is a subtype of `List[T2]` whenever
+`T1` is a subtype of `T2`.
+
+## Performance and Faithful Types
+
+Plum achieves performance by caching the dispatch process.
+Unfortunately, efficient caching is not always possible.
+Efficient caching is possible for so-called _faithful_ types.
+
+````{admonition} Definition: faithful type
+A type `t` is _faithful_ if, for all `x`, the following is true:
+```python
+isinstance(x, t) == issubclass(type(x), t)
+```
+````
+
+For example, `int` is faithful, since `type(1) == int`;
+but `Literal[1]` is not faithful, since `issubclass(int, Literal[1])` is false.
+
+Methods which have signatures that depend only on faithful types will 
+be performant.
+On the other hand, methods which have one or more signatures with one or more
+unfaithful types cannot use caching and will therefore be less performant.
+
+Example:
+
+```python
+from typing import Literal
+
+from plum import dispatch
+
+
+@dispatch
+def add_5_faithful(x: int):
+    return x + 5
+
+
+@dispatch
+def add_5_unfaithful(x: Literal[1]):
+    return x + 5
+```
+
+```python
+>>> %timeit add_5_faithful(1)
+585 ns ± 6.2 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
+
+>>> %timeit add_5_unfaithful(1)
+6.24 µs ± 68.9 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)
+```
+
+Plum implements `is_faithful`, which is a function that attempts to establish whether
+a type is faithful or not:
+
+```python
+>>> from plum import is_faithful
+
+>>> is_faithful(int)
+True
+
+>>> is_faithful(Literal[1])
+False
+```
+
+If you implement, e.g., a type with a custom `__instancecheck__`, then `is_faithful`
+will detect this and conservatively say that your type is not faithful.
+You can tell Plum whether your type is faithful or not by setting `__faithful__`:
+
+```python
+...
+
+class MyClass(metaclass=MyMeta):
+    __faithful__ = True   # Yes, `MyClass` is faithful!
+
+    ...
+```
+
+(moduletype)=
+## `ModuleType`
+
+A niche use case is that you might want to depend on types from packages you have not
+yet imported.
+This can be useful if these packages either bring a lot of dependencies or are slow to
+load.
+This is possible with `ModuleType`.
+
+```{important}
+After the dependency is imported, you must clear all cache using `clear_all_cache`!
+If you do not, due to existing caches, dispatch may behave erroneously.
+```
+
+Example:
+
+```python
+from plum import dispatch, clear_all_cache, ModuleType
+
+EagerTensor = ModuleType("tensorflow.python.framework.ops", "EagerTensor")
+
+
+@dispatch
+def f(x: EagerTensor):
+    return "An eager TF tensor!"
+```
+
+```python
+>>> f(1)
+NotFoundLookupError: For function `f`, `(1,)` could not be resolved.
+
+>>> f.methods
+[Signature(plum.type.ModuleType[tensorflow.python.framework.ops.EagerTensor], implementation=<function f at 0x7fc2a89a5310>)]
+
+>>> import tensorflow as tf  # Very slow...
+
+>>> clear_all_cache()  # Clear dispatch cache.
+
+>>> f(tf.ones(5))
+'An eager TF tensor!'
+```
+
+The object `EagerTensor` is a `type`.
+You can resolve it to what it points to with `resolve_type_hint`:
+
+```python
+>>> EagerTensor
+plum.type.ModuleType[tensorflow.python.framework.ops.EagerTensor]
+
+>>> from plum import resolve_type_hint
+
+>>> resolve_type_hint(EagerTensor)
+tensorflow.python.framework.ops.EagerTensor
+```
+
+(promisedtype)=
+## `PromisedType`
+
+Another problem that can occur is that you want to depend on a type from your package,
+but you just cannot yet access it because of circular imports.
+In this case, you use `PromisedType` to create a proxy type and then deliver the
+dependency when it is available.
+
+```{important}
+You *must* deliver the dependency before the proxy type is used!
+That is, you cannot use the function that uses the proxy type as a type hint
+before the dependency is delivered.
+```
+
+```python
+from plum import dispatch, clear_all_cache, PromisedType
+
+ProxyInt = PromisedType("SpecialInt")  # Proxy for `int`
+
+
+@dispatch
+def f(x: ProxyInt):
+    return "An integer!"
+
+# Deliver the type that `ProxyInt` should point to. Do this before `f` is first used!
+ProxyInt.deliver(int)  
+```
+
+```python
+>>> f(1)
+'An integer!' 
+```
+
+Like for `PromisedType`,
+the object `ProxyInt` is a `type`.
+You can resolve it to what it points to with `resolve_type_hint`:
+
+```python
+>>> ProxyInt
+plum.type.PromisedType[SpecialInt]
+
+>>> from plum import resolve_type_hint
+
+>>> resolve_type_hint(ProxyInt)
+int
+```
diff --git a/docs/union_aliases.md b/docs/union_aliases.md
new file mode 100644
index 00000000..65ab05ce
--- /dev/null
+++ b/docs/union_aliases.md
@@ -0,0 +1,98 @@
+(union-aliases)=
+# Union Aliases
+
+To understand what union aliases are and what problem they solve, consider the
+following example.
+Suppose that we would want to implement a special addition function, and we would
+want to implement it for all NumPy scalar types:
+
+```python
+import numpy as np
+
+from typing import Union
+from plum import dispatch
+
+
+scalar_types = sum(np.sctypes.values(), [])  # All NumPy scalar types
+Scalar = Union[tuple(scalar_types)]  # Union of all NumPy scalar types
+
+
+@dispatch
+def add(x: Scalar, y: Scalar):
+    return x + y
+```
+
+This looks all fine, until you look at the documentation.
+In particular, `help(add)` prints
+
+
+```
+Help on Function in module __main__:
+
+add(x: Union[numpy.int8, numpy.int16, numpy.int32, numpy.int64, numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64, numpy.float16, numpy.float32, numpy.float64, numpy.float128, numpy.complex64, numpy.complex128, numpy.complex256, bool, object, bytes, str, numpy.void], y: Union[numpy.int8, numpy.int16, numpy.int32, numpy.int64, numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64, numpy.float16, numpy.float32, numpy.float64, numpy.float128, numpy.complex64, numpy.complex128, numpy.complex256, bool, object, bytes, str, numpy.void])
+```
+
+While the documentation is accurate, it is not at all helpful to expand the union in
+its many elements, because it obscures the key message: `add(x, y)` is implemented
+for all _scalars_.
+A better option would be to print `add(x: Scalar, y: Scalar)`.
+This is precisely what union aliases do:
+by aliasing a union, you change the way it is displayed.
+Union aliases must be activated explicitly, because the feature
+monkeypatches `Union.__str__` and `Union.__repr__`.
+
+```python
+>>> from plum import activate_union_aliases, set_union_alias
+
+>>> activate_union_aliases()
+
+>>> set_union_alias(Scalar, alias="Scalar")
+```
+
+After this, `help(add)` now prints the following:
+
+```python
+Help on Function in module __main__:
+
+add(x: Union[Scalar], y: Union[Scalar])
+```
+
+Hurray!
+Note that the documentation prints `Union[Scalar]` rather than just `Scalar`.
+This is intentional: it is to prevent breaking code that depends on how unions
+print.
+For example, printing just `Scalar` would omit the type parameter(s). 
+
+Let's see with a few more examples how this works:
+
+```python
+>>> Scalar
+typing.Union[Scalar]
+
+>>> Union[tuple(scalar_types)]
+typing.Union[Scalar]
+
+>>> Union[tuple(scalar_types) + (tuple,)]       # Scalar or tuple
+typing.Union[Scalar, tuple]
+
+>>> Union[tuple(scalar_types) + (tuple, list)]  # Scalar or tuple or list
+typing.Union[Scalar, tuple, list]
+```
+
+If we don't include all of `scalar_types`, we won't see `Scalar`, as desired:
+
+```python
+>>> Union[tuple(scalar_types[:-1])]
+typing.Union[numpy.int8, numpy.int16, numpy.int32, numpy.int64, numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64, numpy.float16, numpy.float32, numpy.float64, numpy.float128, numpy.complex64, numpy.complex128, numpy.complex256, bool, object, bytes, str]
+```
+
+You can deactivate union aliases with `deactivate_union_aliases`:
+
+```python
+>>> from plum import deactivate_union_aliases
+
+>>> deactivate_union_aliases()
+
+>>> Scalar
+typing.Union[numpy.int8, numpy.int16, numpy.int32, numpy.int64, numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64, numpy.float16, numpy.float32, numpy.float64, numpy.float128, numpy.complex64, numpy.complex128, numpy.complex256, bool, object, bytes, str, numpy.void]
+```
diff --git a/plum/__init__.py b/plum/__init__.py
index 49eee502..6335cfa8 100644
--- a/plum/__init__.py
+++ b/plum/__init__.py
@@ -1,11 +1,59 @@
-from ._version import version as __version__  # noqa: F401
+# Plum previously exported a number of types. As of recently, the user can use the
+# versions from `typing`. To not break backward compatibility, we still export these
+# types.
+from functools import partial
+from typing import Dict, List, Tuple, Union  # noqa: F401
 
-from .dispatcher import *
-from .function import *
-from .parametric import *
-from .promotion import *
-from .resolvable import *
-from .signature import *
-from .type import *
+from beartype import BeartypeConf as _BeartypeConf
+from beartype import BeartypeStrategy as _BeartypeStrategy
+from beartype.door import TypeHint as _TypeHint
+from beartype.door import is_bearable as _is_bearable
 
-from . import autoreload
+from .alias import *  # noqa: F401, F403
+from .autoreload import *  # noqa: F401, F403
+from .dispatcher import *  # noqa: F401, F403
+from .function import *  # noqa: F401, F403
+from .parametric import *  # noqa: F401, F403
+from .promotion import *  # noqa: F401, F403
+from .resolver import *  # noqa: F401, F403
+from .signature import *  # noqa: F401, F403
+from .type import *  # noqa: F401, F403
+from .type import resolve_type_hint
+from .util import *  # noqa: F401, F403
+
+# Ensure that type checking is always entirely correct! The default O(1) strategy
+# is super fast, but might yield unpredictable dispatch behaviour. The O(n) strategy
+# actually is not yet available, but we can already opt in to use it.
+_is_bearable = partial(_is_bearable, conf=_BeartypeConf(strategy=_BeartypeStrategy.On))
+
+
+def isinstance(instance, c):
+    """Check if `instance` is of type or type hint `c`.
+
+    This is a drop-in replace for the built-in :func:`ininstance` which supports type
+    hints.
+
+    Args:
+        instance (object): Instance.
+        c (type or object): Type or type hint.
+
+    Returns:
+        bool: Whether `instance` is of type or type hint `c`.
+    """
+    return _is_bearable(instance, resolve_type_hint(c))
+
+
+def issubclass(c1, c2):
+    """Check if `c1` is a subclass or sub-type hint of `c2`.
+
+    This is a drop-in replace for the built-in :func:`issubclass` which supports type
+    hints.
+
+    Args:
+        c1 (type or object): First type or type hint.
+        c2 (type or object): Second type or type hint.
+
+    Returns:
+        bool: Whether `c1` is a subtype or sub-type hint of `c2`.
+    """
+    return _TypeHint(resolve_type_hint(c1)) <= _TypeHint(resolve_type_hint(c2))
diff --git a/plum/alias.py b/plum/alias.py
new file mode 100644
index 00000000..f419d41f
--- /dev/null
+++ b/plum/alias.py
@@ -0,0 +1,167 @@
+"""This module monkey patches `__repr__` and `__str__` of `typing.Union` to control how
+`typing.Unions` are displayed.
+
+Example::
+
+    >> plum.activate_union_aliases()
+
+    >> IntOrFloat = typing.Union[int, float]
+
+    >> IntOrFloat
+    Union[int, float]
+
+    >> plum.set_union_alias(IntOrFloat, "IntOrFloat")
+
+    >> IntOrFloat
+    typing.Union[IntOrFloat]
+
+    >> typing.Union[int, float]
+    typing.Union[IntOrFloat]
+
+    >> typing.Union[int, float, str]
+    typing.Union[IntOrFloat, str]
+
+Note that `IntOrFloat` prints to `typing.Union[IntOrFloat]` rather than just
+`IntOrFloat`. This is deliberate, with the goal of not breaking code that relies on
+parsing how unions print.
+"""
+
+import typing
+from functools import wraps
+
+from .util import get_args
+
+__all__ = ["activate_union_aliases", "deactivate_union_aliases", "set_union_alias"]
+
+_union_type = type(typing.Union[int, float])
+_original_repr = _union_type.__repr__
+_original_str = _union_type.__str__
+
+
+@wraps(_original_repr)
+def _new_repr(self):
+    """Print a `typing.Union`, replacing all aliased unions by their aliased names.
+
+    Returns:
+        str: Representation of a `typing.Union` taking into account union aliases.
+    """
+    args = get_args(self)
+    args_set = set(args)
+
+    # Find all aliased unions contained in this union.
+    found_unions = []
+    found_positions = []
+    found_aliases = []
+    for union, alias in reversed(_aliased_unions):
+        union_set = set(union)
+        if union_set <= args_set:
+            found = False
+            for i, arg in enumerate(args):
+                if arg in union_set:
+                    found_unions.append(union_set)
+                    found_positions.append(i)
+                    found_aliases.append(alias)
+                    found = True
+                    break
+            if not found:  # pragma: no cover
+                # This branch should never be reached.
+                raise AssertionError(
+                    "Could not identify union. This should never happen."
+                )
+
+    # Delete any unions that are contained in strictly bigger unions. We check for
+    # strictly inequality because any union includes itself.
+    for i in range(len(found_unions) - 1, -1, -1):
+        for union in found_unions:
+            if found_unions[i] < union:
+                del found_unions[i]
+                del found_positions[i]
+                del found_aliases[i]
+                break
+
+    # Create a set with all arguments of all found unions.
+    found_args = set()
+    for union in found_unions:
+        found_args |= union
+
+    # Insert the aliases right before the first found argument. When we insert an
+    # element, the positions of following insertions need to be appropriately
+    # incremented.
+    args = list(args)
+    delta = 0
+    # Sort by insertion position to ensure that all following insertions are at higher
+    # indices. This makes the bookkeeping simple.
+    for i, alias in sorted(zip(found_positions, found_aliases), key=lambda x: x[0]):
+        args.insert(i + delta, alias)
+        delta += 1
+
+    # Filter all elements of unions that are aliased.
+    new_args = ()
+    for arg in args:
+        if arg not in found_args:
+            new_args += (arg,)
+    args = new_args
+
+    # Generate a string representation.
+    args_repr = [a if isinstance(a, str) else typing._type_repr(a) for a in args]
+    # Like `typing` does, print `Optional` whenever possible.
+    if len(args) == 2:
+        if args[0] is type(None):  # noqa: E721
+            return f"typing.Optional[{args_repr[1]}]"
+        elif args[1] is type(None):  # noqa: E721
+            return f"typing.Optional[{args_repr[0]}]"
+    # We would like to just print `args_repr[0]` whenever `len(args) == 1`, but
+    # this might break code that parses how unions print.
+    return "typing.Union[" + ", ".join(args_repr) + "]"
+
+
+@wraps(_original_str)
+def _new_str(self):
+    """Does the same as :func:`_new_repr`.
+
+    Returns:
+        str: Representation of the `typing.Union` taking into account union aliases.
+    """
+    return _new_repr(self)
+
+
+def activate_union_aliases():
+    """When printing `typing.Union`s, replace all aliased unions by the aliased names.
+    This monkey patches `__repr__` and `__str__` for `typing.Union`."""
+    _union_type.__repr__ = _new_repr
+    _union_type.__str__ = _new_str
+
+
+def deactivate_union_aliases():
+    """Undo what :func:`.alias.activate` did. This restores the original  `__repr__`
+    and `__str__` for `typing.Union`."""
+    _union_type.__repr__ = _original_repr
+    _union_type.__str__ = _original_str
+
+
+_aliased_unions = []
+
+
+def set_union_alias(union, alias):
+    """Change how a `typing.Union` is printed. This does not modify `union`.
+
+    Args:
+        union (type or type hint): A union.
+        alias (str): How to print `union`.
+
+    Returns:
+        type or type hint: `union`.
+    """
+    if not isinstance(union, _union_type):
+        args = (union,)
+    else:
+        args = get_args(union)
+    for existing_union, existing_alias in _aliased_unions:
+        if set(existing_union) == set(args) and alias != existing_alias:
+            if isinstance(union, _union_type):
+                union_str = _original_str(union)
+            else:
+                union_str = repr(union)
+            raise RuntimeError(f"`{union_str}` already has alias `{existing_alias}`.")
+    _aliased_unions.append((args, alias))
+    return union
diff --git a/plum/autoreload.py b/plum/autoreload.py
index 975cb272..ce8fabf8 100644
--- a/plum/autoreload.py
+++ b/plum/autoreload.py
@@ -1,87 +1,77 @@
-import gc
 import os
 
-from .type import Type, Union
-from .dispatcher import Dispatcher
+from .dispatcher import clear_all_cache
+from .type import type_mapping
 
-__all__ = ["activate", "deactivate"]
+__all__ = ["activate_autoreload", "deactivate_autoreload"]
 
 
 def _update_instances(old, new):
-    """Use garbage collector to find all instances that refer to the old
-    class definition and update their __class__ to point to the new class
-    definition"""
+    """First call the original implementation of Autoreload's :meth:`update_instances`,
+    and then use :obj:`.type._type_mapping` to map type `old` to the type `new`.
 
-    refs = gc.get_referrers(old)
+    Args:
+        old (type): Old type.
+        new (type): New type.
+    """
+    _update_instances_original(old, new)
 
-    updated_plum_type = False
+    type_mapping[old] = new
 
-    for ref in refs:
-        if type(ref) is old:
-            ref.__class__ = new
-        elif type(ref) == Type:
-            updated_plum_type = True
-            ref._type = new
+    # There might be an existing value in `_type_mapping` that should be replaced by
+    # `new`. Resolve these chains.
+    for k, v in type_mapping.items():
+        while v in type_mapping:
+            v = type_mapping[v]
+        type_mapping[k] = v
 
-    # if we updated a plum type, then
-    # use the gc to get all dispatchers and clear
-    # their cache
-    if updated_plum_type:
-        refs = gc.get_referrers(Dispatcher)
-        for ref in refs:
-            if type(ref) is Dispatcher:
-                ref.clear_cache()
+    # Since types have changed, clear the cache of everything.
+    clear_all_cache()
 
 
 _update_instances_original = None
+"""function: Original implementation of :func:`update_instances`."""
 
 
-def activate():
-    """
-    Pirate autoreload's `update_instance` function to handle Plum types.
-    """
-    from IPython.extensions import autoreload
-    from IPython.extensions.autoreload import update_instances
+def activate_autoreload():
+    """Pirate Autoreload's `update_instance` function to have Plum work with
+    Autoreload."""
+    from IPython.extensions import autoreload  # type: ignore
 
     # First, cache the original method so we can deactivate ourselves.
     global _update_instances_original
     if _update_instances_original is None:
         _update_instances_original = autoreload.update_instances
 
-    # Then, override the update_instance method
-    setattr(autoreload, "update_instances", _update_instances)
+    # Then, override :func:`update_instance`.
+    autoreload.update_instances = _update_instances
 
 
-def deactivate():
-    """
-    Disable Plum's autoreload hack.
-    """
+def deactivate_autoreload():
+    """Disable Plum's autoreload hack. This undoes that
+    :func:`.autoreload.activate_autoreload` did."""
     global _update_instances_original
-    if _update_instances_original is None:  # pragma: no cover
+    if _update_instances_original is None:
         raise RuntimeError("Plum Autoreload module was never activated.")
 
     from IPython.extensions import autoreload
 
-    setattr(autoreload, "update_instances", _update_instances_original)
+    autoreload.update_instances = _update_instances_original
 
 
-# Detect `PLUM_AUTORELOAD` env variable
 _autoload = os.environ.get("PLUM_AUTORELOAD", "0").lower()
-if _autoload in ("y", "yes", "t", "true", "on", "1"):  # pragma: no cover
-    _autoload = True
-else:
-    _autoload = False
+"""str: Value of `PLUM_AUTORELOAD` environment variable."""
 
-if _autoload:  # pragma: no cover
+if _autoload in ("y", "yes", "t", "true", "on", "1"):  # pragma: no cover
     try:
-        # Try to load IPython and get the iPython session, but don't crash if
+        # Try to load iPython and get the iPython session, but don't crash if
         # this does not work (for example IPython not installed, or python shell)
-        from IPython import get_ipython
+        from IPython import get_ipython  # type: ignore
 
         ip = get_ipython()
         if ip is not None:
             if "IPython.extensions.storemagic" in ip.extension_manager.loaded:
-                activate()
+                activate_autoreload()
 
     except ImportError:
         pass
diff --git a/plum/dispatcher.py b/plum/dispatcher.py
index 565449c4..1a082813 100644
--- a/plum/dispatcher.py
+++ b/plum/dispatcher.py
@@ -1,25 +1,25 @@
-import logging
-import inspect
-
-from .function import ClassFunction, Function, extract_signature
-from .signature import Signature as Sig
-from .type import subclasscheck_cache
-from .util import is_in_class, get_class, check_future_annotations
+from .function import Function
+from .signature import Signature
+from .util import get_class, is_in_class
 
 __all__ = ["Dispatcher", "dispatch", "clear_all_cache"]
 
-log = logging.getLogger(__name__)
-
 
 class Dispatcher:
-    """A namespace for functions."""
+    """A namespace for functions.
+
+    Attributes:
+        functions (dict[str, :class:`.function.Function`]): Functions by name.
+        classes (dict[str, dict[str, :class:`.function.Function`]]): Methods of
+            all classes by the qualified name of a class.
+    """
 
     def __init__(self):
-        self._functions = {}
-        self._classes = {}
+        self.functions = {}
+        self.classes = {}
 
     def __call__(self, method=None, precedence=0):
-        """Decorator for a particular signature.
+        """Decorator to register for a particular signature.
 
         Args:
             precedence (int, optional): Precedence of the signature. Defaults to `0`.
@@ -27,137 +27,82 @@ def __call__(self, method=None, precedence=0):
         Returns:
             function: Decorator.
         """
-
-        # If `method` is not given, some keywords are set: return another decorator.
         if method is None:
+            return lambda m: self(m, precedence=precedence)
 
-            def decorator(f_):
-                return self(f_, precedence=precedence)
-
-            return decorator
-
-        if check_future_annotations(inspect.currentframe().f_back):
-            signature, return_type = None, None
-            delayed = method
-        else:
-            signature, return_type = extract_signature(method)
-            delayed = None
-
-        def construct_function(owner):
-            return self._add_method(
-                method,
-                [signature],
-                precedence=precedence,
-                return_type=return_type,
-                owner=owner,
-                delayed=delayed,
-            )
-
-        # Defer the construction if `method` is in a class. We defer the construction to
-        # allow the function to hold a reference to the class.
-        if is_in_class(method):
-            return ClassFunction(get_class(method), construct_function)
-        else:
-            return construct_function(None)
+        # The signature will be automatically derived from `method`, so we can safely
+        # set the signature argument to `None`.
+        return self._add_method(method, None, precedence=precedence)
 
-    def multi(
-        self,
-        *signatures,
-        precedence=0,
-        return_type=object,
-    ):
-        """Create a decorator for multiple given signatures.
+    def multi(self, *signatures):
+        """Decorator to register multiple signatures at once.
 
         Args:
-            *signatures (tuple[type]): Signatures.
-            precedence (int, optional): Precedence of the signatures. Defaults to `0`.
-            return_type (type, optional): Expected return type. Defaults to `object.`
+            *signatures (tuple or :class:`.signature.Signature`): Signatures to
+                register.
 
         Returns:
             function: Decorator.
         """
-        signatures = [Sig(*types) for types in signatures]
-
-        def decorator(method):
-            def construct_function(owner):
-                return self._add_method(
-                    method,
-                    signatures,
-                    precedence=precedence,
-                    return_type=return_type,
-                    owner=owner,
+        resolved_signatures = []
+        for signature in signatures:
+            if isinstance(signature, Signature):
+                resolved_signatures.append(signature)
+            elif isinstance(signature, tuple):
+                resolved_signatures.append(Signature(*signature))
+            else:
+                raise ValueError(
+                    f"Signature `{signature}` must be a tuple or of type "
+                    f"`plum.signature.Signature`."
                 )
 
-            # Defer the construction if `method` is in a class. We defer the
-            # construction to allow the function to hold a reference to the class.
-            if is_in_class(method):
-                return ClassFunction(get_class(method), construct_function)
-            else:
-                return construct_function(None)
+        def decorator(method):
+            # The precedence will not be used, so we can safely set it to `None`.
+            return self._add_method(method, *resolved_signatures, precedence=None)
 
         return decorator
 
     def abstract(self, method):
         """Decorator for an abstract function definition. The abstract function
         definition does not implement any methods."""
+        return self._get_function(method)
 
-        def construct_abstract_function(owner):
-            return self._get_function(method, owner)
-
-        # Defer the construction if `method` is in a class. We defer the construction to
-        # allow the function to hold a reference to the class.
-        if is_in_class(method):
-            return ClassFunction(get_class(method), construct_abstract_function)
-        else:
-            return construct_abstract_function(None)
-
-    def _get_function(self, method, owner):
-        name = method.__name__
-
+    def _get_function(self, method):
         # If a class is the owner, use a namespace specific for that class. Otherwise,
         # use the global namespace.
-        if owner:
-            if owner not in self._classes:
-                self._classes[owner] = {}
-            namespace = self._classes[owner]
+        if is_in_class(method):
+            owner = get_class(method)
+            if owner not in self.classes:
+                self.classes[owner] = {}
+            namespace = self.classes[owner]
         else:
-            namespace = self._functions
+            owner = None
+            namespace = self.functions
 
         # Create a new function only if the function does not already exist.
+        name = method.__name__
         if name not in namespace:
             namespace[name] = Function(method, owner=owner)
 
         return namespace[name]
 
-    def _add_method(
-        self,
-        method,
-        signatures,
-        precedence,
-        return_type,
-        owner,
-        delayed=None,
-    ):
-        f = self._get_function(method, owner)
+    def _add_method(self, method, *signatures, precedence):
+        f = self._get_function(method)
         for signature in signatures:
-            f.register(signature, method, precedence, return_type, delayed)
+            f.register(method, signature, precedence)
         return f
 
     def clear_cache(self):
         """Clear cache."""
-        for f in self._functions.values():
+        for f in self.functions.values():
             f.clear_cache()
 
 
 def clear_all_cache():
-    """Clear all cache, including the cache of subclass checks. This
-    should be called if types are modified."""
-    # Clear function caches.
+    """Clear all cache, including the cache of subclass checks. This should be called
+    if types are modified."""
     for f in Function._instances:
         f.clear_cache()
 
-    # Clear subclass check cache.
-    subclasscheck_cache.clear()
-
 
 dispatch = Dispatcher()  #: A default dispatcher for convenience purposes.
diff --git a/plum/function.py b/plum/function.py
index 19eaf981..9351db2a 100644
--- a/plum/function.py
+++ b/plum/function.py
@@ -1,193 +1,21 @@
-import inspect
-import logging
-import typing
 from functools import wraps
-import operator
+from types import MethodType
+from typing import Any
 
-from .resolvable import Promise
-from .signature import Signature
-from .type import ptype, is_object, VarArgs, deliver_forward_reference
-from .util import unquote, check_future_annotations, wrap_lambda
+from .resolver import AmbiguousLookupError, NotFoundLookupError, Resolver
+from .signature import Signature, append_default_args, extract_signature
+from .type import resolve_type_hint
+from .util import repr_short
 
-__all__ = [
-    "extract_signature",
-    "AmbiguousLookupError",
-    "NotFoundLookupError",
-    "promised_convert",
-    "promised_type_of",
-    "ClassFunction",
-    "Function",
-]
+__all__ = ["Function"]
 
-log = logging.getLogger(__name__)
 
-
-# This is a default instance of the Plum `object` type, which is used to speed up
-# cached calls.
-default_obj_type = ptype(object)
-
-
-def _is_not_empty(t):
-    """Check if a type is *not* equal to `inspect.Parameter.empty`, without triggering
-    unwanted `__eq__`-like methods.
-
-    Args:
-        t (type): Type to check.
-
-    Returns:
-        bool: `True` if `t` is *not* `inspect.Parameter.empty`.
-    """
-    return not (
-        hasattr(t, "__module__")
-        and t.__module__ == "inspect"
-        and t == inspect.Parameter.empty
-    )
-
-
-def _inspect_signature(f):
-    """Wrapper of :func:`inspect.signature` which adds support for certain non-function
-    objects.
-
-    Args:
-        f (object): Function-like object.
-
-    Returns:
-        object: Signature.
-    """
-    if isinstance(f, operator.itemgetter):
-        f = wrap_lambda(f)
-    elif isinstance(f, operator.attrgetter):
-        f = wrap_lambda(f)
-    return inspect.signature(f)
-
-
-def extract_signature(f, get_type_hints=False):
-    """Extract the signature from a function.
-
-    Args:
-        f (function): Function to extract signature from.
-        get_type_hints (bool, optional): Use :func:`typing.get_type_hints` to resolve
-            the type hints. Defaults to `False`.
-
-    Returns:
-        tuple[:class:`.signature.Signature`, ptype]: Signature and return type of the
-            function.
-    """
-    if get_type_hints and hasattr(f, "__annotations__"):
-        # Unquote type hints so that they are resolved to the right types.
-        for k, v in f.__annotations__.items():
-            f.__annotations__[k] = unquote(v)
-        for k, v in typing.get_type_hints(f).items():
-            f.__annotations__[k] = v
-
-    # Extract specification.
-    sig = _inspect_signature(f)
-
-    # Get types of arguments.
-    types = []
-    for arg in sig.parameters:
-        p = sig.parameters[arg]
-
-        # Stop once we have seen all positional parameter without a default value.
-        if p.kind in {p.KEYWORD_ONLY, p.VAR_KEYWORD}:
-            break
-
-        if p.kind == p.VAR_POSITIONAL:
-            # Parameter is a varargs.
-            if _is_not_empty(p.annotation):
-                T = VarArgs(ptype(p.annotation))
-            else:
-                T = VarArgs(default_obj_type)
-        else:
-            # Parameter is a regular positional parameter.
-            if _is_not_empty(p.annotation):
-                T = ptype(p.annotation)
-            else:
-                T = default_obj_type
-
-        if _is_not_empty(p.default):
-            default_T = ptype(type(p.default))
-            if not default_T <= T:
-                raise TypeError(
-                    f"Default value {p.default} of type {default_T} does "
-                    f"not match the annotated type {T}."
-                )
-
-        types.append(T)
-
-    # Get possible return type.
-    if _is_not_empty(sig.return_annotation):
-        return_type = ptype(sig.return_annotation)
-    else:
-        return_type = default_obj_type
-
-    # Assemble signature.
-    signature = Signature(*types)
-
-    return signature, return_type
-
-
-def append_default_args(signature, f):
-    """Returns a list of signatures of function `f`, where those signatures are derived
-    from the input arguments of `f` by treating every non-keyword-only argument with a
-    default value as a keyword-only argument turn by turn.
-
-    Args:
-        f (function): Function to extract default arguments from.
-        signature (:class:`.signature.Signature`): Signature of `f` from which to
-            remove default arguments.
-
-    Returns:
-        list[:class:`.signature.Signature`]: list of signatures excluding from 0 to all
-        default arguments.
-    """
-    # Extract specification.
-    sig = _inspect_signature(f)
-
-    signatures = [signature]
-
-    arg_names = list(sig.parameters.keys())
-    # We start at the end and, once we reach non-keyword-only arguments, delete the
-    # argument with defaults values one by one. This generates a sequence of signatures,
-    # which we return.
-    arg_names.reverse()
-    n_args = len(arg_names)
-
-    deleted_args = 0
-    for arg in arg_names:
-        p = sig.parameters[arg]
-
-        # Ignore keyword arguments.
-        if p.kind in {p.KEYWORD_ONLY, p.VAR_KEYWORD}:
-            continue
-
-        # Stop when non-default arguments are reached.
-        if not _is_not_empty(p.default):
-            break
-
-        deleted_args += 1
-        new_sig = Signature(*signature.base[:-deleted_args])
-        signatures.append(new_sig)
-
-    return signatures
-
-
-class AmbiguousLookupError(LookupError):
-    """A signature cannot be resolved due to ambiguity."""
-
-
-class NotFoundLookupError(LookupError):
-    """A signature cannot be resolved because no applicable method can be found."""
-
-
-# These promises are necessary to avoid circular imports.
-promised_convert = Promise()  # This will resolve to `.promotion.convert`.
-promised_type_of = Promise()  # This will resolve to `.parametric.type_of`.
+_promised_convert = None
+"""function or None: This will be set to :func:`.parametric.convert`."""
 
 
 def _convert(obj, target_type):
-    """Convert an object to a particular type. Only converts if `target_type` is not
-    `object`.
+    """Convert an object to a particular type. Only converts if `target_type` is set.
 
     Args:
         obj (object): Object to convert.
@@ -196,63 +24,35 @@ def _convert(obj, target_type):
     Returns:
         object: `object_to_covert` converted to type of `obj_from_target`.
     """
-    if target_type is default_obj_type or is_object(target_type):
+    if target_type is Any:
         return obj
     else:
-        return promised_convert.resolve()(obj, target_type)
-
+        return _promised_convert(obj, target_type)
 
-class ClassFunction(Promise):
-    """A function that is part of a class.
 
-    This wrapper provides a mechanism that allows the function to be constructed with
-    a reference to the class that it owns. The construction is deferred until
-    `__set_name__` is called.
+def _change_function_name(f, name):
+    """It is not always the case that `f.__name__` is writable. To solve this, first
+    create a temporary function that wraps `f` and then change the name.
 
     Args:
-        class_name (str): Name of the class that owns the function.
-        construct_function (function): Constructor for the function: takes in a
-            reference to the class  that owns the function and gives back the function.
+        f (function): Function to change the name of.
+        name (str): New name.
+
+    Returns:
+        function: Function that wraps `f` and has name `name`.
     """
 
-    _pending = []
-
-    def __init__(self, class_name, construct_function):
-        ClassFunction._pending.append(self)
-        self.class_name = class_name
-        self.construct_function = construct_function
-        Promise.__init__(self)
-
-    def __set_name__(self, owner, name):
-        # Deliver the owner as a forward reference.
-        self.owner = owner
-        deliver_forward_reference(owner)
-
-        # Resolve all pending class functions.
-        currently_pending = ClassFunction._pending.copy()
-        ClassFunction._pending.clear()
-        for f in currently_pending:
-            if f.class_name == self.class_name:
-                # They are in the same class. Construct the function and deliver it.
-                f.deliver(f.construct_function(owner))
-            else:
-                # They are not in the same class. The function should remain pending.
-                ClassFunction._pending.append(f)
+    @wraps(f)
+    def f_renamed(*args, **kw_args):
+        return f(*args, **kw_args)
 
-    def __get__(self, instance, owner):
-        return self.resolve().__get__(instance, owner)
+    f_renamed.__name__ = name
+    return f_renamed
 
-    def __call__(self, *args, **kw_args):
-        # This method is called in the following situation:
-        #
-        # ```python
-        # class A:
-        #     @property.setter
-        #     @dispatch
-        #     def name(self, a: str):
-        #         pass
-        # ````
-        return self.resolve().__call__(*args, **kw_args)
+
+_owner_transfer = {}
+"""dict[type, type]: When the keys of this dictionary are detected as the owner of
+a function (see :meth:`Function.owner`), make the corresponding value the owner."""
 
 
 class Function:
@@ -260,7 +60,7 @@ class Function:
 
     Args:
         f (function): Function that is wrapped.
-        owner (type, optional): Class of which the function is part.
+        owner (str, optional): Name of the class that owns the function.
     """
 
     _instances = []
@@ -269,42 +69,69 @@ def __init__(self, f, owner=None):
         Function._instances.append(self)
 
         self._f = f
-        self._methods = {}
-        self._precedences = {}
-
-        # Keep track of whether any of the signatures contains a parametric
-        # type with type determined at runtime by `plum.type_of` instead of `type`.
-        # This is a necessary performance optimisation.
-        self._runtime_type_of = False
-
+        self._doc = ""
+        wraps(f)(self)  # This will correctly populate `self._doc`.
         self._cache = {}
-        self._owner = ptype(owner) if owner else None
 
+        # `owner` is the name of the owner. We will later attempt to resolve to
+        # which class it actually points.
+        self._owner_name = owner
+        self._owner = None
+
+        # Initialise pending and resolved methods.
         self._pending = []
+        self._resolver = Resolver()
         self._resolved = []
 
-        # Copy metadata.
-        self.__name__ = f.__name__
-        self.__qualname__ = f.__qualname__
-        self.__module__ = f.__module__
-        self.__doc__ = f.__doc__
+    @property
+    def owner(self):
+        """object or None: Owner of the function. If `None`, then there is no owner."""
+        if self._owner is None and self._owner_name is not None:
+            name = self._owner_name.split(".")[-1]
+            self._owner = self._f.__globals__[name]
+            # Check if the ownership needs to be transferred to another class. This
+            # can be very important for preventing infinite loops.
+            while self._owner in _owner_transfer:
+                self._owner = _owner_transfer[self._owner]
+        return self._owner
 
     @property
-    def methods(self):
-        """dict[:class:`.signature.Signature`,tuple[function,ptype]]: All available
-        methods."""
+    def __doc__(self):
+        """str or None: Documentation of the function. This consists of the
+        documentation of the function given at initialisation with the documentation
+        of all other registered methods appended."""
         self._resolve_pending_registrations()
-        return self._methods
+
+        # Derive the basis of the docstring from `self._f`.
+        doc = self._doc
+
+        # Append the docstrings of all other implementations to it. Exclude the
+        # docstring from `self._f`, because that one forms the basis.
+        resolver_doc = self._resolver.doc(exclude=self._f)
+        if resolver_doc:
+            # Ensure a newline.
+            while doc[-2:] != "\n\n":
+                doc += "\n"
+            doc += "This function has further implementations documented below.\n\n"
+            doc += resolver_doc
+
+        # If the docstring is empty, return `None`, which is consistent with omitting
+        # the docstring.
+        return doc if doc else None
+
+    @__doc__.setter
+    def __doc__(self, value):
+        # Ensure that `self._doc` remains a string.
+        self._doc = value if value else ""
 
     @property
-    def precedences(self):
-        """dict[:class:`.signature.Signature`,int]: For every signature, the method
-        precedence."""
+    def methods(self):
+        """list[:class:`.signature.Signature`]: All available methods."""
         self._resolve_pending_registrations()
-        return self._precedences
+        return self._resolver.signatures
 
-    def dispatch(self, f=None, precedence=0):
-        """A decorator to extend the function with another signature.
+    def dispatch(self, method=None, precedence=0):
+        """Decorator to extend the function with another signature.
 
         Args:
             precedence (int, optional): Precedence of the signature. Defaults to `0`.
@@ -312,50 +139,38 @@ def dispatch(self, f=None, precedence=0):
         Returns:
             function: Decorator.
         """
-        if f is None:
-            return lambda f_: self.dispatch(f_, precedence=precedence)
+        if method is None:
+            return lambda m: self.dispatch(m, precedence=precedence)
 
-        if check_future_annotations(inspect.currentframe().f_back):
-            signature, return_type = None, None
-            delayed = f
-        else:
-            signature, return_type = extract_signature(f)
-            delayed = None
-
-        self.register(
-            signature,
-            f,
-            precedence=precedence,
-            return_type=return_type,
-            delayed=delayed,
-        )
+        self.register(method, precedence=precedence)
         return self
 
-    def dispatch_multi(self, *signatures, precedence=0, return_type=object):
-        """A decorator to extend the function with multiple signatures.
+    def dispatch_multi(self, *signatures):
+        """Decorator to extend the function with multiple signatures at once.
 
         Args:
-            *signatures (tuple[type] or :class:`.signature.Signature`): Signatures.
-            precedence (int, optional): Precedence of the signatures. Defaults to `0`.
-            return_type (type, optional): Expected return type. Defaults to `object.`
+            *signatures (tuple or :class:`.signature.Signature`): Signatures to
+                register.
 
         Returns:
             function: Decorator.
         """
-
-        def decorator(f):
-            # Register the new method.
-            for signature in signatures:
-                if not isinstance(signature, Signature):
-                    signature = Signature(*signature)
-                self.register(
-                    signature,
-                    f,
-                    precedence=precedence,
-                    return_type=return_type,
+        resolved_signatures = []
+        for signature in signatures:
+            if isinstance(signature, Signature):
+                resolved_signatures.append(signature)
+            elif isinstance(signature, tuple):
+                resolved_signatures.append(Signature(*signature))
+            else:
+                raise ValueError(
+                    f"Signature `{signature}` must be a tuple or of type "
+                    f"`plum.signature.Signature`."
                 )
 
-            # Return the function.
+        def decorator(method):
+            # The precedence will not be used, so we can safely set it to `None`.
+            for signature in resolved_signatures:
+                self.register(method, signature=signature, precedence=None)
             return self
 
         return decorator
@@ -368,227 +183,172 @@ def clear_cache(self, reregister=True):
                 `True`.
         """
         self._cache.clear()
+
         if reregister:
             # Add all resolved to pending.
             self._pending.extend(self._resolved)
 
             # Clear resolved.
             self._resolved = []
-            self._methods.clear()
-            self._precedences.clear()
-            self._runtime_type_of = False
-
-    def register(
-        self,
-        signature,
-        f,
-        precedence=0,
-        return_type=object,
-        delayed=None,
-    ):
+            self._resolver = Resolver()
+
+    def register(self, f, signature=None, precedence=0):
         """Register a method.
 
+        Either `signature` or `precedence` must be given.
+
         Args:
-            signature (:class:`.signature.Signature` or None): Signature of the method.
             f (function): Function that implements the method.
-            precedence (int, optional): Precedence of the function. Defaults
-                to `0`.
-            return_type (type or ptype or None, optional): Return type of the function.
-                Defaults to `object`.
-            delayed (function, optional): If the signature and return type could not
-                yet be extracted, e.g. in the case of
-                `from __future__ import annotations`, then delay the extraction until
-                registration and use `get_type_hints` to resolve all type hints.
+            signature (:class:`.signature.Signature`, optional): Signature. If it is
+                not given, it will be derived from `f`.
+            precedence (int, optional): Precedence of the function. If `signature` is
+                given, then this argument will not be used. Defaults to `0`.
         """
-        # The return type may contain strings, which now need to be converted to a Plum
-        # type. This is to deal with forward references.
-        if not delayed:
-            return_type = ptype(return_type)
-
-        self._pending.append((signature, f, precedence, return_type, delayed))
+        self._pending.append((f, signature, precedence))
 
     def _resolve_pending_registrations(self):
         # Keep track of whether anything registered.
         registered = False
 
         # Perform any pending registrations.
-        for (signature, f, precedence, return_type, delayed) in self._pending:
+        for f, signature, precedence in self._pending:
             # Add to resolved registrations.
-            self._resolved.append((signature, f, precedence, return_type, delayed))
+            self._resolved.append((f, signature, precedence))
 
-            # The method can be passed as the signature, in which we should extract
-            # the signature from the type hints.
-            if delayed:
-                signature, return_type = extract_signature(delayed, get_type_hints=True)
+            # Obtain the signature if it is not available.
+            if signature is None:
+                signature = extract_signature(f, precedence=precedence)
+            else:
+                # Ensure that the implementation is `f`, but make a copy before
+                # mutating.
+                signature = signature.__copy__()
+                signature.implementation = f
+
+            # Ensure that the implementation has the right name, because this name
+            # will show up in the docstring.
+            if getattr(signature.implementation, "__name__", None) != self.__name__:
+                signature.implementation = _change_function_name(
+                    signature.implementation,
+                    self.__name__,
+                )
 
+            # Process default values.
             for subsignature in append_default_args(signature, f):
+                self._resolver.register(subsignature)
                 registered = True
 
-                # If a method with the same signature has already been defined, then
-                # that is fine: we simply overwrite that method.
-
-                # If the return type is `object`, then set it to `default_obj_type`.
-                # This allows for a fast check to speed up cached calls.
-                if is_object(return_type):
-                    return_type = default_obj_type
-
-                self._methods[subsignature] = (f, return_type)
-                self._precedences[subsignature] = precedence
-
-                # Check whether the signature contains a type which requires `type_of`
-                # at runtime.
-                if any(t.runtime_type_of for t in subsignature.types):
-                    self._runtime_type_of = True
-
         if registered:
             self._pending = []
 
             # Clear cache.
-            # TODO: Be more clever, but careful about the tracking of parametric
-            #  types.
             self.clear_cache(reregister=False)
 
-    def resolve_signature(self, signature):
-        """Resolve a signature to get the most specific signature amongst the
-        signatures of all applicable methods.
+    def _enhance_exception(self, e):
+        """Enchance an exception by prepending a prefix to the message of the exception
+        which specifies that the message is for this function.
 
         Args:
-            signature (:class:`.signature.Signature`): Signature to resolve.
+            e (:class:`Exception`): Exception.
 
         Returns:
-            :class:`.signature.Signature`: The most specific signature among the
-                signatures of all applicable methods.
+            :class:`Exception`: `e`, but with a prefix appended to the message.
         """
-        self._resolve_pending_registrations()
-
-        # Find the most specific applicable signature.
-        candidates = [s for s in self._methods.keys() if signature <= s]
-        candidates = find_most_specific(candidates)
-
-        # If only a single candidate is left, the resolution has been
-        # successful.
-        if len(candidates) > 1:
-            # There are multiple candidates. Check their precedences and see
-            # if that breaks the ambiguity.
-            precedences = [self._precedences[c] for c in candidates]
-            highest_precedence = max(*precedences)
-            if len([p for p in precedences if p == highest_precedence]) == 1:
-                # Ambiguity can be resolved by precedence. So do so.
-                return candidates[precedences.index(highest_precedence)]
-
-            # Could not resolve the ambiguity, so error. First, make a nice list
-            # of the candidates and their precedences.
-            listed_candidates = "\n  ".join(
-                [f"{c} (precedence: {self._precedences[c]})" for c in candidates]
-            )
-            raise AmbiguousLookupError(
-                f'For function "{self._f.__name__}", signature {signature} is '
-                f"ambiguous among the following:\n  {listed_candidates}"
-            )
-        elif len(candidates) == 1:
-            return candidates[0]
-        else:
-            class_message = f" of {self._owner}" if self._owner else ""
-            raise NotFoundLookupError(
-                f'For function "{self._f.__name__}"{class_message}, '
-                f"signature {signature} could not be resolved."
-            )
-
-    def resolve_method(self, *types):
-        """Get the method and return type corresponding to types of arguments.
+        # Specify to which function the message pertains.
+        prefix = f"For function `{self.__name__}`"
+        if self.owner:
+            prefix += f" of `{repr_short(self.owner)}`"
+        prefix = prefix + ", "
+        # Return a new exception of the same type which incorporates the prefix.
+        message = str(e)
+        return type(e)(prefix + message[0].lower() + message[1:])
+
+    def resolve_method(self, target, types):
+        """Find the method and return type for arguments.
 
         Args:
-            *types (type): Types of arguments.
+            target (object): Target.
+            types (tuple[type, ...]): Types of the arguments.
 
         Returns:
-            tuple: Tuple containing method and return type.
+            function: Method.
+            type: Return type.
         """
-        # New registrations may invalidate cache, so resolve pending
-        # registrations first.
         self._resolve_pending_registrations()
 
-        # Attempt to use cache.
         try:
-            return self._cache[types]
-        except KeyError:
-            pass
+            # Attempt to find the method using the resolver.
+            signature = self._resolver.resolve(target)
+            method = signature.implementation
+            return_type = signature.return_type
 
-        # Look up the signature.
-        signature = Signature(*types)
+        except AmbiguousLookupError as e:
+            raise self._enhance_exception(e)  # Specify this function.
 
-        if self._owner:
-            try:
-                method, return_type = self._methods[self.resolve_signature(signature)]
-            except NotFoundLookupError as e:
-                method = None
-                return_type = ptype(object)
+        except NotFoundLookupError as e:
+            e = self._enhance_exception(e)  # Specify this function.
 
-                # Walk through the classes in the class's MRO, except for this
-                # class, and try to get the method.
-                for c in self._owner.mro()[1:]:
-                    try:
+            if not self.owner:
+                # Not in a class. Nothing we can do.
+                raise e
+            else:
+                # In a class. Walk through the classes in the class's MRO, except for
+                # this class, and try to get the method.
+                method = None
+                return_type = object
+
+                for c in self.owner.__mro__[1:]:
+                    # Skip the top of the type hierarchy given by `object` and `type`.
+                    # We do not suddenly want to fall back to any unexpected default
+                    # behaviour.
+                    if c in {object, type}:
+                        continue
+
+                    # We need to check `c.__dict__` here instead of using `hasattr`
+                    # since e.g. `c.__le__` will return  even if `c` does not implement
+                    # `__le__`!
+                    if self._f.__name__ in c.__dict__:
                         method = getattr(c, self._f.__name__)
+                    else:
+                        # For some reason, coverage fails to catch the `continue`
+                        # below. Add the do-nothing `_ = None` fixes this.
+                        # TODO: Remove this once coverage properly catches this.
+                        _ = None
+                        continue
 
-                        # Ignore abstract methods.
-                        if (
-                            hasattr(method, "__isabstractmethod__")
-                            and method.__isabstractmethod__
-                        ):
-                            method = None
-                            continue
-
-                        # We found a good candidate. Break.
-                        break
-                    except AttributeError:
-                        pass
-
-                if method in {object.__init__, object.__call__}:
-                    # A magic method of `object` has been found that will surely
-                    # not support the passed arguments. Raise the original exception.
-                    raise e
+                    # Ignore abstract methods.
+                    if getattr(method, "__isabstractmethod__", False):
+                        method = None
+                        continue
+
+                    # We found a good candidate. Break.
+                    break
 
                 if not method:
-                    # If no method has been found after walking through the
-                    # MRO, raise the original exception.
+                    # If no method has been found after walking through the MRO, raise
+                    # the original exception.
                     raise e
-        else:
-            # Not in a class. Simply resolve.
-            method, return_type = self._methods[self.resolve_signature(signature)]
 
-        # Cache lookup.
-        self._cache[types] = (method, return_type)
+        # If the resolver is faithful, then we can perform caching using the types of
+        # the arguments. If the resolver is not faithful, then we cannot.
+        if self._resolver.is_faithful:
+            self._cache[types] = method, return_type
+
         return method, return_type
 
     def __call__(self, *args, **kw_args):
-        # First resolve pending registrations, because the value of
-        # `self._runtime_type_of` depends on it.
-        if len(self._pending) != 0:
+        # Before attempting to use the cache, resolve any unresolved registrations. Use
+        # an `if`-statement to speed up the common case.
+        if self._pending:
             self._resolve_pending_registrations()
 
-        # Get types of arguments for signature. Only use `type_of` if
-        # necessary, because it incurs a significant performance hit.
-        if not self._runtime_type_of:
-            sig_types = tuple([type(x) for x in args])
-        else:
-            sig_types = tuple([promised_type_of.resolve()(x) for x in args])
-
+        # Attempt to use the cache based on the types of the arguments.
+        types = tuple(map(type, args))
         try:
-            # Attempt to use cache. This will also be done in
-            # `self.resolve_method`, but checking here as well speed up
-            # cached calls significantly.
-            method, return_type = self._cache[sig_types]
-
-            # Check for the common case that the return type is object. This is
-            # to speed up the common case.
-            if return_type is default_obj_type:
-                return method(*args, **kw_args)
-            else:
-                return _convert(method(*args, **kw_args), return_type)
+            method, return_type = self._cache[types]
         except KeyError:
-            pass
+            # Cache miss. Run the resolver based on the arguments.
+            method, return_type = self.resolve_method(args, types)
 
-        # Cache failed. Get method and return type, and perform call.
-        method, return_type = self.resolve_method(*sig_types)
         return _convert(method(*args, **kw_args), return_type)
 
     def invoke(self, *types):
@@ -600,7 +360,17 @@ def invoke(self, *types):
         Returns:
             function: Method.
         """
-        method, return_type = self.resolve_method(*[ptype(t) for t in types])
+        # Need this before cache
+        if len(self._pending) > 0:
+            self._resolve_pending_registrations()
+
+        # Attempt to use the cache based on the types.
+        try:
+            method, return_type = self._cache[types]
+        except KeyError:
+            # Cache miss. Run the resolver based on the types.
+            sig_types = Signature(*(resolve_type_hint(t) for t in types))
+            method, return_type = self.resolve_method(sig_types, types)
 
         @wraps(self._f)
         def wrapped_method(*args, **kw_args):
@@ -610,14 +380,14 @@ def wrapped_method(*args, **kw_args):
 
     def __get__(self, instance, owner):
         if instance is not None:
-            return _BoundFunction(self, instance)
+            return MethodType(_BoundFunction(self, instance), instance)
         else:
             return self
 
     def __repr__(self):
         return (
-            f"<function {self._f} with "
-            f"{len(self._pending) + len(self._methods)} method(s)>"
+            f"<function {self._f} with {len(self._resolver)} registered and"
+            f" {len(self._pending)} pending method(s)>"
         )
 
 
@@ -630,61 +400,31 @@ class _BoundFunction:
     """
 
     def __init__(self, f, instance):
-        self.f = f
-        self.instance = instance
+        self._f = f
+        wraps(f._f)(self)  # This will call the setter for `__doc__`.
+        self._instance = instance
 
-        # Copy relevant metadata.
-        self.__name__ = f.__name__
-        self.__module__ = f.__module__
-        self.__doc__ = f.__doc__
+    @property
+    def __doc__(self):
+        return self._f.__doc__
 
-    def __call__(self, *args, **kw_args):
-        return self.f(self.instance, *args, **kw_args)
+    @__doc__.setter
+    def __doc__(self, value):
+        # Don't need to do anything here. The docstring will be derived from `self._f`.
+        # We, however, do need to implement this method, because :func:`wraps` calls
+        # it.
+        pass
+
+    def __call__(self, _, *args, **kw_args):
+        return self._f(self._instance, *args, **kw_args)
 
     def invoke(self, *types):
-        """See :meth:`.function.invoke`."""
+        """See :meth:`.Function.invoke`."""
 
-        @wraps(self.f._f)
+        @wraps(self._f._f)
         def wrapped_method(*args, **kw_args):
-            method = self.f.invoke(type(self.instance), *types)
-            return method(self.instance, *args, **kw_args)
+            # TODO: Can we do this without `type` here?
+            method = self._f.invoke(type(self._instance), *types)
+            return method(self._instance, *args, **kw_args)
 
         return wrapped_method
-
-    def __repr__(self):
-        return repr(self.f)
-
-    def __getattr__(self, item):
-        return getattr(self.f, item)
-
-
-def find_most_specific(signatures):
-    """Find the most specific in a list of signatures.
-
-     Args:
-        signatures (list[:class:`.signature.Signature`]): List of signatures.
-
-    Returns:
-        :class:`.signature.Signature`: Most specific signatures.
-    """
-    candidates = []
-    for signature in signatures:
-        # If none of the candidates are comparable, then add the method as
-        # a new candidate and continue.
-        if not any(c.is_comparable(signature) for c in candidates):
-            candidates += [signature]
-            continue
-
-        # The signature under consideration is comparable with at least one
-        # of the candidates. First, filter any strictly more general
-        # candidates.
-        new_candidates = [c for c in candidates if not signature < c]
-
-        # If the signature under consideration is as specific as at least
-        # one candidate, then and only then add it as a candidate.
-        if any(signature <= c for c in candidates):
-            candidates = new_candidates + [signature]
-        else:
-            candidates = new_candidates
-
-    return candidates
diff --git a/plum/parametric.py b/plum/parametric.py
index 19e2826a..54590928 100644
--- a/plum/parametric.py
+++ b/plum/parametric.py
@@ -1,46 +1,25 @@
-import logging
-import abc
-from functools import partial
+from beartype.door import TypeHint
+from beartype.roar import BeartypeDoorNonpepException
 
 from .dispatcher import Dispatcher
-from .function import (
-    promised_type_of as promised_type_of1,
-)
-from .type import (
-    TypeMeta,
-    promised_type_of as promised_type_of2,
-    ComparableType,
-    Union,
-    PromisedList,
-    PromisedTuple,
-    PromisedDict,
-    PromisedIterable,
-    PromisedSequence,
-    ptype,
-    is_type,
-)
-from .util import multihash
+from .function import _owner_transfer
+from .type import resolve_type_hint
+from .util import repr_short
 
 __all__ = [
+    "CovariantMeta",
     "parametric",
     "type_parameter",
     "kind",
     "Kind",
-    "List",
-    "Tuple",
-    "Dict",
-    "Iterable",
-    "Sequence",
-    "type_of",
     "Val",
 ]
 
-log = logging.getLogger(__name__)
 
 _dispatch = Dispatcher()
 
 
-class ParametricTypeMeta(TypeMeta):
+class ParametricTypeMeta(type):
     """Parametric types can be instantiated with indexing.
 
     A concrete parametric type can be instantiated by calling `Type[Par1, Par2]`.
@@ -50,23 +29,44 @@ class ParametricTypeMeta(TypeMeta):
 
     def __getitem__(cls, p):
         if not cls.concrete:
-            # `p` can be a tuple, in which case it must be splatted.
-            return cls.__new__(cls, *(p if isinstance(p, tuple) else (p,)))
+            # Initialise the type parameters. This can perform, e.g., validation.
+            p = p if isinstance(p, tuple) else (p,)  # Ensure that it is a tuple.
+            p = cls.__init_type_parameter__(*p)
+            # Type parameter has been initialised! Proceed to construct the type.
+            p = p if isinstance(p, tuple) else (p,)  # Again ensure that it is a tuple.
+            return cls.__new__(cls, *p)
         else:
             raise TypeError("Cannot specify type parameters. This type is concrete.")
 
-    def __call__(cls, *args, **kw_args):
-        # `Type(arg1, arg2, **kw_args)` will first construct the
-        # parametric subtype `T = Type[type(arg1), type(arg2)]`
-        # and then call the equivalent of `T(arg1, arg2, **kw_args)`.
+    def __concrete_class__(cls, *args, **kw_args):
+        """If `cls` is not a concrete class, infer the type parameters and return a
+        concrete class. If `cls` is already a concrete class, simply return it.
+
+        Args:
+            *args: Positional arguments passed to the `__init__` method.
+            **kw_args: Keyword arguments passed to the `__init__` method.
 
-        if hasattr(cls, "parametric") and cls.parametric:
+        Returns:
+            type: A concrete class.
+        """
+        if getattr(cls, "parametric", False):
             if not cls.concrete:
                 type_parameter = cls.__infer_type_parameter__(*args, **kw_args)
                 cls = cls[type_parameter]
+        return cls
 
-        # Calls `__new__` and `__init__`.
-        return TypeMeta.__call__(cls, *args, **kw_args)
+    def __init_type_parameter__(cls, *ps):
+        """Function called to initialise the type parameters.
+
+        The default behaviour is to just return `ps`.
+
+        Args:
+            *ps (object): Type parameters.
+
+        Returns:
+            object: Initialised type parameters.
+        """
+        return ps
 
     def __infer_type_parameter__(cls, *args, **kw_args):
         """Function called when the constructor of this parametric type is called
@@ -78,12 +78,11 @@ def __infer_type_parameter__(cls, *args, **kw_args):
 
         Args:
             *args: Positional arguments passed to the `__init__` method.
-            *kw_args: Keyword arguments passed to the `__init__` method.
+            **kw_args: Keyword arguments passed to the `__init__` method.
 
         Returns:
             type or tuple[type]: A type or tuple of types.
         """
-        # TODO: Use `type_of` instead of `type`.
         type_parameter = tuple(type(arg) for arg in args)
         if len(type_parameter) == 1:
             type_parameter = type_parameter[0]
@@ -92,13 +91,13 @@ def __infer_type_parameter__(cls, *args, **kw_args):
     @property
     def parametric(cls):
         """bool: Check whether the type is a parametric type."""
-        return hasattr(cls, "_parametric") and cls._parametric
+        return getattr(cls, "_parametric", False)
 
     @property
     def concrete(cls):
         """bool: Check whether the parametric type is instantiated or not."""
         if cls.parametric:
-            return hasattr(cls, "_concrete") and cls._concrete
+            return getattr(cls, "_concrete", False)
         else:
             raise RuntimeError(
                 "Cannot check whether a non-parametric type is instantiated or not."
@@ -114,24 +113,14 @@ def type_parameter(cls):
                 "Cannot get the type parameter of non-instantiated parametric type."
             )
 
-    @property
-    def runtime_type_of(cls):
-        """bool: Check whether the type requires :func:`.parametric.type_of` at
-        runtime"""
-        return hasattr(cls, "_runtime_type_of") and cls._runtime_type_of
-
 
-def is_concrete(t):
-    """Check if a type `t` is a concrete instance of a parametric type.
-
-    Args:
-        t (type): Type to check.
-
-    Returns:
-        bool: `True` if `t` is a concrete instance of a parametric type and `False`
-            otherwise.
-    """
-    return hasattr(t, "parametric") and t.parametric and t.concrete
+def _default_le_type_par(p_left, p_right):
+    if is_type(p_left) and is_type(p_right):
+        p_left = TypeHint(resolve_type_hint(p_left))
+        p_right = TypeHint(resolve_type_hint(p_right))
+        return p_left <= p_right
+    else:
+        return p_left == p_right
 
 
 class CovariantMeta(ParametricTypeMeta):
@@ -141,511 +130,218 @@ def __subclasscheck__(cls, subclass):
         if is_concrete(cls) and is_concrete(subclass):
             # Check that they are instances of the same parametric type.
             if all(issubclass(b, cls.__bases__) for b in subclass.__bases__):
-                par_subclass = subclass.type_parameter
-                par_cls = cls.type_parameter
-
-                if not isinstance(par_subclass, tuple):
-                    par_subclass = (par_subclass,)
-                if not isinstance(par_cls, tuple):
-                    par_cls = (par_cls,)
-
-                return cls._is_sub_type_parameter(par_cls, subclass, par_subclass)
+                p_sub = subclass.type_parameter
+                p_cls = cls.type_parameter
+                # Ensure that both are in tuple form.
+                p_sub = p_sub if isinstance(p_sub, tuple) else (p_sub,)
+                p_cls = p_cls if isinstance(p_cls, tuple) else (p_cls,)
+                return cls.__le_type_parameter__(p_sub, p_cls)
 
         # Default behaviour to `type`s subclass check.
         return type.__subclasscheck__(cls, subclass)
 
-    def _is_sub_type_parameter(cls, par_cls, subclass, par_subclass):
-        # Handle the case that the parameters are tuples of types.
-        return len(par_subclass) == len(par_cls) and all(
-            (
-                # Type parameter could be a type.
-                ptype(pi_subclass) <= ptype(pi_self)
-                if (is_type(pi_subclass) and is_type(pi_self))
-                # Type parameter could also be an object.
-                else pi_subclass == pi_self
-            )
-            for pi_subclass, pi_self in zip(par_subclass, par_cls)
-        )
+    def __le_type_parameter__(cls, p_left, p_right):
+        # Check that there are an equal number of parameters.
+        if len(p_left) != len(p_right):
+            return False
+        # Check every pair of parameters.
+        return all(_default_le_type_par(p1, p2) for p1, p2 in zip(p_left, p_right))
 
 
-def parametric(Class=None, runtime_type_of=False, metaclass=CovariantMeta):
+def parametric(original_class=None):
     """A decorator for parametric classes.
 
     When the constructor of this parametric type is called before the type parameter
-    has been specified, the type parameters are inferred from the arguments of the
-    constructor by calling the following function.
-
-    The default implementation is shown here, but it is possible to override it.::
+    has been specified, the type parameter is inferred from the arguments of the
+    constructor by calling `__inter_type_parameter__`. The default implementation is
+    shown here, but it is possible to override it::
 
         @classmethod
-        def __infer_type_parameter__(cls, *args, **kw_args) -> Tuple:
+        def __infer_type_parameter__(cls, *args, **kw_args) -> tuple:
             return tuple(type(arg) for arg in args)
 
-    Args:
-        runtime_type_of (bool, optional): Require the use of :func:`.parametric.type_of`
-            at runtime to determine the types of arguments at runtime. Defaults to
-            `False`
-        metaclass (type, optional): Metaclass of the parametric class. Defaults to
-            :class:`.parametric.CovariantMeta`.
+    After the type parameter is given or inferred, `__init_type_parameter__` is called.
+    Again, the default implementation is show here, but it is possible to override it::
+
+        @classmethod
+        def __init_type_parameter__(cls, *ps) -> tuple:
+            return ps
+
+    To determine which one instance of a parametric class is a subclass of another,
+    the type parameters are compared with `__le_type_parameter__`::
+
+        @classmethod
+        def __le_type_parameter__(cls, left, right) -> bool:
+            ...  # Is `left <= right`?
     """
 
-    # Allow the keyword arguments to be passed in without using `functools.partial`
-    # explicitly.
-    if Class is None:
-        return partial(parametric, runtime_type_of=runtime_type_of, metaclass=metaclass)
+    original_meta = type(original_class)
 
-    subclasses = {}
+    # Make a metaclass that derives from both the metaclass of `original_meta` and
+    # `CovariantMeta`, but make sure not to insert `CovariantMeta` twice, because that
+    # will error.
 
-    if not issubclass(Class, object):  # pragma: no cover
-        raise RuntimeError(
-            f"To let {Class} be a parametric class, it must be a new-style class."
-        )
+    if CovariantMeta in original_meta.__mro__:
+        bases = (original_meta,)
+        name = original_meta.__name__
+    else:
+        bases = (CovariantMeta, original_meta)
+        name = f"CovariantMeta[{repr_short(original_meta)}]"
+
+    def __call__(cls, *args, **kw_args):
+        cls = cls.__concrete_class__(*args, **kw_args)
+        return original_meta.__call__(cls, *args, **kw_args)
+
+    meta = type(name, bases, {"__call__": __call__})
+
+    subclasses = {}
 
     def __new__(cls, *ps):
-        # Only create new subclass if it doesn't exist already.
+        # Only create a new subclass if it doesn't exist already.
         if ps not in subclasses:
 
             def __new__(cls, *args, **kw_args):
-                return Class.__new__(cls)
+                return original_class.__new__(cls)
 
             # Create subclass.
-            name = Class.__name__ + "[" + ", ".join(str(p) for p in ps) + "]"
-            SubClass = type.__new__(
-                metaclass,
+            name = original_class.__name__
+            name += "[" + ", ".join(repr_short(p) for p in ps) + "]"
+            subclass = meta(
                 name,
-                (ParametricClass,),
+                (parametric_class,),
                 {"__new__": __new__},
             )
-            SubClass._parametric = True
-            SubClass._concrete = True
-            SubClass._runtime_type_of = runtime_type_of
-            SubClass._type_parameter = ps[0] if len(ps) == 1 else ps
-            SubClass.__module__ = Class.__module__
+            subclass._parametric = True
+            subclass._concrete = True
+            subclass._type_parameter = ps[0] if len(ps) == 1 else ps
+            subclass.__module__ = original_class.__module__
 
             # Attempt to correct docstring.
             try:
-                SubClass.__doc__ = Class.__doc__
+                subclass.__doc__ = original_class.__doc__
             except AttributeError:  # pragma: no cover
                 pass
 
-            subclasses[ps] = SubClass
+            subclasses[ps] = subclass
         return subclasses[ps]
 
     def __init_subclass__(cls, **kw_args):
         cls._parametric = False
-        cls._runtime_type_of = False
+        # If the subclass has the same `__new__` as `ParametricClass`, then we should
+        # replace it with the `__new__` of `Class`. If the user already defined another
+        # `__new__`, then everything is fine.
         if cls.__new__ is __new__:
 
-            def resetted_new(cls, *args, **kw_args):
-                return Class.__new__(cls)
+            def class_new(cls, *args, **kw_args):
+                return original_class.__new__(cls)
 
-            cls.__new__ = resetted_new
-        Class.__init_subclass__(**kw_args)
+            cls.__new__ = class_new
+        original_class.__init_subclass__(**kw_args)
 
     # Create parametric class.
-    ParametricClass = metaclass(
-        Class.__name__,
-        (Class,),
+    parametric_class = meta(
+        original_class.__name__,
+        (original_class,),
         {"__new__": __new__, "__init_subclass__": __init_subclass__},
     )
-    ParametricClass._parametric = True
-    ParametricClass._concrete = False
-    ParametricClass._runtime_type_of = runtime_type_of
-    ParametricClass.__module__ = Class.__module__
+    parametric_class._parametric = True
+    parametric_class._concrete = False
+    parametric_class.__module__ = original_class.__module__
+
+    # When dispatch is used in methods of `original_class`, because we return
+    # `parametric_class`, `parametric_class` will be inferred as the owner of those
+    # functions. This is erroneous, because the owner should be `original_class`. What
+    # will happen is that `original_class` will be the next in the MRO, which means
+    # that, whenever a `NotFoundLookupError` happens, the method will try itself again,
+    # resulting in an infinite loop. To prevent this from happening, we must adjust the
+    # owner.
+    _owner_transfer[parametric_class] = original_class
 
     # Attempt to correct docstring.
     try:
-        ParametricClass.__doc__ = Class.__doc__
+        parametric_class.__doc__ = original_class.__doc__
     except AttributeError:  # pragma: no cover
         pass
 
-    return ParametricClass
-
+    return parametric_class
 
-@_dispatch
-def type_parameter(x):
-    """Get the type parameter of an instance of a parametric type.
-
-    Args:
-        x (object): Instance of a parametric type.
-
-    Returns:
-        object: Type parameter.
-    """
-    return type(x).type_parameter
 
-
-def kind(SuperClass=object):
-    """Create a parametric wrapper type for dispatch purposes.
+def is_concrete(t):
+    """Check if a type `t` is a concrete instance of a parametric type.
 
     Args:
-        SuperClass (type): Super class.
+        t (type): Type to check.
 
     Returns:
-        object: New parametric type wrapper.
+        bool: `True` if `t` is a concrete instance of a parametric type and `False`
+            otherwise.
     """
+    return getattr(t, "parametric", False) and t.concrete
 
-    @parametric
-    class Kind(SuperClass):
-        def __init__(self, *xs):
-            self.xs = xs
 
-        def get(self):
-            return self.xs[0] if len(self.xs) == 1 else self.xs
-
-    return Kind
+def is_type(x):
+    """Check whether `x` is a type or a type hint.
 
-
-Kind = kind()  #: A default kind provided for convenience.
-
-
-@parametric
-class _ParametricList(list):
-    """Parametric list type."""
-
-    @classmethod
-    def __iter_el_type__(cls):
-        return cls.type_parameter
-
-    @classmethod
-    def __getitem_el_type__(cls):
-        return cls.__iter_el_type__()
-
-
-class List(ComparableType):
-    """Parametric list Plum type.
-
-    IMPORTANT:
-        `List` should not be used to generically refer to a list! Use `list` instead.
+    Under the hood, this attempts to construct a :class:`beartype.door.TypeHint` from
+    `x`. If successful, then `x` is deemed a type or type hint.
 
     Args:
-        el_type (type or ptype): Element type.
-    """
-
-    def __init__(self, el_type):
-        self._el_type = ptype(el_type)
-
-    def __hash__(self):
-        return multihash(List, self._el_type)
-
-    def __repr__(self):
-        return f"List[{self._el_type}]"
-
-    def get_types(self):
-        return (_ParametricList[self._el_type],)
+        x (object): Object to check.
 
-    @property
-    def parametric(self):
-        return True
-
-    @property
-    def runtime_type_of(self):
-        return True
-
-
-# Deliver `List`.
-PromisedList.deliver(List)
-
-
-@parametric
-class _ParametricTuple(tuple):
-    """Parametric tuple type."""
-
-    @classmethod
-    def __iter_el_type__(cls):
-        p = cls.type_parameter
-        return Union(*(p if isinstance(p, tuple) else (p,)))
-
-    @classmethod
-    def __getitem_el_type__(cls):
-        return cls.__iter_el_type__()
-
-
-class Tuple(ComparableType):
-    """Parametric tuple Plum type.
-
-    IMPORTANT:
-        `Tuple` should not be used to generically refer to a tuple! Use `tuple` instead.
-
-    Args:
-        *el_types (type or ptype): Element types.
-    """
-
-    def __init__(self, *el_types):
-        self._el_types = tuple(ptype(el_type) for el_type in el_types)
-
-    def __hash__(self):
-        return multihash(Tuple, *self._el_types)
-
-    def __repr__(self):
-        return f'Tuple[{", ".join(map(str, self._el_types))}]'
-
-    def get_types(self):
-        return (_ParametricTuple[self._el_types],)
-
-    @property
-    def parametric(self):
-        return True
-
-    @property
-    def runtime_type_of(self):
-        return True
-
-
-# Deliver `Tuple`.
-PromisedTuple.deliver(Tuple)
-
-
-@parametric
-class _ParametricDict(dict):
-    """Parametric dictionary type."""
-
-    @classmethod
-    def __iter_el_type__(cls):
-        key_type, value_type = cls.type_parameter
-        return key_type
-
-    @classmethod
-    def __getitem_el_type__(cls):
-        key_type, value_type = cls.type_parameter
-        return value_type
-
-
-class Dict(ComparableType):
-    """Parametric dictionary Plum type.
-
-    IMPORTANT:
-        `Dict` should not be used to generically refer to a dictionary! Use `dict` instead.
-
-    Args:
-        key_type (type or ptype): Type of the keys.
-        value_type (type or ptype): Type of the values.
+    Returns:
+        bool: Whether `x` is a type or a type hint.
     """
-
-    def __init__(self, key_type, value_type):
-        self._key_type = ptype(key_type)
-        self._value_type = ptype(value_type)
-
-    def __hash__(self):
-        return multihash(Dict, self._key_type, self._value_type)
-
-    def __repr__(self):
-        return f"Dict[{self._key_type}, {self._value_type}]"
-
-    def get_types(self):
-        return (_ParametricDict[self._key_type, self._value_type],)
-
-    @property
-    def parametric(self):
-        return True
-
-    @property
-    def runtime_type_of(self):
+    try:
+        TypeHint(x)
         return True
+    except BeartypeDoorNonpepException:
+        return False
 
 
-# Deliver `Dict`.
-PromisedDict.deliver(Dict)
-
-
-class ElementTypeMeta(CovariantMeta):
-    """Metaclass of containers of elements with certain magic methods."""
-
-    @property
-    @abc.abstractmethod
-    def required_methods(cls):
-        """list[str]: Required methods."""
-
-    @property
-    @abc.abstractmethod
-    def el_type_method(cls):
-        """str: Method to get the element type."""
-
-    def __subclasscheck__(cls, subclass):
-        if all(hasattr(subclass, name) for name in cls.required_methods):
-            if is_concrete(cls) and is_concrete(subclass):
-                if hasattr(subclass, cls.el_type_method):
-                    subclass_el_type = getattr(subclass, cls.el_type_method)()
-                else:
-                    return False
-                return ptype(subclass_el_type) <= ptype(cls.type_parameter)
-            elif is_concrete(subclass):
-                # Case of `subclass[par] <= cls`. This is always true.
-                return True
-            elif is_concrete(cls):
-                # Case of `subclass <= cls[par]`. This is never true.
-                return False
-            else:
-                # Case of `subclass <= cls`. This is also always true.
-                return True
-        return CovariantMeta.__subclasscheck__(cls, subclass)
-
-
-class IterableMeta(ElementTypeMeta):
-    """Metaclass of iterables."""
-
-    @property
-    def required_methods(cls):
-        return ["__iter__"]
-
-    @property
-    def el_type_method(cls):
-        return "__iter_el_type__"
-
-
-@parametric(metaclass=IterableMeta)
-class _ParametricIterable:
-    """Parametric iterable type."""
-
-
-class _NotSpecified:
-    pass
-
-
-class Iterable(ComparableType):
-    """Parametric iterable Plum type.
+def type_parameter(x):
+    """Get the type parameter of concrete parametric type or an instance of a concrete
+    parametric type.
 
     Args:
-        el_type (type or ptype, optional): Type of the elements.
-    """
-
-    def __init__(self, el_type=_NotSpecified):
-        if el_type is _NotSpecified:
-            self._el_type = None
-        else:
-            self._el_type = ptype(el_type)
-
-    def __hash__(self):
-        if self._el_type is None:
-            return hash(Iterable)
-        else:
-            return multihash(Iterable, self._el_type)
+        x (object): Concrete parametric type or instance thereof.
 
-    def __repr__(self):
-        if self._el_type is None:
-            return "Iterable"
-        else:
-            return f"Iterable[{self._el_type}]"
-
-    def get_types(self):
-        if self._el_type is None:
-            return (_ParametricIterable,)
-        else:
-            return (_ParametricIterable[self._el_type],)
-
-    @property
-    def parametric(self):
-        return True
-
-    @property
-    def runtime_type_of(self):
-        return self._el_type is not None
-
-
-# Deliver `Iterable`.
-PromisedIterable.deliver(Iterable)
-
-
-class SequenceMeta(ElementTypeMeta):
-    """Metaclass of sequences."""
-
-    @property
-    def required_methods(cls):
-        return ["__getitem__", "__len__"]
-
-    @property
-    def el_type_method(cls):
-        return "__getitem_el_type__"
-
-
-@parametric(metaclass=SequenceMeta)
-class _ParametricSequence:
-    """Parametric sequence type."""
-
-
-class Sequence(ComparableType):
-    """Parametric iterable Plum type.
-
-    Args:
-        el_type (type or ptype, optional): Type of the elements.
+    Returns:
+        object: Type parameter.
     """
-
-    def __init__(self, el_type=_NotSpecified):
-        if el_type is _NotSpecified:
-            self._el_type = None
-        else:
-            self._el_type = ptype(el_type)
-
-    def __hash__(self):
-        if self._el_type is None:
-            return hash(Sequence)
-        else:
-            return multihash(Sequence, self._el_type)
-
-    def __repr__(self):
-        if self._el_type is None:
-            return "Sequence"
-        else:
-            return f"Sequence[{self._el_type}]"
-
-    def get_types(self):
-        if self._el_type is None:
-            return (_ParametricSequence,)
-        else:
-            return (_ParametricSequence[self._el_type],)
-
-    @property
-    def parametric(self):
-        return True
-
-    @property
-    def runtime_type_of(self):
-        return self._el_type is not None
-
-
-# Deliver `Sequence`.
-PromisedSequence.deliver(Sequence)
-
-
-def _types_of_iterable(xs):
-    types = {type_of(x) for x in xs}
-    if len(types) == 1:
-        return list(types)[0]
+    if is_type(x):
+        t = x
     else:
-        return Union(*types)
+        t = type(x)
+    if hasattr(t, "parametric"):
+        return t.type_parameter
+    raise ValueError(
+        f"`{x}` is not a concrete parametric type or an instance of a"
+        f" concrete parametric type."
+    )
 
 
-@_dispatch
-def type_of(obj):
-    """Get the Plum type of an object.
+def kind(SuperClass=object):
+    """Create a parametric wrapper type for dispatch purposes.
 
     Args:
-        obj (object): Object to get type of.
+        SuperClass (type): Super class.
 
-    Returns
-        ptype: Plum type of `obj`.
+    Returns:
+        object: New parametric type wrapper.
     """
-    return ptype(type(obj))
-
 
-@_dispatch
-def type_of(obj: list):
-    return List(_types_of_iterable(obj))
-
-
-@_dispatch
-def type_of(obj: tuple):
-    return Tuple(*(type_of(x) for x in obj))
+    @parametric
+    class Kind(SuperClass):
+        def __init__(self, *xs):
+            self.xs = xs
 
+        def get(self):
+            return self.xs[0] if len(self.xs) == 1 else self.xs
 
-@_dispatch
-def type_of(obj: dict):
-    return Dict(_types_of_iterable(obj.keys()), _types_of_iterable(obj.values()))
+    return Kind
 
 
-# Deliver `type_of`.
-promised_type_of1.deliver(type_of)
-promised_type_of2.deliver(type_of)
+Kind = kind()  #: A default kind provided for convenience.
 
 
 @parametric
@@ -677,7 +373,7 @@ def __init__(self, val=None):
             raise ValueError("The value must be specified.")
 
     def __repr__(self):
-        return f"{repr(ptype(type(self)))}()"
+        return repr_short(type(self)) + "()"
 
     def __eq__(self, other):
         return type(self) == type(other)
diff --git a/plum/promotion.py b/plum/promotion.py
index 5dbbbde1..c34f8ae3 100644
--- a/plum/promotion.py
+++ b/plum/promotion.py
@@ -1,9 +1,11 @@
-import logging
+from beartype.door import TypeHint
 
+import plum.function
+
+from . import _is_bearable
 from .dispatcher import Dispatcher
-from .function import promised_convert
-from .parametric import type_of
-from .type import ptype, TypeType
+from .type import resolve_type_hint
+from .util import repr_short
 
 __all__ = [
     "convert",
@@ -13,13 +15,11 @@
     "promote",
 ]
 
-log = logging.getLogger(__name__)
-
 _dispatch = Dispatcher()
 
 
 @_dispatch
-def convert(obj, type_to: TypeType):
+def convert(obj, type_to):
     """Convert an object to a particular type.
 
     Args:
@@ -29,21 +29,21 @@ def convert(obj, type_to: TypeType):
     Returns:
         object: `obj` converted to type `type_to`.
     """
-    return _convert.invoke(type_of(obj), type_to)(obj, type_to)
+    type_to = resolve_type_hint(type_to)
+    # TODO: Can we implement this without using `type`?!
+    return _convert.invoke(type(obj), type_to)(obj, type_to)
 
 
 # Deliver `convert`.
-promised_convert.deliver(convert)
+plum.function._promised_convert = convert
 
 
 @_dispatch
 def _convert(obj, type_to):
-    type_from = type_of(obj)
-    type_to = ptype(type_to)
-    if type_from <= type_to:
+    if _is_bearable(obj, resolve_type_hint(type_to)):
         return obj
     else:
-        raise TypeError(f'Cannot convert a "{type_from}" to a "{type_to}".')
+        raise TypeError(f"Cannot convert `{obj}` to `{repr_short(type_to)}`.")
 
 
 def add_conversion_method(type_from, type_to, f):
@@ -91,20 +91,22 @@ def _promotion_rule(type1, type2):
     """Promotion rule.
 
     Args:
-        type1 (ptype): First type to promote.
-        type2 (ptype): Second type to promote.
+        type1 (type): First type to promote.
+        type2 (type): Second type to promote.
 
     Returns:
         type: Type to convert to.
     """
-    type1 = ptype(type1)
-    type2 = ptype(type2)
-    if type1 <= type2:
+    type1 = resolve_type_hint(type1)
+    type2 = resolve_type_hint(type2)
+    if TypeHint(type1) <= TypeHint(type2):
         return type2
-    elif type2 <= type1:
+    elif TypeHint(type2) <= TypeHint(type1):
         return type1
     else:
-        raise TypeError(f'No promotion rule for "{type1}" and "{type2}".')
+        raise TypeError(
+            f"No promotion rule for `{repr_short(type1)}` and `{repr_short(type2)}`."
+        )
 
 
 @_dispatch
@@ -121,11 +123,11 @@ def add_promotion_rule(type1, type2, type_to):
     def rule(t1: type1, t2: type2):
         return type_to
 
-    if ptype(type1) != ptype(type2):
+    # If the types are the same, the method will get overwritten.
 
-        @_promotion_rule.dispatch
-        def rule(t1: type2, t2: type1):
-            return type_to
+    @_promotion_rule.dispatch
+    def rule(t1: type2, t2: type1):
+        return type_to
 
 
 @_dispatch
@@ -133,7 +135,7 @@ def promote(obj1, obj2, *objs):
     """Promote objects to a common type.
 
     Args:
-        *objs (object): Objects to convert.
+        \\*objs (object): Objects to convert.
 
     Returns:
         tuple: `objs`, but all converted to a common type.
@@ -142,23 +144,28 @@ def promote(obj1, obj2, *objs):
     objs = (obj1, obj2) + objs
 
     # Get the types of the objects.
-    types = [type_of(obj) for obj in objs]
+    # TODO: Can we implement this without calling `type`?!
+    types = [type(obj) for obj in objs]
+
+    def _promote_types(t0, t1):
+        return resolve_type_hint(_promotion_rule.invoke(t0, t1)(t0, t1))
 
     # Find the common type.
-    common_type = _promotion_rule.invoke(types[0], types[1])(types[0], types[1])
-    for i, t in enumerate(types[2:]):
-        common_type = _promotion_rule.invoke(common_type, t)(common_type, t)
+    _promotion_rule._resolve_pending_registrations()
+    common_type = _promote_types(types[0], types[1])
+    for t in types[2:]:
+        common_type = _promote_types(common_type, t)
 
     # Convert objects and return.
-    return tuple(convert(obj, ptype(common_type)) for obj in objs)
+    return tuple(convert(obj, common_type) for obj in objs)
 
 
 @_dispatch
-def promote(obj):
+def promote(obj: object):
     # Promote should always return a tuple to avoid edge cases.
     return (obj,)
 
 
-@_dispatch()
+@_dispatch
 def promote():
     return ()
diff --git a/plum/resolvable.py b/plum/resolvable.py
deleted file mode 100644
index 2f4423b9..00000000
--- a/plum/resolvable.py
+++ /dev/null
@@ -1,55 +0,0 @@
-import abc
-import logging
-
-__all__ = ["ResolutionError", "Resolvable", "Promise"]
-
-log = logging.getLogger(__name__)
-
-
-class ResolutionError(RuntimeError):
-    """Object could not be resolved."""
-
-
-class Resolvable(abc.ABC):
-    """An object that can be resolved and compared."""
-
-    @abc.abstractmethod
-    def resolve(self):
-        """Resolve the object.
-
-        Returns:
-            Promised object.
-        """
-
-
-class Promise(Resolvable):
-    """An object that is promised to be resolvable when asked for."""
-
-    def __init__(self, obj=None):
-        self._obj = obj
-
-    def deliver(self, obj):
-        """Deliver the promise.
-
-        Args:
-            obj: The object to deliver.
-        """
-        self._obj = obj
-
-    def resolve(self):
-        if self._obj is None:
-            raise ResolutionError(f"Promise `{self!r}` was not kept.")
-        else:
-            return self._obj
-
-    @property
-    def resolved(self):
-        """bool: `True` if the promise has been kept."""
-        return self._obj is not None
-
-    def __repr__(self):
-        # Fallback in case subclasses don't overload `__repr__`.
-        if self.resolved:
-            return f"Promise(obj={self._obj!r})"
-        else:
-            return "Promise()"
diff --git a/plum/resolver.py b/plum/resolver.py
new file mode 100644
index 00000000..20428750
--- /dev/null
+++ b/plum/resolver.py
@@ -0,0 +1,139 @@
+import pydoc
+
+__all__ = ["AmbiguousLookupError", "NotFoundLookupError"]
+
+
+class AmbiguousLookupError(LookupError):
+    """A signature cannot be resolved due to ambiguity."""
+
+
+class NotFoundLookupError(LookupError):
+    """A signature cannot be resolved because no applicable method can be found."""
+
+
+class Resolver:
+    """Method resolver.
+
+    Attributes:
+        signatures (list[:class:`.signature.Signature`]): Registered signatures.
+        is_faithful (bool): Whether all signatures are faithful or not.
+    """
+
+    def __init__(self):
+        self.signatures = []
+        self.is_faithful = True
+
+    def doc(self, exclude=None):
+        """Concatenate the docstrings of all methods of this function. Remove duplicate
+        docstrings before concatenating.
+
+        Args:
+            exclude (function, optional): Exclude this implementation from the
+                concatenation.
+
+        Returns:
+            str: Concatenation of all docstrings.
+        """
+        # Generate all docstrings, possibly excluding `exclude`.
+        docs = [
+            pydoc.TextDoc().document(sig.implementation)
+            for sig in self.signatures
+            if not (exclude and sig.implementation == exclude)
+        ]
+        # This can yield duplicates, because of extra methods automatically generated by
+        # :func:`.signature.append_default_args`. We remove these by simply only
+        # keeping unique docstrings.
+        unique_docs = []
+        for d in docs:
+            if d not in unique_docs:
+                unique_docs.append(d)
+        return "\n".join(unique_docs)
+
+    def register(self, signature):
+        """Register a new signature.
+
+        Args:
+            signature (:class:`.signature.Signature`): Signature to add.
+        """
+        existing = [s == signature for s in self.signatures]
+        if any(existing):
+            if sum(existing) != 1:
+                raise AssertionError(
+                    f"The added signature `{signature}` is equal to {sum(existing)} "
+                    f"existing signatures. This should never happen."
+                )
+            self.signatures[existing.index(True)] = signature
+        else:
+            self.signatures.append(signature)
+
+        # Use a double negation for slightly better performance.
+        self.is_faithful = not any(not s.is_faithful for s in self.signatures)
+
+    def __len__(self):
+        return len(self.signatures)
+
+    def resolve(self, target):
+        """Find the most specific signature that satisfies a target.
+
+        Args:
+            target (:class:`.signature.Signature` or tuple[object]): Target to resolve.
+                Must be either a signature to be encompassed or a tuple of arguments.
+
+        Returns:
+            :class:`.signature.Signature`: The most specific signature satisfying
+                `target`.
+        """
+        if isinstance(target, tuple):
+
+            def check(s):
+                # `target` are concrete arguments.
+                return s.match(target)
+
+        else:
+
+            def check(s):
+                # `target` is a signature that must be encompassed.
+                return target <= s
+
+        candidates = []
+        for signature in [s for s in self.signatures if check(s)]:
+            # If none of the candidates are comparable, then add the method as
+            # a new candidate and continue.
+            if not any(c.is_comparable(signature) for c in candidates):
+                candidates += [signature]
+                continue
+
+            # The signature under consideration is comparable with at least one
+            # of the candidates. First, filter any strictly more general candidates.
+            new_candidates = [c for c in candidates if not signature < c]
+
+            # If the signature under consideration is as specific as at least
+            # one candidate, then and only then add it as a candidate.
+            if any(signature <= c for c in candidates):
+                candidates = new_candidates + [signature]
+            else:
+                candidates = new_candidates
+
+        if len(candidates) == 0:
+            # There is no matching signature.
+            raise NotFoundLookupError(f"`{target}` could not be resolved.")
+        elif len(candidates) == 1:
+            # There is exactly one matching signature. Success!
+            return candidates[0]
+        else:
+            # There are multiple matching signatures. Before raising an exception,
+            # attempt to resolve the ambiguity using the precedence of the signatures.
+            precedences = [c.precedence for c in candidates]
+            max_precendence = max(precedences)
+            if sum([p == max_precendence for p in precedences]) == 1:
+                return candidates[precedences.index(max_precendence)]
+            else:
+                # Could not resolve the ambiguity, so error. First, make a nice list
+                # of the candidates and their precedences.
+                listed_candidates = "\n  ".join(
+                    [f"{c} (precedence: {c.precedence})" for c in candidates]
+                )
+                raise AmbiguousLookupError(
+                    f"`{target}` is ambiguous among the following:\n"
+                    f"  {listed_candidates}"
+                )
diff --git a/plum/signature.py b/plum/signature.py
index a6288957..3b2c9ae0 100644
--- a/plum/signature.py
+++ b/plum/signature.py
@@ -1,112 +1,293 @@
-import logging
+import inspect
+import operator
+import typing
 
-from .type import ptype, VarArgs
-from .util import Comparable, multihash
+from beartype.door import TypeHint
+from beartype.peps import resolve_pep563
 
-__all__ = ["Signature"]
+from . import _is_bearable
+from .type import is_faithful, resolve_type_hint
+from .util import Comparable, Missing, multihash, repr_short, wrap_lambda
 
-log = logging.getLogger(__name__)
+__all__ = ["Signature", "extract_signature", "append_default_args"]
 
 
 class Signature(Comparable):
     """Signature.
 
     Args:
-        *types (type or ptype): Types of the arguments.
+        *types (tuple[type, ...]): Types of the arguments.
+        varargs (type, optional): Type of the variable arguments.
+        return_type (type, optional): Type of the return value. Defaults to `Any`.
+        precedence (int, optional): Precedence. Default to `0`.
+        implementation (function, optional): Implementation.
+
+    Attributes:
+        types (tuple[type, ...]): Types of the arguments.
+        varargs (type or :class:`.util.Missing`): Type of the variable number of
+            arguments.
+        has_varargs (bool): Whether `varargs` is not :class:`.util.Missing`.
+        return_type (type): Return type.
+        precedence (int): Precedence.
+        implementation (function or None): Implementation.
+        is_faithful (bool): Whether this signature only uses faithful types.
     """
 
-    def __init__(self, *types):
-        # Ensure that all types are Plum types.
-        self.types = tuple(ptype(t) for t in types)
+    _default_varargs = Missing
+    _default_return_type = typing.Any
+    _default_precedence = 0
+
+    def __init__(
+        self,
+        *types,
+        varargs=_default_varargs,
+        return_type=_default_return_type,
+        precedence=_default_precedence,
+        implementation=None,
+    ):
+        self.types = types
+        self.varargs = varargs
+        self.return_type = return_type
+        self.precedence = precedence
+        self.implementation = implementation
+
+        types_are_faithful = all(is_faithful(t) for t in types)
+        varargs_are_faithful = self.varargs is Missing or is_faithful(self.varargs)
+        self.is_faithful = types_are_faithful and varargs_are_faithful
+
+    @property
+    def has_varargs(self):
+        return self.varargs is not Missing
 
-        # Ensure that only the last type possibly represents varargs.
-        if any([isinstance(t, VarArgs) for t in self.types[:-1]]):
-            raise TypeError("Only the last type can represent varargs.")
+    def __copy__(self):
+        return Signature(
+            *self.types,
+            varargs=self.varargs,
+            return_type=self.return_type,
+            precedence=self.precedence,
+            implementation=self.implementation,
+        )
 
     def __repr__(self):
-        return "Signature({})".format(", ".join(map(repr, self.types)))
+        parts = []
+        if self.types:
+            parts.append(", ".join(map(repr_short, self.types)))
+        if self.varargs != Signature._default_varargs:
+            parts.append("varargs=" + repr_short(self.varargs))
+        if self.return_type != Signature._default_return_type:
+            parts.append("return_type=" + repr_short(self.return_type))
+        if self.precedence != Signature._default_precedence:
+            parts.append("precedence=" + repr(self.precedence))
+        if self.implementation:
+            parts.append("implementation=" + repr(self.implementation))
+        return "Signature(" + ", ".join(parts) + ")"
 
     def __hash__(self):
-        return multihash(Signature, *self.types)
-
-    def __len__(self):
-        return len(self.base)
+        return multihash(Signature, *self.types, self.varargs)
 
-    def expand_varargs_to(self, other):
-        """Expand varargs to a given :class:`.signature.Signature`.
+    def expand_varargs(self, n):
+        """Expand variable arguments.
 
         Args:
-            other (:class:`.signature.Signature`): Other tuple to expand to.
+            n (int): Desired number of types.
 
         Returns:
-            tuple[ptype]: Tuple of Plum types that matches `other`.
+            tuple[type, ...]: Expanded types.
         """
-        if self.has_varargs():
-            expansion_size = max(len(other) - len(self), 0)
-            types = self.base + self.types[-1].expand(expansion_size)
-            return types
+        if self.has_varargs:
+            expansion_size = max(n - len(self.types), 0)
+            return self.types + (self.varargs,) * expansion_size
         else:
-            return self.base
+            return self.types
 
     def __le__(self, other):
-        # Check varargs.
-        if self.has_varargs() and not other.has_varargs():
+        # If this signature has variable arguments, but the other does not, then this
+        # signature cannot be possibly smaller.
+        if self.has_varargs and not other.has_varargs:
             return False
-        elif (
-            self.has_varargs()
-            and other.has_varargs()
-            and self.varargs_type > other.varargs_type
+
+        # If this signature and the other signature both have variable arguments, then
+        # the variable type of this signature must be less than the variable type of the
+        # other signature.
+        if (
+            self.has_varargs
+            and other.has_varargs
+            and not (TypeHint(self.varargs) <= TypeHint(other.varargs))
         ):
             return False
 
-        # Check compatibility.
-        if not self.is_compatible(other):
+        # If the number of types of the signatures are unequal, then the signature
+        # with the fewer number of types must be expanded using variable arguments.
+        if not (
+            len(self.types) == len(other.types)
+            or (len(self.types) > len(other.types) and other.has_varargs)
+            or (len(self.types) < len(other.types) and self.has_varargs)
+        ):
             return False
 
-        # Finally, compare.
+        # Finally, expand the types and compare.
+        self_types = self.expand_varargs(len(other.types))
+        other_types = other.expand_varargs(len(self.types))
         return all(
-            [
-                x <= y
-                for x, y in zip(
-                    self.expand_varargs_to(other), other.expand_varargs_to(self)
-                )
-            ]
+            [TypeHint(x) <= TypeHint(y) for x, y in zip(self_types, other_types)]
         )
 
-    @property
-    def base(self):
-        """Base of the tuple."""
-        return self.types[:-1] if self.has_varargs() else self.types
+    def match(self, values):
+        """Check whether values match the signature.
 
-    def has_varargs(self):
-        """Check whether this signature has varargs.
+        Args:
+            values (tuple): Values.
 
         Returns:
-            bool: `True` if and only if this signature has varargs.
+            bool: `True` if `values` match this signature and `False` otherwise.
         """
-        return len(self.types) > 0 and isinstance(self.types[-1], VarArgs)
+        # `values` must either be exactly many as `self.types`. If there are more
+        # `values`, then there must be variable arguments to cover the arguments.
+        if not (
+            len(self.types) == len(values)
+            or (len(self.types) < len(values) and self.has_varargs)
+        ):
+            return False
+        else:
+            types = self.expand_varargs(len(values))
+            return all(_is_bearable(v, t) for v, t in zip(values, types))
 
-    @property
-    def varargs_type(self):
-        """Type of the varargs."""
-        if not self.has_varargs():
-            raise RuntimeError(
-                "Type of varargs requested, but tuple does not have varargs."
-            )
-        return self.types[-1].type
 
-    def is_compatible(self, other):
-        """Check whether this signature is compatible with another one.
+def _inspect_signature(f):
+    """Wrapper of :func:`inspect.signature` which adds support for certain non-function
+    objects.
 
-        Args:
-            other (:class:`.signature.Signature`): Other signature to check
-                compatibility with.
+    Args:
+        f (object): Function-like object.
 
-        Returns:
-            bool: Compatibility.
-        """
-        return (
-            len(self) == len(other)
-            or (len(self) > len(other) and other.has_varargs())
-            or (len(self) < len(other) and self.has_varargs())
-        )
+    Returns:
+        object: Signature.
+    """
+    if isinstance(f, operator.itemgetter):
+        f = wrap_lambda(f)
+    elif isinstance(f, operator.attrgetter):
+        f = wrap_lambda(f)
+    return inspect.signature(f)
+
+
+def extract_signature(f, precedence=0):
+    """Extract the signature from a function.
+
+    Args:
+        f (function): Function to extract signature from.
+        precedence (int, optional): Precedence of the method.
+
+    Returns:
+        :class:`.Signature`: Signature.
+    """
+    if hasattr(f, "__annotations__"):
+        resolve_pep563(f)  # This mutates `f`.
+        # Override the `__annotations__` attribute, since `resolve_pep563` modifies
+        # `f` too.
+        for k, v in typing.get_type_hints(f).items():
+            f.__annotations__[k] = v
+
+    # Extract specification.
+    sig = _inspect_signature(f)
+
+    # Get types of arguments.
+    types = []
+    varargs = Missing
+    for arg in sig.parameters:
+        p = sig.parameters[arg]
+
+        # Parse and resolve annotation.
+        if p.annotation is inspect.Parameter.empty:
+            annotation = typing.Any
+        else:
+            annotation = resolve_type_hint(p.annotation)
+
+        # Stop once we have seen all positional parameter without a default value.
+        if p.kind in {p.KEYWORD_ONLY, p.VAR_KEYWORD}:
+            break
+
+        if p.kind == p.VAR_POSITIONAL:
+            # Parameter indicates variable arguments.
+            varargs = annotation
+        else:
+            # Parameter is a regular positional parameter.
+            types.append(annotation)
+
+        # If there is a default parameter, make sure that it is of the annotated type.
+        if p.default is not inspect.Parameter.empty:
+            if not _is_bearable(p.default, annotation):
+                raise TypeError(
+                    f"Default value `{p.default}` is not an instance "
+                    f"of the annotated type `{repr_short(annotation)}`."
+                )
+
+    # Get possible return type.
+    if sig.return_annotation is inspect.Parameter.empty:
+        return_type = typing.Any
+    else:
+        return_type = resolve_type_hint(sig.return_annotation)
+
+    # Assemble signature.
+    signature = Signature(
+        *types,
+        varargs=varargs,
+        return_type=return_type,
+        precedence=precedence,
+        implementation=f,
+    )
+
+    return signature
+
+
+def append_default_args(signature, f):
+    """Returns a list of signatures of function `f`, where those signatures are derived
+    from the input arguments of `f` by treating every non-keyword-only argument with a
+    default value as a keyword-only argument turn by turn.
+
+    Args:
+        f (function): Function to extract default arguments from.
+        signature (:class:`.signature.Signature`): Signature of `f` from which to
+            remove default arguments.
+
+    Returns:
+        list[:class:`.signature.Signature`]: list of signatures excluding from 0 to all
+        default arguments.
+    """
+    # Extract specification.
+    f_signature = _inspect_signature(f)
+
+    signatures = [signature]
+
+    arg_names = list(f_signature.parameters.keys())
+    # We start at the end and, once we reach non-keyword-only arguments, delete the
+    # argument with defaults values one by one. This generates a sequence of signatures,
+    # which we return.
+    arg_names.reverse()
+
+    for arg in arg_names:
+        p = f_signature.parameters[arg]
+
+        # Ignore variable arguments and keyword arguments.
+        if p.kind in {p.VAR_KEYWORD, p.KEYWORD_ONLY}:
+            continue
+
+        # Stop when non-variable arguments without a default are reached.
+        if p.kind != p.VAR_POSITIONAL and p.default is inspect.Parameter.empty:
+            break
+
+        # Skip variable arguments. These will always be removed.
+        if p.kind == p.VAR_POSITIONAL:
+            continue
+
+        copy = signatures[-1].__copy__()
+
+        # As specified over, these additional signatures should never have variable
+        # arguments.
+        copy.varargs = Missing
+
+        # Remove the last positional argument.
+        copy.types = copy.types[:-1]
+
+        signatures.append(copy)
+
+    return signatures
diff --git a/plum/type.py b/plum/type.py
index fec85403..31c47650 100644
--- a/plum/type.py
+++ b/plum/type.py
@@ -1,472 +1,305 @@
 import abc
-import inspect
-import logging
 import sys
+import typing
+import warnings
 
-from .resolvable import Resolvable, Promise
-from .util import multihash, Comparable, unquote
+from .util import get_args, get_origin
 
-# Define the type for `A | B` expressions.
-if sys.version_info >= (3, 10):  # pragma: specific no cover 3.7 3.8 3.9
+try:  # pragma: specific no cover 3.7 3.8 3.9
     from types import UnionType
-else:  # pragma: specific no cover 3.10
+except ImportError:  # pragma: specific no cover 3.10
 
     class UnionType:
-        pass
+        """Replacement for :class:`types.UnionType`."""
 
 
-__all__ = [
-    "TypeMeta",
-    "VarArgs",
-    "promised_type_of",
-    "subclasscheck_cache",
-    "ComparableType",
-    "Union",
-    "Type",
-    "ResolvableType",
-    "PromisedType",
-    "deliver_forward_reference",
-    "PromisedList",
-    "PromisedTuple",
-    "PromisedDict",
-    "PromisedIterable",
-    "PromisedSequence",
-    "ptype",
-    "TypeType",
-    "is_object",
-    "is_type",
-]
-log = logging.getLogger(__name__)
-
-
-class TypeMeta(abc.ABCMeta):
-    """Types can also be instantiated with indexing."""
-
-    def __getitem__(self, p):
-        if isinstance(p, tuple):
-            return self(*p)
-        else:
-            return self(p)
-
-
-class AbstractType(metaclass=TypeMeta):
-    """An abstract class defining the top of the Plum type hierarchy.
-
-    Any instance of a subclass of :class:`.type.AbstractType` will be henceforth
-    referred to be of type Plum type or `ptype`.
-    """
-
-    @abc.abstractmethod
-    def __hash__(self):
-        pass  # pragma: no cover
+try:  # pragma: specific no cover 3.7
+    from typing import Literal
+except ImportError:  # pragma: specific no cover 3.8 3.9 3.10
 
-    @abc.abstractmethod
-    def __repr__(self):
-        pass  # pragma: no cover
+    class Literal:
+        """A simple proxy for :class:`typing.Literal`."""
 
-    @property
-    @abc.abstractmethod
-    def parametric(self):
-        """bool: Boolean that indicates whether this is or contains a parametric
-        type."""
 
-    @property
-    @abc.abstractmethod
-    def runtime_type_of(self):
-        """bool: Boolean that indicates whether this type can only be determined at
-        runtime by `plum.type_of` instead of built-in `type`."""
+__all__ = [
+    "PromisedType",
+    "ModuleType",
+    "type_mapping",
+    "resolve_type_hint",
+    "is_faithful",
+]
 
 
-class VarArgs(AbstractType):
-    """Plum type that represents a variable number of the same Plum type.
+class ResolvableType(type):
+    """A resolvable type that will resolve to `type` after `type` has been delivered via
+    :meth:`.ResolvableType.deliver`. Before then, it will resolve to itself.
 
     Args:
-        type (type or ptype, optional): Type or Plum type of the variable
-            number of types. Defaults to `object`.
+        name (str): Name of the type to be delivered.
     """
 
-    __slots__ = ["type"]
-
-    def __init__(self, type=object):
-        self.type = ptype(type)
-
-    def __hash__(self):
-        return multihash(VarArgs, self.type)
+    def __init__(self, name):
+        type.__init__(self, name, (), {})
+        self._type = None
 
-    def __repr__(self):
-        return "VarArgs({})".format(repr(self.type))
+    def __new__(self, name):
+        return type.__new__(self, name, (), {})
 
-    def expand(self, num):
-        """Expand the varargs to a tuple of types.
+    def deliver(self, type):
+        """Deliver the type.
 
         Args:
-            num (int): Length of the tuple.
-
-        Returns:
-            tuple: Expansion.
-        """
-        return (self.type,) * num
-
-    @property
-    def parametric(self):
-        return self.type.parametric
-
-    @property
-    def runtime_type_of(self):
-        return self.type.runtime_type_of
-
-
-promised_type_of = Promise()  # Resolves to `.parametric.type_of`.
-subclasscheck_cache = {}  # Cache results of `__subclasscheck__`.
-
-
-class ComparableType(AbstractType, Comparable):
-    """A Plum type that can be compared to other Plum types."""
-
-    def __le__(self, other):
-        return issubclass(self, other)
-
-    def __subclasscheck__(self, subclass):
-        # Cache results for performance.
-        key = hash(self), hash(subclass)
-        try:
-            return subclasscheck_cache[key]
-        except KeyError:
-            check = all([issubclass(t, self.get_types()) for t in subclass.get_types()])
-            subclasscheck_cache[key] = check  # Cache result of check.
-            return check
-
-    def __instancecheck__(self, instance):
-        return issubclass(promised_type_of.resolve()(instance), self)
-
-    @abc.abstractmethod
-    def get_types(self):
-        """Get the types encapsulated by this Plum type.
+            type (type): Type to deliver.
 
         Returns:
-            tuple[type]: Types encapsulated.
+            :class:`ResolvableType`: `self`.
         """
+        self._type = type
+        return self
 
-    def mro(self):
-        """Get the MRO.
+    def resolve(self):
+        """Resolve the type.
 
         Returns:
-            tuple[type]: MRO.
+            type: If no type has been delivered, this will return itself. If a type
+                `type` has been delivered via :meth:`.ResolvableType.deliver`, this will
+                return that type.
         """
-        types = self.get_types()
-        if len(types) != 1:
-            raise RuntimeError("Exactly one type must be encapsulated to get the MRO.")
-        return types[0].mro()
-
+        if self._type is None:
+            return self
+        else:
+            return self._type
 
-class Union(ComparableType):
-    """A union of Plum types.
 
-    IMPORTANT:
-        `Union` should not be used to generically refer to anything! Use
-            `Union[object]` instead.
+class PromisedType(ResolvableType):
+    """A type that is promised to be available when you will you need it.
 
     Args:
-        *types (type or ptype): Types or Plum types to encapsulate.
-        alias (str, optional): Give the union a name.
+        name (str, optional): Name of the type that is promised. Defaults to
+            `"SomeType"`.
     """
 
-    __slots__ = ["_types"]
-
-    def __init__(self, *types, alias=None):
-        # Lazily convert to a set to avoid resolution errors.
-        self._types = tuple(ptype(t) for t in types)
-
-        # Constuct alias if one is given.
-        if alias:
-            frame = inspect.stack()[1]
-            module = inspect.getmodule(frame[0])
-            if module:
-                self.alias = f"{module.__name__}.{alias}"
-            else:  # pragma: no cover
-                self.alias = alias
-        else:
-            self.alias = None
-
-    def _to_set(self):
-        if not isinstance(self._types, set):
-            self._types = set(self._types)
-
-    def __hash__(self):
-        self._to_set()
-        if len(self._types) == 1:
-            return hash(list(self._types)[0])
-        else:
-            return multihash(Union, frozenset(self._types))
-
-    def __repr__(self):
-        self._to_set()
-
-        # Show the alias if one is given.
-        if self.alias is not None:
-            return self.alias
+    def __init__(self, name="SomeType"):
+        ResolvableType.__init__(self, f"PromisedType[{name}]")
+        self._name = name
 
-        # Otherwise, show all the types it contains.
-        if len(self._types) == 1:
-            return repr(list(self._types)[0])
-        else:
-            return "Union[" + ", ".join(repr(t) for t in self._types) + "]"
-
-    def get_types(self):
-        self._to_set()
-        return sum([t.get_types() for t in self._types], ())
-
-    @property
-    def parametric(self):
-        self._to_set()
-        return any(t.parametric for t in self._types)
+    def __new__(cls, name="SomeType"):
+        return ResolvableType.__new__(cls, f"PromisedType[{name}]")
 
-    @property
-    def runtime_type_of(self):
-        self._to_set()
-        return any(t.runtime_type_of for t in self._types)
 
-
-class Type(ComparableType):
-    """A Plum type encapsulating a single type.
+class ModuleType(ResolvableType):
+    """A type from another module.
 
     Args:
-        type (type): Type to encapsulate.
+        module (str): Module that the type lives in.
+        name (str): Name of the type that is promised.
     """
 
-    __slots__ = ["_type"]
-
-    def __init__(self, type):
-        self._type = type
+    def __init__(self, module, name):
+        if module in {"__builtin__", "__builtins__"}:
+            module = "builtins"
+        ResolvableType.__init__(self, f"ModuleType[{module}.{name}]")
+        self._name = name
+        self._module = module
 
-    def __hash__(self):
-        return multihash(Type, self._type)
+    def __new__(cls, module, name):
+        return ResolvableType.__new__(cls, f"ModuleType[{module}.{name}]")
 
-    def __repr__(self):
-        return f"{self._type.__module__}.{self._type.__name__}"
+    def retrieve(self):
+        """Attempt to retrieve the type from the reference module.
 
-    def get_types(self):
-        return (self._type,)
-
-    @property
-    def parametric(self):
-        # Types don't specify `parametric` except for Plum parametric types.
-        return hasattr(self._type, "parametric") and self._type.parametric
-
-    @property
-    def runtime_type_of(self):
-        # Types don't specify `runtime_type_of` except for custom Plum types.
-        return hasattr(self._type, "runtime_type_of") and self._type.runtime_type_of
-
-
-class ResolvableType(ComparableType, Resolvable):
-    """A resolvable Plum type."""
-
-    def __hash__(self):
-        return hash(ptype(self.resolve()))
-
-    def __repr__(self):
-        if self.resolved:
-            return repr(ptype(self.resolve()))
-        else:
-            return "<ResolvableType: unresolved>"
-
-    def get_types(self):
-        return ptype(self.resolve()).get_types()
-
-    @property
-    def parametric(self):
-        return ptype(self.resolve()).parametric
-
-    @property
-    def runtime_type_of(self):
-        return ptype(self.resolve()).runtime_type_of
-
-
-class PromisedType(ResolvableType, Promise):
-    """A promised Plum type."""
-
-
-_unresolved_forward_references = []
+        Returns:
+            :class:`ModuleType`: `self`.
+        """
+        if self._type is None:
+            if self._module in sys.modules:
+                type = sys.modules[self._module]
+                for name in self._name.split("."):
+                    type = getattr(type, name)
+                self.deliver(type)
+        return self._type is not None
 
 
-def get_forward_reference(name):
-    """Get a type referring to a forward referenced type.
+def _is_hint(x):
+    """Check if an object is a type hint.
 
     Args:
-        name (str): Name of type.
+        x (object): Object.
 
     Returns:
-        ptype: Type referring to `name`.
-    """
-    # The name can possibly be wrapped in an extra set of quotes if the future
-    # `annotations` is used and the type is given as a string. E.g., see
-    #   https://github.com/wesselb/plum/issues/41
-    name = unquote(name)
-    reference = ForwardReferencedType(name)
-    _unresolved_forward_references.append(reference)
-    return reference
-
-
-def deliver_forward_reference(type):
-    """Deliver a forward reference.
-
-    Args:
-        type (type): Type to deliver.
+        bool: `True` if `x` is a type hint and `False` otherwise.
     """
-    currently_unresolved_forward_references = _unresolved_forward_references.copy()
-    _unresolved_forward_references.clear()
-    for reference in currently_unresolved_forward_references:
-        if reference.name == type.__name__:
-            reference.deliver(Type(type))
-        else:
-            _unresolved_forward_references.append(reference)
-
-
-class ForwardReferencedType(PromisedType):
-    """A Plum type referring to forward-references type.
+    try:
+        if x.__module__ == "builtins":  # pragma: specific no cover 3.7 3.8
+            # Check if `x` is a subscripted built-in. We do this by checking the module
+            # of the type of `x`.
+            x = type(x)
+        return x.__module__ in {
+            "types",  # E.g., `tuple[int]`
+            "typing",
+            "collections.abc",  # E.g., `Callable`
+        }
+    except AttributeError:
+        return False
+
+
+def _hashable(x):
+    """Check if an object is hashable.
 
     Args:
-        name (str): Name of the type.
+        x (object): Object to check.
 
-    Attributes:
-        name (str): Name of the type.
+    Returns:
+        bool: `True` if `x` is hashable and `False` otherwise.
     """
-
-    def __init__(self, name):
-        self.name = name
-        PromisedType.__init__(self)
-
-    def __repr__(self):
-        if self.resolved:
-            return super().__repr__()
-        else:
-            return f"ForwardReferencedType(name={self.name!r})"
+    try:
+        hash(x)
+        return True
+    except TypeError:
+        return False
 
 
-PromisedList = Promise()  # This will resolve to `.parametric.List`.
-PromisedTuple = Promise()  # This will resolve to `.parametric.Tuple`.
-PromisedDict = Promise()  # This will resolve to `.parametric.Dict`.
-PromisedIterable = Promise()  # This will resolve to `.parametric.Iterable`.
-PromisedSequence = Promise()  # This will resolve to `.parametric.Sequence`.
+type_mapping = {}
+"""dict: When running :func:`resolve_type_hint`, map keys in this dictionary to the
+values."""
 
 
-def ptype(obj):
-    """Convert object to a type.
+def resolve_type_hint(x):
+    """Resolve all :class:`ResolvableType` in a type or type hint.
 
     Args:
-        obj (object): Object to convert to type.
+        x (type or type hint): Type hint.
 
     Returns:
-        :class:`.type.AbstractType`: Plum type corresponding to `obj`.
+        type or type hint: `x`, but with all :class:`ResolvableType`\\s resolved.
     """
-    # If `obj` is already a Plum type, we're done.
-    if isinstance(obj, AbstractType):
-        return obj
-
-    # Handle mapping from `typing` module.
-    if hasattr(obj, "__module__") and obj.__module__ == "typing":
-        # Print type as string and remove the module prefix.
-        obj_str = str(obj)
-        if obj_str.startswith("typing."):
-            obj_str = str(obj)[len("typing.") :]
-
-        # Dissect the type.
-        parts = obj_str.split("[")
-        obj_str = parts[0]
-        obj_is_parametrised = len(parts) > 1
-
-        # Remove a potential argument.
-        obj_str = obj_str.split("(")[0]
-
-        if obj_str[0] == "<":  # pragma: no cover
-            # The type is private. This is a type of a `typing` type. This case is
-            # necessary for Python 3.7+.
-            pass
-        elif obj_str in ("Union", "Optional"):
-            if obj_is_parametrised:
-                return Union(*(ptype(t) for t in obj.__args__))
-            else:
-                return Union(object)
-        elif obj_str == "List":
-            if obj_is_parametrised:
-                return PromisedList.resolve()(*(ptype(t) for t in obj.__args__))
-            else:
-                return Type(list)
-        elif obj_str == "Tuple":
-            if obj_is_parametrised:
-                return PromisedTuple.resolve()(*(ptype(t) for t in obj.__args__))
-            else:
-                return Type(tuple)
-        elif obj_str == "Dict":
-            if obj_is_parametrised:
-                return PromisedDict.resolve()(*(ptype(t) for t in obj.__args__))
-            else:
-                return Type(dict)
-        elif obj_str == "Iterable":
-            if obj_is_parametrised:
-                return PromisedIterable.resolve()(*(ptype(t) for t in obj.__args__))
-            else:
-                return PromisedIterable.resolve()()
-        elif obj_str == "Sequence":
-            if obj_is_parametrised:
-                return PromisedSequence.resolve()(*(ptype(t) for t in obj.__args__))
+    if _hashable(x) and x in type_mapping:
+        return resolve_type_hint(type_mapping[x])
+    elif _is_hint(x):
+        origin = get_origin(x)
+        args = get_args(x)
+        if args == ():
+            # `origin` might not make sense here. For example, `get_origin(Any)` is
+            # `None`. Since the hint wasn't subscripted, the right thing is to right the
+            # hint itself.
+            return x
+        else:
+            if origin is UnionType:  # pragma: specific no cover 3.7 3.8 3.9
+                # The new union syntax was used.
+                y = args[0]
+                for arg in args[1:]:
+                    y = y | arg
+                return y
             else:
-                return PromisedSequence.resolve()()
-        elif obj_str == "ForwardRef" or obj_str == "_ForwardRef":
-            # This depends on the implementation below!
-            obj = obj.__forward_arg__
-        elif obj_str == "Any":
-            return Type(object)
-        elif obj_str == "Callable":
-            return Type(obj)
+                # Do not resolve the arguments for `Literal`s.
+                if origin != Literal:
+                    args = resolve_type_hint(args)
+                try:
+                    return origin[args]
+                except TypeError as e:  # pragma: specific no cover 3.9 3.10
+                    # In Python 3.7 and 3.8, the origin might be a type that cannot be
+                    # subscripted. As a workaround, we get the name of the type,
+                    # capitalize it, and try to get it from `typing`. So far, this
+                    # seems to have worked fine.
+                    if sys.version_info.minor <= 8:
+                        return getattr(typing, origin.__name__.capitalize())[args]
+                    else:  # pragma: no cover
+                        # This branch can never be reached.
+                        raise e
+
+    elif x is None:
+        return x
+    elif x is Ellipsis:
+        return x
+
+    elif isinstance(x, tuple):
+        return tuple(resolve_type_hint(arg) for arg in x)
+    elif isinstance(x, list):
+        return list(resolve_type_hint(arg) for arg in x)
+    elif isinstance(x, type):
+        if isinstance(x, ResolvableType):
+            if isinstance(x, ModuleType):
+                if not x.retrieve():
+                    # If the type could not be retrieved, then just return the
+                    # wrapper. Namely, `x.resolve()` will then return `x`, which means
+                    # that the below call will result in an infinite recursion.
+                    return x
+            return resolve_type_hint(x.resolve())
         else:
-            raise NotImplementedError(
-                f'There is currently no support for "{obj}". '
-                f"Please open an issue at https://github.com/wesselb/plum/issues"
-            )  # pragma: no cover
-
-    # Support Python 3.10 `int | float` syntax.
-    elif isinstance(obj, UnionType):  # pragma: specific no cover 3.7 3.8 3.9
-        return Union(*(ptype(t) for t in obj.__args__))
+            return x
 
-    # Strings are forward references.
-    if isinstance(obj, str):
-        return get_forward_reference(obj)
+    else:
+        warnings.warn(
+            f"Could not resolve the type hint of `{x}`. "
+            f"I have ended the resolution here to not make your code break, but some "
+            f"types might not be working correctly. "
+            f"Please open an issue at https://github.com/wesselb/plum.",
+            stacklevel=2,
+        )
+        return x
 
-    # If `obj` is a `type`, wrap it in a `Type`.
-    if isinstance(obj, type):
-        return Type(obj)
 
-    if obj is None:
-        return Type(type(None))
+def is_faithful(x):
+    """Check whether a type hint is faithful.
 
-    raise RuntimeError(f'Could not convert "{obj}" to a type.')
+    A type or type hint `t` is defined _faithful_ if, for all `x`, the following holds
+    true::
 
+        isinstance(x, x) == issubclass(type(x), t)
 
-TypeType = Union[type, AbstractType]
-"""ptype: The type of a type or Plum type."""
+    You can control whether types are faithful or not by setting the attribute
+    `__faithful__`::
 
-
-def is_object(t):
-    """A fast comparison to check if a Plum type is `object`.
+        class UnfaithfulType:
+            __faithful__ = False
 
     Args:
-        t (ptype): Type to check.
+        x (type or type hint): Type hint.
 
     Returns:
-        bool: `t` is `object`.
+        bool: Whether `x` is faithful or not.
     """
-    return t.get_types() == (object,)
-
-
-def is_type(t):
-    """Fast check for whether an object is a type.
-
-    Args:
-        t (object): Object to check.
-
-    Returns:
-        bool: `t` is `object`.
-    """
-    return isinstance(t, TypeType.get_types())
+    return _is_faithful(resolve_type_hint(x))
+
+
+def _is_faithful(x):
+    if _is_hint(x):
+        origin = get_origin(x)
+        args = get_args(x)
+        if args == ():
+            # Unsubscripted type hints tend to be faithful. For example, `Any`, `List`,
+            # `Tuple`, `Dict`, `Callable`, and `Generator` are. When we come across a
+            # counter-example, we will refine this logic.
+            return True
+        else:
+            if origin in {typing.Union, typing.Optional}:
+                return all(is_faithful(arg) for arg in args)
+            else:
+                return False
+
+    elif x is None:
+        return True
+    elif x == Ellipsis:
+        return True
+
+    elif isinstance(x, (tuple, list)):
+        return all(is_faithful(arg) for arg in x)
+    elif isinstance(x, type):
+        if hasattr(x, "__faithful__"):
+            return x.__faithful__
+        else:
+            # This is the fallback method. Check whether `__instancecheck__` is default
+            # or not. If it is, assume that it is faithful.
+            return type(x).__instancecheck__ in {
+                type.__instancecheck__,
+                abc.ABCMeta.__instancecheck__,
+            }
+    else:
+        warnings.warn(
+            f"Could not determine whether `{x}` is faithful or not. "
+            f"I have concluded that the type is not faithful, so your code might run "
+            f"with subpar performance. "
+            f"Please open an issue at https://github.com/wesselb/plum.",
+            stacklevel=2,
+        )
+        return False
diff --git a/plum/util.py b/plum/util.py
index 481c739c..b95963d6 100644
--- a/plum/util.py
+++ b/plum/util.py
@@ -1,30 +1,113 @@
 import abc
-import logging
+import sys
+import typing
 
 __all__ = [
-    "wrap_lambda",
+    "get_args",
+    "get_origin",
+    "repr_short",
+    "Missing",
     "multihash",
     "Comparable",
+    "wrap_lambda",
     "is_in_class",
     "get_class",
     "get_context",
-    "check_future_annotations",
-    "unquote",
 ]
 
-log = logging.getLogger(__name__)
+try:  # pragma: specific no cover 3.7
+    from typing import get_args as _get_args
+    from typing import get_origin as _get_origin
 
+    # Wrap the functions, because we'll adjust their docstrings below.
 
-def wrap_lambda(f):
-    """Wrap a function in a lambda function.
+    def get_args(x):
+        return _get_args(x)
+
+    def get_origin(x):
+        return _get_origin(x)
+
+except ImportError:  # pragma: no cover
+    import collections.abc
+
+    # The functions :func:`typing.get_origin` and :func:`typing.get_args` were only
+    # introduced in Python 3.8, but we need them already in Python 3.7. The below is
+    # a copy of their source in `typing.py` from Python 3.8. Since we copied from
+    # the source, we also do not check for coverage.
+
+    def get_origin(x):
+        if isinstance(x, typing._GenericAlias):
+            return x.__origin__
+        if x is typing.Generic:
+            return typing.Generic
+        return None
+
+    def get_args(x):
+        if isinstance(x, typing._GenericAlias) and not x._special:
+            args = x.__args__
+            if get_origin(x) is collections.abc.Callable and args[0] is not Ellipsis:
+                args = (list(args[:-1]), args[-1])
+            return args
+        return ()
+
+
+# If we were to add docstrings directly to the manual definitions of `get_origin` above,
+# then the docstrings would be different depending on whether an `ImportError` happened
+# or not. We don't want that. Hence, we set the docstrings below, regardless of which
+# case happened.
+
+get_origin.__doc__ = """Get the unsubscripted version of a type hint.
+
+Args:
+    x (type hint): Type hint.
+
+Returns:
+    type hint: Unsubcripted version of `x`.
+"""
+
+get_args.__doc__ = """Get the arguments a subscripted type hint.
+
+Args:
+    x (type hint): Type hint.
+
+Returns:
+    tuple: Arguments of `x`.
+"""
+
+
+def repr_short(x):
+    """Representation as a string, but in shorter form. This just calls
+    :func:`typing._type_repr`.
 
     Args:
-        f (function): Function to wrap.
+        x (object): Object.
 
     Returns:
-        function: Wrapped version of `f`.
+        str: Shorter representation of `x`.
     """
-    return lambda x: f(x)
+    # :func:`typing._type_repr` is an internal function, but it should be available in
+    # Python versions 3.7 through 3.11.
+    return typing._type_repr(x)
+
+
+class _MissingType(type):
+    """The type of :class:`Missing`."""
+
+    def __bool__(self):
+        # For some reason, Sphinx does attempt to evaluate `bool(Missing)`. Let's try
+        # to keep Sphinx working correctly by not raising an exception.
+        if "sphinx" in sys.modules:
+            return False
+        else:
+            raise TypeError("`Missing` has no boolean value.")
+
+
+class Missing(metaclass=_MissingType):
+    """A class that can be used to indicate that a value is missing. This class cannot
+    be instantiated and has no boolean value."""
+
+    def __init__(self):
+        raise TypeError("`Missing` cannot be instantiated.")
 
 
 def multihash(*args):
@@ -39,14 +122,12 @@ def multihash(*args):
     return hash(args)
 
 
-class Comparable:
+class Comparable(metaclass=abc.ABCMeta):
     """A mixin that makes instances of the class comparable.
 
     Requires the subclass to just implement `__le__`.
     """
 
-    __metaclass__ = abc.ABCMeta
-
     def __eq__(self, other):
         return self <= other <= self
 
@@ -70,16 +151,26 @@ def is_comparable(self, other):
         """Check whether this object is comparable with another one.
 
         Args:
-            other (:class:`.util.Comparable`): Object to check comparability
-                with.
+            other (object): Object to check comparability with.
 
         Returns:
-            bool: `True` if the object is comparable with `other` and `False`
-                otherwise.
+            Whether the object is comparable with `other`.
         """
         return self < other or self == other or self > other
 
 
+def wrap_lambda(f):
+    """Wrap a callable in a lambda function.
+
+    Args:
+        f (Callable): Function to wrap.
+
+    Returns:
+        function: Wrapped version of `f`.
+    """
+    return lambda x: f(x)
+
+
 def is_in_class(f):
     """Check if a function is part of a class.
 
@@ -87,7 +178,7 @@ def is_in_class(f):
         f (function): Function to check.
 
     Returns:
-        bool: `True` if `f` is part of a class, else `False`.
+        bool: Whether `f` is part of a class.
     """
     parts = f.__qualname__.split(".")
     return len(parts) >= 2 and parts[-2] != "<locals>"
@@ -123,7 +214,7 @@ def get_context(f):
         f (function): Method to get context for.
 
     Returns:
-        str: Context.
+        str: The context of `f`.
     """
     parts = _split_parts(f)
     if is_in_class(f):
@@ -132,33 +223,3 @@ def get_context(f):
     else:
         # Split off function name only.
         return ".".join(parts[:-1])
-
-
-def check_future_annotations(frame=None):
-    """Check if `from __future__ import annotations` is active in a frame.
-
-    Args:
-        frame (object): Frame.
-
-    Returns:
-        bool: `True` if it is activated, otherwise `False`.
-    """
-    return "annotations" in frame.f_globals
-
-
-def unquote(x):
-    """Remove quotes from a string at the outermost level.
-
-    If `x` is not a string, `x` is returned immediately.
-
-    Args:
-        x (str or object): String to remove quotes from.
-
-    Return:
-        str or object: `x` but without quotes.
-    """
-    if not isinstance(x, str):
-        return x
-    while len(x) >= 2 and x[0] == x[-1] and x[0] in {'"', "'"}:
-        x = x[1:-1]
-    return x
diff --git a/requirements.txt b/requirements.txt
index 4c7c376f..481656ec 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,7 +7,9 @@ pytest
 pytest-cov
 coveralls
 black==22.3.0
+isort
 pre-commit
 setuptools_scm[toml]
 setuptools_scm_git_archive
 IPython
+tox
diff --git a/setup.py b/setup.py
index a27d7380..0887b758 100755
--- a/setup.py
+++ b/setup.py
@@ -1,6 +1,6 @@
 from setuptools import find_packages, setup
 
-requirements = []
+requirements = ["beartype"]
 
 setup(
     packages=find_packages(include=["plum*"]),
diff --git a/todo.tasks b/todo.tasks
index 46111763..df9298b7 100644
--- a/todo.tasks
+++ b/todo.tasks
@@ -1,42 +1,19 @@
+TODO:
+    ☐ Explicitly mention that `@dispatch` can be used on magic methods. (Email from Mukesh.)
+    ☐ Standardise printing of type parameters.
+    ☐ Rename `LICENCE` to `LICENCE.txt`.
+
+Documentation:
+    ☐ Union aliases
+    ☐ Callable type
+
 Features:
     Type system:
         ☐ Type intersection.
             ☐ Show method to break ambiguity.
-        ☐ Concrete classes and type variables to perform diagonal dispatch.
-        ☐ Support typing module: parametrised generics and type variables.
-        ☐ Move to __getitem__ for instantiating types.
-        Signatures from annotations:
-            ☐ Test.
-            ☐ Keyword argument to Dispatcher.
-    Efficiency:
-        ☐ Further optimise Function.__call__ with Cython. @critical
-        ☐ Improved type resolution algorithm.
-        ☐ More efficient caching strategy.
-    Other:
         ☐ Improved formatting of error messages.
-    Documentation:
-        ☐ Union aliases.
-        ☐ Callable type.
-
- Other:
-    ☐ Is there a better alternative to numeric method precedences?
-    ☐ Refactor tests to use PyTest: remove raises, fixtures, and parametrisation. @high
-
+        ☐ Type check keyword arguments.
 
-＿＿＿＿＿＿＿＿＿＿＿＿＿＿＿＿＿＿＿
-Archive:
- ✓ Inconsistent behaviour of `A.__subclassses__()` in `test_type.test_self` and `test_dispatcher_function.test_invoke_inheritance` in Python 2.7. @done (19-04-27 22:22) @project(Bugs)
-  See note before `test_dispatcher_function.test_invoke_inheritance`.
- ✓ Split up tests. @critical @done (18-06-19 03:24) @project(Refactor)
- ✓ Docstrings: make distinction between Plum types and Python types. @critical @done (18-06-19 03:24) @project(Refactor)
- ✓ Coverage. @critical @done (18-06-19 03:24) @project(Refactor)
- ✓ mro(): check single types. @done (18-06-19 01:44) @project(Refactor)
- ✓ __repr__ and __str__. @done (18-06-19 01:44) @project(Refactor)
- ✓ Rework Union–Type hierarchy. @done (18-06-19 01:44) @project(Refactor)
- ✓ Plum types in Union/Varargs. @done (18-06-19 01:44) @project(Refactor)
- ✓ Hash consistency of promised types. @done (18-06-19 01:44) @project(Refactor)
- ✓ More neatly handle UnboundCalls. @done (18-06-19 00:14) @project(Refactor)
- ✓ Interaction between cache and adding new methods. @done (18-06-03 11:51) @project(Bugs)
- ✓ Read the Docs. @done (17-11-13 15:43) @project(Documentation)
- ✓ Union type. @done (17-11-10 17:04) @project(Features)
- ✓ Julia naming. @done (17-11-10 15:02) @project(Features)
+    Tooling:
+        ☐ Make package work with `mypy` and linters.
+            Use `@overload`?
\ No newline at end of file
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 00000000..0a070c6b
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,9 @@
+[tox]
+envlist = p37, p38, p39, p310
+
+[testenv]
+commands = 
+    black --check setup.py plum tests
+    isort --check --profile black setup.py plum tests
+    pytest -v --cov plum --cov-report term --cov-report html {posargs: tests}
+deps = -r requirements.txt


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index 811598aa..1406b775 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -2,8 +2,3 @@
 import sys
 
 sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
-
-# noinspection PyUnresolvedReferences
-from plum import *
-
-from .util import *
diff --git a/tests/dispatcher/__init__.py b/tests/advanced/__init__.py
similarity index 100%
rename from tests/dispatcher/__init__.py
rename to tests/advanced/__init__.py
diff --git a/tests/advanced/test_advanced.py b/tests/advanced/test_advanced.py
new file mode 100644
index 00000000..6780cb61
--- /dev/null
+++ b/tests/advanced/test_advanced.py
@@ -0,0 +1,339 @@
+import collections
+import math
+import operator
+from functools import wraps
+from typing import Union
+
+import pytest
+
+from plum import Dispatcher, Function, NotFoundLookupError
+
+
+def test_keywords():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int, *, option=None):
+        return x
+
+    assert f(2) == 2
+    assert f(2, option=None) == 2
+    with pytest.raises(NotFoundLookupError):
+        f(2, None)
+
+
+def test_defaults():
+    dispatch = Dispatcher()
+
+    y_default = 3
+
+    @dispatch
+    def f(x: int, y: int = y_default, *, option=None):
+        return y
+
+    @dispatch
+    def f(x: float, y: int = y_default, *, option=None):
+        return y**2
+
+    assert f(2) == y_default
+    assert f(2, option=None) == y_default
+    assert f(2, 4) == 4
+    assert f(2, y=4) == 4
+    assert f(2, y=4, option=None) == 4
+
+    assert f(2.0) == y_default**2
+    assert f(2.0, y=4) == 4**2
+
+    with pytest.raises(NotFoundLookupError):
+        f(2, 4.0)
+
+    with pytest.raises(NotFoundLookupError):
+        f(2, 4.0, option=2)
+
+    # Check that a wrong default type is caught.
+
+    with pytest.raises(TypeError):
+
+        @dispatch
+        def f_wrong_default(x: int, y: float = y_default):
+            return y
+
+        f_wrong_default._resolve_pending_registrations()
+
+    # Remove this function from global tracking. Otherwise, it might interfere with
+    # other tests.
+    Function._instances.pop(-1)
+
+    # Try multiple arguments.
+
+    @dispatch
+    def g(x: int, y: int = y_default, z: float = 3.0):
+        return (y, z)
+
+    assert g(2) == (y_default, 3.0)
+
+
+def test_redefinition():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int):
+        return "first"
+
+    assert f(1) == "first"
+
+    @dispatch
+    def f(x: int):
+        return "second"
+
+    assert f(1) == "second"
+
+
+def test_metadata_and_printing():
+    dispatch = Dispatcher()
+
+    class A:
+        @dispatch
+        def g(self):
+            """Docs of g"""
+
+    @dispatch
+    def f():
+        """Docs of f"""
+
+    assert f.__name__ == "f"
+    assert f.__qualname__ == "test_metadata_and_printing.<locals>.f"
+    assert f.__module__ == "tests.advanced.test_advanced"
+    assert f.__doc__ == "Docs of f"
+
+    assert f.invoke().__name__ == "f"
+    assert f.invoke().__qualname__ == "test_metadata_and_printing.<locals>.f"
+    assert f.invoke().__module__ == "tests.advanced.test_advanced"
+    assert f.invoke().__doc__ == "Docs of f"
+
+    for g, ts in [(A().g, ()), (A.g, (A,))]:
+        assert g.__name__ == "g"
+        assert g.__qualname__ == "test_metadata_and_printing.<locals>.A.g"
+        assert g.__module__ == "tests.advanced.test_advanced"
+        assert g.__doc__ == "Docs of g"
+
+        assert g.invoke(*ts).__name__ == "g"
+        assert g.invoke(*ts).__qualname__ == "test_metadata_and_printing.<locals>.A.g"
+        assert g.invoke(*ts).__module__ == "tests.advanced.test_advanced"
+        assert g.invoke(*ts).__doc__ == "Docs of g"
+
+
+def test_invoke():
+    dispatch = Dispatcher()
+
+    @dispatch()
+    def f():
+        return "fallback"
+
+    @dispatch
+    def f(x: int):
+        return "int"
+
+    @dispatch
+    def f(x: str):
+        return "str"
+
+    @dispatch
+    def f(x: Union[int, str, float]):
+        return "int, str, or float"
+
+    assert f() == "fallback"
+    assert f(1) == "int"
+    assert f("1") == "str"
+    assert f(1.0) == "int, str, or float"
+    assert f.invoke()() == "fallback"
+    assert f.invoke(int)("1") == "int"
+    assert f.invoke(str)(1) == "str"
+    assert f.invoke(float)(1) == "int, str, or float"
+    assert f.invoke(Union[int, str])(1) == "int, str, or float"
+    assert f.invoke(Union[int, str, float])(1) == "int, str, or float"
+
+
+dispatch = Dispatcher()
+
+
+class A:
+    def do(self, x):
+        return "fallback"
+
+
+class B(A):
+    @dispatch
+    def do(self, x: int):
+        return "int"
+
+
+class C(B):
+    @dispatch
+    def do(self, x: str):
+        return "str"
+
+
+def test_invoke_in_class():
+    c = C()
+
+    # Test bound calls.
+    assert c.do.invoke(str)("1") == "str"
+    assert c.do.invoke(int)(1) == "int"
+    assert c.do.invoke(float)(1.0) == "fallback"
+
+    # Test unbound calls.
+    assert C.do.invoke(C, str)(c, "1") == "str"
+    assert C.do.invoke(C, int)(c, 1) == "int"
+    assert C.do.invoke(C, float)(c, 1.0) == "fallback"
+
+
+def test_unassignable_annotations():
+    class A:
+        @classmethod
+        def create(cls):
+            pass
+
+    # `A.create` will have an attribute `__annotations__`, but it cannot be assigned.
+
+    f = Function(lambda: None)
+    f.dispatch(A.create)
+    f()
+
+
+@pytest.mark.parametrize(
+    "f, x, res",
+    [
+        (operator.attrgetter("x"), collections.namedtuple("NamedTuple", "x")(x=1), 1),
+        (operator.itemgetter("x"), {"x": 1}, 1),
+        (math.sqrt, 4, 2),
+    ],
+)
+def test_strange_functions(f, x, res):
+    g = Function(lambda: None)
+    g.dispatch(f)
+    assert g(x) == res
+
+
+class A2:
+    @dispatch
+    def f(self, x: "A2"):
+        return "self"
+
+    @dispatch
+    def f(self, x: str):
+        return "str"
+
+
+def test_self_reference():
+    a = A2()
+    assert a.f(a) == "self"
+    assert a.f("1") == "str"
+
+
+class A3:
+    @dispatch
+    def f(self, x: int):
+        return "int1"
+
+    class A:
+        @dispatch
+        def f(self, x: int):
+            return "int2"
+
+        @dispatch
+        def f(self, x: str):
+            return "str2"
+
+    @dispatch
+    def f(self, x: str):
+        return "str1"
+
+
+def test_nested_class():
+    a1 = A3()
+    a2 = A3.A()
+
+    assert a1.f(1) == "int1"
+    assert a1.f("1") == "str1"
+
+    assert a2.f(1) == "int2"
+    assert a2.f("1") == "str2"
+
+
+def dec(f):
+    @wraps(f)
+    def f_wrapped(*args, **kw_args):
+        return f(*args, **kw_args)
+
+    return f_wrapped
+
+
+def test_decorator():
+    dispatch = Dispatcher()
+
+    @dec
+    @dispatch
+    @dec
+    def g(x: int):
+        return "int"
+
+    @dec
+    @dispatch
+    @dec
+    def g(x: str):
+        return "str"
+
+    assert g(1) == "int"
+    assert g("1") == "str"
+
+
+class A4:
+    @dec
+    @dispatch
+    @dec
+    def f(self, x: int):
+        return "int"
+
+    @dec
+    @dispatch
+    @dec
+    def f(self, x: str):
+        return "str"
+
+
+def test_decorator_in_class():
+    a = A4()
+
+    assert a.f(1) == "int"
+    assert a.f("1") == "str"
+
+
+class A5:
+    @property
+    def name(self):
+        return "name"
+
+    @name.setter
+    @dispatch
+    def name(self, x: str):
+        return "str"
+
+
+def test_property():
+    a = A5()
+
+    assert a.name == "name"
+    a.name = "another name"
+    with pytest.raises(NotFoundLookupError):
+        a.name = 1
+
+
+def test_none():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: None) -> None:
+        return x
+
+    assert f(None) is None
diff --git a/tests/advanced/test_cases.py b/tests/advanced/test_cases.py
new file mode 100644
index 00000000..dc9a7260
--- /dev/null
+++ b/tests/advanced/test_cases.py
@@ -0,0 +1,211 @@
+import pytest
+
+from plum import AmbiguousLookupError, Dispatcher, NotFoundLookupError
+from plum.type import PromisedType
+
+dispatch = Dispatcher()
+
+
+class Num:
+    pass
+
+
+class Re(Num):
+    pass
+
+
+class Rat(Re):
+    pass
+
+
+class ComputableObject:
+    pass
+
+
+class Device:
+    @dispatch
+    def __init__(self):
+        pass
+
+    @dispatch
+    def do(self, x: Num, y: Num):
+        return "doing two numbers"
+
+    @dispatch
+    def do(self):
+        return "doing nothing"
+
+    @dispatch
+    def do(self, other: "Device"):
+        return "doing a device"
+
+    @dispatch
+    def do(self, x: Re, y: Num):
+        return "doing a real and a number"
+
+    def __add__(self, other):
+        return "unknown device"
+
+    def __radd__(self, other):
+        return other + self
+
+    @dispatch
+    def compute(self, a, b: ComputableObject):
+        return "a result"
+
+    @dispatch
+    def compute(self, a: ComputableObject, b):
+        return "another result"
+
+
+PromisedCalculator = PromisedType("Calculator")
+PromisedHammer = PromisedType("Hammer")
+
+
+class Hammer(Device):
+    @dispatch
+    def __add__(self, other: PromisedHammer):
+        return "super hammer"
+
+    @dispatch
+    def __add__(self, other: PromisedCalculator):
+        return "destroyed calculator"
+
+
+class Calculator(Device):
+    @dispatch
+    def __init__(self, size: int):
+        self.size = size
+        Device.__init__(self)
+
+    @dispatch
+    def __add__(self, other: PromisedCalculator):
+        return "super calculator"
+
+    @dispatch
+    def __add__(self, other: PromisedHammer):
+        return "destroyed calculator"
+
+    @dispatch
+    def compute(self, obj: ComputableObject):
+        return "result"
+
+
+PromisedCalculator.deliver(Calculator)
+PromisedHammer.deliver(Hammer)
+
+
+def test_method_dispatch():
+    device = Device()
+
+    assert device.do() == "doing nothing"
+    assert device.do(Rat(), Rat()) == "doing a real and a number"
+    assert device.do(Re(), Re()) == "doing a real and a number"
+    assert device.do(Num(), Re()) == "doing two numbers"
+    assert device.do(device) == "doing a device"
+
+
+def test_inheritance():
+    device = Device()
+    calc = Calculator(1)
+    hammer = Hammer()
+
+    assert device + calc == "unknown device"
+    assert Device.__add__(device, calc) == "unknown device"
+    assert calc + device == "unknown device"
+    assert Calculator.__add__(calc, device) == "unknown device"
+    assert device + hammer == "unknown device"
+    assert Device.__add__(device, hammer) == "unknown device"
+    assert hammer + device == "unknown device"
+    assert Hammer.__add__(hammer, device) == "unknown device"
+    assert hammer + hammer == "super hammer"
+    assert Hammer.__add__(hammer, hammer) == "super hammer"
+    assert calc + calc == "super calculator"
+    assert Calculator.__add__(calc, calc) == "super calculator"
+    assert hammer + calc == "destroyed calculator"
+    assert Hammer.__add__(hammer, calc) == "destroyed calculator"
+    assert calc + hammer == "destroyed calculator"
+    assert Calculator.__add__(calc, hammer) == "destroyed calculator"
+    assert calc.compute(ComputableObject()) == "result"
+    assert calc.compute(object, ComputableObject()) == "a result"
+    assert calc.compute(ComputableObject(), object) == "another result"
+
+
+def test_inheritance_exceptions():
+    calc = Calculator()
+    o = ComputableObject()
+
+    # Test instantiation.
+    with pytest.raises(NotFoundLookupError):
+        Calculator("1")
+
+    # Test method lookup.
+    with pytest.raises(NotFoundLookupError):
+        calc.compute(1)
+
+    # Test method ambiguity.
+    with pytest.raises(AmbiguousLookupError):
+        calc.compute(o, o)
+    assert calc.compute(object, o) == "a result"
+    assert calc.compute(o, object) == "another result"
+
+
+@dispatch
+def f(a: Num):
+    return "number"
+
+
+@dispatch
+def f(a: Num, b: Num):
+    return "two numbers"
+
+
+@dispatch
+def f(a: Num, b: Rat):
+    return "a number and a float"
+
+
+@dispatch
+def f(a: Num, b: Num, *cs: Num):
+    return "two or more numbers"
+
+
+@dispatch
+def f(a: Num, b: Num, *cs: Re):
+    return "two numbers and more reals"
+
+
+@dispatch
+def f(a: Rat, b: Num, *cs: Re):
+    return "a float, a number, and more reals"
+
+
+@dispatch
+def f(a: Re, b: Num, *cs: Rat):
+    return "a real, a number, and more floats"
+
+
+@dispatch
+def f(*args):
+    return "fallback"
+
+
+def test_varargs():
+    assert f() == "fallback"
+    assert f(Num()) == "number"
+    assert f(Num(), object) == "fallback"
+    assert f(object, Num()) == "fallback"
+    assert f(Num(), Num()) == "two numbers"
+    assert f(Num(), Num(), Num()) == "two or more numbers"
+    with pytest.raises(LookupError):
+        f(Rat(), Rat(), Rat())
+    with pytest.raises(LookupError):
+        f(Rat(), Re(), Rat())
+    with pytest.raises(LookupError):
+        f(Rat(), Num(), Rat())
+    assert f(Rat(), Num(), Re()) == "a float, a number, and more reals"
+    assert f(Re(), Num(), Rat()) == "a real, a number, and more floats"
+    assert f(Num(), Rat(), Rat()) == "two numbers and more reals"
+    assert f(Num(), Num(), Rat()) == "two numbers and more reals"
+    with pytest.raises(LookupError):
+        f(Rat(), Rat())
diff --git a/tests/dispatcher/test_cases.py b/tests/advanced/test_correctness.py
similarity index 70%
rename from tests/dispatcher/test_cases.py
rename to tests/advanced/test_correctness.py
index f3622c39..f02df2f5 100644
--- a/tests/dispatcher/test_cases.py
+++ b/tests/advanced/test_correctness.py
@@ -1,17 +1,25 @@
-import abc
+from functools import wraps
 
 import pytest
 
-from functools import wraps, WRAPPER_UPDATES
-
-from plum import Dispatcher, NotFoundLookupError, AmbiguousLookupError
+from plum import AmbiguousLookupError, Dispatcher, NotFoundLookupError
 from plum.type import PromisedType
-from tests.test_signature import Num, Re, FP
-
 
 dispatch = Dispatcher()
 
 
+class Num:
+    pass
+
+
+class Re(Num):
+    pass
+
+
+class Rat(Re):
+    pass
+
+
 class ComputableObject:
     pass
 
@@ -52,8 +60,8 @@ def compute(self, a: ComputableObject, b):
         return "another result"
 
 
-PromisedCalculator = PromisedType()
-PromisedHammer = PromisedType()
+PromisedCalculator = PromisedType("Calculator")
+PromisedHammer = PromisedType("Hammer")
 
 
 class Hammer(Device):
@@ -93,7 +101,7 @@ def test_method_dispatch():
     device = Device()
 
     assert device.do() == "doing nothing"
-    assert device.do(FP(), FP()) == "doing a real and a number"
+    assert device.do(Rat(), Rat()) == "doing a real and a number"
     assert device.do(Re(), Re()) == "doing a real and a number"
     assert device.do(Num(), Re()) == "doing two numbers"
     assert device.do(device) == "doing a device"
@@ -155,7 +163,7 @@ def f(a: Num, b: Num):
 
 
 @dispatch
-def f(a: Num, b: FP):
+def f(a: Num, b: Rat):
     return "a number and a float"
 
 
@@ -170,12 +178,12 @@ def f(a: Num, b: Num, *cs: Re):
 
 
 @dispatch
-def f(a: FP, b: Num, *cs: Re):
+def f(a: Rat, b: Num, *cs: Re):
     return "a float, a number, and more reals"
 
 
 @dispatch
-def f(a: Re, b: Num, *cs: FP):
+def f(a: Re, b: Num, *cs: Rat):
     return "a real, a number, and more floats"
 
 
@@ -192,78 +200,58 @@ def test_varargs():
     assert f(Num(), Num()) == "two numbers"
     assert f(Num(), Num(), Num()) == "two or more numbers"
     with pytest.raises(LookupError):
-        f(FP(), FP(), FP())
+        f(Rat(), Rat(), Rat())
     with pytest.raises(LookupError):
-        f(FP(), Re(), FP())
+        f(Rat(), Re(), Rat())
     with pytest.raises(LookupError):
-        f(FP(), Num(), FP())
-    assert f(FP(), Num(), Re()) == "a float, a number, and more reals"
-    assert f(Re(), Num(), FP()) == "a real, a number, and more floats"
-    assert f(Num(), FP(), FP()) == "two numbers and more reals"
-    assert f(Num(), Num(), FP()) == "two numbers and more reals"
+        f(Rat(), Num(), Rat())
+    assert f(Rat(), Num(), Re()) == "a float, a number, and more reals"
+    assert f(Re(), Num(), Rat()) == "a real, a number, and more floats"
+    assert f(Num(), Rat(), Rat()) == "two numbers and more reals"
+    assert f(Num(), Num(), Rat()) == "two numbers and more reals"
     with pytest.raises(LookupError):
-        f(FP(), FP())
-
+        f(Rat(), Rat())
 
-def test_abc_abstractmethod():
-    class A(metaclass=abc.ABCMeta):
-        @abc.abstractmethod
-        def f(self, x):
-            pass
-
-    class B(A):
-        @dispatch
-        def f(self, x: int):
-            return x
 
-    # Check that ABC still works.
-    with pytest.raises(TypeError):
-        A()
+class A2:
+    @dispatch
+    def f(self, x: "A2"):
+        return "self"
 
-    # Check that the abstract method is not dispatched to.
-    b = B()
-    assert b.f(1) == 1
-    with pytest.raises(NotFoundLookupError):
-        b.f("1")
+    @dispatch
+    def f(self, x: str):
+        return "str"
 
 
 def test_self_reference():
-    class A:
-        @dispatch
-        def f(self, x: "A"):
-            return "self"
-
-        @dispatch
-        def f(self, x: str):
-            return "str"
-
-    a = A()
+    a = A2()
 
     assert a.f(a) == "self"
     assert a.f("1") == "str"
 
 
-def test_nested_class():
+class A3:
+    @dispatch
+    def f(self, x: int):
+        return "int1"
+
     class A:
         @dispatch
         def f(self, x: int):
-            return "int1"
-
-        class A:
-            @dispatch
-            def f(self, x: int):
-                return "int2"
-
-            @dispatch
-            def f(self, x: str):
-                return "str2"
+            return "int2"
 
         @dispatch
         def f(self, x: str):
-            return "str1"
+            return "str2"
 
-    a1 = A()
-    a2 = A.A()
+    @dispatch
+    def f(self, x: str):
+        return "str1"
+
+
+def test_nested_class():
+    a1 = A3()
+    a2 = A3.A()
 
     assert a1.f(1) == "int1"
     assert a1.f("1") == "str1"
@@ -299,43 +287,45 @@ def g(x: str):
     assert g("1") == "str"
 
 
-def test_decorator_in_class():
-    class A:
-        @dec
-        @dispatch
-        @dec
-        def f(self, x: int):
-            return "int"
+class A4:
+    @dec
+    @dispatch
+    @dec
+    def f(self, x: int):
+        return "int"
 
-        # Cannot use a decorator before `dispatch` here!
-        @dispatch
-        @dec
-        def f(self, x: str):
-            return "str"
+    # Cannot use a decorator before `dispatch` here!
+    @dispatch
+    @dec
+    def f(self, x: str):
+        return "str"
 
-    a = A()
+
+def test_decorator_in_class():
+    a = A4()
 
     assert a.f(1) == "int"
     assert a.f("1") == "str"
 
 
-def test_property():
-    class A:
-        @property
-        def name(self):
-            return "name"
+class A5:
+    @property
+    def name(self):
+        return "name"
 
-        @name.setter
-        @dispatch
-        def name(self, x: str):
-            return "str"
+    @name.setter
+    @dispatch
+    def name(self, x: str):
+        return "str"
+
+    # This setup requires that the class has another method!
+    @dispatch
+    def f(self):
+        pass
 
-        # This setup requires that the class has another method!
-        @dispatch
-        def f(self):
-            pass
 
-    a = A()
+def test_property():
+    a = A5()
 
     assert a.name == "name"
     a.name = "another name"
diff --git a/tests/dispatcher/test_future_annotations.py b/tests/advanced/test_future_annotations.py
similarity index 100%
rename from tests/dispatcher/test_future_annotations.py
rename to tests/advanced/test_future_annotations.py
index f40aeed4..47245134 100644
--- a/tests/dispatcher/test_future_annotations.py
+++ b/tests/advanced/test_future_annotations.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
-from typing import Union
 import math
+from typing import Union
 
 import pytest
 
diff --git a/tests/advanced/test_precedence.py b/tests/advanced/test_precedence.py
new file mode 100644
index 00000000..53adaa3f
--- /dev/null
+++ b/tests/advanced/test_precedence.py
@@ -0,0 +1,56 @@
+import pytest
+
+from plum import AmbiguousLookupError, Dispatcher
+
+dispatch = Dispatcher()
+
+
+class Element:
+    pass
+
+
+class ZeroElement(Element):
+    pass
+
+
+class SpecialisedElement(Element):
+    pass
+
+
+@dispatch
+def mul(a: ZeroElement, b: Element):
+    # Return zero.
+    return a
+
+
+@dispatch
+def mul(a: Element, b: SpecialisedElement):
+    # Perform a specialised operation.
+    return b
+
+
+@dispatch(precedence=1)
+def mul_precedence(a: ZeroElement, b: Element):
+    # Return zero.
+    return a
+
+
+@dispatch
+def mul_precedence(a: Element, b: SpecialisedElement):
+    # Perform a specialised operation.
+    return b
+
+
+def test_precedence():
+    zero = ZeroElement()
+    el = Element()
+    spel = SpecialisedElement()
+
+    assert type(mul(zero, el)) == ZeroElement
+    assert type(mul(el, spel)) == SpecialisedElement
+    with pytest.raises(AmbiguousLookupError):
+        mul(zero, spel)
+
+    assert type(mul_precedence(zero, el)) == ZeroElement
+    assert type(mul_precedence(el, spel)) == SpecialisedElement
+    assert type(mul_precedence(zero, spel)) == ZeroElement
diff --git a/tests/dispatcher/test_return_type.py b/tests/advanced/test_return_type.py
similarity index 50%
rename from tests/dispatcher/test_return_type.py
rename to tests/advanced/test_return_type.py
index fb8c5d45..0118371c 100644
--- a/tests/dispatcher/test_return_type.py
+++ b/tests/advanced/test_return_type.py
@@ -1,10 +1,8 @@
-import pytest
 from typing import Union
 
-from plum import Dispatcher, add_conversion_method
+import pytest
 
-# noinspection PyUnresolvedReferences
-from ..test_promotion import convert
+from plum import Dispatcher, add_conversion_method
 
 
 def test_return_type():
@@ -22,48 +20,21 @@ def f(x: Union[int, str]) -> int:
         f.invoke(str)("1")
 
 
-def test_extension():
-    dispatch = Dispatcher()
-
-    @dispatch
-    def f(x: int):
-        return x
-
-    @f.dispatch
-    def f(x: float) -> str:
-        return str(x)
+class A:
+    def do(self, x):
+        return "hello from A"
 
-    assert f(1.0) == "1.0"
-    assert f.invoke(float)(1.0) == "1.0"
 
+dispatch = Dispatcher()
 
-def test_multi():
-    dispatch = Dispatcher()
 
-    @dispatch.multi((int,), (str,), return_type=int)
-    def g(x: Union[int, str]) -> int:
+class B(A):
+    @dispatch
+    def do(self, x: Union[int, "B", str]) -> Union[int, "B"]:
         return x
 
-    assert g(1) == 1
-    assert g.invoke(int)(1) == 1
-    with pytest.raises(TypeError):
-        g("1")
-    with pytest.raises(TypeError):
-        g.invoke(str)("1")
-
 
 def test_inheritance():
-    class A:
-        def do(self, x):
-            return "hello from A"
-
-    class B(A):
-        _dispatch = Dispatcher()
-
-        @_dispatch
-        def do(self, x: Union[int, "B", str]) -> Union[int, "B"]:
-            return x
-
     b = B()
 
     assert b.do(1) == 1
@@ -73,18 +44,20 @@ def do(self, x: Union[int, "B", str]) -> Union[int, "B"]:
     assert b.do(1.0) == "hello from A"
 
 
-def test_inheritance_self_return():
-    class A:
-        _dispatch = Dispatcher()
+dispatch = Dispatcher()
+
+
+class A2:
+    @dispatch
+    def do(self, x: "A2", ok=True) -> "A2":
+        if ok:
+            return x
+        else:
+            return 2
 
-        @_dispatch
-        def do(self, x: "A", ok=True) -> "A":
-            if ok:
-                return x
-            else:
-                return 2
 
-    a = A()
+def test_inheritance_self_return():
+    a = A2()
     assert a.do(a) is a
     with pytest.raises(TypeError):
         a.do(a, ok=False)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..5c936029
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,33 @@
+import pytest
+
+import plum
+from plum.promotion import _convert, _promotion_rule
+
+
+@pytest.fixture
+def convert():
+    # Save methods.
+    _convert._resolve_pending_registrations()
+    resolved = list(_convert._resolved)
+
+    yield plum.convert
+
+    # Clear methods after use.
+    _convert._resolve_pending_registrations()
+    _convert._pending = []
+    _convert._resolved = resolved
+    _convert.clear_cache(reregister=True)
+
+
+@pytest.fixture
+def promote():
+    # Save methods.
+    _promotion_rule._resolve_pending_registrations()
+    resolved = list(_promotion_rule._resolved)
+
+    yield plum.promote
+
+    # Clear methods after use.
+    _promotion_rule._pending = []
+    _promotion_rule._resolved = resolved
+    _promotion_rule.clear_cache(reregister=True)
diff --git a/tests/dispatcher/test_cache.py b/tests/dispatcher/test_cache.py
deleted file mode 100644
index 00495429..00000000
--- a/tests/dispatcher/test_cache.py
+++ /dev/null
@@ -1,148 +0,0 @@
-from typing import Union, List
-
-from plum import Dispatcher, clear_all_cache
-from plum.type import subclasscheck_cache
-from ..util import benchmark
-
-
-def test_cache_function_call_performance_and_correctness():
-    # Test performance.
-    dispatch = Dispatcher()
-
-    def f_native(x):
-        pass
-
-    @dispatch
-    def f(x):
-        pass
-
-    @dispatch
-    def f(x: Union[int, float]):
-        pass
-
-    @dispatch
-    def f(x: Union[int, float, str]):
-        pass
-
-    dur_native = benchmark(f_native, (1,))
-    dur_plum_first = benchmark(f, (1,), n=1)
-    dur_plum = benchmark(f, (1,))
-
-    # A cached call should not be more than 15 times slower than a native call.
-    assert dur_plum <= 15 * dur_native, "compare native"
-
-    # A first call should not be more than 1000 times slower than a cached call.
-    assert dur_plum_first <= 1000 * dur_plum, "compare first"
-
-    # The cached call should be at least 20 times faster than a first call.
-    assert dur_plum <= dur_plum_first / 20, "cache performance"
-
-    # Test cache correctness.
-    assert f(1) is None, "cache correctness 1"
-
-    @dispatch
-    def f(x: int):
-        return 1
-
-    assert f(1) == 1, "cache correctness 2"
-
-
-def test_cache_class_call_performance():
-    class ANative:
-        def __call__(self, x):
-            pass
-
-        def go(self, x):
-            pass
-
-        def go_again(self, x):
-            pass
-
-    class A:
-        _dispatch = Dispatcher()
-
-        @_dispatch
-        def __call__(self, x: int):
-            pass
-
-        @_dispatch
-        def __call__(self, x: str):
-            pass
-
-        @_dispatch
-        def go(self, x: int):
-            pass
-
-        @_dispatch
-        def go(self, x: str):
-            pass
-
-        @_dispatch
-        def go_again(self, x: int):
-            pass
-
-        @_dispatch
-        def go_again(self, x: str):
-            pass
-
-    a_native = ANative()
-    a = A()
-
-    # Test performance of calls. See above previous test.
-    dur_native = benchmark(a_native, (1,))
-    dur_plum_first = benchmark(a, (1,), n=1)
-    dur_plum = benchmark(a, (1,))
-    assert dur_plum <= 25 * dur_native, "compare native call"
-    assert dur_plum_first <= 1000 * dur_plum, "compare first call"
-    assert dur_plum <= dur_plum_first / 10, "cache performance call"
-
-    # Test performance of method calls.
-    dur_native = benchmark(lambda x: a_native.go(x), (1,))
-    dur_plum_first = benchmark(lambda x: a.go(x), (1,), n=1)
-    dur_plum = benchmark(lambda x: a.go(x), (1,))
-    assert dur_plum <= 25 * dur_native, "compare native method"
-    assert dur_plum_first <= 1000 * dur_plum, "compare first method"
-    assert dur_plum <= dur_plum_first / 10, "cache performance method"
-
-    # Test performance of static calls.
-    dur_native = benchmark(lambda x: ANative.go_again(a_native, x), (1,))
-    dur_plum_first = benchmark(lambda x: A.go_again(a, x), (1,), n=1)
-    dur_plum = benchmark(lambda x: A.go_again(a, x), (1,))
-    assert dur_plum <= 25 * dur_native, "compare native static"
-    assert dur_plum_first <= 1000 * dur_plum, "compare first static"
-    assert dur_plum <= dur_plum_first / 10, "cache performance static"
-
-
-def test_cache_clearing():
-    dispatch = Dispatcher()
-
-    @dispatch
-    def f(x: object):
-        return 1
-
-    @dispatch
-    def f(x: List[int]):
-        return 1
-
-    f(1)
-
-    # Check that cache is used.
-    assert len(f._methods) == 2
-    assert len(f._precedences) == 2
-    assert f._runtime_type_of
-
-    dispatch.clear_cache()
-
-    # Check that cache is cleared.
-    assert len(f._methods) == 0
-    assert len(f._precedences) == 0
-    assert not f._runtime_type_of
-
-    f(1)
-    clear_all_cache()
-
-    # Again check that cache is cleared.
-    assert len(f._methods) == 0
-    assert len(f._precedences) == 0
-    assert len(subclasscheck_cache) == 0
-    assert not f._runtime_type_of
diff --git a/tests/dispatcher/test_dispatcher.py b/tests/dispatcher/test_dispatcher.py
deleted file mode 100644
index fbd53589..00000000
--- a/tests/dispatcher/test_dispatcher.py
+++ /dev/null
@@ -1,478 +0,0 @@
-import operator
-import collections
-from typing import Union, List
-
-
-import pytest
-
-from plum import Dispatcher, Function, NotFoundLookupError, parametric
-
-
-def _build_function():
-    dispatch = Dispatcher()
-
-    @dispatch
-    def f(x: int):
-        pass
-
-    @dispatch
-    def f(x: float):
-        pass
-
-    return f
-
-
-def _build_class(instance):
-    dispatch = Dispatcher()
-
-    class A:
-        @dispatch
-        def f(x: int):
-            pass
-
-        @dispatch
-        def f(x: float):
-            pass
-
-    if instance:
-        return A().f
-    else:
-        return A.f
-
-
-@pytest.mark.parametrize(
-    "build",
-    [
-        _build_function,
-        lambda: _build_class(True),
-        lambda: _build_class(False),
-    ],
-)
-def test_methods_precedences(build):
-    f = build()
-    assert len(f._methods) == 0
-    assert len(f._precedences) == 0
-    # Try `f.methods` first.
-    assert len(f.methods) == 2
-    assert len(f.precedences) == 2
-    assert f.methods is f._methods
-    assert f.precedences is f._precedences
-
-    f = build()
-    assert len(f._methods) == 0
-    assert len(f._precedences) == 0
-    # Try `f.methods` first.
-    assert len(f.precedences) == 2
-    assert len(f.methods) == 2
-    assert f.methods is f._methods
-    assert f.precedences is f._precedences
-
-
-def test_keywords():
-    dispatch = Dispatcher()
-
-    @dispatch
-    def f(x: int, *, option=None):
-        return x
-
-    assert f(2) == 2
-    assert f(2, option=None) == 2
-    with pytest.raises(NotFoundLookupError):
-        f(2, None)
-
-
-def test_defaults():
-    dispatch = Dispatcher()
-
-    y_default = 3
-
-    @dispatch
-    def f(x: int, y: int = y_default, *, option=None):
-        return y
-
-    @dispatch
-    def f(x: float, y: int = y_default, *, option=None):
-        return y**2
-
-    assert f(2) == y_default
-    assert f(2, option=None) == y_default
-    assert f(2, 4) == 4
-    assert f(2, y=4) == 4
-    assert f(2, y=4, option=None) == 4
-
-    assert f(2.0) == y_default**2
-    assert f(2.0, y=4) == 4**2
-
-    with pytest.raises(NotFoundLookupError):
-        f(2, 4.0)
-
-    with pytest.raises(NotFoundLookupError):
-        f(2, 4.0, option=2)
-
-    # Wrong default type
-    with pytest.raises(TypeError):
-
-        @dispatch
-        def f(x: int, y: float = y_default):
-            return y
-
-    # Ignore wrong type annotations on keyword arguments.
-    def f(x: int, y: float = y_default, *, option: int = None):
-        return y
-
-    # Multiple arguments
-    @dispatch
-    def g(x: int, y: int = y_default, z: float = 3.0):
-        return (y, z)
-
-    assert g(2) == (y_default, 3.0)
-
-
-def test_redefinition():
-    dispatch = Dispatcher()
-
-    @dispatch
-    def f(x: int):
-        return "first"
-
-    assert f(1) == "first"
-
-    @dispatch
-    def f(x: int):
-        return "second"
-
-    assert f(1) == "second"
-
-
-def test_abstract():
-    dispatch = Dispatcher()
-
-    @dispatch.abstract
-    def f(x):
-        """docstring"""
-
-    assert len(f.methods) == 0
-    assert len(f.precedences) == 0
-    assert f.__doc__ == "docstring"
-
-    @dispatch
-    def f(x: int):
-        pass
-
-    assert len(f.methods) == 1
-    assert len(f.precedences) == 1
-    assert f.__doc__ == "docstring"
-
-
-def test_abstract_in_class():
-    dispatch = Dispatcher()
-
-    class A:
-        @dispatch.abstract
-        def f(self, x):
-            """docstring"""
-
-    assert len(A.f.methods) == 0
-    assert len(A.f.precedences) == 0
-    assert A.f.__doc__ == "docstring"
-
-    class A:
-        @dispatch.abstract
-        def f(self, x):
-            """docstring"""
-
-        @dispatch
-        def f(self, x: int):
-            pass
-
-    assert len(A.f.methods) == 1
-    assert len(A.f.precedences) == 1
-    assert A.f.__doc__ == "docstring"
-
-
-def test_metadata_and_printing():
-    dispatch = Dispatcher()
-
-    class A:
-        _dispatch = Dispatcher()
-
-        @_dispatch
-        def g(self):
-            """docstring of g"""
-
-    @dispatch
-    def f():
-        """docstring of f"""
-
-    assert f.__name__ == "f"
-    assert f.__qualname__ == "test_metadata_and_printing.<locals>.f"
-    assert f.__module__ == "tests.dispatcher.test_dispatcher"
-    assert f.__doc__ == "docstring of f"
-    assert repr(f) == f"<function {f._f} with 1 method(s)>"
-
-    assert f.invoke().__name__ == "f"
-    assert f.invoke().__qualname__ == "test_metadata_and_printing.<locals>.f"
-    assert f.invoke().__module__ == "tests.dispatcher.test_dispatcher"
-    assert f.invoke().__doc__ == "docstring of f"
-    n = len(hex(id(f))) + 1  # Do not check memory address and extra ">".
-    # Replace `cyfunction` with `function` to play nice with Cython.
-    assert repr(f.invoke())[:-n].replace("cyfunction", "function") == repr(f._f)[:-n]
-
-    a = A()
-    g = a.g
-
-    assert g.__name__ == "g"
-    assert g.__qualname__ == "test_metadata_and_printing.<locals>.A.g"
-    assert g.__module__ == "tests.dispatcher.test_dispatcher"
-    assert g.__doc__ == "docstring of g"
-    assert repr(g) == f'<function {A._dispatch._classes[A]["g"]._f} with 1 method(s)>'
-
-    assert g.invoke().__name__ == "g"
-    assert g.invoke().__qualname__ == "test_metadata_and_printing.<locals>.A.g"
-    assert g.invoke().__module__ == "tests.dispatcher.test_dispatcher"
-    assert g.invoke().__doc__ == "docstring of g"
-    assert (
-        # Replace `cyfunction` with `function` to play nice with Cython.
-        repr(g.invoke())[:-n].replace("cyfunction", "function")
-        == repr(A._dispatch._classes[A]["g"]._f)[:-n]
-    )
-
-
-def test_counting():
-    dispatch = Dispatcher()
-
-    @dispatch.abstract
-    def f(x):
-        pass
-
-    assert repr(f) == f"<function {f._f} with 0 method(s)>"
-
-    @dispatch
-    def f(x: int):
-        pass
-
-    @dispatch
-    def f(x: int):
-        pass
-
-    # At this point, two methods are pending but not yet resolved. The second is a
-    # redefinition of the first, but this will only be clear after the methods are
-    # resolved.
-    assert repr(f) == f"<function {f._f} with 2 method(s)>"
-
-    # Resolve the methods.
-    f(1)
-
-    # Counting should now be right.
-    assert repr(f) == f"<function {f._f} with 1 method(s)>"
-
-    @dispatch
-    def f(x: str):
-        pass
-
-    assert repr(f) == f"<function {f._f} with 2 method(s)>"
-
-
-def test_multi():
-    dispatch = Dispatcher()
-
-    @dispatch
-    def f(x):
-        return "fallback"
-
-    @dispatch.multi((int,), (str,))
-    def f(x: Union[int, str]):
-        return "int or str"
-
-    assert f(1) == "int or str"
-    assert f("1") == "int or str"
-    assert f(1.0) == "fallback"
-
-
-def test_multi_in_class():
-    dispatch = Dispatcher()
-
-    class A:
-        @dispatch
-        def f(self, x):
-            return "fallback"
-
-        @dispatch.multi(
-            (
-                object,
-                int,
-            ),
-            (
-                object,
-                str,
-            ),
-        )
-        def f(self, x: Union[int, str]):
-            return "int or str"
-
-    a = A()
-    assert a.f(1) == "int or str"
-    assert a.f("1") == "int or str"
-    assert a.f(1.0) == "fallback"
-
-
-def test_extension():
-    dispatch = Dispatcher()
-
-    @dispatch
-    def f():
-        return "fallback"
-
-    @f.dispatch
-    def f(x: int):
-        return "int"
-
-    @f.dispatch_multi((str,), (float,))
-    def f(x: Union[str, float]):
-        return "str or float"
-
-    assert f() == "fallback"
-    assert f(1) == "int"
-    assert f("1") == "str or float"
-    assert f(1.0) == "str or float"
-
-
-def test_invoke():
-    dispatch = Dispatcher()
-
-    @dispatch()
-    def f():
-        return "fallback"
-
-    @dispatch
-    def f(x: int):
-        return "int"
-
-    @dispatch
-    def f(x: str):
-        return "str"
-
-    @dispatch
-    def f(x: Union[int, str, float]):
-        return "int, str, or float"
-
-    assert f() == "fallback"
-    assert f(1) == "int"
-    assert f("1") == "str"
-    assert f(1.0) == "int, str, or float"
-    assert f.invoke()() == "fallback"
-    assert f.invoke(int)("1") == "int"
-    assert f.invoke(str)(1) == "str"
-    assert f.invoke(float)(1) == "int, str, or float"
-    assert f.invoke(Union[int, str])(1) == "int, str, or float"
-    assert f.invoke(Union[int, str, float])(1) == "int, str, or float"
-
-
-def test_invoke_in_class():
-    dispatch = Dispatcher()
-
-    class A:
-        def do(self, x):
-            return "fallback"
-
-    class B(A):
-        @dispatch
-        def do(self, x: int):
-            return "int"
-
-    class C(B):
-        @dispatch
-        def do(self, x: str):
-            return "str"
-
-    c = C()
-
-    # Test bound calls.
-    assert c.do.invoke(str)("1") == "str"
-    assert c.do.invoke(int)(1) == "int"
-    assert c.do.invoke(float)(1.0) == "fallback"
-
-    # Test unbound calls.
-    assert C.do.invoke(C, str)(c, "1") == "str"
-    assert C.do.invoke(C, int)(c, 1) == "int"
-    assert C.do.invoke(C, float)(c, 1.0) == "fallback"
-
-
-def test_errors():
-    dispatch = Dispatcher()
-
-    class A:
-        @dispatch
-        def __init__(self):
-            pass
-
-        @dispatch
-        def __call__(self):
-            pass
-
-    for method in [A, A()]:
-        try:
-            # No argument are supported, so passing `1` should result in a look-up error.
-            method(1)
-        except NotFoundLookupError as e:
-            # The message should not contain "object". If it does, that would indicate
-            # that a non-descriptive error message was thrown.
-            assert "object" not in str(e).lower()
-
-
-def test_runtime_type_of_tracking():
-    dispatch = Dispatcher()
-
-    @dispatch
-    def f(x: int):
-        pass
-
-    assert not f._runtime_type_of
-    f(1)
-    assert not f._runtime_type_of
-
-    @parametric
-    class A:
-        pass
-
-    @dispatch
-    def f(x: A[1]):
-        pass
-
-    assert not f._runtime_type_of
-    f(1)
-    assert not f._runtime_type_of
-
-    @dispatch
-    def f(x: List[int]):
-        pass
-
-    assert not f._runtime_type_of
-    f(1)
-    assert f._runtime_type_of
-
-
-def test_unassignable_annotations():
-    class A:
-        @classmethod
-        def create(cls):
-            pass
-
-    # `A.create` will have an attribute `__annotations__`, but it cannot be assigned.
-
-    f = Function(lambda: None)
-    f.dispatch(A.create)
-    f()
-
-
-@pytest.mark.parametrize(
-    "f, x, res",
-    [
-        (operator.attrgetter("x"), collections.namedtuple("NamedTuple", "x")(x=1), 1),
-        (operator.itemgetter("x"), {"x": 1}, 1),
-    ],
-)
-def test_nonfunctions(f, x, res):
-    plum_f = Function(lambda: None)
-    plum_f.dispatch(f)
-    assert plum_f(x) == res
diff --git a/tests/dispatcher/test_precedence.py b/tests/dispatcher/test_precedence.py
deleted file mode 100644
index 0c8f8d6a..00000000
--- a/tests/dispatcher/test_precedence.py
+++ /dev/null
@@ -1,98 +0,0 @@
-from typing import Union
-
-import pytest
-
-from plum import Dispatcher, AmbiguousLookupError
-
-_dispatch = Dispatcher()
-
-
-class Element:
-    pass
-
-
-class ZeroElement(Element):
-    pass
-
-
-class SpecialisedElement(Element):
-    pass
-
-
-@_dispatch
-def mul(a: ZeroElement, b: Element):
-    # Return zero.
-    return a
-
-
-@_dispatch
-def mul(a: Element, b: SpecialisedElement):
-    # Perform a specialised operation.
-    return b
-
-
-@_dispatch(precedence=1)
-def mul_precedence(a: ZeroElement, b: Element):
-    # Return zero.
-    return a
-
-
-@_dispatch
-def mul_precedence(a: Element, b: SpecialisedElement):
-    # Perform a specialised operation.
-    return b
-
-
-def test_precedence():
-    zero = ZeroElement()
-    el = Element()
-    spel = SpecialisedElement()
-
-    assert type(mul(zero, el)) == ZeroElement
-    assert type(mul(el, spel)) == SpecialisedElement
-    with pytest.raises(AmbiguousLookupError):
-        mul(zero, spel)
-
-    assert type(mul_precedence(zero, el)) == ZeroElement
-    assert type(mul_precedence(el, spel)) == SpecialisedElement
-    assert type(mul_precedence(zero, spel)) == ZeroElement
-
-
-def test_extension():
-    dispatch = Dispatcher()
-
-    @dispatch
-    def g(x):
-        return "fallback"
-
-    @g.dispatch(precedence=1)
-    def g(x: Union[int, str]):
-        return "int or str"
-
-    @g.dispatch(precedence=2)
-    def g(x: Union[int, float]):
-        return "int or float"
-
-    assert g("1") == "int or str"
-    assert g(1.0) == "int or float"
-    assert g(1) == "int or float"
-
-
-def test_multi():
-    dispatch = Dispatcher()
-
-    @dispatch
-    def g():
-        return "fallback"
-
-    @dispatch.multi((Union[int, str],), (Union[object, str],), precedence=1)
-    def g(x):
-        return "int or str, or object or str"
-
-    @dispatch.multi((Union[int, float],), (float,), precedence=2)
-    def g(x: Union[int, float]):
-        return "int or float, or float"
-
-    assert g("1") == "int or str, or object or str"
-    assert g(1.0) == "int or float, or float"
-    assert g(1) == "int or float, or float"
diff --git a/tests/test_alias.py b/tests/test_alias.py
new file mode 100644
index 00000000..dce9fd35
--- /dev/null
+++ b/tests/test_alias.py
@@ -0,0 +1,66 @@
+from typing import Union
+
+import pytest
+
+from plum import activate_union_aliases, deactivate_union_aliases, set_union_alias
+from plum.alias import _aliased_unions
+
+
+@pytest.fixture()
+def union_aliases():
+    """Activate union aliases during the test and remove all aliases after the test
+    finishes."""
+    activate_union_aliases()
+    yield
+    deactivate_union_aliases()
+    _aliased_unions.clear()
+
+
+@pytest.mark.parametrize("display", [str, repr])
+def test_union_alias(display, union_aliases):
+    # Check that printing is normal before registering any aliases.
+    assert display(Union[int, str]) in {
+        "typing.Union[int, str]",
+        "typing.Union[str, int]",
+    }
+
+    # Register a simple alias and check that it prints correctly.
+    IntStr = set_union_alias(Union[int, str], alias="IntStr")
+    assert display(IntStr) == "typing.Union[IntStr]"
+    assert display(Union[int, str]) == "typing.Union[IntStr]"
+    assert display(Union[int, str, float]) in {
+        "typing.Union[IntStr, float]",
+        "typing.Union[float, IntStr]",
+    }
+
+    # Register a bigger alias.
+    set_union_alias(Union[int, str, float], alias="IntStrFloat")
+    assert display(Union[int, str]) == "typing.Union[IntStr]"
+    assert display(Union[int, str, float]) == "typing.Union[IntStrFloat]"
+    assert display(Union[int, str, complex]) in {
+        "typing.Union[IntStr, complex]",
+        "typing.Union[complex, IntStr]",
+    }
+    assert display(Union[int, str, float, complex]) in {
+        "typing.Union[IntStrFloat, complex]",
+        "typing.Union[complex, IntStrFloat]",
+    }
+
+
+def test_optional(union_aliases):
+    assert repr(Union[int, None]) == "typing.Optional[int]"
+    assert repr(Union[None, int]) == "typing.Optional[int]"
+
+
+def test_double_registration(union_aliases):
+    # We can register with the same alias, but not with a different alias.
+
+    set_union_alias(Union[int, str], alias="IntStr")
+    set_union_alias(Union[int, str], alias="IntStr")  # This is OK.
+    with pytest.raises(RuntimeError, match=r"already has alias"):
+        set_union_alias(Union[int, str], alias="OtherIntStr")
+
+    set_union_alias(int, alias="MyInt")
+    set_union_alias(int, alias="MyInt")  # This is OK.
+    with pytest.raises(RuntimeError, match=r"already has alias"):
+        set_union_alias(int, alias="MyOtherInt")
diff --git a/tests/test_autoreload.py b/tests/test_autoreload.py
index b19003a9..ea4fc29f 100644
--- a/tests/test_autoreload.py
+++ b/tests/test_autoreload.py
@@ -1,36 +1,36 @@
-import numpy as np
 import pytest
 
-from pathlib import Path
-
-from plum import Dispatcher, autoreload as p_autoreload
+from plum import Dispatcher
+from plum import autoreload as ar
 from plum.function import NotFoundLookupError
 
 
 def test_autoreload_activate_deactivate():
-    p_autoreload.activate()
+    # We shouldn't be able to deactivate before activation.
+    with pytest.raises(
+        RuntimeError,
+        match=r"(?i)plum autoreload module was never activated",
+    ):
+        ar.deactivate_autoreload()
 
-    assert p_autoreload._update_instances_original is not None
-    assert (
-        p_autoreload._update_instances_original.__module__
-        == "IPython.extensions.autoreload"
-    )
+    ar.activate_autoreload()
 
-    from IPython.extensions import autoreload
+    from IPython.extensions import autoreload as iar
 
-    assert autoreload.update_instances.__module__ == "plum.autoreload"
+    # Check that it is activated.
+    assert ar._update_instances_original is not None
+    assert ar._update_instances_original.__module__ == "IPython.extensions.autoreload"
+    assert iar.update_instances.__module__ == "plum.autoreload"
 
-    p_autoreload.deactivate()
+    ar.deactivate_autoreload()
 
-    assert (
-        p_autoreload._update_instances_original.__module__
-        == "IPython.extensions.autoreload"
-    )
-    assert autoreload.update_instances.__module__ == "IPython.extensions.autoreload"
-    assert autoreload.update_instances == p_autoreload._update_instances_original
+    # Check that it is deactivated.
+    assert ar._update_instances_original.__module__ == "IPython.extensions.autoreload"
+    assert iar.update_instances.__module__ == "IPython.extensions.autoreload"
+    assert iar.update_instances == ar._update_instances_original
 
 
-def test_autoreload_works():
+def test_autoreload_correctness():
     dispatch = Dispatcher()
 
     class A1:
@@ -39,22 +39,42 @@ class A1:
     class A2:
         pass
 
+    class A3:
+        pass
+
     @dispatch
     def test(x: A1):
         return 1
 
-    assert test(A1()) == 1
+    a = A1()
 
+    assert isinstance(a, A1)
+    assert test(a) == 1
+
+    assert test(A1()) == 1
     with pytest.raises(NotFoundLookupError):
         test(A2())
+    with pytest.raises(NotFoundLookupError):
+        test(A3())
 
-    a1 = A1()
+    ar._update_instances(A1, A2)
 
-    p_autoreload._update_instances(A1, A2)
+    assert isinstance(a, A2)
+    assert test(a) == 1
 
+    with pytest.raises(NotFoundLookupError):
+        test(A1())
     assert test(A2()) == 1
+    with pytest.raises(NotFoundLookupError):
+        test(A3())
+
+    ar._update_instances(A2, A3)
+
+    assert isinstance(a, A3)
+    assert test(a) == 1
 
     with pytest.raises(NotFoundLookupError):
         test(A1())
-
-    assert isinstance(a1, A2)
+    with pytest.raises(NotFoundLookupError):
+        test(A2())
+    assert test(A3()) == 1
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 00000000..76ae41d5
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,182 @@
+from typing import List, Union
+
+from plum import Dispatcher, Function, clear_all_cache
+
+from .util import benchmark
+
+
+def assert_cache_performance(f, f_native):
+    # Time the performance of a native call.
+    dur_native = benchmark(f_native, (1,), n=250, burn=10)
+
+    def resolve_registrations():
+        for f in Function._instances:
+            f._resolve_pending_registrations()
+
+    def setup_no_cache():
+        clear_all_cache()
+        resolve_registrations()
+
+    # Time the performance of a cache miss.
+    dur_first = benchmark(f, (1,), n=250, burn=10, setup=setup_no_cache)
+
+    # Time the performance of a cache hit.
+    clear_all_cache()
+    resolve_registrations()
+    dur = benchmark(f, (1,), n=250, burn=10)
+
+    # A cached call should not be more than 50 times slower than a native call.
+    assert dur <= 50 * dur_native
+
+    # A first call should not be more than 2000 times slower than a cached call.
+    assert dur_first <= 2000 * dur
+
+    # The cached call should be at least 5 times faster than a first call.
+    assert dur <= dur_first / 5
+
+
+def test_cache_function():
+    dispatch = Dispatcher()
+
+    def f_native(x):
+        pass
+
+    @dispatch
+    def f(x):
+        pass
+
+    @dispatch
+    def f(x: Union[int, float]):
+        pass
+
+    @dispatch
+    def f(x: Union[int, float, str]):
+        pass
+
+    # Test performance.
+    assert_cache_performance(f, f_native)
+
+    # Test cache correctness.
+    assert f(1) is None
+
+    @dispatch
+    def f(x: int):
+        return 1
+
+    assert f(1) == 1
+
+
+# This class needs to be in the global scope, otherwise it cannot its methods cannot
+# obtains a reference to it.
+
+
+class A:
+    _dispatch = Dispatcher()
+
+    @_dispatch
+    def __call__(self, x: int):
+        pass
+
+    @_dispatch
+    def __call__(self, x: str):
+        pass
+
+    @_dispatch
+    def go(self, x: int):
+        pass
+
+    @_dispatch
+    def go(self, x: str):
+        pass
+
+    @_dispatch
+    def go_again(self, x: int):
+        pass
+
+    @_dispatch
+    def go_again(self, x: str):
+        pass
+
+
+def test_cache_class():
+    class ANative:
+        def __call__(self, x):
+            pass
+
+        def go(self, x):
+            pass
+
+        def go_again(self, x):
+            pass
+
+    a_native = ANative()
+    a = A()
+
+    # Test performance of calls.
+    assert_cache_performance(a, a_native)
+
+    # Test performance of method calls.
+    assert_cache_performance(lambda x: a.go(x), lambda x: a_native.go(x))
+
+    # Test performance of static calls.
+    assert_cache_performance(
+        lambda x: A.go_again(a, x),
+        lambda x: ANative.go_again(a_native, x),
+    )
+
+
+def test_cache_clearing():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int):
+        return 1
+
+    @dispatch
+    def f(x: float):
+        return 2
+
+    assert len(f._cache) == 0
+    assert len(f._resolver) == 0
+
+    assert f(1) == 1
+    # Check that cache is used.
+    assert len(f._cache) == 1
+    assert len(f._resolver) == 2
+
+    # Clear via the dispatcher.
+    dispatch.clear_cache()
+    assert len(f._cache) == 0
+    assert len(f._resolver) == 0
+
+    # Run the function again.
+    assert f(1) == 1
+    assert len(f._cache) == 1
+    assert len(f._resolver) == 2
+
+    # Clear via `clear_all_cache`.
+    clear_all_cache()
+    assert len(f._cache) == 0
+    assert len(f._resolver) == 0
+
+    # Run the function one last time.
+    assert f(1) == 1
+    assert len(f._cache) == 1
+    assert len(f._resolver) == 2
+
+
+def test_cache_unfaithful():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int):
+        return 1
+
+    @dispatch
+    def f(x: List[int]):
+        return 2
+
+    # Since `f` is not faithful, no cache should be accumulated.
+    assert f(1) == 1
+    assert f([1]) == 2
+    assert len(f._cache) == 0
diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py
new file mode 100644
index 00000000..d831034c
--- /dev/null
+++ b/tests/test_dispatcher.py
@@ -0,0 +1,74 @@
+import pytest
+
+from plum import Dispatcher
+from plum.signature import Signature
+
+
+def test_dispatch_function():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int):
+        pass
+
+    @dispatch(precedence=1)
+    def g(x: float):
+        pass
+
+    assert set(dispatch.functions.keys()) == {"f", "g"}
+    assert dispatch.functions["f"].methods[0] == Signature(int)
+    assert dispatch.functions["g"].methods[0] == Signature(float)
+    assert dispatch.functions["g"].methods[0].precedence == 1
+
+
+def test_dispatch_class():
+    dispatch = Dispatcher()
+
+    class A:
+        @dispatch
+        def f(x: int):
+            pass
+
+    class B:
+        @dispatch(precedence=1)
+        def g(x: float):
+            pass
+
+    a = "tests.test_dispatcher.test_dispatch_class.<locals>.A"
+    b = "tests.test_dispatcher.test_dispatch_class.<locals>.B"
+    assert set(dispatch.classes.keys()) == {a, b}
+    assert dispatch.classes[a]["f"].methods[0] == Signature(int)
+    assert dispatch.classes[b]["g"].methods[0] == Signature(float)
+    assert dispatch.classes[b]["g"].methods[0].precedence == 1
+
+
+def test_dispatch_multi():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int):
+        return "int"
+
+    @dispatch.multi((float,), Signature(str, precedence=1))
+    def f(x):
+        return "float or str"
+
+    assert f(1) == "int"
+    assert f(1.0) == "float or str"
+    assert f("1") == "float or str"
+    assert dispatch.functions["f"].methods[2].precedence == 1
+
+    # Check that arguments to `dispatch.multi` must be tuples or signatures.
+    with pytest.raises(ValueError):
+        dispatch.multi(1)
+
+
+def test_abstract():
+    dispatch = Dispatcher()
+
+    @dispatch.abstract
+    def f(x):
+        """Docs"""
+
+    assert f.__doc__ == "Docs"
+    assert f.methods == []
diff --git a/tests/test_function.py b/tests/test_function.py
new file mode 100644
index 00000000..20f0fde7
--- /dev/null
+++ b/tests/test_function.py
@@ -0,0 +1,507 @@
+import abc
+import typing
+
+import pytest
+
+from plum import Dispatcher
+from plum.function import Function, _change_function_name, _convert, _owner_transfer
+from plum.resolver import AmbiguousLookupError, NotFoundLookupError, Resolver
+from plum.signature import Signature
+
+
+def test_convert_reference():
+    class A:
+        pass
+
+    a = A()
+    assert _convert(a, typing.Any) is a  # Nothing should happen.
+    assert _convert(a, tuple) == (a,)
+
+
+def test_change_function_name():
+    def f(x):
+        """Doc"""
+
+    g = _change_function_name(f, "g")
+
+    assert g.__name__ == "g"
+    assert g.__doc__ == "Doc"
+
+
+def test_function():
+    def f(x):
+        """Doc"""
+
+    g = Function(f)
+
+    assert g.__name__ == "f"
+    assert g.__doc__ == "Doc"
+
+    # Check global tracking of functions.
+    assert Function._instances[-1] == g
+
+
+def test_repr():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int):
+        return "int"
+
+    @dispatch
+    def f(x: str):
+        return "str"
+
+    assert repr(f) == f"<function {f._f} with 0 registered and 2 pending method(s)>"
+
+    # Register all methods.
+    assert f(1) == "int"
+
+    assert repr(f) == f"<function {f._f} with 2 registered and 0 pending method(s)>"
+
+    @dispatch
+    def f(x: float):
+        return "float"
+
+    assert repr(f) == f"<function {f._f} with 2 registered and 1 pending method(s)>"
+
+    # Again register all methods.
+    assert f(1) == "int"
+
+    assert repr(f) == f"<function {f._f} with 3 registered and 0 pending method(s)>"
+
+
+# `A` needs to be in the global scope for owner resolution to work.
+
+
+class A:
+    pass
+
+
+def test_owner():
+    def f(x):
+        pass
+
+    assert Function(f).owner is None
+    assert Function(f, owner="A").owner is A
+
+
+@pytest.fixture()
+def owner_transfer():
+    # Save and clear.
+    before = dict(_owner_transfer)
+    _owner_transfer.clear()
+
+    yield _owner_transfer
+
+    # Restore.
+    _owner_transfer.clear()
+    _owner_transfer.update(before)
+
+
+def test_owner_transfer(owner_transfer):
+    def f(x):
+        pass
+
+    class B:
+        pass
+
+    # Transfer once.
+    owner_transfer[A] = B
+    assert Function(f, owner="A").owner is B
+
+    class C:
+        pass
+
+    # Transfer twice.
+    owner_transfer[B] = C
+    assert Function(f, owner="A").owner is C
+
+
+def test_doc(monkeypatch):
+    # Test empty documentation.
+    assert Function(lambda: None).__doc__ is None
+
+    # Test the self-exclusion mechanism.
+    def f(x: int):
+        """Doc"""
+
+    f = Function(f).dispatch(f)
+    assert f.__doc__ == "Doc"
+
+    @f.dispatch
+    def f(x: float):
+        pass
+
+    assert "further implementations" in f.__doc__
+
+    # Let the resolver return something simple to make testing easier.
+    monkeypatch.setattr(Resolver, "doc", lambda _, exclude: "more docs")
+
+    def f(x: int):
+        """Doc"""
+
+    f = Function(f).dispatch(f)
+    assert f.__doc__ == (
+        "Doc\n\n"
+        "This function has further implementations documented below.\n\n"
+        "more docs"
+    )
+
+
+def test_methods():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int):
+        pass
+
+    @dispatch
+    def f(x: float):
+        pass
+
+    assert f.methods == [Signature(int), Signature(float)]
+
+
+def test_function_dispatch():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int):
+        return "int"
+
+    @f.dispatch
+    def implementation(x: float):
+        return "float"
+
+    @f.dispatch(precedence=1)
+    def other_implementation(x: str):
+        return "str"
+
+    assert f(1) == "int"
+    assert f(1.0) == "float"
+    assert f("1") == "str"
+    assert f._resolver.resolve(("1",)).precedence == 1
+
+
+def test_function_multi_dispatch():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int):
+        return "int"
+
+    @f.dispatch_multi((float,), Signature(str, precedence=1))
+    def implementation(x):
+        return "float or str"
+
+    assert f(1) == "int"
+    assert f(1.0) == "float or str"
+    assert f("1") == "float or str"
+    assert f._resolver.resolve(("1",)).precedence == 1
+
+    # Check that arguments to `f.dispatch_multi` must be tuples or signatures.
+    with pytest.raises(ValueError):
+        f.dispatch_multi(1)
+
+
+def test_register():
+    def f(x: int):
+        pass
+
+    g = Function(f)
+    g.register(f)
+
+    assert g._pending == [(f, None, 0)]
+    assert g._resolved == []
+    assert len(g._resolver) == 0
+
+
+def test_resolve_pending_registrations():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int):
+        return "int"
+
+    # Populate cache.
+    assert f(1) == "int"
+
+    # At this point, there should be nothing to register, so a call should not clear
+    # the cache.
+    assert f._pending == []
+    f._resolve_pending_registrations()
+    assert len(f._cache) == 1
+
+    @f.dispatch
+    def f(x: str):
+        pass
+
+    # Now there is something to register. A call should clear the cache.
+    assert len(f._pending) == 1
+    f._resolve_pending_registrations()
+    assert len(f._pending) == 0
+    assert len(f._cache) == 0
+
+    # Register in two ways using multi and the wrong name.
+    @f.dispatch_multi((float,), Signature(complex))
+    def not_f(x):
+        return "float or complex"
+
+    # Even though we used `not_f`, dispatch should work correctly.
+    assert not_f(1.0) == "float or complex"
+    assert not_f(1j) == "float or complex"
+
+    # Check the expansion of default values.
+
+    @dispatch
+    def g(x: int, y: float = 1.0, z: complex = 1j):
+        return "ok"
+
+    assert g(1) == "ok"
+    assert g(1, 1.0) == "ok"
+    assert g(1, 1.0, 1j) == "ok"
+
+    assert g(1, y=1.0, z=1j) == "ok"
+    assert g(1, 1.0, z=1j) == "ok"
+
+
+def test_enhance_exception():
+    def f():
+        pass
+
+    f = Function(f).dispatch(f)
+
+    def g():
+        pass
+
+    g = Function(g, owner="A").dispatch(g)
+
+    e = ValueError("Go!")
+
+    assert isinstance(f._enhance_exception(e), ValueError)
+    assert str(f._enhance_exception(e)) == "For function `f`, go!"
+
+    assert isinstance(g._enhance_exception(e), ValueError)
+    expected = "For function `g` of `tests.test_function.A`, go!"
+    assert str(g._enhance_exception(e)) == expected
+
+
+def test_call_exception_enhancement():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int, y):
+        pass
+
+    @dispatch
+    def f(x, y: int):
+        pass
+
+    with pytest.raises(NotFoundLookupError, match="(?i)^for function `f`, "):
+        f("1", "1")
+
+    with pytest.raises(AmbiguousLookupError, match="(?i)^for function `f`, "):
+        f(1, 1)
+
+
+# We already defined an `A` above. The classes below again need to be in the global
+# scope.
+
+dispatch = Dispatcher()
+
+
+class B(metaclass=abc.ABCMeta):
+    @dispatch
+    def __init__(self):  # noqa: B027
+        pass
+
+    def do(self, x):
+        return "B"
+
+    @abc.abstractmethod
+    def do_something_else(self, x):
+        pass
+
+
+# Put a class in the middle of the two to make sure that MRO resolution works well.
+
+
+class Inbetween(B):
+    pass
+
+
+class C(Inbetween):
+    @dispatch
+    def __init__(self):
+        pass
+
+    @dispatch
+    def __call__(self):
+        return "C"
+
+    @dispatch
+    def do(self, x: int):
+        return "C"
+
+    @dispatch
+    def do_something_else(self, x: int):
+        return "C"
+
+    @dispatch
+    def __le__(self, other: int):
+        return 1
+
+
+def test_call_mro():
+    c = C()
+
+    # If method cannot be found, the next in the MRO should be invoked.
+    assert c.do(1) == "C"
+    assert c.do(1.0) == "B"
+
+    # Test a dunder method.
+    assert (c <= 2) == 1
+    with pytest.raises(
+        NotFoundLookupError,
+        match=r"(?i)^for function `__le__` of `tests.test_function.C`",
+    ):
+        c <= "2"  # noqa
+
+
+def test_call_abstract():
+    # Check that ABC still works.
+    with pytest.raises(TypeError):
+        B()
+    c = C()
+
+    # Abstract methods should be ignored.
+    assert c.do_something_else(1) == "C"
+    with pytest.raises(NotFoundLookupError):
+        c.do_something_else(1.0)
+
+
+def test_call_object():
+    with pytest.raises(
+        NotFoundLookupError,
+        match=r"(?i)^for function `__init__` of `tests.test_function.B`",
+    ):
+        # Construction requires no arguments. Giving an argument should propagate to
+        # `B` and then error.
+        C(1)
+
+    with pytest.raises(
+        NotFoundLookupError,
+        match=r"(?i)^for function `__call__` of `tests.test_function.C`",
+    ):
+        # Calling requires no arguments.
+        C()(1)
+
+
+dispatch = Dispatcher()
+
+
+class D(type):
+    @dispatch
+    def __call__(self, x: str):
+        pass
+
+
+class E(D):
+    @dispatch
+    def __init__(self, name: str, bases: typing.Tuple[type], methods: dict):
+        pass
+
+    @dispatch
+    def __call__(self):
+        pass
+
+
+def test_call_type():
+    class A:
+        pass
+
+    """Exactly like :func:`test_call_object`."""
+    with pytest.raises(
+        NotFoundLookupError,
+        match=r"(?i)^for function `__init__` of `tests.test_function.E`",
+    ):
+        E("Test", (A, object), {})  # Must have exactly one base.
+
+    with pytest.raises(
+        NotFoundLookupError,
+        match=r"(?i)^for function `__call__` of `tests.test_function.D`",
+    ):
+        # The call method will be tried at :class:`D` and only then error.
+        E("Test", (object,), {})(1)
+
+
+def test_call_convert():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x) -> tuple:
+        return x
+
+    assert f(1) == (1,)
+
+
+def test_invoke():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int):
+        return "int"
+
+    @dispatch
+    def f(x: float):
+        return "float"
+
+    @dispatch
+    def f(x: str):
+        return "str"
+
+    assert f.invoke(int)(None) == "int"
+    assert f.invoke(float)(None) == "float"
+    assert f.invoke(str)(None) == "str"
+
+
+def test_invoke_convert():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int) -> tuple:
+        return x
+
+    assert f.invoke(int)(1) == (1,)
+
+
+def test_invoke_wrapping():
+    dispatch = Dispatcher()
+
+    @dispatch
+    def f(x: int):
+        """Docs"""
+
+    assert f.invoke(int).__name__ == "f"
+    assert f.invoke(int).__doc__ == "Docs"
+
+
+def test_bound():
+    dispatch = Dispatcher()
+
+    class A:
+        @dispatch
+        def do(self, x: int):
+            """Docs"""
+            return "int"
+
+    assert A().do.__doc__ == "Docs"
+    assert A.do.__doc__ == "Docs"
+
+    assert A().do.invoke(int)(1) == "int"
+    assert A.do.invoke(A, int)(A(), 1) == "int"
+
+    # Also test that `invoke` is wrapped, like above.
+    assert A().do.invoke(int).__doc__ == "Docs"
+    assert A.do.invoke(A, int).__doc__ == "Docs"
diff --git a/tests/test_init.py b/tests/test_init.py
new file mode 100644
index 00000000..98954dce
--- /dev/null
+++ b/tests/test_init.py
@@ -0,0 +1,37 @@
+import typing
+from typing import List, Union
+
+import pytest
+
+import plum
+from plum import ModuleType
+
+
+@pytest.mark.xfail()
+def test_beartype_on_strategy():
+    # The `O(n)` strategy is not yet supported.
+    for _ in range(10):
+        assert not plum.isinstance([1, 1, 1, 1, None], List[int])
+
+
+def test_isinstance():
+    # Check that subscripted generics work and types are resolved.
+    assert plum.isinstance(
+        1,
+        Union[float, ModuleType("builtins", "int")],  # noqa: F821
+    )
+
+
+def test_issubclass():
+    # Check that subscripted generics work and types are resolved.
+    assert plum.issubclass(
+        Union[ModuleType("builtins", "int"), float],  # noqa: F821
+        Union[ModuleType("numbers", "Number"), complex],  # noqa: F821
+    )
+
+
+def test_backward_compatibility():
+    assert plum.Dict == typing.Dict
+    assert plum.List == typing.List
+    assert plum.Tuple == typing.Tuple
+    assert plum.Union == typing.Union
diff --git a/tests/test_parametric.py b/tests/test_parametric.py
index fbcd48eb..d9fa79c0 100644
--- a/tests/test_parametric.py
+++ b/tests/test_parametric.py
@@ -1,26 +1,21 @@
+import abc
+from numbers import Number
+from typing import Optional, Tuple, Union
+
 import numpy as np
 import pytest
 
 from plum import (
     Dispatcher,
-    parametric,
-    type_parameter,
-    kind,
     Kind,
-    ptype,
-    type_of,
-    Type,
-    PromisedType,
-    List,
-    Tuple,
-    Dict,
-    Iterable,
-    Sequence,
-    Union,
+    ModuleType,
     NotFoundLookupError,
     Val,
+    kind,
+    parametric,
+    type_parameter,
 )
-from plum.parametric import _types_of_iterable, CovariantMeta
+from plum.parametric import CovariantMeta, is_concrete, is_type
 
 
 def test_covariantmeta():
@@ -31,14 +26,12 @@ class A(metaclass=CovariantMeta):
         A.concrete
 
 
-def test_covariance():
-    assert issubclass(List[int], List[object])
-    assert issubclass(List[List[int]], List[List[object]])
-    assert not issubclass(List[int], List[str])
-    assert not issubclass(List[list], List[int])
+class MyType(CovariantMeta):
+    pass
 
 
-def test_parametric():
+@pytest.mark.parametrize("metaclass", [type, MyType])
+def test_parametric(metaclass):
     class Base1:
         pass
 
@@ -46,7 +39,7 @@ class Base2:
         pass
 
     @parametric
-    class A(Base1):
+    class A(Base1, metaclass=metaclass):
         pass
 
     assert issubclass(A, Base1)
@@ -67,10 +60,10 @@ class A(Base1):
     assert issubclass(type(a1), Base1)
     assert not issubclass(type(a1), Base2)
 
-    # Test multiple type parameters
+    # Test multiple type parameters.
     assert A[1, 2] == A[1, 2]
 
-    def tuple_elements_are_identical(tup1, tup2):
+    def tuples_are_identical(tup1, tup2):
         if len(tup1) != len(tup2):
             return False
         for x, y in zip(tup1, tup2):
@@ -84,8 +77,8 @@ def tuple_elements_are_identical(tup1, tup2):
     assert type_parameter(A[1.0]()) == 1.0
     assert type_parameter(A[1, 2]()) == (1, 2)
     assert type_parameter(A[a1]()) is a1
-    assert tuple_elements_are_identical(type_parameter(A[a1, a2]()), (a1, a2))
-    assert tuple_elements_are_identical(type_parameter(A[1, a2]()), (1, a2))
+    assert tuples_are_identical(type_parameter(A[a1, a2]()), (a1, a2))
+    assert tuples_are_identical(type_parameter(A[1, a2]()), (1, a2))
 
     # Test that an error is raised if type parameters are specified twice.
     T = A[1]
@@ -159,7 +152,32 @@ def __init__(self, v, w, x, y, z):
     assert type(E(1, 2, 3, 4, 5)) == E[int, int, int, int, int]
 
 
-def test_constructor():
+def test_parametric_covariance():
+    @parametric
+    class A:
+        pass
+
+    # Test covariance.
+    assert issubclass(A[int], A[Number])
+    assert not issubclass(A[int], A[float])
+
+    # Check that the number of arguments must be right.
+    assert not issubclass(A[int], A[Number, Number])
+    assert issubclass(A[int, int], A[Number, Number])
+    assert not issubclass(A[int, int, int], A[Number, Number])
+
+    # Test that type parameters are resolved.
+    assert issubclass(
+        A[ModuleType("builtins", "int")],
+        A[ModuleType("numbers", "Number")],
+    )
+
+    # Check a mix between equatable objects and types.
+    assert issubclass(A[1, int], A[1, Number])
+    assert not issubclass(A[2, int], A[1, Number])
+
+
+def test_parametric_constructor():
     @parametric
     class A:
         def __init__(self, x, *, y=3):
@@ -168,13 +186,11 @@ def __init__(self, x, *, y=3):
 
     assert A.parametric
     assert not A.concrete
-    assert not A.runtime_type_of
     with pytest.raises(RuntimeError):
         A.type_parameter
 
     assert A[float].parametric
     assert A[float].concrete
-    assert not A[float].runtime_type_of
     assert A[float].type_parameter == float
 
     a1 = A[float](5.0)
@@ -188,518 +204,281 @@ def __init__(self, x, *, y=3):
     assert type_parameter(a1) == float
     assert type_parameter(a2) == float
     assert type(a1) == type(a2)
-    assert type(a1).__name__ == type(a2).__name__ == f"A[{float}]"
+    assert type(a1).__name__ == type(a2).__name__ == "A[float]"
 
-    @parametric(runtime_type_of=True)
-    class B:
-        def __init__(self, x, y):
-            self.x = x
-            self.y = y
-
-    assert B.parametric
-    assert not B.concrete
-    assert B.runtime_type_of
-    with pytest.raises(RuntimeError):
-        B.type_parameter
-
-    assert B[float].parametric
-    assert B[float].concrete
-    assert B[float].runtime_type_of
-    assert B[float].type_parameter == float
 
-    b1 = B[float, int](5.0, 3)
-    b2 = B(5.0, 3)
-
-    assert b1.x == 5.0
-    assert b2.x == 5.0
-    assert b1.y == 3
-    assert b2.y == 3
-
-    assert type_parameter(b1) == (float, int)
-    assert type_parameter(b2) == (float, int)
-    assert type(b1) == type(b2)
-    assert type(b1).__name__ == type(b2).__name__ == f"B[{float}, {int}]"
+@parametric
+class NTuple:
+    dispatch = Dispatcher()
 
+    def __init__(self, *args):
+        # Check that the arguments satisfy the type specification.
+        n, t = type_parameter(self)
+        if len(args) != n or any(not isinstance(arg, t) for arg in args):
+            raise ValueError("Incorrect arguments!")
 
-def test_override_type_parameters():
-    @parametric
-    class NTuple:
-        @classmethod
-        def __infer_type_parameter__(self, *args):
-            # Mimicks the type parameters of an `NTuple`.
-            T = type(args[0])
-            N = len(args)
-            return (N, T)
+        self.args = args
 
-        def __init__(self, *args):
-            T = type(self)._type_parameter[1]
-            assert all(isinstance(arg, T) for arg in args)
-            self.args = args
-
-    assert NTuple[3, int] == type(NTuple(1, 2, 3))
+    @classmethod
+    @dispatch
+    def __init_type_parameter__(self, n: int, t: type):
+        """Check whether the type parameters are valid."""
+        # In this case, we use `@dispatch` to check the validity of the type
+        # parameter.
+        return n, t
+
+    @classmethod
+    def __infer_type_parameter__(self, *args):
+        """Inter the type parameter from the arguments."""
+        n = len(args)
+        # For simplicity, take the type of the first argument! We could do something
+        # more refined here.
+        t = type(args[0])
+        return n, t
+
+
+def test_parametric_override_infer_type_parameter():
+    # Check type parameter inference.
+    assert isinstance(NTuple(1, 2, 3), NTuple[3, int])
+
+    # Check type parameter initialisation.
+    with pytest.raises(NotFoundLookupError):
+        NTuple[2, "int"]
+    with pytest.raises(NotFoundLookupError):
+        NTuple[None, int]
 
+    # Check argument validation.
+    assert NTuple[2, int](1, 2)
+    with pytest.raises(ValueError):
+        assert NTuple[2, int](1, 2, 3)
+    with pytest.raises(ValueError):
+        assert NTuple[2, int](1, 2.0)
 
-def test_kind():
-    assert Kind[1] == Kind[1]
-    assert Kind[1] != Kind[2]
-    assert Kind[1](1).get() == 1
-    assert Kind[2](1, 2).get() == (1, 2)
+    # Check covariance.
+    assert issubclass(NTuple[2, int], NTuple[2, Number])
+    assert not issubclass(NTuple[2, int], NTuple[2, float])
+    assert not issubclass(NTuple[3, int], NTuple[2, Number])
 
-    Kind2 = kind()
-    assert Kind2[1] != Kind[1]
-    assert Kind[1] == Kind[1]
-    assert Kind2[1] == Kind2[1]
 
-    # Test providing a superclass, where the default should be `object`.
-    class SuperClass:
-        pass
+class NDArrayMeta(type):
+    def __instancecheck__(self, x):
+        if self.concrete:
+            shape, dtype = self.type_parameter
+        else:
+            shape, dtype = None, None
+        return (
+            isinstance(x, np.ndarray)
+            and (shape is None or x.shape == shape)
+            and (dtype is None or x.dtype == dtype)
+        )
 
-    Kind3 = kind(SuperClass)
-    assert issubclass(Kind3[1], SuperClass)
-    assert not issubclass(Kind2[1], SuperClass)
-    assert issubclass(Kind2[1], object)
 
+dispatch = Dispatcher()
 
-def test_list():
-    # Standard type tests.
-    assert hash(List[int]) == hash(List[int])
-    assert hash(List[int]) != hash(List[str])
-    assert hash(List[List[int]]) == hash(List[List[int]])
-    assert hash(List[List[int]]) != hash(List[List[str]])
-    assert repr(List[int]) == f"List[{Type(int)!r}]"
-    assert issubclass(List[int].get_types()[0], list)
-    assert not issubclass(List[int].get_types()[0], int)
-    assert not issubclass(List[int].get_types()[0], tuple)
-
-    # Test instance check.
-    assert isinstance([], List[Union[object]])
-    assert isinstance([1, 2], List[Union[int]])
-
-    # Check tracking of parametric.
-    assert List[int].parametric
-    assert ptype(List[List[int]]).parametric
-    assert ptype(Union[List[int]]).parametric
-    promise = PromisedType()
-    promise.deliver(List[int])
-    assert promise.resolve().parametric
-
-    # Check tracking of runtime `type_of`.
-    assert List[int].runtime_type_of
-    assert ptype(List[List[int]]).runtime_type_of
-    assert ptype(Union[List[int]]).runtime_type_of
-    promise = PromisedType()
-    promise.deliver(List[int])
-    assert promise.resolve().runtime_type_of
-
-    # Test correctness.
-    dispatch = Dispatcher()
 
+@parametric
+class NDArray(np.ndarray, metaclass=NDArrayMeta):
+    @classmethod
     @dispatch
-    def f(x):
-        return "fallback"
-
+    def __init_type_parameter__(
+        cls,
+        shape: Optional[Tuple[int, ...]],
+        dtype: Optional[type],
+    ):
+        """Validate the type parameter."""
+        return shape, dtype
+
+    @classmethod
     @dispatch
-    def f(x: list):
-        return "list"
+    def __le_type_parameter__(
+        cls,
+        left: Tuple[Optional[Tuple[int, ...]], Optional[type]],
+        right: Tuple[Optional[Tuple[int, ...]], Optional[type]],
+    ):
+        """Define an order on type parameters. That is, check whether `left <= right`
+        or not."""
+        shape_left, dtype_left = left
+        shape_right, dtype_right = right
+        le_shape = shape_right is None or shape_left == shape_right
+        le_dtype = dtype_right is None or dtype_left == dtype_right
+        return le_shape and le_dtype
+
+
+def test_parametric_override_init_type_parameter():
+    # Construct the parametric type in the right way.
+    assert issubclass(NDArray[(2, 2), int], np.ndarray)
+    assert issubclass(NDArray[(2, 2), None], np.ndarray)
+    assert issubclass(NDArray[None, int], np.ndarray)
+    assert issubclass(NDArray[None, None], np.ndarray)
+
+    # Construct it in an incorrect way.
+    with pytest.raises(NotFoundLookupError):
+        NDArray[None]
+    with pytest.raises(NotFoundLookupError):
+        NDArray[1, int]
+    with pytest.raises(NotFoundLookupError):
+        NDArray[(2, 2), 1]
 
-    @dispatch
-    def f(x: List[int]):
-        return "list of int"
 
-    @dispatch
-    def f(x: List[List[int]]):
-        return "list of list of int"
-
-    assert f([1]) == "list of int"
-    assert f(1) == "fallback"
-    assert f([1, 2]) == "list of int"
-    assert f([1, 2, "3"]) == "list"
-    assert f([[1]]) == "list of list of int"
-    assert f([[1], [1]]) == "list of list of int"
-    assert f([[1], [1, 2]]) == "list of list of int"
-    assert f([[1], [1, 2, "3"]]) == "list"
-
-
-def test_tuple():
-    # Standard type tests.
-    assert hash(Tuple[int]) == hash(Tuple[int])
-    assert hash(Tuple[int]) != hash(Tuple[str])
-    assert hash(Tuple[Tuple[int]]) == hash(Tuple[Tuple[int]])
-    assert hash(Tuple[Tuple[int]]) != hash(Tuple[Tuple[str]])
-    assert repr(Tuple[int]) == f"Tuple[{Type(int)!r}]"
-    assert issubclass(Tuple[int].get_types()[0], tuple)
-    assert not issubclass(Tuple[int].get_types()[0], int)
-    assert not issubclass(Tuple[int].get_types()[0], list)
-
-    # Test instance check.
-    assert isinstance((), Tuple())
-    assert isinstance((1, 2), Tuple[int, int])
-
-    # Check tracking of parametric.
-    assert Tuple[int].parametric
-    assert ptype(List[Tuple[int]]).parametric
-    assert ptype(Union[Tuple[int]]).parametric
-    promise = PromisedType()
-    promise.deliver(Tuple[int])
-    assert promise.resolve().parametric
-
-    # Check tracking of runtime `type_of`.
-    assert Tuple[int].runtime_type_of
-    assert ptype(List[Tuple[int]]).runtime_type_of
-    assert ptype(Union[Tuple[int]]).runtime_type_of
-    promise = PromisedType()
-    promise.deliver(Tuple[int])
-    assert promise.resolve().runtime_type_of
-
-    # Test correctness.
+def test_parametric_override_le_type_parameter():
     dispatch = Dispatcher()
 
     @dispatch
-    def f(x):
-        return "fallback"
-
-    @dispatch
-    def f(x: tuple):
-        return "tup"
+    def f(x: np.ndarray):
+        return "array"
 
     @dispatch
-    def f(x: Tuple[int]):
-        return "tup of int"
+    def f(x: NDArray[(2, 2), None]):
+        return "2x2 array"
 
     @dispatch
-    def f(x: Tuple[int, int]):
-        return "tup of double int"
+    def f(x: NDArray[None, int]):
+        return "int array"
 
     @dispatch
-    def f(x: Tuple[Tuple[int]]):
-        return "tup of tup of int"
+    def f(x: NDArray[(2, 2), int]):
+        return "2x2 int array"
 
-    @dispatch
-    def f(x: Tuple[Tuple[int], Tuple[int]]):
-        return "tup of double tup of int"
-
-    @dispatch
-    def f(x: Tuple[int, Tuple[int, int]]):
-        return "tup of int and tup of double int"
-
-    assert f((1,)) == "tup of int"
-    assert f(1) == "fallback"
-    assert f((1, 2)) == "tup of double int"
-    assert f((1, 2, "3")) == "tup"
-    assert f(((1,),)) == "tup of tup of int"
-    assert f(((1,), (1,))) == "tup of double tup of int"
-    assert f((1, (1, 2))) == "tup of int and tup of double int"
-    assert f(((1,), (1, 2))) == "tup"
-
-
-def test_dict():
-    # Standard type tests.
-    assert hash(Dict[int, float]) == hash(Dict[int, float])
-    assert hash(Dict[int, float]) != hash(Tuple[int, str])
-    assert hash(Dict[int, float]) != hash(Tuple[str, float])
-    assert hash(Dict[Tuple[int], float]) == hash(Dict[Tuple[int], float])
-    assert hash(Dict[Tuple[int], float]) != hash(Dict[Tuple[str], float])
-    assert repr(Dict[int, float]) == f"Dict[{Type(int)!r}, {Type(float)!r}]"
-    assert issubclass(Dict[int, float].get_types()[0], dict)
-    assert not issubclass(Dict[int, float].get_types()[0], int)
-
-    # Test instance check.
-    assert isinstance({}, Dict[Union[object], Union[object]])
-    assert isinstance({1: 2.0}, Dict[int, float])
-
-    # Check tracking of parametric.
-    assert Dict[int, float].parametric
-    assert ptype(Dict[Tuple[int], float]).parametric
-    assert ptype(Union[Dict[int, float]]).parametric
-    promise = PromisedType()
-    promise.deliver(Dict[int, float])
-    assert promise.resolve().parametric
-
-    # Check tracking of runtime `type_of`.
-    assert Dict[int, float].runtime_type_of
-    assert ptype(Dict[Tuple[int], float]).runtime_type_of
-    assert ptype(Union[Dict[int, float]]).runtime_type_of
-    promise = PromisedType()
-    promise.deliver(Dict[int, float])
-    assert promise.resolve().runtime_type_of
-
-    # Test correctness.
-    dispatch = Dispatcher()
-
-    @dispatch
-    def f(x):
-        return "fallback"
+    assert f(np.ones((3, 3))) == "array"
+    assert f(np.ones((3, 3), int)) == "int array"
+    assert f(np.ones((2, 2))) == "2x2 array"
+    assert f(np.ones((2, 2), int)) == "2x2 int array"
+    with pytest.raises(NotFoundLookupError):
+        assert f(1.0)
 
-    @dispatch
-    def f(x: dict):
-        return "dict"
 
-    @dispatch
-    def f(x: Dict[int, int]):
-        return "int to int"
+def test_parametric_custom_metaclass():
+    @parametric
+    class A(metaclass=abc.ABCMeta):
+        @abc.abstractmethod
+        def method(self):
+            pass
 
-    @dispatch
-    def f(x: Dict[str, int]):
-        return "str to int"
+    class B(A[1]):
+        pass
 
-    @dispatch
-    def f(x: Dict[Union[int, str], int]):
-        return "int or str to int"
-
-    assert f(1) == "fallback"
-    assert f({1: 1}) == "int to int"
-    assert f({"1": 1}) == "str to int"
-    assert f({"1": 1, 1: 1}) == "int or str to int"
-    assert f({"1": "1", 1: 1}) == "dict"
-
-
-def test_iterable():
-    # Standard type tests.
-    assert hash(Iterable[int]) == hash(Iterable[int])
-    assert hash(Iterable[int]) != hash(Iterable[str])
-    assert hash(Iterable[int]) != hash(Iterable())
-    assert hash(Iterable[Iterable[int]]) == hash(Iterable[Iterable[int]])
-    assert hash(Iterable[Iterable[int]]) != hash(Iterable[Iterable[str]])
-    assert repr(Iterable()) == "Iterable"
-    assert repr(Iterable[int]) == f"Iterable[{Type(int)!r}]"
-
-    # Test instance check.
-    assert isinstance([], Iterable())
-    assert isinstance([], Iterable[object])
-    assert isinstance((1, 2.0), Iterable())
-    assert isinstance((1, 2.0), Iterable[Union[int, float]])
-    assert isinstance([1, 2], Iterable())
-    assert isinstance([1, 2], Iterable[int])
-    assert isinstance((x for x in [1, 2]), Iterable())
-
-    # Test subclass check.
-    assert issubclass(ptype(list), Iterable())
-    assert issubclass(List[int], Iterable())
-    assert issubclass(List[int], Iterable[int])
-    assert not issubclass(ptype(list), Iterable[int])
-    assert issubclass(Iterable[int], Iterable[object])
-
-    # Check tracking of parametric.
-    assert Iterable[int].parametric
-    assert ptype(Iterable[Iterable[int]]).parametric
-    assert ptype(Union[Iterable[int]]).parametric
-    promise = PromisedType()
-    promise.deliver(Iterable[int])
-    assert promise.resolve().parametric
-
-    # Check tracking of runtime `type_of`.
-    assert Iterable[int].runtime_type_of
-    assert ptype(Iterable[Iterable[int]]).runtime_type_of
-    assert ptype(Union[Iterable[int]]).runtime_type_of
-    promise = PromisedType()
-    promise.deliver(Iterable[int])
-    assert promise.resolve().runtime_type_of
-
-    assert not Iterable().runtime_type_of
-    assert ptype(Iterable[Iterable()]).runtime_type_of
-    assert not ptype(Union[Iterable()]).runtime_type_of
-    promise = PromisedType()
-    promise.deliver(Iterable())
-    assert not promise.resolve().runtime_type_of
-
-    # Test correctness.
-    dispatch = Dispatcher()
+    class C(B):
+        pass
 
     @parametric
-    class A:
-        def __init__(self, el_type):
+    class D(C):
+        def method(self):
             pass
 
-        def __iter__(self):
+    class E(A):
+        def method(self):
             pass
 
+    with pytest.raises(TypeError):
+        A()
+    with pytest.raises(TypeError):
+        B()
+    with pytest.raises(TypeError):
+        C()
+    D()
+    E()
+
+
+def test_parametric_custom_metaclass_name_metaclass():
+    """Test that the name of the new metaclass is right."""
+
     @parametric
+    class A(metaclass=abc.ABCMeta):
+        pass
+
     class B(A):
-        @classmethod
-        def __iter_el_type__(cls):
-            return cls.type_parameter
+        pass
 
-    @dispatch
-    def f(x):
-        return "fallback"
+    @parametric
+    class C(B, metaclass=abc.ABCMeta):
+        pass
 
-    @dispatch
-    def f(x: Iterable()):
-        return "it"
+    for c in [A, B, C]:
+        assert type(c).__name__ == "CovariantMeta[abc.ABCMeta]"
 
-    @dispatch
-    def f(x: Iterable[int]):
-        return "it of int"
 
-    @dispatch
-    def f(x: Iterable[Iterable[object]]):
-        return "it of it"
-
-    assert f(1) == "fallback"
-    # Test various iterators:
-    assert f((x for x in [1, 2])) == "it"
-    assert f(A(1)) == "it"
-    assert f(A(1.0)) == "it"
-    assert f(B(1)) == "it of int"
-    assert f(B(1.0)) == "it"
-    assert f([1]) == "it of int"
-    assert f([1.0]) == "it"
-    assert f({1: 1}) == "it of int"
-    assert f({1.0: 1}) == "it"
-    assert f((1, 1)) == "it of int"
-    assert f((1.0, 1)) == "it"
-    # Test nested iterators:
-    assert f([[1]]) == "it of it"
-    assert f(([1], [1, 2, "3"])) == "it of it"
-
-
-def test_sequence():
-    # Standard type tests.
-    assert hash(Sequence[int]) == hash(Sequence[int])
-    assert hash(Sequence[int]) != hash(Sequence[str])
-    assert hash(Sequence[int]) != hash(Sequence())
-    assert hash(Sequence[Sequence[int]]) == hash(Sequence[Sequence[int]])
-    assert hash(Sequence[Sequence[int]]) != hash(Sequence[Sequence[str]])
-    assert repr(Sequence()) == "Sequence"
-    assert repr(Sequence[int]) == f"Sequence[{Type(int)!r}]"
-
-    # Test instance check.
-    assert isinstance([], Sequence())
-    assert isinstance([], Sequence[object])
-    assert isinstance((1, 2.0), Sequence())
-    assert isinstance((1, 2.0), Sequence[Union[int, float]])
-    assert isinstance([1, 2], Sequence())
-    assert isinstance([1, 2], Sequence[int])
-    assert not isinstance((x for x in [1, 2]), Sequence())
-
-    # Test subclass check.
-    assert issubclass(ptype(list), Sequence())
-    assert issubclass(List[int], Sequence())
-    assert issubclass(List[int], Sequence[int])
-    assert not issubclass(ptype(list), Sequence[int])
-    assert issubclass(Sequence[int], Sequence[object])
-
-    # Check tracking of parametric.
-    assert Sequence[int].parametric
-    assert ptype(Sequence[Sequence[int]]).parametric
-    assert ptype(Union[Sequence[int]]).parametric
-    promise = PromisedType()
-    promise.deliver(Sequence[int])
-    assert promise.resolve().parametric
-
-    # Check tracking of runtime `type_of`.
-    assert Sequence[int].runtime_type_of
-    assert ptype(Sequence[Sequence[int]]).runtime_type_of
-    assert ptype(Union[Sequence[int]]).runtime_type_of
-    promise = PromisedType()
-    promise.deliver(Sequence[int])
-    assert promise.resolve().runtime_type_of
-
-    assert not Sequence().runtime_type_of
-    assert ptype(Sequence[Sequence()]).runtime_type_of
-    assert not ptype(Union[Sequence()]).runtime_type_of
-    promise = PromisedType()
-    promise.deliver(Sequence())
-    assert not promise.resolve().runtime_type_of
-
-    # Test correctness.
+@parametric
+class A:
     dispatch = Dispatcher()
 
-    @parametric
-    class A:
-        def __init__(self, el_type):
-            pass
+    @dispatch
+    def f(self):
+        pass
 
-        def __len__(self):
-            pass
 
-        def __getitem__(self, item):
-            pass
+def test_parametric_owner_inference():
+    # The owner should not be what's returned by `@parametric`, which is a proxy.
+    # Rather, the owner should be the class that really owns the methods, which should
+    # be the direct superclass of the proxy class.
+    assert A.f.owner != A
+    assert "f" not in A.__dict__
+    assert A.f.owner == A.__mro__[1]
+    assert "f" in A.__mro__[1].__dict__
+
+
+def test_is_concrete():
+    class A:
+        pass
 
     @parametric
-    class B(A):
-        @classmethod
-        def __getitem_el_type__(cls):
-            return cls.type_parameter
+    class B:
+        pass
 
-    @dispatch
-    def f(x):
-        return "fallback"
+    assert not is_concrete(A)
+    assert not is_concrete(B)
+    assert is_concrete(B[1])
 
-    @dispatch
-    def f(x: Sequence()):
-        return "seq"
 
-    @dispatch
-    def f(x: Sequence[int]):
-        return "seq of int"
+def test_is_type():
+    assert is_type(int)
+    assert is_type(Union[int, float])
+    assert not is_type(1)
 
-    @dispatch
-    def f(x: Sequence[Sequence[object]]):
-        return "seq of seq"
-
-    assert f(1) == "fallback"
-    assert f((x for x in [1, 2])) == "fallback"
-    # Test various sequences:
-    assert f(A(1)) == "seq"
-    assert f(A(1.0)) == "seq"
-    assert f(B(1)) == "seq of int"
-    assert f(B(1.0)) == "seq"
-    assert f([1]) == "seq of int"
-    assert f([1.0]) == "seq"
-    assert f({1: 1}) == "seq of int"
-    assert f({1: 1.0}) == "seq"
-    assert f((1, 1)) == "seq of int"
-    assert f((1.0, 1)) == "seq"
-    # Test nested sequences:
-    assert f([[1]]) == "seq of seq"
-    assert f(([1], [1, 2, "3"])) == "seq of seq"
-
-
-def test_types_of_iterables():
-    assert _types_of_iterable([1]) == Type(int)
-    assert _types_of_iterable(["1"]) == Type(str)
-    assert _types_of_iterable([1, "1"]) == Union[int, str]
-    assert _types_of_iterable((1,)) == Type(int)
-    assert _types_of_iterable(("1",)) == Type(str)
-    assert _types_of_iterable((1, "1")) == Union[int, str]
-
-
-def test_type_of():
-    assert type_of(1) == Type(int)
-    assert type_of("1") == Type(str)
-    assert type_of([1]) == List[int]
-    assert type_of([1, "1"]) == List[Union[int, str]]
-    assert type_of([1, "1", (1,)]) == List[Union[int, str, Tuple[int]]]
-    assert type_of((1,)) == Tuple[int]
-    assert type_of(("1",)) == Tuple[str]
-    assert type_of((1, "1")) == Tuple[int, str]
-    assert type_of((1, "1", [1])) == Tuple[int, str, List[int]]
-
-
-def test_type_of_extension():
-    dispatch = Dispatcher()
 
-    @parametric(runtime_type_of=True)
-    class NPArray(np.ndarray):
+def test_type_parameter():
+    @parametric
+    class A:
+        pass
+
+    class B:
         pass
 
-    @type_of.dispatch
-    def type_of_extension(x: np.ndarray):
-        return NPArray[x.ndim]
+    assert type_parameter(A()) == ()
+    assert type_parameter(A[1]) == 1
+    assert type_parameter(A[1]()) == 1
+    assert type_parameter(A["1"]) == "1"
+    assert type_parameter(A["1"]()) == "1"
 
-    @dispatch
-    def f(x: NPArray[1]):
-        return "vector"
+    with pytest.raises(
+        RuntimeError,
+        match=r"(?i)cannot get the type parameter of non-instantiated parametric",
+    ):
+        type_parameter(A)
+    with pytest.raises(ValueError, match=r"not a concrete parametric type"):
+        type_parameter(B)
+    with pytest.raises(ValueError, match=r"not a concrete parametric type"):
+        type_parameter(B())
 
-    @dispatch
-    def f(x: NPArray[2]):
-        return "matrix"
 
-    assert f(np.random.randn(10)) == "vector"
-    assert f(np.random.randn(10, 10)) == "matrix"
-    with pytest.raises(NotFoundLookupError):
-        f(np.random.randn(10, 10, 10))
+def test_kind():
+    assert Kind[1] == Kind[1]
+    assert Kind[1] != Kind[2]
+    assert Kind[1](1).get() == 1
+    assert Kind[2](1, 2).get() == (1, 2)
+
+    Kind2 = kind()
+    assert Kind2[1] != Kind[1]
+    assert Kind[1] == Kind[1]
+    assert Kind2[1] == Kind2[1]
+
+    # Test providing a superclass, where the default should be `object`.
+    class SuperClass:
+        pass
+
+    Kind3 = kind(SuperClass)
+    assert issubclass(Kind3[1], SuperClass)
+    assert not issubclass(Kind2[1], SuperClass)
+    assert issubclass(Kind2[1], object)
 
 
 def test_val():
diff --git a/tests/test_promotion.py b/tests/test_promotion.py
index 517ab1c2..460bcbb9 100644
--- a/tests/test_promotion.py
+++ b/tests/test_promotion.py
@@ -1,32 +1,26 @@
+from numbers import Number
 from typing import Union
 
 import pytest
 
 import plum
-from plum import add_conversion_method, add_promotion_rule, promote, conversion_method
-from plum.promotion import _convert
-from .test_signature import Num, Re, FP
+from plum import add_conversion_method, add_promotion_rule, conversion_method
+from plum.promotion import _promotion_rule
 
 
-@pytest.fixture
-def convert():
-    # Save methods.
-    _convert._resolve_pending_registrations()
-    methods = dict(_convert._methods)
-    precedences = dict(_convert._precedences)
-    resolved = list(_convert._resolved)
+class Num:
+    pass
 
-    yield plum.convert
 
-    # Clear methods after use.
-    _convert._resolve_pending_registrations()
-    _convert._methods = methods
-    _convert._precedences = precedences
-    _convert._resolved = resolved
-    _convert.clear_cache()
+class Re(Num):
+    pass
 
 
-def test_conversion(convert):
+class Rat(Re):
+    pass
+
+
+def test_convert(convert):
     # Test basic conversion.
     assert convert(1.0, float) == 1.0
     assert convert(1.0, object) == 1.0
@@ -38,7 +32,7 @@ def test_conversion(convert):
     assert convert(r, Re) == r
     assert convert(r, Num) == r
     with pytest.raises(TypeError):
-        convert(r, FP)
+        convert(r, Rat)
 
     # Test `add_conversion_method`.
     add_conversion_method(float, int, lambda _: 2.0)
@@ -47,13 +41,22 @@ def test_conversion(convert):
     assert convert(1.0, int) == 2.0
 
     # Test `conversion_method`.
-    @conversion_method(Num, FP)
+
+    @conversion_method(Num, Rat)
     def num_to_fp(x):
         return 3.0
 
     assert convert(r, Re) == r
     assert convert(r, Num) == r
-    assert convert(r, FP) == 3.0
+    assert convert(r, Rat) == 3.0
+
+
+def test_convert_resolve_type_hints(convert):
+    add_conversion_method(int, float, lambda x: 2.0)
+    # The below calls will only work if the type hint is resolved.
+    assert convert(1, plum.ModuleType("builtins", "float")) == 2.0
+    # This tests the one in the fallback of `_convert`.
+    assert convert(1, plum.ModuleType("builtins", "int")) == 1
 
 
 def test_default_conversion_methods():
@@ -75,7 +78,7 @@ def test_default_conversion_methods():
     assert plum.convert("test".encode(), str) == "test"
 
 
-def test_promotion(convert):
+def test_promote(convert, promote):
     assert promote() == ()
     assert promote(1) == (1,)
     assert promote(1.0) == (1.0,)
@@ -95,11 +98,11 @@ def test_promotion(convert):
     with pytest.raises(TypeError):
         promote(1.0, 1)
 
-    add_conversion_method(int, float, float)
+    add_conversion_method(int, float, lambda x: x + 1.0)
 
-    assert promote(1, 1.0) == (1.0, 1.0)
-    assert promote(1, 1, 1.0) == (1.0, 1.0, 1.0)
-    assert promote(1.0, 1.0, 1) == (1.0, 1.0, 1.0)
+    assert promote(1, 1.0) == (2.0, 1.0)
+    assert promote(1, 1, 1.0) == (2.0, 2.0, 1.0)
+    assert promote(1.0, 1.0, 1) == (1.0, 1.0, 2.0)
 
     with pytest.raises(TypeError):
         promote(1, "1")
@@ -110,32 +113,45 @@ def test_promotion(convert):
     with pytest.raises(TypeError):
         promote("1", 1.0)
 
-    add_promotion_rule(str, Union[int, float], Union[int, float])
+    add_promotion_rule(str, Union[int, float], float)
     add_conversion_method(str, Union[int, float], float)
 
-    assert promote(1, "1", "1") == (1.0, 1.0, 1.0)
-    assert promote("1", 1, 1) == (1.0, 1.0, 1.0)
-    assert promote(1.0, "1", 1) == (1.0, 1.0, 1.0)
-    assert promote("1", 1.0, 1) == (1.0, 1.0, 1.0)
+    assert promote(1, "1", "1") == (2.0, 1.0, 1.0)
+    assert promote("1", 1, 1) == (1.0, 2.0, 2.0)
+    assert promote(1.0, "1", 1) == (1.0, 1.0, 2.0)
+    assert promote("1", 1.0, 1) == (1.0, 1.0, 2.0)
 
     add_promotion_rule(str, int, float)
     add_promotion_rule(str, float, float)
     add_conversion_method(str, float, lambda x: "lel")
 
-    assert promote(1, "1", 1.0) == (1.0, "lel", 1.0)
-    assert promote("1", 1, 1.0) == ("lel", 1.0, 1.0)
-    assert promote(1.0, "1", 1) == (1.0, "lel", 1.0)
+    assert promote(1, "1", 1.0) == (2.0, "lel", 1.0)
+    assert promote("1", 1, 1.0) == ("lel", 2.0, 1.0)
+    assert promote(1.0, "1", 1) == (1.0, "lel", 2.0)
     assert promote("1", 1.0, "1") == ("lel", 1.0, "lel")
 
 
-def test_inheritance(convert):
-    add_promotion_rule(Num, FP, Num)
+def test_promote_resolve_type_hints(convert, promote):
+    t = _promotion_rule(
+        plum.ModuleType("builtins", "int"),
+        plum.ModuleType("numbers", "Number"),
+    )
+    assert t == Number
+    t = _promotion_rule(
+        plum.ModuleType("numbers", "Number"),
+        plum.ModuleType("builtins", "int"),
+    )
+    assert t == Number
+
+
+def test_inheritance(convert, promote):
+    add_promotion_rule(Num, Rat, Num)
     add_promotion_rule(Num, Re, Num)
-    add_promotion_rule(FP, Re, Num)
-    add_conversion_method(FP, Num, lambda x: "Num from FP")
+    add_promotion_rule(Rat, Re, Num)
+    add_conversion_method(Rat, Num, lambda x: "Num from Rat")
     add_conversion_method(Re, Num, lambda x: "Num from Re")
 
     n = Num()
-    assert promote(n, FP()) == (n, "Num from FP")
+    assert promote(n, Rat()) == (n, "Num from Rat")
     assert promote(Re(), n) == ("Num from Re", n)
-    assert promote(Re(), FP()) == ("Num from Re", "Num from FP")
+    assert promote(Re(), Rat()) == ("Num from Re", "Num from Rat")
diff --git a/tests/test_resolvable.py b/tests/test_resolvable.py
deleted file mode 100644
index 01423777..00000000
--- a/tests/test_resolvable.py
+++ /dev/null
@@ -1,36 +0,0 @@
-import pytest
-
-from plum.resolvable import Promise, ResolutionError
-
-
-def test_promise():
-    p = Promise()
-
-    # Check delivery process.
-    with pytest.raises(ResolutionError):
-        p.resolve()
-    p.deliver(1)
-    assert p.resolve() == 1
-
-    # Check that we can deliver twice.
-    p.deliver(2)
-    assert p.resolve() == 2
-
-
-def test_promise_repr():
-    # Test contruction with object.
-    p = Promise(1)
-    assert repr(p) == "Promise(obj=1)"
-
-    # Test contruction without object.
-    p = Promise()
-    assert repr(p) == "Promise()"
-
-    class MockClass:
-        pass
-
-    # Test `__repr__` after delivery process.
-    for T in [int, MockClass]:
-        p = Promise()
-        p.deliver(T)
-        assert repr(T) in repr(p)
diff --git a/tests/test_resolver.py b/tests/test_resolver.py
new file mode 100644
index 00000000..9e18df41
--- /dev/null
+++ b/tests/test_resolver.py
@@ -0,0 +1,156 @@
+import pydoc
+import typing
+
+import pytest
+
+from plum.resolver import AmbiguousLookupError, NotFoundLookupError, Resolver
+from plum.signature import Signature
+
+
+def test_initialisation():
+    r = Resolver()
+    # Without any registered signatures, the resolver should be faithful.
+    assert r.is_faithful
+
+
+def test_doc(monkeypatch):
+    # Let the `pydoc` documenter simply return the docstring. This makes testing
+    # simpler.
+    monkeypatch.setattr(pydoc.TextDoc, "document", lambda _, x: x.__doc__)
+
+    r = Resolver()
+
+    class _MockFunction:
+        def __init__(self, doc):
+            self.__doc__ = doc
+
+    class _MockSignature:
+        def __init__(self, doc):
+            self.implementation = _MockFunction(doc)
+
+    # Circumvent the use of :meth:`.resolver.Resolver.register`.
+    r.signatures = [
+        _MockSignature("first"),
+        _MockSignature("second"),
+        _MockSignature("third"),
+    ]
+    assert r.doc() == "first\nsecond\nthird"
+
+    # Test that duplicates are excluded.
+    r.signatures = [
+        _MockSignature("first"),
+        _MockSignature("second"),
+        _MockSignature("second"),
+        _MockSignature("third"),
+    ]
+    assert r.doc() == "first\nsecond\nthird"
+
+    # Test that the explicit exclusion mechanism also works.
+    assert r.doc(exclude=r.signatures[3].implementation) == "first\nsecond"
+
+
+def test_register():
+    r = Resolver()
+
+    # Test that faithfulness is tracked correctly.
+    r.register(Signature(int))
+    r.register(Signature(float))
+    assert r.is_faithful
+    r.register(Signature(typing.Tuple[int]))
+    assert not r.is_faithful
+
+    # Test that signatures can be replaced.
+    new_s = Signature(float)
+    assert len(r) == 3
+    assert r.signatures[1] is not new_s
+    r.register(new_s)
+    assert len(r) == 3
+    assert r.signatures[1] is new_s
+
+    # Test the edge case that should never happen.
+    r.signatures[2] = Signature(float)
+    with pytest.raises(
+        AssertionError,
+        match=r"(?i)the added signature `(.*)` is equal to 2 existing signatures",
+    ):
+        r.register(Signature(float))
+
+
+def test_len():
+    r = Resolver()
+    assert len(r) == 0
+    r.register(Signature(int))
+    assert len(r) == 1
+    r.register(Signature(float))
+    assert len(r) == 2
+    r.register(Signature(float))
+    assert len(r) == 2
+
+
+def test_resolve():
+    class A:
+        pass
+
+    class B1(A):
+        pass
+
+    class B2(A):
+        pass
+
+    class C1(B1, B2):
+        pass
+
+    class C2(B2):
+        pass
+
+    class Unrelated:
+        pass
+
+    class Missing:
+        pass
+
+    s_a = Signature(A)
+    s_b1 = Signature(B1)
+    s_b2 = Signature(B2)
+    s_c1 = Signature(C1)
+    s_c2 = Signature(C2)
+    s_u = Signature(Unrelated)
+    s_m = Signature(Missing)
+
+    r = Resolver()
+    r.register(s_b1)
+    # Import this after `s_b1` to test all branches.
+    r.register(s_a)
+    r.register(s_b2)
+    # Do not register `s_c1`.
+    r.register(s_c2)
+    r.register(s_u)
+    # Also do not register `s_m`.
+
+    # Resolve by signature.
+    assert r.resolve(s_a) == s_a
+    assert r.resolve(s_b1) == s_b1
+    assert r.resolve(s_b2) == s_b2
+    with pytest.raises(AmbiguousLookupError):
+        r.resolve(s_c1)
+    assert r.resolve(s_c2) == s_c2
+    assert r.resolve(s_u) == s_u
+    with pytest.raises(NotFoundLookupError):
+        r.resolve(s_m)
+
+    # Resolve by type.
+    assert r.resolve((A(),)) == s_a
+    assert r.resolve((B1(),)) == s_b1
+    assert r.resolve((B2(),)) == s_b2
+    with pytest.raises(AmbiguousLookupError):
+        r.resolve((C1(),))
+    assert r.resolve((C2(),)) == s_c2
+    assert r.resolve((Unrelated(),)) == s_u
+    with pytest.raises(NotFoundLookupError):
+        r.resolve((Missing(),))
+
+    # Test that precedence can correctly break the ambiguity.
+    s_b1.precedence = 1
+    assert r.resolve(s_c1) == s_b1
+    s_b2.precedence = 2
+    assert r.resolve(s_c1) == s_b2
diff --git a/tests/test_signature.py b/tests/test_signature.py
index 94277cf4..63118510 100644
--- a/tests/test_signature.py
+++ b/tests/test_signature.py
@@ -1,95 +1,221 @@
+import inspect
+import operator
+from numbers import Number as Num
+from numbers import Real as Re
+from typing import Any, Tuple
+
 import pytest
-import sys
 
 from plum.signature import Signature as Sig
-from plum.type import Type, VarArgs, Union
-
-
-def test_instantiation():
-    with pytest.raises(TypeError):
-        Sig(VarArgs(int), int)
-    with pytest.raises(TypeError):
-        Sig(VarArgs(int, str))
-
-
-class Num:
-    pass
-
-
-class Re(Num):
-    pass
-
-
-class FP(Re):
-    pass
-
-
-def test_properties():
-    assert hash(Sig(Num)) == hash(Sig(Num))
-    assert hash(Sig(Num)) != hash(Type(Num))
-    assert len(Sig(Num)) == 1
-    assert len(Sig(Num, VarArgs(Num))) == 1
-
-
-def test_representation():
-    assert repr(Sig()) == "Signature()"
-    assert repr(Sig(Num, Re)) == f"Signature({Type(Num)!r}, {Type(Re)!r})"
-
+from plum.signature import _inspect_signature, append_default_args, extract_signature
+from plum.util import Missing
+
+
+def test_instantiation_copy():
+    s = Sig(
+        int,
+        int,
+        varargs=float,
+        return_type=complex,
+        precedence=1,
+        implementation=lambda *xs: complex(sum(xs)),
+    )
+    for _ in range(2):
+        assert s.types == (int, int)
+        assert s.has_varargs
+        assert s.varargs == float
+        assert s.return_type == complex
+        assert s.precedence == 1
+        assert s.implementation(1, 2, 3.0) == 6 + 0j
+        assert s.is_faithful
+
+        # Test copying.
+        s = s.__copy__()
+
+    # Test defaults.
+    s = Sig(int, int)
+    assert not s.has_varargs
+    assert s.varargs == Missing
+    assert s.return_type == Any
+    assert s.implementation is None
+
+    # Test faithfulness check.
+    assert Sig(int, int).is_faithful
+    assert Sig(int, int, varargs=int).is_faithful
+    assert not Sig(Tuple[int], int, varargs=int).is_faithful
+    assert not Sig(int, Tuple[int], varargs=int).is_faithful
+    assert not Sig(int, int, varargs=Tuple[int]).is_faithful
+
+
+def _impl(x, y, *z):
+    return str(x)
+
+
+@pytest.mark.parametrize(
+    "sig, expected",
+    [
+        (
+            Sig(),
+            "Signature()",
+        ),
+        (
+            Sig(int),
+            "Signature(int)",
+        ),
+        (
+            Sig(int, float),
+            "Signature(int, float)",
+        ),
+        (
+            Sig(int, float, varargs=complex),
+            "Signature(int, float, varargs=complex)",
+        ),
+        (
+            Sig(int, float, varargs=complex, return_type=str),
+            "Signature(int, float, varargs=complex, return_type=str)",
+        ),
+        (
+            Sig(int, float, varargs=complex, return_type=str, precedence=1),
+            "Signature(int, float, varargs=complex, return_type=str, precedence=1)",
+        ),
+        (
+            Sig(
+                int,
+                float,
+                varargs=complex,
+                return_type=str,
+                precedence=1,
+                implementation=_impl,
+            ),
+            f"Signature(int, float, varargs=complex, return_type=str, precedence=1,"
+            f" implementation={_impl!r})",
+        ),
+    ],
+)
+def test_repr(sig, expected):
+    assert repr(sig) == expected
+
+
+def test_hash():
+    assert hash(Sig(int)) == hash(Sig(int))
+    sigs = {Sig(int), Sig(int, int), Sig(int, int, varargs=int)}
+    assert len(sigs) == 3
+
+
+def test_expand_varargs():
+    # Case of no variable arguments:
+    assert Sig(int, int).expand_varargs(3) == (int, int)
+
+    # Case of variable arguments:
+    s = Sig(int, int, varargs=float)
+    assert s.expand_varargs(2) == (int, int)
+    assert s.expand_varargs(3) == (int, int, float)
+    assert s.expand_varargs(4) == (int, int, float, float)
+
+
+def test_comparison():
+    # Variable arguments shortcuts:
+    assert not Sig(varargs=int) <= Sig()
+    assert not Sig(varargs=Num) <= Sig(varargs=int)
+
+    # Expandability shortcuts:
+    assert not Sig(int) <= Sig(int, int)
+    assert not Sig(int) <= Sig(int, int, varargs=int)
+    assert not Sig(int, int, varargs=int) <= Sig(int)
+
+    # Test expansion:
+    assert Sig(varargs=int) <= Sig(Re, varargs=Re)
+    assert Sig(int, varargs=int) <= Sig(Re, varargs=Re)
+    assert Sig(int, int, varargs=int) <= Sig(Re, varargs=Re)
+
+    assert not Sig(varargs=Num) <= Sig(Re, varargs=Re)
+    assert not Sig(Num, varargs=int) <= Sig(Re, varargs=Re)
+    assert not Sig(int, int, varargs=Num) <= Sig(Re, varargs=Re)
+
+    assert Sig(float, varargs=int) <= Sig(float, Re, varargs=Re)
+    assert Sig(float, int, varargs=int) <= Sig(float, Re, varargs=Re)
+    assert Sig(float, int, int, varargs=int) <= Sig(float, Re, varargs=Re)
+
+
+def test_match():
+    assert Sig(int).match((1,))
+    assert Sig(int, int).match((1, 2))
+    assert Sig(int, varargs=int).match((1,))
+    assert Sig(int, varargs=int).match((1, 2))
+    assert Sig(int, varargs=int).match((1, 2, 3))
+
+    # Wrong type:
+    assert not Sig(int).match((1.0,))
+    assert not Sig(int, int).match((1, 2.0))
+    assert not Sig(int, varargs=int).match((1.0,))
+    assert not Sig(int, varargs=int).match((1, 2.0))
+    assert not Sig(int, varargs=int).match((1, 2, 3.0))
+
+    # Wrong number:
+    assert not Sig(int).match((1, 2))
+    assert not Sig(int, int).match((1,))
+    assert not Sig(int, varargs=int).match(())
+
+
+def test_inspect_signature():
+    assert isinstance(_inspect_signature(lambda x: x), inspect.Signature)
+    assert len(_inspect_signature(lambda x: x).parameters) == 1
+    assert len(_inspect_signature(operator.itemgetter(1)).parameters) == 1
+    assert len(_inspect_signature(operator.attrgetter("x")).parameters) == 1
+
+
+def assert_signature(f, *types, varargs=Missing, return_type=Any):
+    sig = extract_signature(f)
+    assert sig.types == types
+    assert sig.varargs == varargs
+    assert sig.return_type == return_type
+
+
+def test_extract_signature():
+    def f():
+        pass
+
+    # Check implementation and precedence.
+    assert extract_signature(f).implementation == f
+    assert extract_signature(f).precedence == 0
+    assert extract_signature(f, precedence=1).precedence == 1
+
+    # Check defaults.
+    assert_signature(f)
 
-def test_comparability():
-    assert Sig(Num).is_comparable(Sig(FP))
-    assert not Sig(Num).is_comparable(Sig(int))
+    # Check a more complex example.
 
+    def f(a: int, b, *c: float, **kw_args: Num) -> Re:
+        pass
 
-def test_comparisons():
-    assert Sig(Num, FP) <= Sig(Num, FP)
-    assert Sig(Num, FP) >= Sig(Num, FP)
-    assert Sig(Num, FP) == Sig(Num, FP)
-    assert Sig(Num, Re) < Sig(Num, Num)
-    assert Sig(Num, Num) > Sig(Num, Re)
-    assert Sig(Num, Re) < Sig(Num, Num)
-    assert Sig(FP, Num) < Sig(Num, Num)
-    assert Sig(FP, FP) < Sig(Num, Num)
+    assert_signature(f, int, Any, varargs=float, return_type=Re)
 
+    # Check that default values must be right.
 
-def test_varargs_properties():
-    expanded_sig = Sig(Num, Num, VarArgs(Num)).expand_varargs_to(Sig(Num))
-    assert expanded_sig == (Type(Num), Type(Num))
-    expanded_sig = Sig(Num, VarArgs(Num)).expand_varargs_to(Sig(Num, Num, Num))
-    assert expanded_sig == (Type(Num), Type(Num), Type(Num))
-    assert Sig(Num).expand_varargs_to(Sig(Num, Num)) == (Type(Num),)
-    assert Sig(Num, VarArgs(FP)).base == (Type(Num),)
-    assert Sig(VarArgs(Num)).has_varargs()
-    assert not Sig(Num).has_varargs()
-    assert Sig(VarArgs(Num)).varargs_type == Type(Num)
-    with pytest.raises(RuntimeError):
-        Sig(Num).varargs_type
+    def f_good(a: int = 1):
+        pass
 
+    def f_bad(a: int = 1.0):
+        pass
 
-def test_varargs_comparisons():
-    assert Sig(Num, VarArgs(Num)) < Sig(VarArgs(object))
-    assert Sig(Num, VarArgs(Num)) > Sig(Num)
-    assert Sig(FP, VarArgs(Num)) < Sig(Num, VarArgs(Num))
-    assert Sig(FP, VarArgs(FP)) < Sig(FP, VarArgs(Num))
-    assert Sig(FP, VarArgs(FP)) <= Sig(FP, VarArgs(Num))
-    assert Sig(FP, VarArgs(FP)) != Sig(FP, VarArgs(Num))
-    assert Sig(FP, Num) < Sig(FP, Num, VarArgs(Num))
-    assert Sig(Num, Num) < Sig(Num, VarArgs(Num))
-    assert not Sig(Num, Num).is_comparable(Sig(FP, VarArgs(FP)))
-    assert not Sig(Num, VarArgs(FP)).is_comparable(Sig(Num, Num))
-    assert Sig(VarArgs(Num)) == Sig(Num, VarArgs(Num))
-    assert Sig(VarArgs(Num)) > Sig(Re, VarArgs(Num))
-    assert Sig(Num, VarArgs(Num)) == Sig(Num, Num, Num, VarArgs(Num))
+    assert_signature(f_good, int)
+    with pytest.raises(
+        TypeError,
+        match=r"Default value `1.0` is not an instance of the annotated type `int`.",
+    ):
+        extract_signature(f_bad)
 
 
-def test_union():
-    assert Sig(Union(Num, Num)) == Sig(Union(Num))
-    assert Sig(Union(FP, Num)) == Sig(Union(Num))
-    assert Sig(Union(FP, Num)) >= Sig(Union(Re))
-    assert Sig(Union(FP)) < Sig(Union(Num, Re))
+def test_append_default_args():
+    def f(a: int, b=1, c: float = 1.0, *d: complex, option=None, **other_options):
+        pass
 
+    sigs = append_default_args(extract_signature(f), f)
+    assert len(sigs) == 3
+    assert (sigs[0].types, sigs[0].varargs) == ((int, Any, float), complex)
+    assert (sigs[1].types, sigs[1].varargs) == ((int, Any), Missing)
+    assert (sigs[2].types, sigs[2].varargs) == ((int,), Missing)
 
-@pytest.mark.skipif(sys.version_info < (3, 10), reason="requires python3.10 or higher")
-def test_union_new_syntax():
-    assert Sig(eval("float | int")) == Sig(Union(float, int))
-    assert Sig(eval("Num | FP")) == Sig(Union(Num, FP))
+    # Test that `itemgetter` is supported.
+    f = operator.itemgetter(0)
+    assert len(append_default_args(extract_signature(f), f)) == 1
diff --git a/tests/test_type.py b/tests/test_type.py
index f431d8c8..a4d7ba18 100644
--- a/tests/test_type.py
+++ b/tests/test_type.py
@@ -1,241 +1,245 @@
+import abc
+import sys
 import typing
 
+if sys.version_info.minor <= 8:
+    from typing import Callable
+else:
+    from collections.abc import Callable
+
+try:
+    from typing import Literal
+except ImportError:
+
+    class LiteralMeta(type):
+        """A simple proxy for :class:`typing.Literal`."""
+
+        def __getitem__(self, item):
+            return self
+
+    class Literal(metaclass=LiteralMeta):
+        __faithful__ = False
+
+
 import pytest
 
-from plum.parametric import List, Tuple, Dict, Iterable, Sequence
-from plum.resolvable import ResolutionError
 from plum.type import (
-    VarArgs,
-    Union,
-    Type,
+    ModuleType,
     PromisedType,
-    deliver_forward_reference,
-    get_forward_reference,
-    ptype,
-    is_object,
-    is_type,
-    TypeType,
+    ResolvableType,
+    _is_hint,
+    is_faithful,
+    resolve_type_hint,
+    type_mapping,
 )
 
 
-def test_varargs():
-    assert hash(VarArgs(int)) == hash(VarArgs(int))
-    assert repr(VarArgs(int)) == f"VarArgs({Type(int)!r})"
-    assert VarArgs(int).expand(2) == (Type(int), Type(int))
-    assert not VarArgs(int).parametric
-    assert not VarArgs(int).runtime_type_of
-    assert VarArgs(List[int]).parametric
-    assert VarArgs(List[int]).runtime_type_of
-
-
-def test_comparabletype():
-    assert isinstance(1, Union[int])
-    assert not isinstance("1", Union[int])
-    assert isinstance("1", Union[int, str])
-    assert issubclass(Union[int], Union[int])
-    assert issubclass(Union[int], Union[int, str])
-    assert not issubclass(Union[int, str], Union[int])
-    assert Union[int].mro() == int.mro()
-    with pytest.raises(RuntimeError):
-        Union[int, str].mro()
-
-
-def test_union():
-    assert hash(Union[int, str]) == hash(Union[str, int])
-    assert repr(Union[int, str]) == repr(Union[int, str])
-    assert set(Union[int, str].get_types()) == {str, int}
-    assert not Union[int].parametric
-    assert not Union[int].runtime_type_of
-
-    assert Union[Tuple[int], Tuple[int, int]].parametric
-    assert Union[Tuple[int], Tuple[int, int]].runtime_type_of
-
-    # Test equivalence between `Union` and `Type`.
-    assert hash(Union[int]) == hash(Type(int))
-    assert hash(Union[int, str]) != hash(Type(int))
-    assert repr(Union[int]) == repr(Type(int))
-    assert repr(Union[int, str]) != repr(Type(int))
-
-    # Test lazy conversion to set.
-    t = Union[int, int, str]
-    assert isinstance(t._types, tuple)
-    t.get_types()
-    assert isinstance(t._types, set)
-
-    # Test aliases.
-    assert repr(Union(int, alias="MyUnion")) == "tests.test_type.MyUnion"
-    assert repr(Union(int, str, alias="MyUnion")) == "tests.test_type.MyUnion"
-
-
-def test_type():
-    assert hash(Type(int)) == hash(Type(int))
-    assert hash(Type(int)) != hash(Type(str))
-    assert repr(Type(int)) == f"{int.__module__}.{int.__name__}"
-    assert Type(int).get_types() == (int,)
-    assert not Type(int).parametric
-    assert not Type(int).runtime_type_of
-    assert Type(List[int]).parametric
-    assert Type(List[int]).runtime_type_of
+def test_resolvabletype():
+    t = ResolvableType("int")
+    assert t.__name__ == "int"
+    assert t.resolve() is t
+    assert t.deliver(int) is t
+    assert t.resolve() is int
 
 
 def test_promisedtype():
-    t = PromisedType()
-    with pytest.raises(ResolutionError):
-        hash(t)
-    with pytest.raises(ResolutionError):
-        t.get_types()
+    t = PromisedType("int")
+    assert t.__name__ == "PromisedType[int]"
+    assert t.resolve() is t
+    assert t.deliver(int) is t
+    assert t.resolve() is int
 
-    assert "unresolved" in repr(t)
-
-    t.deliver(Type(int))
-    assert hash(t) == hash(Type(int))
-    assert repr(t) == repr(Type(int))
-    assert t.get_types() == Type(int).get_types()
-    assert not t.parametric
-    assert not t.runtime_type_of
 
+def test_promsedtype_default_name():
     t = PromisedType()
-    t.deliver(List[int])
-    assert t.parametric
-    assert t.runtime_type_of
+    assert t.__name__ == "PromisedType[SomeType]"
 
 
-def test_forwardreferencedtype():
-    class A:
-        pass
+@pytest.mark.parametrize(
+    "module, name, type",
+    [
+        ("typing", "Union", typing.Union),
+        ("__builtin__", "int", int),
+        ("__builtins__", "int", int),
+        ("builtins", "int", int),
+    ],
+)
+def test_moduletype(module, name, type):
+    t = ModuleType(module, name)
+    assert t.__name__ == f"ModuleType[{module}.{name}]"
+    assert t.resolve() is t
+    assert t.retrieve()
+    assert t.resolve() is type
 
-    class B:
-        pass
+    t = ModuleType("<nonexistent>", "f")
+    assert not t.retrieve()
 
-    t_a = get_forward_reference("A")
-    t_b = get_forward_reference("B")
 
-    with pytest.raises(ResolutionError):
-        t_a.resolve()
-    with pytest.raises(ResolutionError):
-        t_b.resolve()
+def test_is_hint():
+    assert not _is_hint(int)
+    assert _is_hint(typing.Union[int, float])
+    assert _is_hint(Callable)
 
-    deliver_forward_reference(A)
 
-    assert t_a == Type(A)
-    with pytest.raises(ResolutionError):
-        t_b.resolve()
+@pytest.mark.skipif(
+    sys.version_info < (3, 9),
+    reason="Requires Python 3.9 or higher.",
+)
+def test_is_hint_subscripted_builtins():
+    assert _is_hint(tuple[int])
 
-    deliver_forward_reference(B)
 
-    assert t_a == Type(A)
-    assert t_b == Type(B)
+@pytest.mark.skipif(
+    sys.version_info < (3, 10),
+    reason="Requires Python 3.10 or higher.",
+)
+def test_is_hint_new_union():
+    assert int | float
+
+
+def test_type_mapping():
+    assert resolve_type_hint(int) is int
+    try:
+        type_mapping[int] = float
+        assert resolve_type_hint(int) is float
+    finally:
+        del type_mapping[int]
+
+
+@pytest.mark.parametrize(
+    "pseudo_int",
+    [
+        PromisedType("int").deliver(int),
+        # We deliver a promised type to a promised type, which means that the
+        # resolution must resolve deliveries.
+        PromisedType("int").deliver(PromisedType("int").deliver(int)),
+        ModuleType("builtins", "int"),
+    ],
+)
+def test_resolve_type_hint(pseudo_int):
+    # Test leaves.
+    assert resolve_type_hint(None) is None
+    assert resolve_type_hint(Ellipsis) is Ellipsis
+    assert resolve_type_hint(int) is int
+    assert resolve_type_hint(typing.Any) is typing.Any
+    assert resolve_type_hint(Callable) is Callable
 
+    # Test composition.
+    assert resolve_type_hint((pseudo_int, pseudo_int)) == (int, int)
+    assert resolve_type_hint([pseudo_int, pseudo_int]) == [int, int]
 
-@pytest.mark.parametrize("name", ['"A"', "'A'", "\"'A'\""])
-def test_forwardreferencedtype_unquoting(name):
-    class A:
-        pass
+    def _combo1(fake, real):
+        return typing.Union[fake, float], typing.Union[real, float]
 
-    t = get_forward_reference(name)
-    deliver_forward_reference(A)
-    t.resolve()
-    assert t == Type(A)
+    def _combo2(fake, real):
+        return Callable[[fake, float], fake], Callable[[real, float], real]
 
+    def _combo3(fake, real):
+        return _combo2(*_combo1(fake, real))
 
-def test_ptype():
-    class A:
-        pass
+    def _combo4(fake, real):
+        return _combo3(*_combo2(*_combo1(fake, real)))
 
-    t = Type(int)
-    assert ptype(t) is t
-    assert ptype(int) == t
+    for combo in [_combo1, _combo2, _combo3, _combo4]:
+        fake, real = combo(pseudo_int, int)
+        assert resolve_type_hint(fake) == real
 
-    # Check `None` as valid type annotation.
-    assert ptype(None) == Type(type(None))
+    class A:
+        pass
 
-    # Check conversion of strings.
-    t = ptype("A")
-    deliver_forward_reference(A)
-    assert t == Type(A)
+    # Test warning.
+    a = A()
+    with pytest.warns(Warning, match=r"(?i)could not resolve the type hint"):
+        assert resolve_type_hint(a) is a
 
-    with pytest.raises(RuntimeError):
-        ptype(1)
 
+def test_resolve_type_hint_moduletype_recursion():
+    t = ModuleType("<nonexistent>", "f")
+    assert resolve_type_hint(t) == t
 
-def test_ptype_typing_mapping():
-    class A:
-        pass
 
+@pytest.mark.skipif(
+    sys.version_info < (3, 10),
+    reason="Requires Python 3.10 or higher.",
+)
+def test_resolve_type_hint_new_union():
+    assert resolve_type_hint(float | int) == float | int
+
+
+def test_is_faithful():
+    # Example of a not faithful type.
+    t_nf = Callable[[int], int]
+
+    # Test leaves.
+    assert is_faithful(typing.Any)
+    assert is_faithful(Callable)
+    assert is_faithful(None)
+    assert is_faithful(Ellipsis)
+
+    # Test composition.
+    # Lists:
+    assert is_faithful([int, float])
+    assert not is_faithful([int, t_nf])
+    # Tuples:
+    assert is_faithful((int, float))
+    assert not is_faithful((int, t_nf))
+    # `Callable`:
+    assert not is_faithful(Callable[[int], int])
     # `Union`:
-    assert ptype(typing.Union[typing.Union[int], list]) == Union[Union[int], list]
-    assert ptype(typing.Union) == Union[object]
+    assert is_faithful(typing.Union[int, float])
+    assert not is_faithful(typing.Union[int, t_nf])
 
-    # `Optional`:
-    assert ptype(typing.Optional[int]) == Union[int, type(None)]
-    assert ptype(typing.Optional) == Union[object]
+    # Test warning.
+    with pytest.warns(
+        Warning, match=r"(?i)could not determine whether `(.*)` is faithful or not"
+    ):
+        assert not is_faithful(1)
 
-    # `List`:
-    assert ptype(typing.List[typing.List[int]]) == List[List[int]]
-    assert ptype(typing.List) == Type(list)
 
-    # `Tuple`:
-    assert ptype(typing.Tuple[typing.Tuple[int], list]) == Tuple[Tuple[int], list]
-    assert ptype(typing.Tuple) == Type(tuple)
-
-    # `Dict`:
-    assert ptype(typing.Dict[typing.List[int], list]) == Dict[List[int], list]
-    assert ptype(typing.Dict) == Type(dict)
-
-    # `Iterable`:
-    assert ptype(typing.Iterable[typing.List[int]]) == Iterable[List[int]]
-    assert ptype(typing.Iterable) == Iterable()
+def test_is_faithful_custom_metaclass():
+    class A:
+        pass
 
-    # `Sequence`:
-    assert ptype(typing.Sequence[typing.List[int]]) == Sequence[List[int]]
-    assert ptype(typing.Sequence) == Sequence()
+    class BMeta(type):
+        def __instancecheck__(self, cls):
+            pass
 
-    # `ForwardRef`:
-    if hasattr(typing, "ForwardRef"):
-        t = ptype(typing.ForwardRef("A"))
-    else:
-        # The `typing` package is different for Python 3.6.
-        t = ptype(typing._ForwardRef("A"))
-    deliver_forward_reference(A)
-    assert t == Type(A)
+    class B(metaclass=BMeta):
+        pass
 
-    # `Any`:
-    assert ptype(typing.Any) == ptype(object)
+    assert is_faithful(A)
+    assert not is_faithful(B)
 
-    # `Callable`:
-    assert ptype(typing.Callable) == Type(typing.Callable)
 
-    # Check propagation of conversion of strings.
-    t = ptype(typing.Union["A"])
-    deliver_forward_reference(A)
-    assert t == ptype(Union[A])
+def test_is_faithful_abcmeta():
+    class A(metaclass=abc.ABCMeta):
+        pass
 
-    t = ptype(typing.List["A"])
-    deliver_forward_reference(A)
-    assert t == ptype(List[A])
+    assert is_faithful(A)
 
-    t = ptype(typing.Tuple["A"])
-    deliver_forward_reference(A)
-    assert t == ptype(Tuple[A])
 
+def test_is_faithful_dunder():
+    """Check that `__faithful__` works."""
 
-def test_typetype():
-    Promised = PromisedType()
-    Promised.deliver(int)
+    class UnfaithfulClass:
+        __faithful__ = False
 
-    assert ptype(type(int)) <= TypeType
-    assert ptype(type(Promised)) <= TypeType
+    class FaithfulClass:
+        __faithful__ = True
 
-    assert not (ptype(int) <= TypeType)
-    assert not (ptype(Promised) <= TypeType)
+    assert not is_faithful(UnfaithfulClass)
+    assert is_faithful(FaithfulClass)
 
 
-def test_is_object():
-    assert is_object(Type(object))
-    assert not is_object(Type(int))
+@pytest.mark.skipif(
+    sys.version_info < (3, 10),
+    reason="Requires Python 3.10 or higher.",
+)
+def test_is_faithful_new_union():
+    assert not is_faithful(int | float)
 
 
-def test_is_type():
-    assert is_type(int)
-    assert is_type(Type(int))
-    assert not is_type(1)
+def test_is_faithful_literal(recwarn):
+    assert not is_faithful(Literal[1])
+    # There should be no warnings.
+    assert len(recwarn) == 0
diff --git a/tests/test_util.py b/tests/test_util.py
index b6e1d7f2..998a93c0 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -1,6 +1,44 @@
+import sys
+import typing
+
 import numpy as np
+import pytest
+
+from plum.util import (
+    Comparable,
+    Missing,
+    get_class,
+    get_context,
+    is_in_class,
+    multihash,
+    repr_short,
+    wrap_lambda,
+)
+
+
+def test_repr_short():
+    class A:
+        pass
+
+    assert repr_short(int) == "int"
+    assert repr_short(A) == "tests.test_util.test_repr_short.<locals>.A"
+    assert repr_short(typing.Union[int, float]) == "typing.Union[int, float]"
+
 
-from plum.util import multihash, Comparable, is_in_class, get_class, get_context
+def test_missing():
+    # `Missing` cannot be instantiated.
+    with pytest.raises(TypeError):
+        Missing()
+
+    # `Missing` also has no boolean value.
+    with pytest.raises(TypeError):
+        bool(Missing)
+
+    # However, if Sphinx is loaded, `Missing` should evaluate to `False`.
+    assert "sphinx" not in sys.modules
+    sys.modules["sphinx"] = None
+    assert not Missing
+    del sys.modules["sphinx"]
 
 
 def test_multihash():
@@ -39,6 +77,10 @@ def f(self):
     pass
 
 
+def test_wrap_lambda():
+    assert wrap_lambda(int)("1") == 1
+
+
 def test_is_in_class():
     assert is_in_class(A.f)
     assert is_in_class(A().f)
diff --git a/tests/util.py b/tests/util.py
index 906f248c..45d563c3 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -1,17 +1,29 @@
 from time import time
 
 
-def benchmark(f, args, n=1000):
+def benchmark(f, args, n=1000, burn=0, setup=lambda: None):
     """Benchmark the performance of a function `f` called with arguments
     `args` in microseconds.
 
     Args:
         f (function): Function to benchmark.
         args (tuple): Argument to call `f` with.
-        n (int): Repetitions.
+        n (int, optional): Repetitions. Defaults to 1000.
+        burn (int, optional): Perform so many repetitions before timing.
+        setup (function, optional): Call this function before every call to `f`.
+
+    Returns:
+        float: Average execution time in milliseconds.
     """
-    start = time()
-    for i in range(n):
+    # Perform burn-in.
+    for _ in range(burn):
+        setup()
+        f(*args)
+    # Perform timing.
+    durations = []
+    for _ in range(n):
+        setup()
+        start = time()
         f(*args)
-    dur = time() - start
-    return dur * 1e6 / n
+        durations.append(time() - start)
+    return sum(durations) * 1e6 / n