Repository: 8451/labrea (language: python)

Issue:
Rewrite: Version 2.0.0
Full rewrite re-expressing `Evaluatable` as a monad, where the abilities of datasets (evaluating default arguments, overloading, caching, side-effects) have been broken out into separate concepts that can be composed. Some highlights

- `Evaluatable`s can be piped through transformations using `.apply` or `>>`
- Any `Evaluatable` can be cached using `cached(evaluatable, cache)`
- Any `Evaluatable` can be overloaded using `Overloaded(dispatch, overloads_dict, default)`
- Any `Evaluatable` can have side-effects using `Computation(evaluatable, effects)`
- Pipelines have been re-imagined as `Evaluatable`s that return a function of a single value
  + Pipelines are now composed using the '+' operator
  + Pipelines can be applied to an `Evaluatable` using the `>>` operator 


Discussion / hints:

Fix patch:
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 3ff4ff7..f22168a 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 1.4.0
+current_version = 2.0.0
 commit = false
 tag = false
 serialize =
diff --git a/README.md b/README.md
index cb0a62b..41b7a38 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,20 @@
+<picture align="center">
+  <source media="(prefers-color-scheme: dark)" srcset="docs/source/static/labrea-logo-white.png">
+  <img alt="Labrea Logo" src="docs/source/static/labrea-logo-black.png">
+</picture>
+
+-----------------
+
 # Labrea
+A framework for declarative, functional dataset definitions.
 
-![](https://img.shields.io/badge/version-1.4.0-blue.svg)
+![](https://img.shields.io/badge/version-2.0.0-blue.svg)
 [![lifecycle](https://img.shields.io/badge/lifecycle-stable-green.svg)](https://www.tidyverse.org/lifecycle/#stable)
 [![PyPI Downloads](https://img.shields.io/pypi/dm/labrea.svg?label=PyPI%20downloads)](https://pypi.org/project/labrea/)
 [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)
 [![Coverage](https://raw.githubusercontent.com/8451/labrea/meta/coverage/coverage.svg)](https://github.com/8451/labrea/tree/meta/coverage)
 [![docs](https://img.shields.io/badge/docs-latest-brightgreen.svg?style=flat)](https://8451.github.io/labrea)
 
-A framework for declarative, functional dataset definitions.
-
 ## Installation
 Labrea is available for install via pip.
 
@@ -25,8 +31,8 @@ pip install git+https://github.com/8451/labrea@develop
 ## Usage
 See our usage guide [here](docs/source/usage.md).
 
-Labrea exposes a `dataset` decorator that allows you to define datasets and their dependencies in a declarative manner. 
-Dependencies can either be other datasets or `Option`s, which are values that can be passed in at runtime via a 
+Labrea exposes a `dataset` decorator that allows you to define datasets and their dependencies in a declarative manner.
+Dependencies can either be other datasets or `Option`s, which are values that can be passed in at runtime via a
 dictionary.
 
 ```python
@@ -46,7 +52,7 @@ def transactions(path: str = Option('PATHS.SALES')) -> pd.DataFrame:
 
 @dataset
 def sales_by_region(
-        stores_: pd.DataFrame = stores, 
+        stores_: pd.DataFrame = stores,
         transactions_: pd.DataFrame = transactions
 ) -> pd.DataFrame:
     """Merge stores to transactions, sum sales by region"""
diff --git a/docs/source/api.rst b/docs/source/api.rst
index 1ccba28..5ceaeba 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -1,87 +1,270 @@
 Labrea API
 ==========
 
-Options
--------
-.. autoclass:: labrea.options.Option
+Core Types
+----------
+.. autoclass:: labrea.types.Evaluatable
    :members:
    :show-inheritance:
 
-Datasets
---------
-.. autofunction:: labrea.dataset
+.. autoclass:: labrea.Value
+   :members:
+   :show-inheritance:
 
-.. autofunction:: labrea.abstractdataset
+.. autoclass:: labrea.types.Apply
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.types.Bind
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.types.Cacheable
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.types.Validatable
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.types.Explainable
+    :members:
+    :show-inheritance:
 
-.. autoclass:: labrea.datasets.Dataset
+Options
+-------
+.. autoclass:: labrea.Option
    :members:
    :show-inheritance:
 
-.. autoclass:: labrea.datasets.Overload
+.. autoclass:: labrea.WithOptions
    :members:
    :show-inheritance:
 
-Templates
----------
-.. autoclass:: labrea.template.Template
+.. autofunction:: labrea.WithDefaultOptions
+
+Datasets
+--------
+.. autoclass:: labrea.dataset.Dataset
    :members:
    :show-inheritance:
 
-Collections
------------
-.. autoclass:: labrea.DatasetList
+.. autofunction:: labrea.dataset
+
+.. autofunction:: labrea.abstractdataset
+
+Conditionals
+------------
+.. autoclass:: labrea.Switch
     :members:
     :show-inheritance:
 
-.. autoclass:: labrea.DatasetDict
-   :members:
-   :show-inheritance:
+.. autoclass:: labrea.conditional.CaseWhen
+    :members:
+    :show-inheritance:
+
+.. autofunction:: labrea.case
 
-Control Flow
-------------
 .. autoclass:: labrea.Coalesce
     :members:
     :show-inheritance:
 
-.. autoclass:: labrea.Switch
+Overloads
+---------
+
+.. autoclass:: labrea.Overloaded
     :members:
     :show-inheritance:
 
 Interfaces
 ----------
+.. autoclass:: labrea.interface.Interface
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.interface.Implementation
+    :members:
+    :show-inheritance:
+
 .. autofunction:: labrea.interface
+.. autofunction:: labrea.implements
+
+Iterables & Collections
+-----------------------
+.. autoclass:: labrea.Iter
+    :members:
+    :show-inheritance:
+
 
-.. autofunction:: labrea.Interface.implementation
+.. autofunction:: labrea.evaluatable_list
+.. autofunction:: labrea.evaluatable_tuple
+.. autofunction:: labrea.evaluatable_set
+.. autofunction:: labrea.evaluatable_dict
 
-.. autoclass:: labrea.Interface
+
+.. autoclass:: labrea.Map
     :members:
     :show-inheritance:
 
 Pipelines
 ---------
-.. autoclass:: labrea.LabreaPipeline
+.. autoclass:: labrea.pipeline.PipelineStep
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.pipeline.Pipeline
+    :members:
+    :show-inheritance:
+
+.. autofunction:: labrea.pipeline_step
+
+Pipeline Helper Functions
+-------------------------
+.. autofunction:: labrea.functions.map
+.. autofunction:: labrea.functions.filter
+.. autofunction:: labrea.functions.reduce
+
+Templates
+---------
+.. autoclass:: labrea.Template
    :members:
    :show-inheritance:
 
+
 DatasetClasses
 --------------
 .. autofunction:: labrea.datasetclass
 
-Types
---------
-.. autoclass:: labrea.types.Evaluatable
-   :members:
-   :show-inheritance:
 
-.. autoclass:: labrea.types.Validatable
-   :members:
-   :show-inheritance:
+Function Application
+--------------------
+.. autoclass:: labrea.arguments.Arguments
+    :members:
+    :show-inheritance:
 
-.. autoclass:: labrea.types.Value
-   :members:
-   :show-inheritance:
+.. autoclass:: labrea.arguments.EvaluatableArgs
+    :members:
+    :show-inheritance:
 
-Multithreading
---------------
-.. automodule:: labrea.multithreading
+.. autoclass:: labrea.arguments.EvaluatableKwargs
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.arguments.EvaluatableArguments
+    :members:
+    :show-inheritance:
+
+.. autofunction:: labrea.arguments.arguments
+
+.. autoclass:: labrea.application.FunctionApplication
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.application.PartialApplication
+    :members:
+    :show-inheritance:
+
+
+Runtime
+-------
+.. autoclass:: labrea.runtime.Request
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.runtime.Runtime
+    :members:
+    :show-inheritance:
+
+.. autofunction:: labrea.runtime.current_runtime
+.. autofunction:: labrea.runtime.handle
+.. autofunction:: labrea.runtime.handle_by_default
+.. autofunction:: labrea.runtime.inherit
+
+
+Computation
+-----------
+.. autoclass:: labrea.computation.Effect
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.computation.ChainedEffect
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.computation.CallbackEffect
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.computation.Computation
+    :members:
+    :show-inheritance:
+
+
+Caching
+-------
+.. autoclass:: labrea.cache.Cached
+    :members:
+    :show-inheritance:
+
+.. autofunction:: labrea.cached
+
+.. autoclass:: labrea.cache.Cache
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.cache.MemoryCache
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.cache.NoCache
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.cache.CacheSetRequest
     :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.cache.CacheGetRequest
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.cache.CacheExistsRequest
+    :members:
+    :show-inheritance:
+
+
+Logging
+-------
+.. autoclass:: labrea.logging.LogRequest
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.logging.Logged
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.logging.LogEffect
+    :members:
+    :show-inheritance:
+
+.. autofunction:: labrea.logging.disabled
+.. autofunction:: labrea.logging.DEBUG
+.. autofunction:: labrea.logging.INFO
+.. autofunction:: labrea.logging.WARNING
+.. autofunction:: labrea.logging.ERROR
+.. autofunction:: labrea.logging.CRITICAL
+
+
+Exceptions
+----------
+.. autoclass:: labrea.exceptions.EvaluationError
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.exceptions.KeyNotFoundError
+    :members:
+    :show-inheritance:
+
+.. autoclass:: labrea.exceptions.InsufficientInformationError
+    :members:
+    :show-inheritance:
diff --git a/docs/source/changelog.md b/docs/source/changelog.md
index f3a7d90..abd66d3 100644
--- a/docs/source/changelog.md
+++ b/docs/source/changelog.md
@@ -1,4 +1,82 @@
 # Changelog
 
+## Unreleased (2.0.0)
+
+
+### Breaking Changes
+- Module structure has been fully changed.
+  + Any imports that look like `from labrea.<module> import <object>` will need to be updated 
+- Changes to dataset
+  + To overload a dataset, you now *must* provide a `dispatch=` argument to the decorator
+    - Previously, a default dispatch of `Option('LABREA.IMPLEMENTATIONS.pkg.module.dataset_name')` was used
+    - Similarly, a dispatch argument is required for the `@interface` decorator
+  + `@my_dataset.overload` now takes `alias=` as either an alias or a list of aliases
+    - Previously, `aliases=` was used for a list of aliases
+  + `where=` argument is now renamed `defaults=` for clarity
+  + `@dataset` no longer accepts an `alias=` argument
+  + `callbacks=` has been renamed to `effects=`
+    - Now takes functions of a single argument, or `Evaluatable` objects that return such functions
+    - Can also subclass the `labrea.computation.Effect` class to define custom effects
+- Changes to options
+  + Previously, it was implicitly assumed that options must be a JSON-serializable dictionary
+  + This is now enforced. Providing a non-serializable option will raise an error
+    - e.g. a Pandas DataFrame cannot be provided as an option value 
+- Changes to pipelines
+  + `LabreaPipeline.step` has been removed in favor of the `pipeline_step` decorator
+    - Defining pipeline steps looks the same (like a dataset with empty first arg)
+  + Composing pipeline steps is now done with the `+` operator rather than the `>>` operator
+    - This is because `>>` is now used as an alias for the new `.apply` method on all `Evaluatable` objects
+  + Pipelines can now be invoked in one of two ways
+    - `pipeline(options)(value)`
+    - `pipeline.transform(value, options)`
+      + Here options can be omitted and an empty dictionary will be used
+  + Pipelines now return the plain value, rather than the special `LabreaPipelineData` object
+- Changes to Caching 
+  + Datasets are cached, but overloads do not have their own cache
+    - This means that if you overload a dataset, it will be cached in the parent dataset's cache rather than in a separate cache
+  + The `get`, `set`, and `exists` hooks in the `Cache` ABC have changed signatures
+    - This only impacts those who were writing custom cache implementations 
+- `Evaluatable` is now an `ABC` rather than a `Protocol`
+  + This only impacts those who are extending the labrea framework with custom types
+- Implementing multiple interfaces at once is now done using the `@implements` decorator
+  + Previously done with `@Interface.implementation(Interface1, Interface2, alias='ALIAS')` 
+  + `@implements(Interface1, Interface2, alias='ALIAS')` 
+
+### New Features
+- All `Evaluatable` objects (`datasets`, `Options`, etc.) now have a `.explain()` method that returns a set of option keys that are required to evaluate the object
+  - `Option('A').explain() == {'A'}` 
+  - Datasets will return every option key that is required to evaluate the dataset
+- All `Evaluatable` objects can be called with no argument and an empty options dictionary will be inferred
+- All `Evaluatable` objects now support the following methods for chaining transformations
+  + `apply` (aliased as `>>`)
+    + Used to apply a function to the result of an object
+    + e.g. `my_dataset >> (lambda x: x + 1)`
+  + `bind`
+    + Used to apply a function to the result of an object and return a new labrea object based on the result
+    + e.g. `my_dataset.bind(lambda x: my_dataset2 if x > 0 else my_dataset3)`
+- Utility functions for some common operations for chaning together
+  + `labrea.functions.map` recreates the `map` function for labrea objects
+    + `my_list_dataset >> labrea.functions.map(lambda x: x + 1)`
+  + `labrea.functions.filter` recreates the `filter` function for labrea objects
+    + `my_list_dataset >> labrea.functions.filter(lambda x: x > 0)`
+  + `labrea.functions.reduce` recreates the `reduce` function for labrea objects
+    + `my_list_dataset >> labrea.functions.reduce(lambda x, y: x + y)`
+- New `@pipeline_step` decorator for defining pipeline steps
+  + Pipelines are now *evaluatable*, meaning they act like a dataset the returns a function of one variable
+    - This means a pipeline can be provided as an argument to a dataset definition
+- New case / when / otherwise syntax for defining conditional logic that can't be expressed with switches/overloads
+  + `case(Option('A')).when(lambda a: a > 0, positive_dataset).otherwise(negative_dataset)`
+- New `Map` type for mapping a dataset over multiple options values
+  + `Map(Option('A'), {'A': [1, 2, 3]}).apply(list)()` == `[1, 2, 3]` 
+- New `WithOptions` type that can be used to provide pre-set options to an `Evaluatable` object
+  + `my_dataset_a_1 = WithOptions(my_dataset, {'A': 1})`
+- New `@implements` decorator for implementing multiple interfaces at once
+  + `@implements(Interface1, Interface2, alias='ALIAS')`  
+- New `Cached` type that can cache any labrea object (not just datasets)
+- New `Overloaded` type that can provide overloads for any labrea object (not just datasets)
+- New `FunctionApplication`, `PartialApplication`, and `EvaluatableArguments` types that are the foundational building blocks for datasets and pipeline steps
+- New effects system and corresponding `Computation` type that performs side-effects after an evaluation of a labrea object
+- New `runtime` module used for handling managed effects
+
 ## Version 1.4.0
 - Release as open source
\ No newline at end of file
diff --git a/docs/source/static/labrea-logo-black.png b/docs/source/static/labrea-logo-black.png
new file mode 100644
index 0000000..48ed85b
Binary files /dev/null and b/docs/source/static/labrea-logo-black.png differ
diff --git a/docs/source/static/labrea-logo-white.png b/docs/source/static/labrea-logo-white.png
new file mode 100644
index 0000000..212c4ba
Binary files /dev/null and b/docs/source/static/labrea-logo-white.png differ
diff --git a/docs/source/usage.md b/docs/source/usage.md
index 7fdef72..66cf922 100644
--- a/docs/source/usage.md
+++ b/docs/source/usage.md
@@ -235,50 +235,26 @@ from labrea import Option
 
 config = {
     'A': 'a',
-    'B': 'b'
+    'V': 'b'
 }
 
 Option('X', 1)(config)          ## 1
-Option('Y', '{A}/{B}')(config)  ## 'a/b'
-```
-
-### Templates
-
-Similar to the built-in f-strings, Labrea provides a `Template` type for
-string interpolation. This can be useful for creating strings that depend on
-config values, or for creating strings that depend on the results of other
-datasets.
-
-```python
-from labrea import dataset, Option, Template
-
-@dataset
-def b_dataset(
-        b: str = Option('B')
-) -> str:
-    return b
-
-template = Template(
-    '{A} {:b:}',
-    b=b_dataset
-)
-
-template({'A': 'Hello', 'B': 'World!'})  ## 'Hello World!'
+Option('Y', '{A}/{V}')(config)  ## 'a/b'
 ```
 
 ### Switches
 
 Sometimes your dataset might have different dependencies depending on some 
-input parameter or other condition. We can express this simply using `Switch`es.
+input parameter or other condition. We can express this simply using `switch`es.
 
 In this example, we have different logic for cloud vs on-prem, and can 
-express that this way. `Switch` takes a string representing the option we want
+express that this way. `switch` takes a string representing the option we want
 to switch over, a dictionary mapping config values to corresponding datasets,
 and (optionally) a default value if the config value is missing or does not 
 appear in our mapping.
 
 ```python
-from labrea import dataset, Switch
+from labrea import dataset, switch
 
 
 @dataset
@@ -293,7 +269,7 @@ def onprem_inputs():
 
 @dataset
 def final_data(
-        inputs = Switch(
+        inputs = switch(
             'ENVIRONMENT',
             {
                 'CLOUD': cloud_inputs,
@@ -308,12 +284,12 @@ final_data({'ENVIRONMENT': 'CLOUD'})  ## uses cloud_inputs as inputs arg
 final_data({'ENVIRONMENT': 'ONPREM'})  ## uses onprem_inputs as inputs arg
 ```
 
-The first argument to `Switch` can also be another dataset. In this example, we
+The first argument to `switch` can also be another dataset. In this example, we
 could automatically determine the environment in another dataset rather than 
 pass it explicitly in the config.
 
 ```python
-from labrea import dataset, Switch
+from labrea import dataset, switch
 
 
 @dataset
@@ -323,7 +299,7 @@ def inferred_environment():
 
 @dataset
 def final_data(
-        inputs = Switch(
+        inputs = switch(
             inferred_environment,
             {
                 'CLOUD': cloud_inputs,
@@ -341,110 +317,21 @@ the first one that can evaluate.
 ```python
 from labrea import Coalesce, Option
 
-x = Coalesce(Option('A'), Option('B'), Option('C'))
+x = Coalesce(Option('A'), Option('V'), Option('C'))
 
 x({'A': 1}) == 1
-x({'B': 2}) == 2
+x({'V': 2}) == 2
 x({'C': 3}) == 3
-x({'A': 1, 'B': 2}) == 1
-x({'B': 2, 'C': 3}) == 2
-x({})  ## EvaluationError
+x({'A': 1, 'V': 2}) == 1
+x({'V': 2, 'C': 3}) == 2
+x()  ## EvaluationError
 
 
-y = Coalesce(Option('A'), Option('B'), None)
+y = Coalesce(Option('A'), Option('V'), None)
 y({'A': 1}) == 1
-y({'B': 2}) == 2
-y({'A': 1, 'B': 2}) == 1
-y({}) is None
-```
-
-### Without Decorators
-
-What if you want to have your normal function, but then also create a dataset 
-version of that function? We can wrap an existing function in a dataset like so.
-
-```python
-import pandas as pd
-
-from labrea import dataset, Option
-
-
-def read_data(
-        path: str,
-        fmt: str = 'csv'
-) -> pd.DataFrame:
-    if fmt == 'csv':
-        return pd.read_csv(path)
-    elif fmt == 'excel':
-        return pd.read_excel(path)
-    else:
-        raise ValueError('Only csv and excel files are accepted.')
-
-
-input_data = dataset.where(
-    path=Option('INPUT.PATH'), 
-    fmt=Option('INPUT.FMT')
-).wrap(
-    read_data
-)
-```
-
-### Dataset Classes
-
-You may want to write classes with more complex behavior that use datasets
-and options as their inputs. Similar to the built-in dataclasses, we can use
-the `@datasetclass` decorator to create a class whose `__init__` method takes
-an options dictionary and automatically evaluates dependencies like a dataset.
-
-```python
-from typing import Set
-
-import pandas as pd
-
-from labrea import dataset, datasetclass, Option
-
-@dataset
-def input_data(
-        path: str = Option('INPUT.PATH'),
-        fmt: str = Option('INPUT.FMT', 'csv')
-) -> pd.DataFrame:
-    if fmt == 'csv':
-        return pd.read_csv(path)
-    elif fmt == 'excel':
-        return pd.read_excel(path)
-    else:
-        raise ValueError('Only csv and excel files are accepted.')
-
-@dataset
-def distinct_stores(data: pd.DataFrame = input_data) -> Set[str]:
-    return set(data['store_id'])
-
-@dataset
-def distinct_regions(data: pd.DataFrame = input_data) -> Set[str]:
-    return set(data['region_id'])
-
-
-@datasetclass
-class MyClass:
-    data: pd.DataFrame = input_data
-    stores: Set[str] = distinct_stores
-    region: Set[str] = distinct_regions
-    
-    def lookup_store(self, store_id: str):
-        if store_id not in self.stores:
-            return None
-        
-        return self.data[self.data['store_id'] == store_id]]
-
-options = {
-    'INPUT.PATH': '/path/to/input.xlsx',
-    'INPUT.FMT': 'excel'
-}
-
-my_data = MyClass(options)
-
-my_data.regions == {'region_1', 'region_2', 'region_3'} 
-my_data.lookup_store('<my_store>') == pd.DataFrame(...)
+y({'V': 2}) == 2
+y({'A': 1, 'V': 2}) == 1
+y() is None
 ```
 
 ### Overloads
@@ -477,7 +364,7 @@ By default, if nothing is provided, we use the default implementation in the bod
 ```python
 input_data({'INPUT': {'PATH': '/input/data/path'}})  ## Use default implementation
 input_data({'INPUT': {'SOURCE': 'MOCK'}}) == ['a', 'b', 'c'] 
-input_data({'INPUT': {'SOURCE': 'UNKNOWN_SOURCE'}})  ## EvaluationError
+input_data({'INPUT': {'SOURCE': 'UNKNOWN_SOURCE'}})  ## Error
 ```
 
 #### Abstract Datasets
@@ -519,7 +406,7 @@ from labrea import abstractdataset, dataset, interface, Option
 
 @interface(dispatch='ENVIRONMENT')
 class DataSource:
-    @staticmethod
+    @staticmethod  # Adding staticmethod appeases linters/IDEs that don't understand interfaces
     @abstractdataset
     def store() -> pd.DataFrame:
         """Returns a dataframe of store data."""
@@ -532,7 +419,7 @@ class DataSource:
     @staticmethod
     @dataset
     def store_ids(
-            store_: pd.DataFrame = store.__func__
+            store_: pd.DataFrame = store.__func__  # Use .__func__ to refer to the abstract dataset itself
     ) -> set[str]:
         """Derives the set of store ids from the store dataframe. This implementation is shared across all environments
         by default, but can be overridden if necessary."""
@@ -601,12 +488,114 @@ def num_stores(
     return len(store_ids)
 ```
 
+### Collections
+
+Labrea provides a few helper functions for creating collections of datasets. For example,
+you might have a list of datasets that you want to provide as a single input to another
+dataset. You can use the `evaluatable_list` function to accomplist this.
+
+```python
+from labrea import evaluatable_list, dataset
+
+@dataset
+def x() -> int:
+    return 1
+
+@dataset
+def y() -> int:
+    return 2
+
+@dataset
+def z(
+        x_and_y: list[int] = evaluatable_list(x, y)
+) -> list[int]:
+    return x_and_y
+
+
+z() == [1, 2]
+```
+
+The `evaluatable_tuple` and `evaluatable_set` functions work similarly. There is also a `evaluatable_dict`
+function that takes a dictionary mapping (static) keys to labrea objects.
+
+```python
+from labrea import evaluatable_dict
+
+@dataset
+def z(
+        xy_dict: dict[str, int] = evaluatable_dict({'x': x, 'y': y})
+) -> dict[str, int]:
+    return xy_dict
+
+z() == {'x': 1, 'y': 2}
+```
+
+### Map
+Sometimes you want to use a dataset multiple times with different options. This can be accomplished using the
+`Map` type. `Map` takes a dataset and a dictionary mapping option keys to labrea objects that return lists
+(or other iterables) of values. When the `Map` object is evaluated, it will call the dataset with each of the
+values in the dictionary, and return an iterable of tuples, where the first element is the options set on that 
+iteration, and the second element is the value. Like the build-in `map`, this iterable is lazy and is not a 
+list.
+
+```python
+from labrea import dataset, Option, Map
+
+
+@dataset
+def x_plus_y(
+        x: int = Option('X'),
+        y: int = Option('Y')
+) -> int:
+    return x + y
+
+
+mapped = Map(x_plus_y, {'X': Option('X_LIST')})
+
+for keys, value in mapped({'X_LIST': [1, 2, 3], 'Y': 10}):
+    print(keys, value)
+
+## {'X': 1} 11
+## {'X': 2} 12
+## {'X': 3} 13
+```
+
+`Map` objects have a `.values` property that can be used to only get the values.
+
+```python
+for value in mapped.values({'X_LIST': [1, 2, 3], 'Y': 10}):
+    print(value)
+    
+## 11
+## 12
+## 13
+```
+
+### In-Line Transformations
+Sometimes you want to perform a transformation on a dataset (or other object) that is not worth creating 
+a new dataset for. A common example is an Option that you want to parse as a date. You can use the
+`>>` operator (or the equivalent `.apply()` method) to perform this transformation in-line. The `>>`
+operator is shared by all Labrea objects.
+
+```python
+from labrea import Option
+import datetime as dt
+
+start_date = Option('START_DATE') >> dt.datetime.fromisoformat
+
+start_date({'START_DATE': '2022-01-01'}) == dt.datetime(2022, 1, 1)
+```
+
 ### Pipelines
 Labrea datasets are really useful when you know the dependency tree in advance. However, sometimes 
 you want to write code that performs some transformation on an arbitrary input, and perhaps you want 
 to perform a series of these transformations in an arbitrary order. To accomplish this, Labrea exposes
-a custom implementation of the `Pipeline` class from the [`funtional-pypelines`](https://github.com/8451/functional-pypelines)
-library that affords the same conveniences of writing datasets.
+a `Pipeline` class, where each step is created using the `@pipeline_step` decorator. 
+
+Pipelines look similar to datasets, but their first argument is always the input to the pipeline, and
+should not have a default value. To combine pipeline steps into a pipeline, use the `+` operator. Pipelines
+can be evaluated like a dataset, and it will return a function of 1 variable. If you want to run the pipeline
+on a value, use the `.transform(input, options)` method.
 
 For example, if you were building a feature engineering pipeline, you could write a series of functions
 that take a dataframe and add new columns, and then chain different subsets of these functions together
@@ -614,7 +603,7 @@ to create different feature sets.
 
 ```python
 import pandas as pd
-from labrea import LabreaPipeline, Option, dataset
+from labrea import pipeline_step, Option, dataset
 
 @dataset
 def store_sales(path: str = Option('PATH.STORE_SALES')) -> pd.DataFrame:
@@ -625,7 +614,7 @@ def store_square_footage(path: str = Option('PATH.STORE_SQFT')) -> pd.DataFrame:
     pd.read_csv(path)
 
 
-@LabreaPipeline.step
+@pipeline_step
 def add_sales(
         df: pd.DataFrame,
         sales: pd.DataFrame = store_sales
@@ -633,7 +622,7 @@ def add_sales(
     return pd.merge(df, sales, on='store_id', how='left')
 
 
-@LabreaPipeline.step
+@pipeline_step
 def add_square_footage(
         df: pd.DataFrame,
         sqft: pd.DataFrame = store_square_footage
@@ -641,7 +630,7 @@ def add_square_footage(
     return pd.merge(df, sqft, on='store_id', how='left')
 
 
-@LabreaPipeline.step
+@pipeline_step
 def add_sales_per_sqft(
         df: pd.DataFrame,
         sales: pd.DataFrame = store_sales,
@@ -653,9 +642,9 @@ def add_sales_per_sqft(
     return df.drop(columns=['sales', 'sqft'])
 
 
-basic_features = add_sales >> add_square_footage
+basic_features = add_sales + add_square_footage
 derived_features = add_sales_per_sqft
-all_features = basic_features >> derived_features
+all_features = basic_features + derived_features
 
 
 stores = pd.read_csv('/path/to/stores.csv')
@@ -664,68 +653,133 @@ options = {
     'PATH.STORE_SQFT': '/path/to/store_sqft.csv'
 }
 
-basic_features(stores, options)
+basic_features.transform(stores, options)
 ## Returns a dataframe with columns from stores and new columns sales and sqft
 
-derived_features(stores, options)
+derived_features.transform(stores, options)
 ## Returns a dataframe with columns from stores and a new column sales_per_sqft
 
-all_features(stores, options)
+all_features.transform(stores, options)
 ## Returns a dataframe with columns from stores and new columns sales, sqft, and sales_per_sqft
 ```
 
-### Callbacks
-Sometimes you want to run some code after a dataset has been evaluated, such as logging the result, 
-validating the result, etc. Callbacks can be added to datasets either when created using the
-`callbacks=` argument to the `@dataset` decorator, or by using the `.add_callbacks` method on the
-dataset.
+Pipelines can also be used as inline transformations on other Labrea objects.
+
+```python
+from labrea import dataset, Option, pipeline_step
+
+@dataset
+def letters() -> list[str]:
+    return ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
+
+
+@pipeline_step
+def take_first_n(
+        lst: list[str],
+        n: int = Option('N')
+) -> list[str]:
+    return lst[:n]
+
+
+first_n_letters = letters >> take_first_n
+
+first_n_letters({'N': 3}) == ['a', 'b', 'c']
+```
 
-Callbacks are any callable that takes two arguments, the first being the value of the dataset and
-the second being the options dictionary that was passed to the dataset. The callable can return a
-value, which will be used as the new value of the dataset, or it can return `None`, in which case
-the value of the dataset will not be changed.
+### Helper Pipelines
+Labrea provides a few helper functions for creating common pipeline steps. These are
+`map`, `filter`, and `reduce`, all under the `labrea.functions` module.
 
 ```python
-def print_callback(value, options):
-    print(f'Value of dataset is {value}')
-    return value
+from labrea import dataset
+import labrea.functions as lf
 
-@dataset(callbacks=[print_callback])
-def my_dataset() -> int:
-    return 1
+@dataset
+def numbers() -> list[int]:
+    return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
+
+sum_squared_evens = (
+        numbers >> 
+        lf.filter(lambda x: x % 2 == 0) >> 
+        lf.map(lambda x: x**2) >> 
+        lf.reduce(lambda x, y: x + y)
+)
+
+sum_squared_evens() == 220
+```
+
+
+### Templates
+
+Similar to the built-in f-strings, Labrea provides a `Template` type for
+string interpolation. This can be useful for creating strings that depend on
+config values, or for creating strings that depend on the results of other
+datasets.
 
-my_dataset({})  ## prints 'Value of dataset is 1'
+```python
+from labrea import dataset, Option, Template
 
 @dataset
-def my_other_dataset() -> int:
-    return 2
+def b_dataset(
+        b: str = Option('B')
+) -> str:
+    return b
 
-my_other_dataset.add_callbacks(print_callback)
+template = Template(
+    '{A} {:b:}',
+    b=b_dataset
+)
 
-my_other_dataset({})  ## prints 'Value of dataset is 2'
+template({'A': 'Hello', 'B': 'World!'})  ## 'Hello World!'
 ```
 
-If a dataset uses caching, the callbacks will only be run the first time the dataset is evaluated
-with a given set of options. If you want to run the callbacks every time the dataset is evaluated,
-you can disable caching using the `@dataset.nocache` decorator.
+### Dataset Classes
 
-There are also pre-callbacks, which receive the dataset object and options dictionary before the 
-dataset is evaluated. These can be added using the `add_pre_callbacks` method on the dataset.
+You may want to write classes with more complex behavior that use datasets
+and options as their inputs. Similar to the built-in dataclasses, we can use
+the `@datasetclass` decorator to create a class whose `__init__` method takes
+an options dictionary and automatically evaluates dependencies like a dataset.
 
 ```python
+from typing import Set
+
+import pandas as pd
+
+from labrea import dataset, datasetclass, Option
+
 @dataset
-def my_dataset() -> int:
-    return 1
+def input_data(
+        path: str = Option('INPUT.PATH'),
+        fmt: str = Option('INPUT.FMT', 'csv')
+) -> pd.DataFrame:
+    if fmt == 'csv':
+        return pd.read_csv(path)
+    elif fmt == 'excel':
+        return pd.read_excel(path)
+    else:
+        raise ValueError('Only csv and excel files are accepted.')
 
-def print_pre_callback(dataset, options):
-    print(f'About to evaluate {dataset.__name__} with options {options}')
-    
-my_dataset.add_pre_callbacks(print_pre_callback)
-my_dataset({})  ## prints 'About to evaluate my_dataset with options {}'
-```
+@dataset
+def distinct_stores(data: pd.DataFrame = input_data) -> Set[str]:
+    return set(data['store_id'])
 
-Conveniently, `LabreaPipeline` objects meet the requirements of a callback, so you can add a pipeline
-as a callback to a dataset.
+@dataset
+def distinct_regions(data: pd.DataFrame = input_data) -> Set[str]:
+    return set(data['region_id'])
+
+
+gi
+
+options = {
+    'INPUT.PATH': '/path/to/input.xlsx',
+    'INPUT.FMT': 'excel'
+}
+
+my_data = MyClass(options)
+
+my_data.regions == {'region_1', 'region_2', 'region_3'} 
+my_data.lookup_store('<my_store>') == pd.DataFrame(...)
+```
 
 ### Typing
 
@@ -764,9 +818,8 @@ This will signal to the type checker that `Option('X').result` should be treated
 as the resulting value of the `Option`, rather than the option value itself.
 
 The `.result` property is shared among all Labrea types (`Option`, `Dataset`,
-`Switch`, `DatasetDict`, `DatasetList`, and `DatasetClass`). Whenever you are
-defining a dataset or DatasetClass, use the `.result` suffix on all your 
-dependencies to pass type checking.
+etc.). Whenever you are defining a dataset or DatasetClass, use the `.result` 
+suffix on all your dependencies to pass type checking.
 
 
 #### Subscripting
@@ -804,12 +857,10 @@ def first_char(
     return x[0]
 ```
 
-#### DatasetClasses
+#### MyPy Plugin
 
-MyPy has trouble understanding that DatasetClasses will have a `.result` attribute
-to reference. Labrea provides a MyPy plugin that handles this issue. 
-You use it by adding the following to a `mypy.ini` or `setup.cfg` file in your
-project.
+MyPy has trouble understanding the way the decorators for interfaces and dataset classes work.
+A MyPy plugin is provided to help with this. To use it, add the following to your `mypy.ini` file:
 
 
 ```
diff --git a/labrea/__init__.py b/labrea/__init__.py
index 19ffa1d..d9516a4 100644
--- a/labrea/__init__.py
+++ b/labrea/__init__.py
@@ -1,34 +1,59 @@
-from . import _version as version
-from .coalesce import Coalesce
-from .collections import DatasetDict, DatasetList
-from .datasetclasses import Field, datasetclass
-from .datasets import Dataset, Overload, abstractdataset, dataset
-from .interfaces import Interface, interface
-from .options import Option
-from .pipelines import LabreaPipeline
-from .switch import Switch
+from . import _version, exceptions, types
+from .cache import cached
+from .coalesce import Coalesce, coalesce
+from .collections import (
+    DatasetDict,
+    DatasetList,
+    DatasetSet,
+    DatasetTuple,
+    evaluatable_dict,
+    evaluatable_list,
+    evaluatable_set,
+    evaluatable_tuple,
+)
+from .conditional import Switch, case, switch
+from .dataset import abstractdataset, dataset
+from .datasetclass import datasetclass
+from .interface import implements, interface
+from .iterable import Iter, Map
+from .option import Option, WithDefaultOptions, WithOptions
+from .overload import Overloaded
+from .pipeline import pipeline_step
 from .template import Template
 from .types import Value
 
-
-__version__ = version.__version__
+__version__ = _version.__version__
 
 
 __all__ = [
-    "dataset",
-    "abstractdataset",
-    "Dataset",
-    "Overload",
-    "interface",
-    "Interface",
-    "Template",
+    "Coalesce",
+    "DatasetDict",
+    "DatasetList",
+    "DatasetSet",
+    "DatasetTuple",
+    "Iter",
+    "Map",
     "Option",
+    "WithDefaultOptions",
+    "WithOptions",
+    "Overloaded",
     "Switch",
-    "DatasetList",
-    "DatasetDict",
-    "datasetclass",
-    "Field",
+    "Template",
     "Value",
-    "Coalesce",
-    "LabreaPipeline",
+    "abstractdataset",
+    "cached",
+    "case",
+    "coalesce",
+    "dataset",
+    "datasetclass",
+    "evaluatable_dict",
+    "evaluatable_list",
+    "evaluatable_set",
+    "evaluatable_tuple",
+    "exceptions",
+    "implements",
+    "interface",
+    "pipeline_step",
+    "switch",
+    "types",
 ]
diff --git a/labrea/_aliases.py b/labrea/_aliases.py
deleted file mode 100644
index 97c1bf1..0000000
--- a/labrea/_aliases.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from typing import Any
-
-from .types import Alias, MultiAlias
-
-
-def default_alias(obj: Any) -> Alias:
-    try:
-        name = obj.__name__
-    except AttributeError:
-        raise TypeError(
-            f"A default alias for {obj} could not be created, please provide "
-            f"one manually."
-        )
-
-    module = getattr(obj, "__module__", None)
-
-    return name if module in ("__main__", None) else f"{module}.{name}"
-
-
-def default_aliases(obj: Any) -> MultiAlias:
-    return [default_alias(obj)]
diff --git a/labrea/_import.py b/labrea/_import.py
deleted file mode 100644
index 9d6b374..0000000
--- a/labrea/_import.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from functools import reduce
-from importlib import import_module
-from types import ModuleType
-from typing import Any, List, Tuple, TypeVar
-
-A = TypeVar("A")
-
-
-def _import_module(path: List[str]) -> Tuple[ModuleType, List[str]]:
-    if len(path) == 0:
-        raise ImportError
-
-    try:
-        return import_module(".".join(path)), []
-    except ModuleNotFoundError:
-        path, attrs = path[:-1], path[-1:]
-        module, rest = _import_module(path)
-        return module, rest + attrs
-
-
-def _getattr(namespace: Any, name: str):
-    try:
-        return getattr(namespace, name)
-    except AttributeError as err:
-        raise ImportError(f'"{namespace}" does not define attribute "{name}"') from err
-
-
-def import_string(dotted_path: str) -> Any:
-    """
-    Import a dotted module path and return the attribute/class designated by
-    the last name in the path. Raise ImportError if the import failed.
-    """
-    module, attrs = _import_module(dotted_path.split("."))
-
-    return reduce(_getattr, attrs, module)
-
-
-def require(*args: str):
-    for arg in args:
-        _ = import_string(arg)
diff --git a/labrea/_missing.py b/labrea/_missing.py
new file mode 100644
index 0000000..9513e21
--- /dev/null
+++ b/labrea/_missing.py
@@ -0,0 +1,17 @@
+from enum import Enum
+from typing import TypeVar, Union
+
+T = TypeVar("T")
+
+
+class Missing(Enum):
+    token = 0
+
+    def __repr__(self) -> str:
+        return "MISSING"
+
+
+MISSING = Missing.token
+
+
+MaybeMissing = Union[Missing, T]
diff --git a/labrea/_version.py b/labrea/_version.py
index 3e8d9f9..8c0d5d5 100644
--- a/labrea/_version.py
+++ b/labrea/_version.py
@@ -1,1 +1,1 @@
-__version__ = "1.4.0"
+__version__ = "2.0.0"
diff --git a/labrea/application.py b/labrea/application.py
new file mode 100644
index 0000000..29c5535
--- /dev/null
+++ b/labrea/application.py
@@ -0,0 +1,283 @@
+import sys
+
+if sys.version_info < (3, 10):
+    from typing_extensions import Concatenate, ParamSpec
+else:
+    from typing import Concatenate, ParamSpec
+
+import inspect
+from typing import Callable, Dict, Generic, Optional, Set, TypeVar, Union, overload
+
+from .arguments import Arguments, arguments
+from .types import Evaluatable, MaybeEvaluatable, Options
+
+P = ParamSpec("P")
+A = TypeVar("A", covariant=True)
+X = TypeVar("X")
+
+
+class FunctionApplication(Generic[P, A], Evaluatable[A]):
+    """A class representing the application of a function to a set of arguments.
+
+    This class is what is used by the :class:`labrea.dataset.Dataset` under the hood.
+
+    Arguments
+    ---------
+    __func : Callable[P, A]
+        The function to apply.
+    *args : MaybeEvaluatable[P.args]
+        The positional arguments to evaluate.
+    **kwargs : MaybeEvaluatable[P.kwargs]
+        The keyword arguments to evaluate.
+    """
+
+    func: Callable[P, A]
+    arguments: Evaluatable[Arguments[P]]
+    _repr: str
+
+    def __init__(
+        self,
+        __func: Callable[P, A],
+        /,
+        *args: "MaybeEvaluatable[P.args]",
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ):
+        self.func = __func
+        self.arguments = arguments(*args, **kwargs)
+
+        self._repr = (
+            f"FunctionApplication("
+            f"{self.func.__name__}, "
+            f"{', '.join(map(repr, args))}"
+            f"{', '.join(f'{key}={value!r}' for key, value in kwargs.items())}"
+            f")"
+        )
+
+    def evaluate(self, options: Options) -> A:
+        args = self.arguments.evaluate(options)
+        return self.func(*args.args, **args.kwargs)
+
+    def validate(self, options: Options) -> None:
+        self.arguments.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.arguments.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.arguments.explain(options)
+
+    def __repr__(self) -> str:
+        return self._repr
+
+    @overload
+    @classmethod
+    def lift(
+        cls,
+        __func: Callable[P, A],
+        /,
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ) -> "FunctionApplication[P, A]":
+        ...  # pragma: no cover
+
+    @overload
+    @classmethod
+    def lift(
+        cls,
+        __func: None = None,
+        /,
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ) -> Callable[[Callable[P, A]], "FunctionApplication[P, A]"]:
+        ...  # pragma: no cover
+
+    @classmethod
+    def lift(
+        cls,
+        __func: Optional[Callable[P, A]] = None,
+        /,
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ) -> Union[
+        "FunctionApplication[P, A]",
+        Callable[[Callable[P, A]], "FunctionApplication[P, A]"],
+    ]:
+        """Lift a function definition with Evaluatable default arguments to a FunctionApplication.
+
+        This is used by the :func:`labrea.dataset` decorator under the hood. Can be used
+        as a decorator with or without keyword arguments for the defaults.
+
+        Arguments
+        ---------
+        __func : Callable[P, A]
+            The function to apply.
+        **kwargs : MaybeEvaluatable[P.kwargs]
+            Default values for the keyword arguments of the function.
+
+        Returns
+        -------
+        FunctionApplication[P, A]
+            The lifted function application.
+
+
+        Example Usage
+        -------------
+        >>> @FunctionApplication.lift
+        ... def a_squared(a: int = Option('A')) -> int:
+        ...     return a ** 2
+        >>>
+        >>> @FunctionApplication(a=Option('A'))
+        ... def a_squared(a: int) -> int:
+        ...     return a ** 2
+        >>>
+        >>> a_squared = FunctionApplication.lift(lambda a: a**2, a=Option('A'))
+        """
+        if __func is None:
+            return lambda f: cls.lift(f, **kwargs)
+
+        signature = inspect.signature(__func)
+        eval_kwargs: Dict[str, Evaluatable["P.kwargs"]] = {}
+
+        for param in signature.parameters.values():
+            default = kwargs.get(param.name, param.default)
+            if default is param.empty:
+                raise TypeError(
+                    f"Cannot lift function {__func} with non-defaulted parameters"
+                )
+            eval_kwargs[param.name] = Evaluatable.ensure(default)
+
+        return FunctionApplication(__func, **eval_kwargs)
+
+
+class PartialApplication(Generic[X, P, A], Evaluatable[Callable[[X], A]]):
+    """A class representing the partial application of a function to a set of arguments.
+
+    This class is used by the :func:`labrea.pipeline_step` decorator under the hood.
+
+    Arguments
+    ---------
+    __func : Callable[Concatenate[X, P], A]
+        The function to apply.
+    *args : MaybeEvaluatable[P.args]
+        The positional arguments to evaluate.
+    **kwargs : MaybeEvaluatable[P.kwargs]
+        The keyword arguments to evaluate.
+    """
+
+    func: Callable[Concatenate[X, P], A]
+    arguments: Evaluatable[Arguments[P]]
+    _repr: str
+
+    def __init__(
+        self,
+        __func: Callable[Concatenate[X, P], A],
+        /,
+        *args: Evaluatable["P.args"],
+        **kwargs: Evaluatable["P.kwargs"],
+    ):
+        self.func = __func
+        self.arguments = arguments(*args, **kwargs)
+
+        self._repr = (
+            f"PartialApplication("
+            f"{self.func.__name__}, "
+            f"{', '.join(map(repr, args))}"
+            f"{', '.join(f'{key}={value!r}' for key, value in kwargs.items())}"
+            f")"
+        )
+
+    def evaluate(self, options: Options) -> Callable[[X], A]:
+        args = self.arguments.evaluate(options)
+        return lambda x: self.func(x, *args.args, **args.kwargs)
+
+    def validate(self, options: Options) -> None:
+        self.arguments.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.arguments.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.arguments.explain(options)
+
+    def __repr__(self) -> str:
+        return self._repr
+
+    @overload
+    @classmethod
+    def lift(
+        cls,
+        __func: Callable[Concatenate[X, P], A],
+        /,
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ) -> "PartialApplication[X, P, A]":
+        ...  # pragma: no cover
+
+    @overload
+    @classmethod
+    def lift(
+        cls,
+        __func: None = None,
+        /,
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ) -> Callable[[Callable[Concatenate[X, P], A]], "PartialApplication[X, P, A]"]:
+        ...  # pragma: no cover
+
+    @classmethod
+    def lift(
+        cls,
+        __func: Optional[Callable[Concatenate[X, P], A]] = None,
+        /,
+        **kwargs: "MaybeEvaluatable[P.kwargs]",
+    ) -> Union[
+        "PartialApplication[X, P, A]",
+        Callable[[Callable[Concatenate[X, P], A]], "PartialApplication[X, P, A]"],
+    ]:
+        """Lift a function definition with Evaluatable default arguments to a PartialApplication.
+
+        This is used by the :func:`labrea.pipeline_step` decorator under the hood. Can be used
+        as a decorator with or without keyword arguments for the defaults.
+
+        Arguments
+        ---------
+        __func : Callable[Concatenate[X, P], A]
+            The function to apply.
+        **kwargs : MaybeEvaluatable[P.kwargs]
+            Default values for the keyword arguments of the function.
+
+        Returns
+        -------
+        PartialApplication[X, P, A]
+            The lifted partial application.
+
+
+        Example Usage
+        -------------
+        >>> @PartialApplication.lift
+        ... def a_plus_b(a: int, b: int = Option('B')) -> int:
+        ...     return a + b
+        >>>
+        >>> @PartialApplication(b=Option('B'))
+        ... def a_plus_b(a: int, b: int) -> int:
+        ...     return a + b
+        >>>
+        >>> a_plus_b = PartialApplication.lift(lambda a, b: a + b, b=Option('B'))
+        """
+        if __func is None:
+            return lambda f: cls.lift(f, **kwargs)
+
+        signature = inspect.signature(__func)
+        eval_kwargs: Dict[str, Evaluatable["P.kwargs"]] = {}
+
+        for i, param in enumerate(signature.parameters.values()):
+            default = kwargs.get(param.name, param.default)
+            if i == 0 and default is not param.empty:
+                raise TypeError(
+                    f"Cannot create a PartialApplication with a default value "
+                    f"for the first parameter of {__func}"
+                )
+            elif i > 0 and default is param.empty:
+                raise TypeError(
+                    f"Cannot lift function {__func} with non-defaulted parameters "
+                    f"after the first parameter"
+                )
+            elif default is not param.empty:
+                eval_kwargs[param.name] = Evaluatable.ensure(default)
+
+        return PartialApplication(__func, **eval_kwargs)
diff --git a/labrea/arguments.py b/labrea/arguments.py
new file mode 100644
index 0000000..f09b1bf
--- /dev/null
+++ b/labrea/arguments.py
@@ -0,0 +1,167 @@
+import sys
+
+if sys.version_info < (3, 10):
+    from typing_extensions import ParamSpec
+else:
+    from typing import ParamSpec
+
+from typing import Dict, Generic, Optional, Set, Tuple
+
+from .types import Evaluatable, MaybeEvaluatable, Options
+
+P = ParamSpec("P")
+
+
+class Arguments(Generic[P]):
+    """A class representing the arguments of a function or method.
+
+    Arguments
+    ---------
+    *args : P.args
+        The positional arguments.
+    **kwargs : P.kwargs
+        The keyword arguments.
+    """
+
+    args: P.args
+    kwargs: P.kwargs
+
+    def __init__(self, *args: P.args, **kwargs: P.kwargs):
+        self.args = args
+        self.kwargs = kwargs
+
+    def __repr__(self) -> str:
+        args_repr = ", ".join(map(repr, self.args))
+        kwargs_repr = ", ".join(
+            f"{key}={value!r}" for key, value in self.kwargs.items()
+        )
+        return f"Arguments({', '.join(filter(bool, [args_repr, kwargs_repr]))})"
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, Arguments):
+            return NotImplemented
+        return self.args == other.args and self.kwargs == other.kwargs
+
+
+class EvaluatableArgs(Generic[P], Evaluatable["P.args"]):
+    """A class representing a set of arguments that can be evaluated.
+
+    Arguments
+    ---------
+    *args : Evaluatable[P.args]
+        The arguments to evaluate.
+    """
+
+    args: Tuple[Evaluatable, ...]
+
+    def __init__(self, *args: Evaluatable["P.args"]):
+        self.args = args
+
+    def evaluate(self, options: Options) -> "P.args":
+        return tuple(arg.evaluate(options) for arg in self.args)  # type: ignore
+
+    def validate(self, options: Options) -> None:
+        for arg in self.args:
+            arg.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return set().union(*(arg.keys(options) for arg in self.args))
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return set().union(*(arg.explain(options) for arg in self.args))
+
+    def __repr__(self) -> str:
+        return f"EvaluatableArgs({', '.join(map(repr, self.args))})"
+
+
+class EvaluatableKwargs(Generic[P], Evaluatable["P.kwargs"]):
+    """A class representing a set of keyword arguments that can be evaluated.
+
+    Arguments
+    ---------
+    **kwargs : Evaluatable[P.kwargs]
+        The keyword arguments to evaluate.
+    """
+
+    kwargs: Dict[str, Evaluatable]
+
+    def __init__(self, **kwargs: Evaluatable["P.kwargs"]):
+        self.kwargs = kwargs
+
+    def evaluate(self, options: Options) -> "P.kwargs":
+        return {key: value.evaluate(options) for key, value in self.kwargs.items()}  # type: ignore
+
+    def validate(self, options: Options) -> None:
+        for value in self.kwargs.values():
+            value.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return set().union(*(value.keys(options) for value in self.kwargs.values()))
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return set().union(*(value.explain(options) for value in self.kwargs.values()))
+
+    def __repr__(self) -> str:
+        return (
+            f"EvaluatableKwargs("
+            f"{', '.join(f'{key}={value!r}' for key, value in self.kwargs.items())}"
+            f")"
+        )
+
+
+class EvaluatableArguments(Evaluatable[Arguments[P]]):
+    """A class representing a set of arguments that can be evaluated.
+
+    Arguments
+    ---------
+    *args : MaybeEvaluatable[P.args]
+        The positional arguments to evaluate.
+    **kwargs : MaybeEvaluatable[P.kwargs]
+        The keyword arguments to evaluate
+    """
+
+    args: EvaluatableArgs[P]
+    kwargs: EvaluatableKwargs[P]
+
+    def __init__(self, *args: Evaluatable["P.args"], **kwargs: Evaluatable["P.kwargs"]):
+        self.args = EvaluatableArgs(*args)
+        self.kwargs = EvaluatableKwargs(**kwargs)
+
+    def evaluate(self, options: Options) -> Arguments[P]:
+        return Arguments(*self.args.evaluate(options), **self.kwargs.evaluate(options))
+
+    def validate(self, options: Options) -> None:
+        self.args.validate(options)
+        self.kwargs.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.args.keys(options).union(self.kwargs.keys(options))
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.args.explain(options) | self.kwargs.explain(options)
+
+    def __repr__(self) -> str:
+        return (
+            f"EvaluatableParameters("
+            f"{', '.join(map(repr, self.args.args))}, "
+            f"{', '.join(f'{key}={value!r}' for key, value in self.kwargs.kwargs.items())}"
+            f")"
+        )
+
+
+def arguments(
+    *args: MaybeEvaluatable["P.args"], **kwargs: MaybeEvaluatable["P.kwargs"]
+) -> Evaluatable[Arguments[P]]:
+    """Create an EvaluatableArguments object from the given arguments.
+
+    Arguments
+    ---------
+    *args : MaybeEvaluatable[P.args]
+        The positional arguments to evaluate.
+    **kwargs : MaybeEvaluatable[P.kwargs]
+        The keyword arguments to evaluate
+    """
+    return EvaluatableArguments(
+        *(Evaluatable.ensure(arg) for arg in args),
+        **{key: Evaluatable.ensure(value) for key, value in kwargs.items()},
+    )
diff --git a/labrea/cache.py b/labrea/cache.py
index 90c7a96..1443bc5 100644
--- a/labrea/cache.py
+++ b/labrea/cache.py
@@ -1,185 +1,412 @@
-from abc import ABCMeta, abstractmethod
-from typing import Generic, TypeVar
+from abc import ABC, abstractmethod
+from typing import Any, Callable, Dict, Generic, Optional, Set, TypeVar, Union, overload
 
-from confectioner.templating import get_dotted_key
-
-from .types import Evaluatable, JSONDict, JSONType
+from . import runtime
+from .option import Option
+from .types import Evaluatable, Options
 
 A = TypeVar("A")
 
 
-class CacheInvalidationError(Exception):
-    """Raised when a cache is invalidated"""
+class CacheFailure(Exception):
+    """An exception raised when a cache operation fails."""
 
-    pass
+    evaluatable: Evaluatable
+    options: Options
+    cache: "Cache"
 
 
-def json_hash(json: JSONType):
-    if isinstance(json, dict):
-        keys = tuple(sorted(json.keys()))
-        vals = [json[key] for key in keys]
-        return hash((hash(keys), json_hash(vals)))
-    if isinstance(json, list):
-        return hash(tuple(json_hash(val) for val in json))
-    else:
-        return hash(json)
+class CacheGetFailure(CacheFailure):
+    """An exception raised when a cache get operation fails."""
 
+    def __init__(
+        self, evaluatable: Evaluatable, options: Options, cache: "Cache"
+    ) -> None:
+        self.evaluatable = evaluatable
+        self.options = options
+        self.cache = cache
 
-class Cache(Generic[A], metaclass=ABCMeta):
-    """A cache for Datasets
+        super().__init__(f"Failed to retrieve {evaluatable} from cache {cache}")
 
-    A cache is used to store the results of evaluating a Dataset. Every time a
-    Dataset is evaluated, the cache is checked to see if the result has already
-    been computed. If it has, the cached result is returned. If not, the
-    Dataset is evaluated and the result is stored in the cache.
 
-    Because the same Dataset can be evaluated with different options, the cache
-    is keyed by the options dictionary. The keys returned by the Cache's
-    keys() method are used to generate the key for the cache lookup, so the
-    keys() method should return all keys that affect the evaluation of the
-    Dataset.
+class CacheSetFailure(CacheFailure):
+    """An exception raised when a cache set operation fails."""
 
-    The Cache class is abstract and must be subclassed to implement the
-    exists(), get(), and set() methods.
-    """
+    evaluatable: Evaluatable
+    options: Options
+    cache: "Cache"
+    value: Any
 
-    evaluatable: Evaluatable[A]
+    def __init__(
+        self, evaluatable: Evaluatable, options: Options, cache: "Cache", value: Any
+    ) -> None:
+        self.evaluatable = evaluatable
+        self.options = options
+        self.cache = cache
+        self.value = value
 
-    def __init__(self, _evaluatable: Evaluatable[A]):
-        """Create a new Cache
+        super().__init__(f"Failed to add value {value} from {evaluatable} to {cache}")
 
-        Parameters
-        ----------
-        _evaluatable : Evaluatable[A]
-            The Evaluatable that this Cache is caching.
-        """
-        self.evaluatable = _evaluatable
 
-    @abstractmethod
-    def exists(self, keys: JSONDict) -> bool:
-        """Check if there is a cached value for the given keys
+class CacheExistsFailure(CacheFailure):
+    """An exception raised when a cache exists operation fails."""
 
-        Parameters
-        ----------
-        keys : JSONDict
-            The keys to check for in the cache.
+    evaluatable: Evaluatable
+    options: Options
+    cache: "Cache"
 
-        Returns
-        -------
-        bool
-            True if there is a cached value for the given keys,
-            False otherwise.
+    def __init__(
+        self, evaluatable: Evaluatable, options: Options, cache: "Cache"
+    ) -> None:
+        self.evaluatable = evaluatable
+        self.options = options
+        self.cache = cache
 
-        Raises
-        ------
-        CacheInvalidationError
-            If the cached value is no longer valid.
-        """
-        ...  # pragma: nocover
+        super().__init__(f"Failed to check if {evaluatable} exists in cache {cache}")
+
+
+class Cache(Generic[A], ABC):
+    """A class representing a cache of values that can be evaluated."""
 
     @abstractmethod
-    def get(self, keys: JSONDict) -> A:
-        """Get the cached value for the given keys
+    def get(self, evaluatable: Evaluatable[A], options: Options) -> A:
+        """Get a key from the cache.
 
-        Parameters
-        ----------
-        keys : JSONDict
-            The keys to lookup in the cache
+        Arguments
+        ---------
+        evaluatable: Evaluatable
+            The evaluatable to get from the cache.
+        options : Options
+            The options dictionary to evaluate against.
 
         Returns
         -------
         A
-            The cached value for the given keys.
+            The value from the cache.
 
         Raises
         ------
-        KeyError
-            If there is no cached value for the given keys.
-        CacheInvalidationError
-            If the cached value is no longer valid.
+        CacheGetFailure
+            If the key cannot be retrieved from the cache.
         """
-        ...  # pragma: nocover
+        raise NotImplementedError  # pragma: nocover
 
     @abstractmethod
-    def set(self, keys: JSONDict, value: A):
-        """Set the cached value for the given keys
-
-        Parameters
-        ----------
-        keys : JSONDict
-            The keys to set in the cache
+    def set(self, evaluatable: Evaluatable[A], options: Options, value: A) -> None:
+        """Set a key in the cache.
+
+        Arguments
+        ---------
+        evaluatable: Evaluatable
+            The evaluatable to set in the cache.
+        options : Options
+            The options dictionary to evaluate against.
         value : A
-            The value to set in the cache
+            The value to set in the cache.
 
         Raises
         ------
-        CacheInvalidationError
-            If the cached value is no longer valid.
+        CacheSetFailure
+            If the key cannot be set in the cache.
         """
-        ...  # pragma: nocover
+        raise NotImplementedError  # pragma: nocover
 
-    def keys(self, options: JSONDict) -> JSONDict:
-        """Identify the keys that affect the evaluation of the Dataset
+    def exists(self, evaluatable: Evaluatable[A], options: Options) -> bool:
+        """Check if a key exists in the cache.
 
-        Parameters
-        ----------
-        options : JSONDict
-            The options dictionary to use to evaluate the Dataset
+        Arguments
+        ---------
+        evaluatable: Evaluatable
+            The evaluatable to check in the cache.
+        options : Options
+            The options dictionary to evaluate against.
 
         Returns
         -------
-        JSONDict
-            The keys that affect the evaluation of the Dataset, and their
-            values.
+        bool
+            Whether the value exists in the cache.
+
+        Raises
+        ------
+        CacheExistsFailure
+            If the existence of the key cannot be checked in the cache.
         """
-        return {
-            key: get_dotted_key(key, dict(options))
-            for key in self.evaluatable.keys(options)
-        }
+        try:
+            self.get(evaluatable, options)
+            return True
+        except CacheGetFailure:
+            return False
 
-    def __getitem__(self, options: JSONDict):
-        keys = self.keys(options)
-        return self.get(keys)
 
-    def __setitem__(self, options: JSONDict, value: A):
-        keys = self.keys(options)
-        self.set(keys, value)
+class NoCache(Cache[Any]):
+    """A class representing a cache that does not store any values."""
 
-    def __contains__(self, options: JSONDict):
-        keys = self.keys(options)
-        return self.exists(keys)
+    def get(self, evaluatable: Evaluatable, options: Options) -> Any:
+        raise CacheGetFailure(evaluatable, options, self)
 
+    def set(self, evaluatable: Evaluatable, options: Options, value: Any) -> None:
+        pass
 
-class NoCache(Cache[A]):
-    """A Cache that does not cache anything"""
 
-    def exists(self, keys: JSONDict) -> bool:
-        return False
+class MemoryCache(Cache[A]):
+    """A class representing a cache that stores values in memory."""
 
-    def get(self, keys: JSONDict) -> A:
-        raise KeyError
+    _cache: Dict[bytes, A]
 
-    def set(self, keys: JSONDict, value: A):
-        pass
+    def __init__(self) -> None:
+        self._cache = {}
 
+    def get(self, evaluatable: Evaluatable, options: Options) -> A:
+        try:
+            return self._cache[evaluatable.fingerprint(options)]
+        except KeyError as e:
+            raise CacheGetFailure(evaluatable, options, self) from e
+
+    def set(self, evaluatable: Evaluatable, options: Options, value: A) -> None:
+        self._cache[evaluatable.fingerprint(options)] = value
+
+    def exists(self, evaluatable: Evaluatable, options: Options) -> bool:
+        return evaluatable.fingerprint(options) in self._cache
 
-class MemoryCache(Cache[A]):
-    """A Cache that stores values in memory
 
-    A simple in-memory cache that stores values in a dictionary.
+class CacheSetRequest(runtime.Request[A]):
+    """A request to set a value in a cache.
+
+    Arguments
+    ---------
+    evaluatable : Evaluatable
+        The evaluatable that produced the value.
+    options : Options
+        The options dictionary that was used to evaluate the value.
+    value : A
+        The value to set in the cache.
+    cache : Cache
+        The cache to set the value in.
     """
 
-    _cache: dict
+    evaluatable: Evaluatable
+    options: Options
+    value: A
+    cache: Cache[A]
+
+    def __init__(
+        self, evaluatable: Evaluatable[A], options: Options, value: A, cache: Cache[A]
+    ):
+        self.evaluatable = evaluatable
+        self.options = options
+        self.value = value
+        self.cache = cache
+
+
+class CacheGetRequest(runtime.Request[A]):
+    """A request to get a value from a cache.
+
+    Arguments
+    ---------
+    evaluatable : Evaluatable
+        The evaluatable to get from the cache.
+    options : Options
+        The options dictionary to evaluate against.
+    cache : Cache
+        The cache to get the value from.
+    """
 
-    def __init__(self, *args, **kwargs):
-        self._cache = {}
-        super().__init__(*args, **kwargs)
+    evaluatable: Evaluatable[A]
+    options: Options
+    cache: Cache[A]
+
+    def __init__(self, evaluatable: Evaluatable, options: Options, cache: Cache[A]):
+        self.evaluatable = evaluatable
+        self.options = options
+        self.cache = cache
+
+
+class CacheExistsRequest(runtime.Request[bool]):
+    """A request to check if a value exists in a cache.
+
+    Arguments
+    ---------
+    evaluatable : Evaluatable
+        The evaluatable to check in the cache.
+    options : Options
+        The options dictionary to evaluate against.
+    cache : Cache
+        The cache to check the value in.
+    """
+
+    evaluatable: Evaluatable
+    options: Options
+    cache: Cache
+
+    def __init__(self, evaluatable: Evaluatable, options: Options, cache: Cache):
+        self.evaluatable = evaluatable
+        self.options = options
+        self.cache = cache
+
+
+def _cache_disabled(
+    request: Union[CacheSetRequest, CacheGetRequest, CacheExistsRequest]
+) -> bool:
+    return Option("LABREA.CACHE.DISABLED", Option("LABREA.CACHE.DISABLE", False))(
+        request.options
+    )
+
+
+@CacheSetRequest.handle
+def _set_cache_handler(request: CacheSetRequest[A]) -> A:
+    if _cache_disabled(request):
+        return _disabled_set_cache_handler(request)
+
+    request.cache.set(request.evaluatable, request.options, request.value)
+    try:
+        return request.cache.get(request.evaluatable, request.options)
+    except CacheGetFailure:
+        return request.value
+
+
+@CacheGetRequest.handle
+def _get_cache_handler(request: CacheGetRequest[A]) -> A:
+    if _cache_disabled(request):
+        return _disabled_get_cache_handler(request)
+
+    return request.cache.get(request.evaluatable, request.options)
+
+
+@CacheExistsRequest.handle
+def _exists_cache_handler(request: CacheExistsRequest) -> bool:
+    if _cache_disabled(request):
+        return _disabled_exists_cache_handler(request)
+
+    return request.cache.exists(request.evaluatable, request.options)
 
-    def exists(self, keys: JSONDict) -> bool:
-        return json_hash(keys) in self._cache
 
-    def get(self, keys: JSONDict) -> A:
-        return self._cache[json_hash(keys)]
+def _disabled_set_cache_handler(request: CacheSetRequest[A]) -> A:
+    return request.value
 
-    def set(self, keys: JSONDict, value: A):
-        self._cache[json_hash(keys)] = value
+
+def _disabled_get_cache_handler(request: CacheGetRequest[A]) -> A:
+    raise CacheGetFailure(request.evaluatable, request.options, request.cache)
+
+
+def _disabled_exists_cache_handler(request: CacheExistsRequest) -> bool:
+    return False
+
+
+def disabled() -> runtime.Runtime:
+    return runtime.handle(
+        {
+            CacheSetRequest: _disabled_set_cache_handler,
+            CacheGetRequest: _disabled_get_cache_handler,
+            CacheExistsRequest: _disabled_exists_cache_handler,
+        }
+    )
+
+
+class Cached(Evaluatable[A]):
+    """A class representing an Evaluatable that may be cached.
+
+    When evaluated, this class will check if the value exists in the cache
+    before evaluating the underlying Evaluatable. If the value is not in the
+    cache, the value will be evaluated and stored in the cache before being
+    returned.
+
+    Arguments
+    ---------
+    evaluatable : Evaluatable
+        The evaluatable to cache.
+    cache : Cache
+        The cache to store the value in.
+    """
+
+    evaluatable: Evaluatable[A]
+    cache: Cache[A]
+
+    def __init__(self, evaluatable: Evaluatable[A], cache: Cache[A]):
+        self.evaluatable = evaluatable
+        self.cache = cache
+
+    def evaluate(self, options: Options) -> A:
+        """Return the (possibly cached) result of evaluating the evaluatable."""
+        if CacheExistsRequest(self.evaluatable, options, self.cache).run():
+            try:
+                return CacheGetRequest(self.evaluatable, options, self.cache).run()
+            except CacheGetFailure:
+                pass
+
+        value = self.evaluatable.evaluate(options)
+
+        return CacheSetRequest(self.evaluatable, options, value, self.cache).run()
+
+    def validate(self, options: Options) -> None:
+        """If the value is not in the cache, validate the evaluatable."""
+        if not CacheExistsRequest(self.evaluatable, options, self.cache).run():
+            self.evaluatable.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        """Return the keys required to evaluate the evaluatable."""
+        return self.evaluatable.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the keys required to evaluate the evaluatable."""
+        return self.evaluatable.explain(options)
+
+    def __repr__(self) -> str:
+        return f"Cached({self.evaluatable!r}, {self.cache!r})"
+
+
+@overload
+def cached(__x: Evaluatable[A], cache: Optional[Cache[A]] = None) -> Evaluatable[A]:
+    ...  # pragma: nocover
+
+
+@overload
+def cached(__x: Cache[A]) -> Callable[[Evaluatable[A]], Evaluatable[A]]:
+    ...  # pragma: nocover
+
+
+def cached(
+    __x: Union[Cache[A], Evaluatable[A]],
+    cache: Optional[Cache[A]] = None,
+) -> Union[Callable[[Evaluatable[A]], Evaluatable[A]], Evaluatable[A]]:
+    """Return an Evaluatable that caches the result of evaluating the input Evaluatable.
+
+    Can be used as a decorator or a function. If used as a decorator, can be used with or without
+    providing a cache object. If no cache object is provided, a MemoryCache will be used.
+
+    Arguments
+    ---------
+    __x : Union[Cache, Evaluatable]
+        The cache object or evaluatable to cache.
+    cache : Optional[Cache], optional
+        The cache object to use, by default None, in which case a MemoryCache is used.
+
+    Returns
+    -------
+    Union[Callable[[Evaluatable[A]], Evaluatable[A]], Evaluatable[A]]
+        The cached evaluatable, or a function that takes an evaluatable and returns a cached
+        evaluatable.
+
+
+    Example Usage
+    -------------
+    >>> from labrea import cached, Option
+    >>> from labrea.cache import MemoryCache
+    >>> from labrea.application import FunctionApplication
+    >>> x_cached = cached(Option('X'))
+    >>> x_cached_with_cache = cached(Option('X'), MemoryCache())
+    >>>
+    >>> @cached
+    ... @FunctionApplication.lift
+    ... def y(x: int = Option('X')) -> int:
+    ...     return x
+    >>>
+    >>> @cached(MemoryCache())
+    ... @FunctionApplication.lift
+    ... def z(x: int = Option('X')) -> int:
+    ...     return x
+    """
+    if isinstance(__x, Cache):
+        return lambda evaluatable: cached(evaluatable, __x)
+    else:
+        cache = cache or MemoryCache()
+        return Cached(__x, cache)
diff --git a/labrea/callbacks.py b/labrea/callbacks.py
deleted file mode 100644
index 411dbb4..0000000
--- a/labrea/callbacks.py
+++ /dev/null
@@ -1,55 +0,0 @@
-import typing
-from collections import OrderedDict
-from typing import Generic, Optional, TypeVar
-
-from .types import Callback, Evaluatable, JSONDict
-
-A = TypeVar("A", covariant=True)
-
-
-PreCallback = Callback[Evaluatable[A], JSONDict]
-PostCallback = Callback[A, A]
-
-
-class EarlyExit(Generic[A], Exception):
-    """An exception to signal that the evaluation should stop early."""
-
-    value: A
-
-    def __init__(self, value: A, *args):
-        self.value = value
-        super().__init__(*args)
-
-
-class Callbacks(Generic[A]):
-    """A collection of callbacks to be applied to an Evaluatable.
-
-    Callbacks are functions that take an Evaluatable and an options dictionary
-    and return a value. They are used to extend the behavior of an Evaluatable
-    by running a computation before or after the .evaluate() method is called.
-
-    Parameters
-    ----------
-    pre : typing.OrderedDict[PreCallback]
-        A list of callbacks to run before the .evaluate() method is called.
-        Takes an Evaluatable and an options dictionary and optionally returns
-        a new Evaluatable and options dictionary.
-    post : typing.OrderedDict[PostCallback]
-        A list of callbacks to run after the .evaluate() method is called.
-        Takes the result of the .evaluate() method and an options dictionary
-        and optionally returns a new result.
-    """
-
-    pre: typing.OrderedDict[str, PreCallback[A]]
-    post: typing.OrderedDict[str, PostCallback[A]]
-
-    def __init__(
-        self,
-        pre: Optional[typing.OrderedDict[str, PreCallback[A]]] = None,
-        post: Optional[typing.OrderedDict[str, PostCallback[A]]] = None,
-    ):
-        self.pre = pre or OrderedDict()
-        self.post = post or OrderedDict()
-
-    def __repr__(self):
-        return f"{self.__class__.__name__}(pre={self.pre}, post={self.post})"
diff --git a/labrea/coalesce.py b/labrea/coalesce.py
index 0f571f1..0a4afee 100644
--- a/labrea/coalesce.py
+++ b/labrea/coalesce.py
@@ -1,8 +1,9 @@
-from typing import List, Set, TypeVar, Union
+from typing import Any, List, Optional, Set, TypeVar
 
-from .types import Evaluatable, EvaluationError, JSONDict, ValidationError, Value
+from .exceptions import EvaluationError
+from .types import Evaluatable, Options
 
-A = TypeVar("A")
+A = TypeVar("A", covariant=True)
 
 
 class Coalesce(Evaluatable[A]):
@@ -11,73 +12,65 @@ class Coalesce(Evaluatable[A]):
     Takes 1 or more Evaluatables as arguments. Evaluates each Evaluatable in
     order until one can be evaluated. If none can be evaluated, raises an
     EvaluationError.
+
+    Aliases: :func:`labrea.coalesce`, :class:`labrea.Coalesce`
+
+
+    Arguments
+    ---------
+    *evaluatables : MaybeEvaluatable[A]
+        The evaluatables to evaluate.
+
+
+    Example Usage
+    -------------
+    >>> from labrea import Coalesce, Option
+    >>> c = Coalesce(Option('A.X'), Option('B.Y'))
+    >>> c({'A': {'X': 1}, 'B': {'Y': 2}})
+    1
+    >>> c({'B': {'Y': 2}})
+    2
     """
 
     members: List[Evaluatable[A]]
 
-    def __init__(self, __first, *__rest: Union[A, Evaluatable[A]]):
-        """Create a new Coalesce Evaluatable
-
-        Parameters
-        ----------
-        __first : Union[A, Evaluatable[A]]
-            The first Evaluatable to try to evaluate.
-        *__rest : Union[A, Evaluatable[A]]
-            The rest of the Evaluatables to try to evaluate.
-        """
-        self.members = [
-            arg if isinstance(arg, Evaluatable) else Value(arg)
-            for arg in (__first, *__rest)
-        ]
-
-    def evaluate(self, options: JSONDict) -> A:
-        """Evaluate the first Evaluatable that can be evaluated
-
-        See Also
-        --------
-        labrea.types.Evaluatable.evaluate
-        """
-        for member in self.members:
-            try:
-                member.validate(options)
-                return member.evaluate(options)
-            except (ValidationError, EvaluationError):
-                continue
+    def __init__(self, __first: Evaluatable[A], *__rest: Evaluatable[A]):
+        self.members = [Evaluatable.ensure(arg) for arg in (__first, *__rest)]
+
+    def evaluate(self, options: Options) -> A:
+        """Evaluate the first Evaluatable that can be evaluated"""
+        return self._delegate("evaluate", options)
 
-        raise EvaluationError("No members of Coalesce returned a result")
+    def validate(self, options: Options) -> None:
+        """Determines if any of the Evaluatables can be validated."""
+        return self._delegate("validate", options)
 
-    def validate(self, options: JSONDict) -> None:
-        """Attempt to validate each Evaluatable in order
+    def keys(self, options: Options) -> Set[str]:
+        """Return the keys for the first Evaluatable that can be evaluated."""
+        return self._delegate("keys", options)
 
-        If none can be validated, raises a ValidationError.
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the explanation for the first Evaluatable that can be evaluated."""
+        try:
+            return self._delegate("explain", options)
+        except EvaluationError:
+            return self.members[-1].explain(options)
+
+    def _delegate(self, method: str, options: Optional[Options] = None) -> Any:
+        options = options or {}
+        err: Optional[EvaluationError] = None
 
-        See Also
-        --------
-        labrea.types.Validatable.validate
-        """
         for member in self.members:
             try:
                 member.validate(options)
-                return
-            except ValidationError:
-                continue
-
-        self.members[0].validate(options)
+                return getattr(member, method)(options)
+            except EvaluationError as e:
+                err = e
 
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Return the keys for the first Evaluatable that can be validated
+        raise err  # type: ignore
 
-        If none can be validated, returns an empty set.
+    def __repr__(self) -> str:
+        return f"Coalesce({', '.join(map(repr, self.members))})"
 
-        See Also
-        --------
-        labrea.types.Validatable.keys
-        """
-        for member in self.members:
-            try:
-                member.validate(options)
-                return member.keys(options)
-            except ValidationError:
-                continue
 
-        return set()
+coalesce = Coalesce
diff --git a/labrea/collections.py b/labrea/collections.py
index 85151b6..cb6f8d5 100644
--- a/labrea/collections.py
+++ b/labrea/collections.py
@@ -1,114 +1,167 @@
-from typing import Dict, List, Set, TypeVar
+from typing import Dict, Hashable, List, Set, Tuple, TypeVar, Union, overload
 
-from .types import Evaluatable, JSONDict
+from .iterable import Iter
+from .types import Evaluatable, Value
 
 A = TypeVar("A")
-K = TypeVar("K")
+K = TypeVar("K", bound=Hashable)
+V = TypeVar("V")
+T1 = TypeVar("T1")
+T2 = TypeVar("T2")
+T3 = TypeVar("T3")
+T4 = TypeVar("T4")
+T5 = TypeVar("T5")
+T6 = TypeVar("T6")
+T7 = TypeVar("T7")
+T8 = TypeVar("T8")
 
 
-class DatasetList(Evaluatable[List[A]]):
-    """A list of Evaluatables that can be evaluated as a list of values
+def evaluatable_list(*evaluatables: Evaluatable[A]) -> Evaluatable[List[A]]:
+    """Create an Evaluatable that evaluates to a list of values.
 
-    Turns a list of Evaluatables into a single Evaluatable that evaluates to a
-    list of values.
-    """
-
-    contents: List[Evaluatable[A]]
-
-    def __init__(self, *contents: Evaluatable[A]):
-        """Create a new DatasetList Evaluatable
-
-        Parameters
-        ----------
-        *contents : Evaluatable[A]
-            The Evaluatables to include in the list.
-        """
-        self.contents = list(contents)
-
-    def evaluate(self, options: JSONDict) -> List[A]:
-        """Evaluate the Evaluatables in the list
-
-        Return a list containing the results of evaluating each Evaluatable in
-        the list.
-
-        See Also
-        --------
-        labrea.types.Evaluatable.evaluate
-        """
-        return [dep.evaluate(options) for dep in self.contents]
-
-    def validate(self, options: JSONDict):
-        """Validate each Evaluatable in the list.
+    This function is a convenience wrapper around Iter.apply(list).
 
-        See Also
-        --------
-        labrea.types.Validatable.validate
-        """
-        for val in self.contents:
-            val.validate(options)
+    Aliases: :func:`labrea.DatasetList`, :func:`labrea.evaluatable_list`
 
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Return the keys that each Evaluatable in the list depends on.
+    Arguments
+    ---------
+    *evaluatables : Evaluatable[A]
+        The objects to evaluate.
+    """
+    return Iter(*evaluatables).apply(list)
+
+
+@overload
+def evaluatable_tuple(
+    __e1: Evaluatable[T1],
+) -> Evaluatable[Tuple[T1]]:
+    ...
+
+
+@overload
+def evaluatable_tuple(
+    __e1: Evaluatable[T1],
+    __e2: Evaluatable[T2],
+) -> Evaluatable[Tuple[T1, T2]]:
+    ...
+
+
+@overload
+def evaluatable_tuple(
+    __e1: Evaluatable[T1],
+    __e2: Evaluatable[T2],
+    __e3: Evaluatable[T3],
+) -> Evaluatable[Tuple[T1, T2, T3]]:
+    ...
+
+
+@overload
+def evaluatable_tuple(
+    __e1: Evaluatable[T1],
+    __e2: Evaluatable[T2],
+    __e3: Evaluatable[T3],
+    __e4: Evaluatable[T4],
+) -> Evaluatable[Tuple[T1, T2, T3, T4]]:
+    ...
+
+
+@overload
+def evaluatable_tuple(
+    __e1: Evaluatable[T1],
+    __e2: Evaluatable[T2],
+    __e3: Evaluatable[T3],
+    __e4: Evaluatable[T4],
+    __e5: Evaluatable[T5],
+) -> Evaluatable[Tuple[T1, T2, T3, T4, T5]]:
+    ...
+
+
+@overload
+def evaluatable_tuple(
+    __e1: Evaluatable[T1],
+    __e2: Evaluatable[T2],
+    __e3: Evaluatable[T3],
+    __e4: Evaluatable[T4],
+    __e5: Evaluatable[T5],
+    __e6: Evaluatable[T6],
+) -> Evaluatable[Tuple[T1, T2, T3, T4, T5, T6]]:
+    ...
+
+
+@overload
+def evaluatable_tuple(
+    __e1: Evaluatable[T1],
+    __e2: Evaluatable[T2],
+    __e3: Evaluatable[T3],
+    __e4: Evaluatable[T4],
+    __e5: Evaluatable[T5],
+    __e6: Evaluatable[T6],
+    __e7: Evaluatable[T7],
+) -> Evaluatable[Tuple[T1, T2, T3, T4, T5, T6, T7]]:
+    ...
+
+
+@overload
+def evaluatable_tuple(
+    __e1: Evaluatable[T1],
+    __e2: Evaluatable[T2],
+    __e3: Evaluatable[T3],
+    __e4: Evaluatable[T4],
+    __e5: Evaluatable[T5],
+    __e6: Evaluatable[T6],
+    __e7: Evaluatable[T7],
+    __e8: Evaluatable[T8],
+) -> Evaluatable[Tuple[T1, T2, T3, T4, T5, T6, T7, T8]]:
+    ...
+
+
+def evaluatable_tuple(*evaluatables: Evaluatable[A]) -> Evaluatable[Tuple[A, ...]]:
+    """Create an Evaluatable that evaluates to a tuple of values.
+
+    This function is a convenience wrapper around Iter.apply(tuple).
+
+    Aliases: :func:`labrea.DatasetTuple`, :func:`labrea.evaluatable_tuple`
+
+    Arguments
+    ---------
+    *evaluatables : Evaluatable[A]
+        The objects to evaluate.
+    """
+    return Iter(*evaluatables).apply(tuple)
 
-        See Also
-        --------
-        labrea.types.Validatable.keys
-        """
-        return {key for val in self.contents for key in val.keys(options)}
 
-    def __repr__(self):
-        return f'DatasetList({", ".join(map(repr, self.contents))})'
+def evaluatable_set(*evaluatables: Evaluatable[K]) -> Evaluatable[Set[K]]:
+    """Create an Evaluatable that evaluates to a set of values.
 
+    This function is a convenience wrapper around Iter.apply(set).
 
-class DatasetDict(Evaluatable[Dict[K, A]]):
-    """A dict of Evaluatables that can be evaluated as a dict of values
+    Aliases: :func:`labrea.DatasetSet`, :func:`labrea.evaluatable_set`
 
-    Turns a dict of Evaluatables into a single Evaluatable that evaluates to a
-    dict of values.
+    Arguments
+    ---------
+    *evaluatables : Evaluatable[K]
+        The objects to evaluate.
     """
+    return Iter(*evaluatables).apply(set)
 
-    contents: Dict[K, Evaluatable[A]]
-
-    def __init__(self, contents: Dict[K, Evaluatable[A]]):
-        """Create a new DatasetDict Evaluatable
-
-        Parameters
-        ----------
-        contents : Dict[K, Evaluatable[A]]
-            The Evaluatables to include in the dict.
-        """
-        self.contents = contents.copy()
 
-    def evaluate(self, options: JSONDict) -> Dict[K, A]:
-        """Evaluate the Evaluatables in the dict
+def evaluatable_dict(contents: Dict[K, Evaluatable[V]]) -> Evaluatable[Dict[K, V]]:
+    """Create an Evaluatable that evaluates to a dictionary of values.
 
-        Return a dict containing the results of evaluating each Evaluatable in
-        the dict.
+    This function is a convenience wrapper around Iter.apply(dict).
 
-        See Also
-        --------
-        labrea.types.Evaluatable.evaluate
-        """
-        return {key: val.evaluate(options) for key, val in self.contents.items()}
+    Aliases: :func:`labrea.DatasetDict`, :func:`labrea.evaluatable_dict`
 
-    def validate(self, options: JSONDict):
-        """Validate each Evaluatable in the dict values.
-
-        See Also
-        --------
-        labrea.types.Validatable.validate
-        """
-        for val in self.contents.values():
-            val.validate(options)
-
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Return the keys that each Evaluatable in the dict values depends on.
+    Arguments
+    ---------
+    contents : Dict[K, Evaluatable[V]]
+        The objects to evaluate.
+    """
+    pairs = (Iter[Union[K, V]](Value(key), val) for key, val in contents.items())
+    return Iter(*pairs).apply(dict)  # type: ignore
 
-        See Also
-        --------
-        labrea.types.Validatable.keys
-        """
-        return {key for val in self.contents.values() for key in val.keys(options)}
 
-    def __repr__(self):
-        return f"DatasetDict({repr(self.contents)})"
+DatasetList = evaluatable_list
+DatasetTuple = evaluatable_tuple
+DatasetSet = evaluatable_set
+DatasetDict = evaluatable_dict
diff --git a/labrea/computation.py b/labrea/computation.py
new file mode 100644
index 0000000..1078032
--- /dev/null
+++ b/labrea/computation.py
@@ -0,0 +1,178 @@
+from abc import ABC, abstractmethod
+from typing import Callable, List, Optional, Set, TypeVar
+
+from .option import Option
+from .types import (
+    Evaluatable,
+    Explainable,
+    MaybeEvaluatable,
+    Options,
+    Transformation,
+    Validatable,
+)
+
+A = TypeVar("A")
+
+_EFFECTS_DISABLED = Option("LABREA.EFFECTS.DISABLED", False)
+
+
+class Effect(Transformation[A, None], Validatable, Explainable, ABC):
+    """Abstract base class for effects.
+
+    Effects are transformations that are applied to values, but do not return a new value.
+    They are intended to encapsulate side effects, such as logging, data validation, or
+    other operations that do not affect the value being returned by an Evaluatable.
+    """
+
+    @abstractmethod
+    def transform(self, value: A, options: Optional[Options] = None) -> None:
+        """Apply the effect to a value.
+
+        Arguments
+        ----------
+        value : A
+            The value to apply the effect to.
+        options : Options
+            The options dictionary to evaluate against.
+
+        Returns
+        -------
+        A
+            The value after the effect has been applied.
+
+        Raises
+        ------
+        EvaluationError
+            If the effect cannot be applied to the value.
+        """
+        raise NotImplementedError  # pragma: nocover
+
+
+class ChainedEffect(Effect[A]):
+    """An effect that chains multiple effects together.
+
+    This effect applies a sequence of effects to a value in order.
+
+    Arguments
+    ---------
+    *effects : Effect
+        The effects to chain together.
+    """
+
+    effects: List[Effect[A]]
+
+    def __init__(self, *effects: Effect[A]):
+        self.effects = list(effects)
+
+    def transform(self, value: A, options: Optional[Options] = None) -> None:
+        """Perform each effect in sequence."""
+        for effect in self.effects:
+            effect.transform(value, options)
+
+    def validate(self, options: Options) -> None:
+        """Validate each effect."""
+        for effect in self.effects:
+            effect.validate(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the option keys required to perform each effect."""
+        return set().union(*(effect.explain(options) for effect in self.effects))
+
+    def __repr__(self) -> str:
+        return f"ChainedEffect({', '.join(map(repr, self.effects))})"
+
+
+class CallbackEffect(Effect[A]):
+    """An effect that applies a callback to a value.
+
+    This allows any function of one value to be used as an effect. The
+    callback can also be an Evaluatable that returns a function,
+    which allows :code:`Pipeline` objects to be used as effects.
+
+    Arguments
+    ---------
+    callback : MaybeEvaluatable[Callable[[A], None]]
+        The callback to apply to the value. Either a function of one
+        value that returns None, or an Evaluatable that returns such
+        a function.
+    """
+
+    callback: Evaluatable[Callable[[A], None]]
+
+    def __init__(self, callback: MaybeEvaluatable[Callable[[A], None]]):
+        self.callback = Evaluatable.ensure(callback)
+
+    def transform(self, value: A, options: Optional[Options] = None) -> None:
+        """Apply the callback to the value."""
+        self.callback(options)(value)
+
+    def validate(self, options: Options) -> None:
+        """Validate the callback."""
+        self.callback.validate(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the option keys required to perform the callback."""
+        return self.callback.explain(options)
+
+    def __repr__(self) -> str:
+        return f"CallbackEffect({self.callback!r})"
+
+
+class Computation(Evaluatable[A]):
+    """A computation that applies an effect to a value.
+
+    This class combines an Evaluatable with an Effect to create a new
+    Evaluatable that applies the effect to the value returned by the
+    original Evaluatable. This allows side effects to be applied to
+    values without modifying the original Evaluatable.
+
+    If the :code:`LABREA.EFFECTS.DISABLED` option is set to True, the
+    effect will not be applied to the value. This allows the effect to
+    be disabled in certain contexts, such as testing or debugging.
+
+
+    Arguments
+    ---------
+    evaluatable : Evaluatable[A]
+        The Evaluatable to apply the effect to.
+    effect : Effect[A]
+        The Effect to apply to the value returned by the Evaluatable.
+    """
+
+    evaluatable: Evaluatable[A]
+    effect: Effect[A]
+
+    def __init__(self, evaluatable: Evaluatable[A], effect: Effect[A]):
+        self.evaluatable = evaluatable
+        self.effect = effect
+
+    def evaluate(self, options: Options) -> A:
+        """Evaluate the Evaluatable and apply the Effect to the result before returning."""
+        value = self.evaluatable.evaluate(options)
+
+        if not _EFFECTS_DISABLED(options):
+            self.effect.transform(value, options)
+
+        return value
+
+    def validate(self, options: Options) -> None:
+        """Validate the Evaluatable and the Effect."""
+        self.evaluatable.validate(options)
+
+        if not _EFFECTS_DISABLED(options):
+            self.effect.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        """Return the option keys required to evaluate the Evaluatable."""
+        return self.evaluatable.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the option keys required to evaluate the Evaluatable and apply the Effect."""
+        return (
+            self.evaluatable.explain(options)
+            if _EFFECTS_DISABLED(options)
+            else self.evaluatable.explain(options) | self.effect.explain(options)
+        )
+
+    def __repr__(self) -> str:
+        return f"Computation({self.evaluatable!r}, {self.effect!r})"
diff --git a/labrea/conditional.py b/labrea/conditional.py
new file mode 100644
index 0000000..3ce5984
--- /dev/null
+++ b/labrea/conditional.py
@@ -0,0 +1,362 @@
+import sys
+
+if sys.version_info < (3, 11):
+    from typing import NoReturn as Never
+else:
+    from typing import Never
+
+import functools
+from typing import (
+    Any,
+    Callable,
+    Generic,
+    Hashable,
+    Mapping,
+    Optional,
+    Sequence,
+    Set,
+    Tuple,
+    TypeVar,
+    Union,
+    overload,
+)
+
+from ._missing import MISSING, MaybeMissing
+from .exceptions import EvaluationError, InsufficientInformationError
+from .option import Option
+from .types import Evaluatable, MaybeEvaluatable, Options
+
+A = TypeVar("A")
+B = TypeVar("B")
+C = TypeVar("C")
+V = TypeVar("V")
+
+
+class SwitchError(EvaluationError):
+    """An error raised when a switch statement encounters an invalid key."""
+
+    def __init__(
+        self,
+        dispatch: Evaluatable[Hashable],
+        value: Hashable,
+        lookup: Mapping[Hashable, Any],
+    ):
+        super().__init__(
+            f"Evaluated to {value}, "
+            f"but must be one of {', '.join(map(str, lookup.keys()))}.",
+            dispatch,
+        )
+
+
+class _DependsOn(Generic[A, B], Evaluatable[B]):
+    evaluatable: Evaluatable[B]
+    depends: Evaluatable[A]
+
+    def __init__(
+        self, evaluatable: MaybeEvaluatable[B], depends: MaybeEvaluatable[A]
+    ) -> None:
+        self.evaluatable = Evaluatable.ensure(evaluatable)
+        self.depends = Evaluatable.ensure(depends)
+
+    def evaluate(self, options: Options) -> B:
+        return self.evaluatable.evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        self.evaluatable.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        return self.evaluatable.keys(options) | self.depends.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        return self.evaluatable.explain(options) | self.depends.explain(options)
+
+    def __repr__(self) -> str:
+        return f"_DependsOn({self.evaluatable!r}, {self.depends!r})"  # pragma: no cover
+
+
+class Switch(Evaluatable[V]):
+    """A class representing a switch statement.
+
+    This class takes a dispatch evaluatable and a mapping of keys to evaluatables.
+    When evaluated, the dispatch is evaluated and the corresponding value is
+    evaluated and returned. If the key is not found in the mapping, or the dispatch
+    cannot be evaluated, a default value can be provided. If no default is provided,
+    and the switch cannot choose a branch, an error is raised.
+
+    Aliases: :func:`labrea.switch`, :class:`labrea.Switch`
+
+
+    Arguments
+    ---------
+    dispatch : Union[str, Evaluatable[Hashable]]
+        The dispatch evaluatable. This is used to determine which branch to take.
+    lookup : Mapping[Hashable, MaybeEvaluatable[V]]
+        A mapping of keys to evaluatables. The key is determined by the dispatch.
+        Values can also be plain values.
+    default : MaybeMissing[MaybeEvaluatable[V]], optional
+        The default value to use if the key is not found in the mapping. Can be
+        an evaluatable, or a plain value.
+
+
+    Example Usage
+    -------------
+    >>> from labrea import Option, switch
+    >>> s = switch(Option('A'), {True: Option('X'), False: Option('Y')}, Option('Z'))
+    >>> s({'A': True, 'X': 'Hello', 'Y': 'World', 'Z': 'Default'})
+    'Hello'
+    >>> s({'A': False, 'X': 'Hello', 'Y': 'World', 'Z': 'Default'})
+    'World'
+    >>> s({'A': None, 'X': 'Hello', 'Y': 'World', 'Z': 'Default'})
+    'Default'
+    >>> s({'X': 'Hello', 'Y': 'World', 'Z': 'Default'})
+    'Default'
+    """
+
+    dispatch: Evaluatable[Hashable]
+    lookup: Mapping[Hashable, Evaluatable[V]]
+    default: MaybeMissing[Evaluatable[V]]
+
+    def __init__(
+        self,
+        dispatch: Union[str, Evaluatable[Hashable]],
+        lookup: Mapping[Hashable, Evaluatable[V]],
+        default: MaybeMissing[MaybeEvaluatable[V]] = MISSING,
+    ) -> None:
+        self.dispatch = (
+            dispatch if isinstance(dispatch, Evaluatable) else Option(dispatch)
+        )
+        self.lookup = {key: Evaluatable.ensure(value) for key, value in lookup.items()}
+        self.default = (
+            Evaluatable.ensure(default) if default is not MISSING else default
+        )
+
+    def _dispatch(self, options: Options) -> Hashable:
+        return self.dispatch.evaluate(options)
+
+    def _lookup(self, options: Options) -> Evaluatable[V]:
+        try:
+            key = self._dispatch(options)
+        except EvaluationError as e:
+            if self.default is MISSING:
+                raise e
+            return self.default
+
+        if key not in self.lookup:
+            if self.default is MISSING:
+                raise SwitchError(self.dispatch, key, self.lookup)  # type: ignore  [arg-type]
+            return self.default
+
+        return _DependsOn(self.lookup[key], self.dispatch)  # type: ignore  [arg-type]
+
+    def evaluate(self, options: Options) -> V:
+        """Evaluate the switch statement and return the result."""
+        return self._lookup(options).evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        """Validate that the switch statement can be evaluated."""
+        self._lookup(options).validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        """Return the option keys required by the switch statement."""
+        return self._lookup(options).keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the option keys required by the switch statement."""
+        options = options or {}
+        try:
+            chosen = self._lookup(options)
+        except EvaluationError as e:
+            raise InsufficientInformationError(
+                "Could not determine the switch branch", self
+            ) from e
+
+        return chosen.explain(options)
+
+    def __repr__(self) -> str:
+        if self.default is MISSING:
+            return f"switch({self.dispatch!r}, {self.lookup!r})"
+
+        return f"switch({self.dispatch!r}, {self.lookup!r}, {self.default!r})"
+
+
+switch = Switch
+
+
+class CaseWhenError(EvaluationError):
+    """An error raised when a case when statement encounters an invalid key."""
+
+    def __init__(self, dispatch: Evaluatable[A], value: A):
+        super().__init__(
+            f"Evaluated to {value}, which does not match any of the cases, "
+            f"and no default was provided.",
+            dispatch,
+        )
+
+
+class CaseWhen(Generic[A, B], Evaluatable[B]):
+    """A class representing a case when statement.
+
+    This class takes a dispatch evaluatable and a sequence of cases. Each case is
+    a condition and a result. When evaluated, the dispatch is evaluated and the
+    corresponding case is matched. If no case is matched, a default value can be
+    provided. If no default is provided, and the case cannot choose a branch, an
+    error is raised.
+
+    This class is not usually instantiated directly. Instead, use the :code:`case` function.
+    """
+
+    dispatch: Evaluatable[A]
+    cases: Sequence[Tuple[Evaluatable[Callable[[A], bool]], Evaluatable[B]]]
+    default: MaybeMissing[Evaluatable[B]]
+
+    def __init__(
+        self,
+        dispatch: Evaluatable[A],
+        cases: Sequence[Tuple[Evaluatable[Callable[[A], bool]], Evaluatable[B]]],
+        default: MaybeMissing[Evaluatable[B]] = MISSING,
+    ) -> None:
+        self.dispatch = dispatch
+        self.cases = [(condition, result) for condition, result in cases]
+        self.default = default
+
+    def _evaluate(self, value: A, options: Options) -> Evaluatable[B]:
+        for condition, result in self.cases:
+            if condition.evaluate(options)(value):
+                return result
+
+        if self.default is not MISSING:
+            return self.default
+
+        raise CaseWhenError(self.dispatch, value)
+
+    def _bound(self, options: Options) -> Evaluatable[B]:
+        return self.dispatch.bind(functools.partial(self._evaluate, options=options))
+
+    def evaluate(self, options: Options) -> B:
+        """Evaluate the case when statement and return the result."""
+        return self._bound(options).evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        """Validate that the case when statement can be evaluated."""
+        self._bound(options).validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        """Return the option keys required by the case when statement."""
+        return self._bound(options).keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the option keys required by the case when statement."""
+        return self._bound(options or {}).explain(options)
+
+    @overload
+    def when(
+        self,
+        condition: MaybeEvaluatable[Callable[[A], bool]],
+        result: Evaluatable[C],
+    ) -> "CaseWhen[A, Union[B, C]]":
+        ...
+
+    @overload
+    def when(
+        self,
+        condition: MaybeEvaluatable[Callable[[A], bool]],
+        result: C,
+    ) -> "CaseWhen[A, Union[B, C]]":
+        ...
+
+    def when(
+        self,
+        condition: MaybeEvaluatable[Callable[[A], bool]],
+        result: MaybeEvaluatable[C],
+    ) -> "CaseWhen[A, Union[B, C]]":
+        """Add a case to the case when statement. Returns a new instance.
+
+        Arguments
+        ---------
+        condition : Union[str, Callable[[A], bool]]
+            The condition to match. Can be a plain value or a callable.
+        result : MaybeEvaluatable[B]
+            The result to return if the condition is matched.
+        """
+        return CaseWhen(
+            self.dispatch,
+            [*self.cases, (Evaluatable.ensure(condition), Evaluatable.ensure(result))],
+            self.default,
+        )
+
+    @overload
+    def otherwise(self, default: Evaluatable[C]) -> "CaseWhen[A, Union[B, C]]":
+        ...
+
+    @overload
+    def otherwise(self, default: C) -> "CaseWhen[A, Union[B, C]]":
+        ...
+
+    def otherwise(self, default: MaybeEvaluatable[C]) -> "CaseWhen[A, Union[B, C]]":
+        """Add a default value to the case when statement. Returns a new instance.
+
+        Arguments
+        ---------
+        default : MaybeEvaluatable[B]
+            The default value to return if no case is matched.
+        """
+        return CaseWhen(self.dispatch, self.cases, Evaluatable.ensure(default))
+
+    def __repr__(self) -> str:
+        _base = f"case({self.dispatch!r})"
+        _cases = ", ".join(
+            f"when({condition!r}, {result!r})" for condition, result in self.cases
+        )
+        _default = (
+            f".otherwise({self.default!r})" if self.default is not MISSING else ""
+        )
+        return f"{_base}.{_cases}{_default}"
+
+
+@overload
+def case(dispatch: Evaluatable[A]) -> CaseWhen[A, Never]:
+    ...
+
+
+@overload
+def case(dispatch: A) -> CaseWhen[A, Never]:
+    ...
+
+
+def case(dispatch: MaybeEvaluatable[A]) -> CaseWhen[A, Never]:
+    """Create a case when statement.
+
+    This is the preferred method for creating a case when statement.
+    It allows for chaining multiple cases and a default value.
+
+    Arguments
+    ---------
+    dispatch : MaybeEvaluatable[A]
+        The dispatch evaluatable. This is used to determine which branch to take.
+
+    Returns
+    -------
+    CaseWhen[A, B]
+        A new case when statement with no cases or default.
+
+
+    Example Usage
+    -------------
+    >>> from labrea import Option, case
+    >>> c = case(
+    ...     Option('A')
+    ... ).when(
+    ...     lambda a: a < 0,
+    ...     Option('X')
+    ... ).when(
+    ...     lambda a: a > 0,
+    ...     Option('Y')
+    ... ).otherwise(Option('Z'))
+    >>> c({'A': -1, 'X': 'Negative', 'Y': 'Positive', 'Z': 'Zero'})
+    'Negative'
+    >>> c({'A': 1, 'X': 'Negative', 'Y': 'Positive', 'Z': 'Zero'})
+    'Positive'
+    >>> c({'A': 0, 'X': 'Negative', 'Y': 'Positive', 'Z': 'Zero'})
+    'Zero'
+    """
+    return CaseWhen(Evaluatable.ensure(dispatch), [])
diff --git a/labrea/dataset.py b/labrea/dataset.py
new file mode 100644
index 0000000..8bff9e8
--- /dev/null
+++ b/labrea/dataset.py
@@ -0,0 +1,557 @@
+import sys
+
+if sys.version_info < (3, 10):
+    from typing_extensions import ParamSpec
+else:
+    from typing import ParamSpec
+
+import functools
+import logging
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Generic,
+    Hashable,
+    List,
+    Optional,
+    Set,
+    TypeVar,
+    Union,
+    overload,
+)
+
+from confectioner import mix
+
+from ._missing import MISSING, MaybeMissing
+from .application import FunctionApplication
+from .cache import Cache, MemoryCache, NoCache, cached
+from .computation import CallbackEffect, ChainedEffect, Computation, Effect
+from .logging import Logged
+from .option import Option, WithDefaultOptions, WithOptions
+from .overload import Overloaded
+from .types import Evaluatable, MaybeEvaluatable, Options, Value
+
+A = TypeVar("A", covariant=True)
+P = ParamSpec("P")
+Callback = MaybeEvaluatable[Callable[[A], None]]
+
+
+class Dataset(Evaluatable[A]):
+    """A class representing a dataset.
+
+    Datasets are the building blocks of Labrea programs. They represent the result of performing
+    some calculation over a set of inputs. Datasets can be composed together to create complex
+    call graphs that represent the flow of data through a program. Datasets are parameterized
+    using :code:`Options`, which allow for user input.
+
+    Datasets are not created directly; instead, they are created using the :code:`dataset`
+    decorator. This decorator takes a function that defines the dataset and returns a new
+    :code:`Dataset` object. When a dataset is evaluated, the default values to each argument
+    are evaluated using the provided options. The dataset is then evaluated using the
+    evaluated arguments.
+
+
+    Example Usage
+    -------------
+    >>> @dataset
+    ... def a_squared(a: float = Option('A')) -> float:
+    ...     return a ** 2
+    >>>
+    >>> @dataset
+    ... def b_squared(b: float = Option('B')) -> float:
+    ...     return b ** 2
+    >>>
+    >>> @dataset
+    ... def hypotenuse(a2: float = a_squared, b2: float = b_squared) -> float:
+    ...     return (a2 + b2) ** 0.5
+    >>>
+    >>> hypotenuse({'A': 3, 'B': 4})
+    5.0
+    """
+
+    overloads: Overloaded[A]
+    effects: List[Effect[A]]
+    cache: Cache[A]
+    options: Options
+    default_options: Options
+    _effects_disabled: bool
+
+    def __init__(
+        self,
+        overloads: Overloaded[A],
+        effects: List[Effect[A]],
+        cache: Cache[A],
+        options: Options,
+        default_options: Options,
+    ):
+        self.overloads = overloads
+        self.effects = effects.copy()
+        self.cache = cache
+        self.options = options
+        self.default_options = default_options
+        self._effects_disabled = False
+
+    @property
+    def _composed(self) -> Evaluatable[A]:
+        computation = Computation(
+            self.overloads,
+            ChainedEffect(*self.effects),
+        )
+        return WithDefaultOptions(
+            WithOptions(
+                cached(
+                    Logged(
+                        self.overloads if self._effects_disabled else computation,
+                        level=logging.INFO,
+                        name=self.__module__,
+                        msg=f"Labrea: Evaluating {self!r}",
+                    ),
+                    self.cache,
+                ),
+                self.options,
+            ),
+            self.default_options,
+        )
+
+    def evaluate(self, options: Options) -> A:
+        """Evaluates the dataset using the provided options."""
+        return self._composed.evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        """Validates the dataset using the provided options."""
+        self._composed.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        """Returns the option keys required by the dataset."""
+        return self._composed.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Returns the option keys required by the dataset."""
+        return self._composed.explain(options)
+
+    def __repr__(self) -> str:
+        kind = "AbstractDataset" if self.is_abstract else "Dataset"
+        if hasattr(self, "__qualname__"):
+            return f"<{kind} {self.__qualname__}>"
+        if hasattr(self, "__name__"):
+            return f"<{kind} {self.__name__}>"
+
+        return (  # pragma: no cover
+            f"Dataset("
+            f"{self.overloads!r}, "
+            f"{', '.join(map(repr, self.effects))}, "
+            f"{self.cache!r})"
+        )
+
+    def overload(
+        self,
+        alias: Union[Hashable, List[Hashable]],
+    ) -> Callable[[Callable[P, A]], FunctionApplication[P, A]]:
+        """Overloads the dataset with a new implementation. Used as a decorator.
+
+        Overloading a dataset allows you to provide a new implementation for the dataset.
+        Overloads are selected based on the dispatch value provided to the dataset. If no
+        dispatch value is provided, the default implementation is used. Datasets can also
+        be abstract, in which no default implementation is provided, and an error is raised
+        if no overload can be found.
+
+        Datasets must be created with a dispath argument in order to use overloads. If you
+        are trying to overload a dataset that you do not own (i.e. from a third-party library),
+        you can use my_dataset.set_dispatch(dispatch) to set the dispatch manually. The
+        dispatch can be any Evaluatable object that returns a hashable value, or a string
+        representing the Option key to use.
+
+        Arguments
+        ---------
+        alias : Union[Hashable, List[Hashable]]
+            The name or names to use for the overload.
+
+        Returns
+        -------
+        FunctionApplication[P, A]
+            The Evaluatable object representing the new implementation.
+
+
+        Example Usage
+        -------------
+        >>> @dataset(dispatch='INPUT.SOURCE'))
+        ... def input_data(path: str = Option('INPUT.PATH')) -> list[str]:
+        ...     with open(path, 'r') as f:
+        ...         return f.read()
+        >>>
+        >>> @input_data.overload('MOCK')
+        ... def mock_input_data() -> list[str]:
+        ...     return ['Mock', 'Data']
+        >>>
+        >>> input_data({'INPUT': {'PATH': 'data.txt'}})
+        ['Data', 'From', 'File']
+        >>> input_data({'INPUT': {'SOURCE': 'MOCK'}})
+        ['Mock', 'Data']
+        """
+        if self.overloads.dispatch == Value(MISSING):
+            raise ValueError(
+                "Cannot add overloads to a dataset without a dispatch. Add a dispatch by using "
+                "@dataset(dispatch=...) in the dataset definition. If you are trying to overload "
+                "a dataset that you do not own (i.e. from a third-party library), you can use "
+                "my_dataset.set_dispatch(dispatch) to set the dispatch manually."
+            )
+
+        if not isinstance(alias, list):
+            alias = [alias]
+
+        def decorator(func: Callable[P, A]) -> FunctionApplication[P, A]:
+            overload_ = FunctionApplication.lift(func)
+            for key in alias:
+                self.register(key, overload_)
+            return overload_
+
+        return decorator
+
+    def register(self, key: Hashable, value: Evaluatable[A]) -> None:
+        """Registers a new overload for the dataset.
+
+        Registers a new overload for the dataset. Can be used if you want an overload to be
+        a pre-existing Evaluatable object, like an Option.
+
+        Arguments
+        ---------
+        key : Hashable
+            The name to use for the overload.
+        value : Evaluatable[A]
+            The Evaluatable object representing the new implementation.
+
+
+        Example Usage
+        -------------
+        >>> @dataset(dispatch='INPUT.SOURCE'))
+        ... def input_data(path: str = Option('INPUT.PATH')) -> list[str]:
+        ...     with open(path, 'r') as f:
+        ...         return f.read()
+        >>>
+        >>> input_data.register('MOCK', Value(['Mock', 'Data']))
+        """
+        self.overloads.register(key, value)
+
+    def set_dispatch(self, dispatch: Evaluatable[Hashable]) -> None:
+        """Sets the dispatch value for the dataset.
+
+        This is a stateful operation that changes the dispatch value for the dataset. This
+        is intended for use with third-party datasets that do not have a dispatch value set,
+        but you want to add an overload to.
+
+        Arguments
+        ---------
+        dispatch : Evaluatable[Hashable]
+            The dispatch value to use for the dataset.
+        """
+
+        self.overloads = Overloaded(
+            dispatch,
+            self.overloads.lookup.copy(),
+            default=self.overloads.default,
+        )
+
+    def set_cache(self, cache: Union[Cache[A], Callable[..., Cache[A]]]) -> None:
+        """Sets the cache for the dataset.
+
+        Sets the cache for the dataset. The cache can be any object that implements the Cache
+        interface, or a callable that returns a Cache object. This is a stateful operation that
+        changes the cache for the dataset. This is intended for use with third-party datasets
+        that do not have a cache set, but you want to add a cache to.
+
+        Arguments
+        ---------
+        cache : Union[Cache[A], Callable[..., Cache[A]]]
+            The cache to use for the dataset.
+        """
+        cache = cache if isinstance(cache, Cache) else cache()
+        if not isinstance(cache, Cache):
+            raise TypeError(f"Invalid cache: {cache}")
+
+        self.cache = cache
+
+    def add_effects(self, *effects: Union[Effect[A], Callback[A]]) -> None:
+        """Adds effects to the dataset.
+
+        Adds effects to the dataset. Effects are applied to the dataset when it is evaluated.
+        This is a stateful operation that changes the effects for the dataset. This is intended
+        for use with third-party datasets that you would like to add effects to (i.e. logging).
+
+        Arguments
+        ---------
+        effects : Union[Effect[A], Callback[A]]
+            The effects to add to the dataset.
+        """
+        for effect in effects:
+            if isinstance(effect, Effect):
+                self.effects.append(effect)
+            else:
+                self.effects.append(CallbackEffect(effect))
+
+    add_effect = add_effects
+
+    def disable_effects(self) -> None:
+        """Disables effects for the dataset.
+
+        Disables effects for the dataset. Effects are not executed when the dataset is evaluated.
+        This is a stateful operation that changes the effects for the dataset. This is intended
+        for use with third-party datasets that you would like to disable effects for.
+        """
+        self._effects_disabled = True
+
+    def enable_effects(self) -> None:
+        """Enables effects for the dataset.
+
+        Enables effects for the dataset. Effects are executed when the dataset is evaluated.
+        This is a stateful operation that changes the effects for the dataset. This is intended
+        for use with third-party datasets that you would like to enable effects for.
+        """
+        self._effects_disabled = False
+
+    def with_options(self, options: Options) -> "Dataset[A]":
+        """Returns a new dataset with the provided options pre-set.
+
+        The provided options are pre-set, and cannot be overridden by the user. This
+        can simplify the creation of multiple datasets that have the same inputs and
+        calculations.
+
+        Arguments
+        ---------
+        options : Options
+            The options to pre-set for the dataset.
+
+        Returns
+        -------
+        Dataset[A]
+            A new dataset with the provided options pre-set.
+
+
+        Example Usage
+        -------------
+        >>> @dataset
+        ... def a_to_power(a: float = Option('A'), power: float = Option('POWER')) -> float:
+        ...     return a ** power
+        >>>
+        >>> a_squared = a_to_power.with_options({'POWER': 2})
+        """
+        return Dataset(
+            self.overloads,
+            self.effects,
+            self.cache,
+            mix(self.options, options),  # type: ignore
+            self.default_options,
+        )
+
+    def with_default_options(self, options: Options) -> "Dataset[A]":
+        """Returns a new dataset with the provided options as default.
+
+        The provided options are set as the default options for the dataset. These options
+        can be overridden by the user. This can simplify the creation of multiple datasets
+        that have the same inputs and calculations.
+
+        Arguments
+        ---------
+        options : Options
+            The options to set as the default for the dataset.
+
+        Returns
+        -------
+        Dataset[A]
+            A new dataset with the provided options as default.
+        """
+        return Dataset(
+            self.overloads,
+            self.effects,
+            self.cache,
+            self.options,
+            mix(self.default_options, options),  # type: ignore
+        )
+
+    @property
+    def default(self) -> MaybeMissing[Evaluatable[A]]:
+        """The default implementation of the dataset."""
+        return self.overloads.default
+
+    @property
+    def is_abstract(self) -> bool:
+        """Whether the dataset is abstract (i.e. has no default implementation)."""
+        return self.overloads.default is MISSING
+
+
+class DatasetFactory(Generic[A]):
+    effects: List[Effect[A]]
+    cache: Union[Cache[A], Callable[..., Cache[A]], None]
+    dispatch: Evaluatable[Hashable]
+    defaults: Dict[str, Evaluatable[Any]]
+    options: Options
+    defaut_options: Options
+    abstract: bool
+
+    def __init__(
+        self,
+        effects: Optional[List[Effect[A]]] = None,
+        cache: Union[Cache[A], Callable[..., Cache[A]], None] = None,
+        dispatch: Union[Evaluatable[Hashable], str, None] = None,
+        defaults: Optional[Dict[str, Any]] = None,
+        abstract: bool = False,
+        options: Optional[Options] = None,
+        default_options: Optional[Options] = None,
+    ):
+        self.effects = effects or []
+        self.cache = cache
+        self.defaults = {
+            key: Evaluatable.ensure(val) for key, val in (defaults or {}).items()
+        }
+        self.abstract = abstract
+        self.options = options or {}
+        self.default_options = default_options or {}
+
+        if dispatch is None:
+            self.dispatch = Value(MISSING)
+        elif isinstance(dispatch, str):
+            self.dispatch = Option(dispatch)
+        else:
+            self.dispatch = dispatch
+
+    @overload
+    def __call__(
+        self,
+        definition: Callable[..., A],
+        /,
+        *,
+        effects: Optional[List[Union[Effect[A], Callback[A]]]] = ...,
+        cache: Union[Cache[A], Callable[..., Cache[A]], None] = ...,
+        dispatch: Optional[Union[Evaluatable[Hashable], str]] = ...,
+        defaults: Optional[Dict[str, Any]] = ...,
+        abstract: Optional[bool] = ...,
+        options: Optional[Options] = ...,
+        default_options: Optional[Options] = ...,
+    ) -> Dataset[A]:
+        ...  # pragma: no cover
+
+    @overload
+    def __call__(
+        self,
+        /,
+        *,
+        effects: Optional[List[Union[Effect[A], Callback[A]]]] = ...,
+        cache: Union[Cache[A], Callable[..., Cache[A]], None] = ...,
+        dispatch: Optional[Union[Evaluatable[Hashable], str]] = ...,
+        defaults: Optional[Dict[str, Any]] = ...,
+        abstract: Optional[bool] = ...,
+        options: Optional[Options] = ...,
+        default_options: Optional[Options] = ...,
+    ) -> "DatasetFactory[A]":
+        ...  # pragma: no cover
+
+    @overload
+    def __call__(
+        self,
+        definition: None,
+        /,
+        *,
+        effects: Optional[List[Union[Effect[A,], Callback[A]]]] = ...,
+        cache: Union[Cache[A], Callable[..., Cache[A]], None] = ...,
+        dispatch: Optional[Union[Evaluatable[Hashable], str]] = ...,
+        defaults: Optional[Dict[str, Any]] = ...,
+        abstract: Optional[bool] = ...,
+        options: Optional[Options] = ...,
+        default_options: Optional[Options] = ...,
+    ) -> "DatasetFactory[A]":
+        ...  # pragma: no cover
+
+    def __call__(
+        self,
+        definition: Optional[Callable[..., A]] = None,
+        /,
+        *,
+        effects: Optional[List[Union[Effect[A], Callback[A]]]] = None,
+        cache: Union[Cache[A], Callable[..., Cache[A]], None] = None,
+        dispatch: Optional[Union[Evaluatable[Hashable], str]] = None,
+        defaults: Optional[Dict[str, Any]] = None,
+        abstract: Optional[bool] = None,
+        options: Optional[Options] = None,
+        default_options: Optional[Options] = None,
+    ) -> Union["DatasetFactory", Dataset[A]]:
+        _effects = [
+            effect if isinstance(effect, Effect) else CallbackEffect(effect)
+            for effect in (effects or [])
+        ]
+        factory = self.update(
+            effects=_effects,
+            cache=cache,
+            dispatch=dispatch,
+            defaults=defaults,
+            abstract=abstract,
+            options=options,
+            default_options=default_options,
+        )
+
+        if definition is not None:
+            return factory.wrap(definition)
+
+        return factory
+
+    def wrap(self, definition: Callable[..., A]) -> Dataset[A]:
+        overloads: Overloaded[A]
+        if not self.abstract:
+            lifted = FunctionApplication.lift(definition, **self.defaults)
+            overloads = Overloaded(self.dispatch, {}, lifted)
+        else:
+            if self.dispatch == Value(MISSING):
+                raise ValueError("Abstract datasets must have a dispatch")
+            overloads = Overloaded(self.dispatch, {})
+
+        cache: Cache
+        if self.cache is None:
+            cache = MemoryCache()
+        elif callable(self.cache):
+            cache = self.cache()
+        elif isinstance(self.cache, Cache):
+            cache = self.cache
+        else:
+            raise TypeError(f"Invalid cache: {self.cache}")
+
+        _dataset = Dataset(
+            overloads,
+            effects=self.effects,
+            cache=cache,
+            options=self.options,
+            default_options=self.default_options,
+        )
+
+        functools.update_wrapper(_dataset, definition, updated=())
+
+        return _dataset
+
+    def where(self, **defaults: Any) -> "DatasetFactory":
+        return self.update(defaults=defaults)
+
+    def update(
+        self,
+        effects: Optional[List[Effect[A]]] = None,
+        cache: Union[Cache[A], Callable[..., Cache[A]], None] = None,
+        dispatch: Optional[Union[Evaluatable[Hashable], str]] = None,
+        defaults: Optional[Dict[str, Any]] = None,
+        abstract: Optional[bool] = None,
+        options: Optional[Options] = None,
+        default_options: Optional[Options] = None,
+    ) -> "DatasetFactory":
+        return DatasetFactory(
+            effects=[*self.effects, *(effects or [])],
+            cache=cache or self.cache,
+            dispatch=dispatch or self.dispatch,
+            defaults={**self.defaults, **(defaults or {})},
+            abstract=abstract if abstract is not None else self.abstract,
+            options=options or self.options,
+            default_options=default_options or self.default_options,
+        )
+
+    @property
+    def nocache(self) -> "DatasetFactory":
+        return self.update(cache=NoCache())
+
+
+dataset: DatasetFactory = DatasetFactory()
+abstractdataset: DatasetFactory = dataset(abstract=True)
diff --git a/labrea/datasetclasses.py b/labrea/datasetclass.py
similarity index 56%
rename from labrea/datasetclasses.py
rename to labrea/datasetclass.py
index e0e2060..5eef223 100644
--- a/labrea/datasetclasses.py
+++ b/labrea/datasetclass.py
@@ -1,21 +1,25 @@
 import functools
 import operator
 from types import new_class
-from typing import Any, Generic, Set, Type, TypeVar
+from typing import Any, Dict, Optional, Set, Type, TypeVar
 
-from .types import Evaluatable, JSONDict, Value
+from confectioner.templating import set_dotted_key
+
+from .types import Evaluatable, Options, Value
 
 A = TypeVar("A")
 
 
-class DatasetClassMeta(type, Generic[A]):
+class _DatasetClassMeta(type, Evaluatable[A]):
+    """Metaclass for DatasetClass objects."""
+
     def __init__(cls, *args, **kwargs):
         annotations = functools.reduce(
             operator.or_,
             (
                 getattr(base, "__annotations__", {})
                 for base in reversed(cls.__bases__)
-                if base is not DatasetClass
+                if base is not _DatasetClassMixin
             ),
         )
 
@@ -23,26 +27,26 @@ def __init__(cls, *args, **kwargs):
             try:
                 val = getattr(cls, key)
             except AttributeError:
-                raise AttributeError(
+                raise ValueError(
                     f"Annotation {key} on class {cls.__name__} has "
                     f"no default value."
                 )
 
             if not isinstance(val, Evaluatable):
-                setattr(cls, key, Field(val))
+                setattr(cls, key, Value(val))
 
         super().__init__(*args, **kwargs)
 
-    def evaluate(cls, options: JSONDict) -> A:
+    def evaluate(cls, options: Options) -> A:
         return cls(options)
 
-    def validate(cls, options: JSONDict) -> None:
+    def validate(cls, options: Options) -> None:
         for key in dir(cls):
             dependency = getattr(cls, key, None)
             if isinstance(dependency, Evaluatable) and not key.startswith("__"):
                 dependency.validate(options)
 
-    def keys(cls, options: JSONDict) -> Set[str]:
+    def keys(cls, options: Options) -> Set[str]:
         return {
             key
             for name in dir(cls)
@@ -53,22 +57,36 @@ def keys(cls, options: JSONDict) -> Set[str]:
             for key in getattr(cls, name).keys(options)
         }
 
+    def explain(cls, options: Optional[Options] = None) -> Set[str]:
+        return {
+            key
+            for name in dir(cls)
+            if (
+                isinstance(getattr(cls, name), Evaluatable)
+                and not name.startswith("__")
+            )
+            for key in getattr(cls, name).explain(options)
+        }
+
+    def __subclasses__(cls=None):
+        return []
+
     @property
     def result(cls) -> A:
         return cls  # type: ignore
 
+    def __repr__(self):
+        return f"<DatasetClass {self.__name__}>"
 
-class Field(Value):
-    """A wrapper for a static value.
 
-    This is an alias for :class:`labrea.types.Value`.
-    """
+class _DatasetClassMixin:
+    """Mixin for DatasetClass objects."""
 
-    pass
+    _repr_options: Dict[str, Any]
 
+    def __init__(self, options: Optional[Options] = None):
+        options = options or {}
 
-class DatasetClass:
-    def __init__(self, options: JSONDict):
         key: str
         val: Any
         for key in dir(self.__class__):
@@ -76,8 +94,22 @@ def __init__(self, options: JSONDict):
             if isinstance(val, Evaluatable) and not key.startswith("__"):
                 setattr(self, key, val.evaluate(options))
 
+        self._repr_options = {}
+        for key in sorted(self.__class__.keys(options)):  # type: ignore [attr-defined]
+            value = options.get(key)
+            set_dotted_key(key, value, self._repr_options)
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}({self._repr_options!r})"
+
+    def __eq__(self, other):
+        return (
+            isinstance(other, self.__class__)
+            and self._repr_options == other._repr_options
+        )
+
 
-def datasetclass(c: Type[A]) -> DatasetClassMeta[A]:
+def datasetclass(c: Type[A]) -> _DatasetClassMeta[A]:
     """Create a new DatasetClass from a class definition.
 
     DatasetClasses are classes that when instatiated, evaluate their
@@ -86,7 +118,7 @@ def datasetclass(c: Type[A]) -> DatasetClassMeta[A]:
     evaluated at runtime.
 
     Any members of the class that are not Evaluatables are wrapped in
-    :class:`labrea.datasetclasses.Field` instances.
+    :class:`labrea.Value` instances.
 
     Example Usage
     -------------
@@ -102,10 +134,11 @@ def datasetclass(c: Type[A]) -> DatasetClassMeta[A]:
     ...     c: bool = True
     ...
     >>> inst = MyDatasetClass({'A': 'Hello World!', 'B': 1})
-    >>> print(inst.a, inst.b, inst.c)  # Hello World! 1 True
+    >>> print(inst.a, inst.b, inst.c)
+    Hello World! 1 True
     """
     dataset_class = new_class(
-        c.__name__, (c, DatasetClass), kwds={"metaclass": DatasetClassMeta}
+        c.__name__, (c, _DatasetClassMixin), kwds={"metaclass": _DatasetClassMeta}
     )
 
     functools.update_wrapper(dataset_class, c, updated=())
diff --git a/labrea/datasets.py b/labrea/datasets.py
deleted file mode 100644
index 187ae19..0000000
--- a/labrea/datasets.py
+++ /dev/null
@@ -1,1012 +0,0 @@
-import concurrent.futures
-import functools
-import inspect
-import logging
-import math
-import threading
-import typing
-import uuid
-import warnings
-from abc import ABC, abstractmethod
-from collections import OrderedDict
-from typing import (
-    Any,
-    Callable,
-    Dict,
-    Generic,
-    List,
-    Optional,
-    Set,
-    Type,
-    TypeVar,
-    Union,
-)
-
-from confectioner import mix
-
-import labrea.multithreading
-
-from ._aliases import default_alias, default_aliases
-from ._import import require
-from .cache import Cache, CacheInvalidationError, MemoryCache, NoCache
-from .callbacks import Callbacks, EarlyExit, PostCallback, PreCallback
-from .options import Option
-from .pipelines import LabreaPipelineData
-from .switch import Switch
-from .types import Alias, Evaluatable, EvaluationError, JSONDict, MultiAlias, Value
-
-A = TypeVar("A")
-T = TypeVar("T")
-
-
-class DatasetLike(ABC, Evaluatable[A]):
-    name: str
-    _callbacks: Callbacks[A]
-    _cache: Cache[A]
-    _options: JSONDict
-    _lock: threading.Lock
-    _logger: logging.Logger
-
-    def __init__(
-        self,
-        cache: Union[Callable[[Evaluatable], Cache], Cache, None] = None,
-        options: Optional[JSONDict] = None,
-        callbacks: Union[Callbacks[A], Dict[str, PostCallback[A]], None] = None,
-        lock: Optional[threading.Lock] = None,
-        logger: Optional[logging.Logger] = None,
-        name: Optional[str] = None,
-        doc: Optional[str] = None,
-        **kwargs,
-    ):
-        if isinstance(cache, Cache):
-            self._cache = cache
-        else:
-            self._cache = (cache or MemoryCache)(self)
-        self._options = options or {}
-
-        if isinstance(callbacks, dict):
-            self._callbacks = Callbacks(post=OrderedDict(callbacks))
-        elif callbacks is None:
-            self._callbacks = Callbacks()
-        elif not isinstance(callbacks, Callbacks):
-            raise TypeError(
-                f"Callbacks must be a list of PostCallbacks, a Callbacks "
-                f"object, or None, but is of type {type(callbacks)}."
-            )
-        else:
-            self._callbacks = callbacks
-
-        self.name = name or getattr(self, "__name__", str(self))  # type: ignore
-        if doc is not None:
-            self.__doc__ = doc
-
-        self._lock = lock or threading.Lock()
-        self._logger = logger or logging.getLogger(
-            getattr(self, "__module__", __name__)
-        )
-
-    @property
-    def callbacks(self) -> Callbacks[A]:
-        return Callbacks(
-            pre=OrderedDict(
-                {
-                    "__insert_options": self._insert_options,
-                    "__cache_get": self._cache_get,
-                    **self._callbacks.pre,
-                    "__log_evaluation": self._log_evaluation,
-                }
-            ),
-            post=OrderedDict({**self._callbacks.post, "__cache_set": self._cache_set}),
-        )
-
-    def add_pre_callbacks(
-        self, *args: PreCallback[A], **kwargs: PreCallback[A]
-    ) -> None:
-        self._callbacks.pre.update(
-            {
-                **{
-                    f"callback_{str(uuid.uuid4()).replace('-', '')}": callback
-                    for callback in args
-                },
-                **kwargs,
-            }
-        )
-
-    def add_post_callbacks(
-        self, *args: PostCallback[A], **kwargs: PostCallback[A]
-    ) -> None:
-        self._callbacks.post.update(
-            {
-                **{
-                    f"callback_{str(uuid.uuid4()).replace('-', '')}": callback
-                    for callback in args
-                },
-                **kwargs,
-            }
-        )
-
-    def add_callbacks(self, *args: PostCallback[A], **kwargs: PostCallback[A]) -> None:
-        self.add_post_callbacks(*args, **kwargs)
-
-    def _insert_options(self, obj: Evaluatable[A], options: JSONDict) -> JSONDict:
-        return mix(options, self._options)  # type: ignore
-
-    def _cache_get(self, obj: Evaluatable[A], options: JSONDict) -> None:
-        if (
-            options.get("LABREA", {})  # type: ignore
-            .get("CACHE", {})
-            .get("DISABLE", False)
-        ):
-            return
-
-        keys = self._cache.keys(options)
-        if self._cache.exists(keys):
-            self._logger.debug(f"Labrea: Retrieving {self.name} " f"from cache")
-            raise EarlyExit(self._cache.get(keys))
-
-    def _log_evaluation(self, obj: Evaluatable[A], options: JSONDict):
-        self._logger.info(f"Labrea: Evaluating {self.name}")
-
-    def _cache_set(self, value: A, options: JSONDict) -> A:
-        keys = self._cache.keys(options)
-        self._cache.set(keys, value)
-        if self._cache.exists(keys):
-            return self._cache.get(keys)
-        else:
-            return value
-
-    @staticmethod
-    def _check_return(
-        old: T, new: Union[T, LabreaPipelineData[Optional[T]], None]
-    ) -> T:
-        if isinstance(new, LabreaPipelineData):
-            new = new.value
-
-        return old if new is None else new
-
-    @abstractmethod
-    def _evaluate(self, options: JSONDict) -> A:
-        pass
-
-    def _evaluate_with_callbacks(self, options: JSONDict) -> A:
-        for name, pre_callback in self.callbacks.pre.items():
-            self._logger.debug(f"Labrea: Running pre-callback {name} for {self.name}")
-            try:
-                options = self._check_return(options, pre_callback(self, options))
-            except EarlyExit as e:
-                return e.value
-
-        value = self._evaluate(options)
-
-        for name, post_callback in self.callbacks.post.items():
-            self._logger.debug(f"Labrea: Running post-callback {name} for {self.name}")
-            try:
-                value = self._check_return(value, post_callback(value, options))
-            except EarlyExit as e:
-                return e.value
-
-        return value
-
-    def evaluate(self, options: JSONDict) -> A:
-        with self._lock:
-            retries = 0
-            max_retries: Union[int, float] = Option(
-                "LABREA.CACHE.MAX_RETRIES", 5
-            ).evaluate(options)
-            max_retries = max_retries or math.inf
-            while True:
-                try:
-                    return self._evaluate_with_callbacks(options)
-                except CacheInvalidationError as e:
-                    if retries < max_retries:
-                        retries += 1
-                        self._logger.debug(
-                            f"Labrea: Caught CacheInvalidationError for "
-                            f"{self.name}: {e.args}"
-                        )
-                        self._logger.debug(
-                            f"Labrea: Performing retry {retries} of "
-                            f"{max_retries} for {self.name}"
-                        )
-                        continue
-                    else:
-                        raise EvaluationError(
-                            f"Cache invalid for {self.name} - "
-                            f"maximum retries exceeded."
-                        ) from e
-
-
-class Overload(DatasetLike[A]):
-    """Overload Class, an Evaluatable wrapper for a function.
-
-    An Overload is an individual implementation of a Dataset.
-
-    Notes
-    -----
-    This class is not intended to be instantiated directly. Instead, use
-    <mydataset>.overload as a decorator.
-
-    See Also
-    --------
-    :class:`Dataset`
-    :meth:`Dataset.overload`
-    """
-
-    _definition: Callable[..., A]
-    _parent: Optional["Dataset"]
-    _eval_kwargs: Dict[str, Evaluatable]
-    _init_kwargs: Dict[str, Any]
-    _aliases: MultiAlias
-
-    def __init__(
-        self,
-        definition: Callable[..., A],
-        parent: Optional["Dataset"] = None,
-        defaults: Optional[Dict[str, Evaluatable]] = None,
-        aliases: Optional[MultiAlias] = None,
-        cache: Union[Callable[[Evaluatable], Cache], Cache, None] = NoCache,
-        _stack: int = 0,
-        **kwargs,
-    ):
-        definition_closure = inspect.getclosurevars(definition)
-        enclosed = {**definition_closure.globals, **definition_closure.nonlocals}
-        for key, val in enclosed.items():
-            if isinstance(val, Evaluatable):
-                warnings.warn(
-                    f"Variable {key} in the body of {definition.__name__} "
-                    f"refers to a {type(val)} object from the enclosing "
-                    f"scope. Did you mean to include {key} in the function "
-                    f"signature like "
-                    f'"def {definition.__name__}({key}={key}, ...)"?',
-                    stacklevel=_stack + 1,
-                )
-
-        signature = inspect.signature(definition)
-        defaults = {} if defaults is None else defaults
-        eval_kwargs = {
-            **{key: val.default for key, val in signature.parameters.items()},
-            **defaults,
-        }
-
-        self._init_kwargs = {
-            "definition": definition,
-            "defaults": defaults,
-            "parent": parent,
-            "aliases": aliases,
-            **kwargs,
-        }
-
-        for key, val in eval_kwargs.items():
-            if val is signature.empty:
-                raise TypeError(
-                    f"Dataset arguments must all have defaults, but {key} has "
-                    f"no default provided in {definition}."
-                )
-            if not isinstance(val, Evaluatable):
-                eval_kwargs[key] = Value(val)
-
-        functools.update_wrapper(self, definition)
-
-        self._definition = definition  # type: ignore
-        self._parent = parent
-        self._eval_kwargs = eval_kwargs
-        self._aliases = aliases or default_aliases(self)
-
-        if self._parent is not None:
-            for alias in self._aliases:
-                self._parent.register(alias, self)
-
-        super().__init__(
-            definition=definition,
-            parent=parent,
-            defaults=defaults,
-            aliases=aliases,
-            cache=cache,
-            **kwargs,
-        )
-
-    def validate(self, options: JSONDict) -> None:
-        """Validate the Overload.
-
-        Validate the Overload using the options dictionary, recursively
-        validating all dependencies.
-
-        See Also
-        --------
-        :meth:`labrea.types.Validatable.validate`
-        """
-        for dependency in self._eval_kwargs.values():
-            dependency.validate(options)
-
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Get the keys that this Overload depends on.
-
-        Get the keys that this Overload depends on using the options
-        dictionary, recursively getting keys for all dependencies.
-
-        See Also
-        --------
-        :meth:`labrea.types.Validatable.keys`
-        """
-        return {
-            key
-            for dependency in self._eval_kwargs.values()
-            for key in dependency.keys(options)
-        }
-
-    def _evaluate_inputs(self, options: JSONDict) -> Dict[str, Any]:
-        if labrea.multithreading.is_parallel():
-            return self._evaluate_inputs_multithread(options)
-        else:
-            return self._evaluate_inputs_singlethread(options)
-
-    def _evaluate_inputs_multithread(self, options: JSONDict) -> Dict[str, Any]:
-        result = {}
-
-        with concurrent.futures.ThreadPoolExecutor() as executor:
-            future_to_key = {
-                executor.submit(
-                    lambda evaluatable, opts: evaluatable.evaluate(opts), val, options
-                ): key
-                for key, val in self._eval_kwargs.items()
-            }
-
-            for future in concurrent.futures.as_completed(future_to_key):
-                key = future_to_key[future]
-                result[key] = future.result()
-
-        return result
-
-    def _evaluate_inputs_singlethread(self, options: JSONDict) -> Dict[str, Any]:
-        return {key: val.evaluate(options) for key, val in self._eval_kwargs.items()}
-
-    def _evaluate(self, options: JSONDict) -> A:
-        return self._definition(**self._evaluate_inputs(options))
-
-    def cached(self, options: JSONDict) -> bool:
-        return options in self._cache
-
-    def where(self, **kwargs) -> "Overload[A]":
-        return OverloadFactory(
-            dataset=self._parent,
-            defaults={**self._init_kwargs.get("defaults", {}), **kwargs},
-            cache=self._init_kwargs.get("cache", None),
-            callbacks=self._callbacks,
-        ).wrap(self._definition)
-
-    def cache(self, cache: Callable[[Evaluatable], Cache]) -> "Overload[A]":
-        return OverloadFactory(
-            dataset=self._parent,
-            defaults=self._init_kwargs.get("defaults", {}).copy(),
-            cache=cache,
-            callbacks=self._callbacks,
-        ).wrap(self._definition)
-
-    @property
-    def nocache(self) -> "Overload[A]":
-        return self.cache(NoCache)
-
-    def __repr__(self):
-        words = []
-
-        if self._parent is None:
-            words.append("Unbound")
-
-        words.append("Overload")
-
-        if self.__qualname__:
-            words.append(self.__qualname__)
-
-        if self._parent is not None:
-            words.extend(["of", self._parent.__repr__()])
-
-        return f'<{" ".join(words)}>'
-
-
-class Dataset(DatasetLike[A]):
-    """Dataset Class, an Evaluatable wrapper for a function.
-
-    A Dataset is an Evaluatable wrapper around a function whose arguments are
-    Evaluatables. The Dataset will evaluate the defaults to each argument
-    using the options dictionary, and then pass the evaluated values to the
-    function. The Dataset will then cache the result of the function call
-    for the specific options used to evaluate that call.
-
-    A Dataset has 0 or more Overloads, which are the different implementations
-    of the Dataset. A Dataset can be abstract, in which case it has no
-    Overloads, or it can be concrete, in which case it has at least one
-    Overload. The default Overload is the one that will be used if no
-    implementation is specified. Which Overload is used can be specified
-    using the LABREA.IMPLEMENTATIONS.<dataset> option in the options,
-    or by providing a dispatch Evaluatable to the Dataset.
-
-    Notes
-    -----
-    This class is not intended to be instantiated directly. Instead, use the
-    :func:`dataset` or :func:`abstractdataset` factory functions,
-    which normally should be used as decorators.
-    """
-
-    _overloads: Switch[A]
-    _alias: Alias
-
-    def __init__(self, overloads: Switch[A], alias: Alias = None, **kwargs):
-        self._overloads = overloads
-        self._alias = alias or default_alias(self)
-
-        super().__init__(overloads=overloads, alias=alias, **kwargs)
-
-    @property
-    def overload(self) -> "OverloadFactory":
-        """Decorator for creating an Overload for this Dataset.
-
-        This method is used to create an Overload for this Dataset. It can be
-        used as a decorator, like :code:`@<dataset>.overload`, or as a
-        function, like :code:`<dataset>.overload(<definition>)`.
-
-        Parameters
-        ----------
-        definition : Callable[..., A]
-            The function to wrap in an Overload.
-        where : Dict[str, Any], optional
-            Default values for arguments to the Overload.
-        cache : Callable[[Overload], Cache], optional
-            A callable that returns a Cache object for the Overload. If not
-            provided, a MemoryCache will be used.
-        alias : Alias, optional
-            An Alias for the Overload. If not provided, the name of
-            the Overload will be used.
-        aliases : MultiAlias, optional
-            A list of Aliases for the Overload. If not provided, the name of
-            the Overload will be used.
-
-        See Also
-        --------
-        :class:`Overload`
-        """
-        return OverloadFactory(dataset=self)
-
-    def register(self, alias: Alias, overload: Overload[A]):
-        """Register an Overload for this Dataset.
-
-        This method is used to register an Overload for this Dataset. It is
-        not normally used directly, but is used by the OverloadFactory.
-        """
-        self._overloads.lookup[alias] = overload
-
-    def _import(self, options: JSONDict) -> None:
-        require(*options.get("LABREA", {}).get("REQUIRE", []))  # type: ignore
-
-        try:
-            name = self._overloads.option.evaluate(options)
-            if name not in self._overloads.lookup:
-                warnings.warn(
-                    f"Implementation {name} was specified for {self}, but "
-                    f"could not be found. Falling back on default "
-                    f"implementation if available."
-                )
-        except EvaluationError:
-            pass
-
-    def _evaluate(self, options: JSONDict) -> A:
-        """Evaluate the Dataset.
-
-        Identify the overload to use, and evaluate it using the options,
-        recursively evaluating all dependencies.
-
-        See Also
-        --------
-        :meth:`labrea.types.Evaluatable.evaluate`
-        """
-        self._import(options)
-        return self._overloads.evaluate(options)
-
-    def validate(self, options: JSONDict) -> None:
-        """Validate the Dataset.
-
-        Identify the overload to use, and validate it using the options,
-        recursively validating all dependencies.
-
-        See Also
-        --------
-        :meth:`labrea.types.Validatable.validate`
-        """
-        self._import(options)
-        self._overloads.validate(options)
-
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Get the keys that this Dataset depends on.
-
-        Identify the overload to use, and get the keys that it depends on
-        using the options, recursively getting all dependencies.
-
-        See Also
-        --------
-        :meth:`labrea.types.Validatable.keys`
-        """
-        self._import(options)
-        return self._overloads.keys(options)
-
-    @property
-    def is_abstract(self) -> bool:
-        return not self._overloads.has_default
-
-    @property
-    def default(self) -> Overload[A]:
-        return self._overloads.default  # type: ignore
-
-    def where(self, **kwargs) -> Overload[A]:
-        if self.is_abstract:
-            raise TypeError("Cannot use where method on an abstract dataset.")
-
-        return self.default.where(**kwargs)
-
-    def cache(self, cache: Callable[[Evaluatable], Cache]) -> Overload[A]:
-        if self.is_abstract:
-            raise TypeError("Cannot use cache method on an abstract dataset.")
-
-        return self.default.cache(cache)
-
-    @property
-    def nocache(self) -> "Dataset[A]":
-        return self.with_cache(NoCache)
-
-    def with_options(self, options: JSONDict) -> "Dataset[A]":
-        return Dataset(
-            self._overloads,
-            alias=self._alias,
-            options=mix(self._options, options),  # type: ignore
-            callbacks=self._callbacks,
-            cache=self._cache,
-            name=self.name,
-            doc=self.__doc__,
-        )
-
-    def with_cache(
-        self, cache: Union[Callable[[Evaluatable], Cache], Cache]
-    ) -> "Dataset[A]":
-        return Dataset(
-            self._overloads,
-            alias=self._alias,
-            options=self._options,
-            callbacks=self._callbacks,
-            cache=cache,
-            name=self.name,
-            doc=self.__doc__,
-        )
-
-    def set_cache(self, cache: Union[Callable[[Evaluatable], Cache], Cache]):
-        self._cache = cache if isinstance(cache, Cache) else cache(self)
-
-    def __repr__(self):
-        return f'<Dataset {self._alias or "object"}>'
-
-
-class OverloadFactory(Generic[A]):
-    _dataset: Optional[Dataset[A]]
-    _defaults: Dict[str, Any]
-    _cache: Union[Type[Cache], Callable[[Evaluatable], Cache]]
-    _aliases: MultiAlias
-    _callbacks: Optional[Callbacks[A]]
-    _options: JSONDict
-
-    def __init__(
-        self,
-        dataset: Optional[Dataset[A]] = None,
-        defaults: Optional[Dict[str, Any]] = None,
-        cache: Optional[Callable[[Evaluatable], Cache]] = None,
-        aliases: Optional[MultiAlias] = None,
-        callbacks: Optional[Callbacks[A]] = None,
-        options: Optional[JSONDict] = None,
-    ):
-        self._dataset = dataset
-        self._defaults = defaults if defaults is not None else {}
-        self._cache = cache if cache is not None else MemoryCache  # type: ignore
-        self._aliases = aliases or []
-        self._callbacks = callbacks
-        self._options = options or {}
-
-    @typing.overload
-    def __call__(
-        self,
-        definition: Callable[..., A],
-        /,
-    ) -> Overload[A]:
-        ...  # pragma: nocover
-
-    @typing.overload
-    def __call__(self, /, **kwargs: Any) -> "OverloadFactory":
-        ...  # pragma: nocover
-
-    def __call__(
-        self,
-        definition: Optional[Callable[..., A]] = None,
-        /,
-        *,
-        where: Optional[Dict[str, Any]] = None,
-        cache: Optional[Callable[[Evaluatable], Cache]] = None,
-        alias: Optional[Alias] = None,
-        aliases: Optional[MultiAlias] = None,
-        callbacks: Union[
-            Callbacks[A], List[PostCallback[A]], Dict[str, PostCallback[A]], None
-        ] = None,
-        options: Optional[JSONDict] = None,
-        _stack: int = 1,
-        **kwargs,
-    ) -> Union["OverloadFactory[A]", Overload[A]]:
-        aliases = aliases or []
-        if alias is not None:
-            aliases.append(alias)
-
-        factory = self
-        if where is not None:
-            factory = factory.where(**where)
-        if cache is not None:
-            factory = factory.cache(cache)
-        if aliases:
-            factory = factory.alias(aliases)
-        if callbacks:
-            factory = factory.callbacks(callbacks)
-        if options:
-            factory = factory.options(options)
-
-        if definition is None:
-            return factory
-
-        return factory.wrap(definition, _stack=_stack + 1)
-
-    def wrap(self, definition: Callable[..., A], _stack: int = 1) -> Overload[A]:
-        overload = Overload(
-            definition=definition,
-            parent=self._dataset,
-            cache=self._cache,
-            defaults=self._defaults,
-            aliases=self._aliases,
-            callbacks=self._callbacks,
-            options=self._options,
-            doc=getattr(definition, "__doc__", None),
-            _stack=_stack + 1,
-        )
-
-        return overload
-
-    def where(self, **kwargs) -> "OverloadFactory[A]":
-        return OverloadFactory(
-            self._dataset,
-            defaults={**self._defaults, **kwargs},
-            cache=self._cache,
-            aliases=self._aliases,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    def cache(self, cache: Callable[[Evaluatable], Cache]) -> "OverloadFactory[A]":
-        return OverloadFactory(
-            self._dataset,
-            defaults=self._defaults,
-            cache=cache,
-            aliases=self._aliases,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    @property
-    def nocache(self) -> "OverloadFactory[A]":
-        return self.cache(NoCache)
-
-    def alias(self, aliases: MultiAlias) -> "OverloadFactory[A]":
-        return OverloadFactory(
-            self._dataset,
-            defaults=self._defaults,
-            cache=self._cache,
-            aliases=aliases,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    def callbacks(
-        self,
-        callbacks: Union[
-            Callbacks[A], List[PostCallback[A]], Dict[str, PostCallback[A]]
-        ],
-    ) -> "OverloadFactory[A]":
-        if isinstance(callbacks, dict):
-            callbacks = Callbacks(post=OrderedDict(callbacks))
-        elif isinstance(callbacks, list):
-            callbacks = Callbacks(
-                post=OrderedDict(
-                    {
-                        f'callback_{str(uuid.uuid4()).replace("-", "")}': callback
-                        for callback in callbacks
-                    }
-                )
-            )
-        return OverloadFactory(
-            self._dataset,
-            defaults=self._defaults,
-            cache=self._cache,
-            aliases=self._aliases,
-            callbacks=callbacks,
-            options=self._options,
-        )
-
-    def options(self, options: JSONDict) -> "OverloadFactory[A]":
-        return OverloadFactory(
-            self._dataset,
-            defaults=self._defaults,
-            cache=self._cache,
-            aliases=self._aliases,
-            callbacks=self._callbacks,
-            options=mix(self._options, options),  # type: ignore
-        )
-
-
-class DatasetFactory(Generic[A]):
-    """A factory function for creating Datasets.
-
-    Used to create Datasets. Normally used as a decorator, like @dataset.
-    Can either be used bare like @dataset, or with extra arguments like
-    @dataset(dispatch=...).
-
-    Parameters
-    ----------
-    definition : Callable[..., A]
-        The function to wrap in a Dataset.
-    dispatch : Evaluatable[str], optional
-        An Evaluatable that returns the name of the implementation to use.
-        If not provided, the LABREA.IMPLEMENTATIONS.<dataset> option will
-        be used.
-    abstract : bool, optional
-        Whether the Dataset is abstract or not. If abstract, it will have
-        no default implementation.
-    defaults : Dict[str, Any], optional
-        Default values for arguments to the Dataset.
-    cache : Callable[[Overload], Cache], optional
-        A callable that returns a Cache object for the Dataset. If not
-        provided, a MemoryCache will be used.
-    callbacks : Callbacks[A] | List[PostCallback[A]] | Dict[str, PostCallback[A]], optional
-        Callbacks to be used for the Dataset. If a list is provided, the names
-        will be generated automatically.
-    alias : Alias, optional
-        An Alias for the default Overload. If not provided, the name of
-        the Dataset will be used.
-
-
-    Example Usage
-    -------------
-    >>> from labrea import dataset, Option
-    >>> @dataset
-    ... def my_dataset(a: str = Option('A')) -> str:
-    ...     return a
-    >>>
-    >>> print(my_dataset({'A': 'Hello World!'})) # Hello World!
-    >>>
-    >>> @dataset(dispatch=Option('OVERLOADED_DATASET.DISPATCH'))
-    ... def overloaded_dataset(b: str = Option('A')) -> str:
-    ...     return b
-    >>>
-    >>> @overloaded_dataset.overload(alias='OVERLOAD_1')
-    ... def _overload_1(b: str = Option('')) -> str:
-    ...     return b.upper()
-    >>>
-    >>> print(overloaded_dataset({
-    ...     'OVERLOADED_DATASET.DISPATCH': 'OVERLOAD_1',
-    ...     'A': 'Hello World!'}
-    ... )) # HELLO WORLD!
-
-
-    See Also
-    --------
-    :class:`Dataset`
-    """
-
-    _dispatch: Optional[Evaluatable[str]]
-    _abstract: bool
-    _defaults: Dict[str, Any]
-    _cache: Union[Type[Cache], Callable[[Evaluatable], Cache], None]
-    _alias: Optional[Alias]
-    _callbacks: Optional[Callbacks[A]]
-    _options: JSONDict
-
-    def __init__(
-        self,
-        dispatch: Optional[Evaluatable[str]] = None,
-        abstract: bool = False,
-        defaults: Optional[Dict[str, Any]] = None,
-        cache: Optional[Callable[[Evaluatable], Cache]] = None,
-        alias: Optional[Alias] = None,
-        callbacks: Optional[Callbacks[A]] = None,
-        options: Optional[JSONDict] = None,
-    ):
-        self._dispatch = dispatch
-        self._abstract = abstract
-        self._defaults = defaults or {}
-        self._cache = cache
-        self._alias = alias
-        self._callbacks = callbacks
-        self._options = options or {}
-
-    @typing.overload
-    def __call__(
-        self,
-        definition: Callable[..., A],
-        /,
-    ) -> Dataset[A]:
-        ...  # pragma: nocover
-
-    @typing.overload
-    def __call__(self, /, **kwargs: Any) -> "DatasetFactory[A]":
-        ...  # pragma: nocover
-
-    def __call__(
-        self,
-        definition: Optional[Callable[..., A]] = None,
-        /,
-        *,
-        dispatch: Optional[Evaluatable[str]] = None,
-        where: Optional[Dict[str, Any]] = None,
-        cache: Optional[Callable[[Evaluatable], Cache]] = None,
-        alias: Optional[Alias] = None,
-        callbacks: Union[
-            Callbacks[A], List[PostCallback[A]], Dict[str, PostCallback[A]], None
-        ] = None,
-        options: Optional[JSONDict] = None,
-        _stack: int = 1,
-        **kwargs,
-    ) -> Union["DatasetFactory[A]", Dataset[A]]:
-        factory = self
-        if dispatch is not None:
-            factory = factory.dispatch(dispatch)
-        if where is not None:
-            factory = factory.where(**where)
-        if cache is not None:
-            factory = factory.cache(cache)
-        if alias is not None:
-            factory = factory.alias(alias)
-        if callbacks is not None:
-            factory = factory.callbacks(callbacks)
-        if options is not None:
-            factory = factory.options(options)
-
-        if definition is None:
-            return factory
-
-        return factory.wrap(definition, _stack=_stack + 1)
-
-    def __getattribute__(self, item):
-        if super().__getattribute__("_abstract") and item in ("cache", "where"):
-            warnings.warn(
-                f"Method {item} has no effect for an abstract dataset", stacklevel=1
-            )
-
-        return super().__getattribute__(item)
-
-    def wrap(self, definition: Callable[..., A], _stack: int = 1) -> Dataset[A]:
-        alias = self._alias or default_alias(definition)
-
-        dispatch = self._dispatch or Option(f"LABREA.IMPLEMENTATIONS.{alias}")
-        overloads: Switch[A] = Switch(dispatch, {})
-
-        dataset = Dataset(
-            overloads,
-            alias=alias,
-            doc=getattr(definition, "__doc__", None),
-            cache=self._cache,
-            name=getattr(definition, "__name__", None),
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-        if not self._abstract:
-            overloads.default = Overload(
-                definition, parent=dataset, defaults=self._defaults
-            )
-
-        return dataset
-
-    def dispatch(self, dispatch: Union[str, Evaluatable[str]]) -> "DatasetFactory[A]":
-        dispatch_: Evaluatable[str]
-        if isinstance(dispatch, str):
-            dispatch_ = Option(dispatch)
-        else:
-            dispatch_ = dispatch
-
-        return DatasetFactory(
-            dispatch=dispatch_,
-            abstract=self._abstract,
-            defaults=self._defaults,
-            cache=self._cache,
-            alias=self._alias,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    def cache(self, cache: Callable[[Evaluatable], Cache]) -> "DatasetFactory[A]":
-        return DatasetFactory(
-            dispatch=self._dispatch,
-            abstract=self._abstract,
-            defaults=self._defaults,
-            cache=cache,
-            alias=self._alias,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    def where(self, **kwargs) -> "DatasetFactory[A]":
-        return DatasetFactory(
-            dispatch=self._dispatch,
-            abstract=self._abstract,
-            defaults={**self._defaults, **kwargs},
-            cache=self._cache,
-            alias=self._alias,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    @property
-    def nocache(self) -> "DatasetFactory[A]":
-        return self.cache(NoCache)
-
-    def alias(self, alias: Alias) -> "DatasetFactory[A]":
-        return DatasetFactory(
-            dispatch=self._dispatch,
-            abstract=self._abstract,
-            defaults=self._defaults,
-            cache=self._cache,
-            alias=alias,
-            callbacks=self._callbacks,
-            options=self._options,
-        )
-
-    def callbacks(
-        self,
-        callbacks: Union[
-            Callbacks[A],
-            List[PostCallback[A]],
-            Dict[str, PostCallback[A]],
-        ],
-    ) -> "DatasetFactory[A]":
-        if isinstance(callbacks, dict):
-            callbacks = Callbacks(post=OrderedDict(callbacks))
-        elif isinstance(callbacks, list):
-            callbacks = Callbacks(
-                post=OrderedDict(
-                    {
-                        f'callback_{str(uuid.uuid4()).replace("-", "")}': callback
-                        for callback in callbacks
-                    }
-                )
-            )
-        return DatasetFactory(
-            dispatch=self._dispatch,
-            abstract=self._abstract,
-            defaults=self._defaults,
-            cache=self._cache,
-            alias=self._alias,
-            callbacks=callbacks,
-            options=self._options,
-        )
-
-    def options(self, options: JSONDict) -> "DatasetFactory[A]":
-        return DatasetFactory(
-            dispatch=self._dispatch,
-            abstract=self._abstract,
-            defaults=self._defaults,
-            cache=self._cache,
-            alias=self._alias,
-            callbacks=self._callbacks,
-            options=mix(self._options, options),  # type: ignore
-        )
-
-
-dataset: DatasetFactory = DatasetFactory()
-abstractdataset: DatasetFactory = DatasetFactory(abstract=True)
-abstractdataset.__doc__ = """Factory function for creating abstract Datasets.
-
-    This is just an alias for :code:`dataset(abstract=True)`.
-
-    See Also
-    --------
-    :func:`labrea.datasets.dataset`
-    :class:`Dataset`
-"""
diff --git a/labrea/exceptions.py b/labrea/exceptions.py
new file mode 100644
index 0000000..b77354a
--- /dev/null
+++ b/labrea/exceptions.py
@@ -0,0 +1,41 @@
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from .types import Evaluatable
+
+
+class EvaluationError(Exception):
+    """Error raised when an object cannot be evaluated."""
+
+    msg: str
+    source: "Evaluatable"
+
+    def __init__(self, msg: str, source: "Evaluatable") -> None:
+        self.msg = msg
+        self.source = source
+
+        super().__init__(msg, source)
+
+    def __str__(self):
+        return f"Originating in {self.source} | {self.msg}"
+
+
+class KeyNotFoundError(EvaluationError):
+    """Error raised when a key is not found in the options dictionary."""
+
+    key: str
+    source: "Evaluatable"
+
+    def __init__(self, key: str, source: "Evaluatable") -> None:
+        self.key = key
+
+        super().__init__(f"Key '{self.key}' not found", source)
+
+
+class InsufficientInformationError(EvaluationError):
+    """Error raised when not enough information is provided to explain an object."""
+
+    def __init__(self, reason: str, source: "Evaluatable") -> None:
+        super().__init__(
+            f"Insufficient information to evaluate {source}: {reason}", source
+        )
diff --git a/labrea/functions.py b/labrea/functions.py
new file mode 100644
index 0000000..1307860
--- /dev/null
+++ b/labrea/functions.py
@@ -0,0 +1,123 @@
+import builtins
+import functools
+from typing import Callable, Iterable, TypeVar
+
+from ._missing import MISSING, MaybeMissing
+from .application import FunctionApplication
+from .pipeline import PipelineStep
+from .types import MaybeEvaluatable
+
+A = TypeVar("A")
+B = TypeVar("B")
+
+
+def map(
+    func: MaybeEvaluatable[Callable[[A], B]]
+) -> PipelineStep[Iterable[A], Iterable[B]]:
+    """Create a pipeline step that maps a function over an iterable.
+
+    This allows for calling map in a functional style over an iterable
+    using the :code:`.apply()` method or the :code:`>>` operator on an
+    Evaluatable object that returns an iterable.
+
+    Arguments
+    ----------
+    func : MaybeEvaluatable[Callable[[A], B]]
+        The function to apply to each element of the iterable. This can be
+        an Evaluatable that returns a function, or a constant function.
+
+
+    Example Usage
+    -------------
+    >>> from labrea import Option
+    >>> import labrea.functions as F
+    >>>
+    >>> (Option('A') >> F.map(lambda x: x + 1))({'A': [1, 2, 3, 4]})
+    [2, 3, 4, 5]
+    """
+    return PipelineStep(
+        FunctionApplication(
+            functools.partial, builtins.map, func  # type: ignore [arg-type]
+        ),
+        f"map({func!r})",
+    )
+
+
+def filter(
+    func: MaybeEvaluatable[Callable[[A], bool]]
+) -> PipelineStep[Iterable[A], Iterable[A]]:
+    """Create a pipeline step that filters an iterable using a predicate function.
+
+    This allows for calling filter in a functional style over an iterable
+    using the :code:`.apply()` method or the :code:`>>` operator on an
+    Evaluatable object that returns an iterable.
+
+    Arguments
+    ----------
+    func : MaybeEvaluatable[Callable[[A], bool]]
+        The predicate function to apply to each element of the iterable. This
+        can be an Evaluatable that returns a function, or a constant function.
+
+
+    Example Usage
+    -------------
+    >>> from labrea import Option
+    >>> import labrea.functions as F
+    >>>
+    >>> (Option('A') >> F.filter(lambda x: x % 2 == 0))({'A': [1, 2, 3, 4]})
+    [2, 4]
+    """
+    return PipelineStep(
+        FunctionApplication(
+            functools.partial, builtins.filter, func  # type: ignore [arg-type]
+        ),
+        f"filter({func!r})",
+    )
+
+
+def _reduce(
+    func: Callable[[A, A], A], iterable: Iterable[A], initial: MaybeMissing[A]
+) -> A:
+    if initial is MISSING:
+        return functools.reduce(func, iterable)
+
+    return functools.reduce(func, iterable, initial)
+
+
+def reduce(
+    func: MaybeEvaluatable[Callable[[A, A], A]],
+    initial: MaybeMissing[MaybeEvaluatable[A]] = MISSING,
+) -> PipelineStep[Iterable[A], A]:
+    """Create a pipeline step that reduces an iterable using a binary function.
+
+    This allows for calling reduce in a functional style over an iterable
+    using the :code:`.apply()` method or the :code:`>>` operator on an
+    Evaluatable object that returns an iterable.
+
+    Arguments
+    ----------
+    func : MaybeEvaluatable[Callable[[A, A], A]]
+        The binary function to apply to each element of the iterable. This
+        can be an Evaluatable that returns a function, or a constant function.
+    initial : MaybeMissing[MaybeEvaluatable[A]], optional
+        The initial value to use for the reduction. If not provided, the
+        first element of the iterable is used as the initial value. This
+        can be an Evaluatable that returns a value, or a constant value.
+
+
+    Example Usage
+    -------------
+    >>> from labrea import Option
+    >>> import labrea.functions as F
+    >>>
+    >>> (Option('A') >> F.reduce(lambda x, y: x + y))({'A': [1, 2, 3, 4]})
+    10
+    """
+    return PipelineStep(
+        FunctionApplication(
+            functools.partial, _reduce, func, initial=initial  # type: ignore [arg-type]
+        ),
+        f"reduce({func!r})"
+        if initial is MISSING
+        else f"reduce({func!r}, initial={initial!r})",
+    )
diff --git a/labrea/interface.py b/labrea/interface.py
new file mode 100644
index 0000000..f5e4adb
--- /dev/null
+++ b/labrea/interface.py
@@ -0,0 +1,335 @@
+from types import FunctionType
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Hashable,
+    List,
+    Protocol,
+    Tuple,
+    Type,
+    TypeVar,
+    Union,
+)
+
+from .application import FunctionApplication
+from .dataset import Dataset, abstractdataset, dataset
+from .option import Option
+from .types import Evaluatable, Value
+
+T = TypeVar("T", bound=Type)
+
+
+def interface(dispatch: Union[Evaluatable[Hashable], str]) -> Callable[[T], "T"]:
+    """A decorator that creates an interface from a class.
+
+    An interface is a class that defines a set of members that must be implemented
+    by its implementations. The interface is defined by the members of the class,
+    which are abstract datasets. The interface must have a dispatch, which is an
+    evaluatable that returns a key that is used to determine which implementation.
+
+    Downstream datasets can use interface members as if they were normal datasets.
+    This allows developers to define a set of related datasets that must be implemented
+    for a particular use case, enabling polymorphism and dependency injection.
+
+    Arguments
+    ----------
+    dispatch : Evaluatable[Hashable] or str
+        The evaluatable that determines which implementation to use. This can be
+        an evaluatable (like an Option) or a string representing an Option.
+
+    Returns
+    -------
+    Callable[[T], T]
+        A decorator that creates an interface from a class.
+
+
+    Example Usage
+    -------------
+    >>> from labrea import interface, abstractdataset, dataset, Option
+    >>> @interface("DISPATCH.KEY")
+    ... class MyInterface:
+    ...     a: float  # Implicit member
+    ...
+    ...     # Explicit member
+    ...     @abstractdataset
+    ...     def b() -> str:
+    ...         pass
+    ...
+    ...     # Member with default implementation
+    ...     @dataset
+    ...     def c() -> str:
+    ...         return 'C'
+    ...
+    ...     # Member with default implementation, not using @dataset
+    ...     d: str = Option('D')
+    >>>
+    >>> @dataset
+    ... def a_squared(a: float = MyInterface.a) -> float:
+    ...     return a ** 2
+    >>>
+    """
+    if isinstance(dispatch, type):
+        raise TypeError(
+            "@interface requires that an Evaluatable (or str representing an Option) be provided "
+            "to dispatch over, but a type was provided. Did you forget to provide a dispatch? "
+            '(e.g. @interface instead of @interface("DISPATCH.KEY"))?'
+        )
+
+    if isinstance(dispatch, str):
+        dispatch = Option(dispatch)
+
+    def wrapper(cls):
+        return Interface(cls.__name__, cls.__bases__, dict(cls.__dict__), dispatch)
+
+    return wrapper
+
+
+def implements(
+    *interfaces: "Interface", alias: Union[Hashable, List[Hashable], None] = None
+) -> Callable[[T], T]:
+    """Decorator used for implementing one or more interfaces at once.
+
+    Arguments
+    ---------
+    *interfaces : Interface
+        The interfaces to implement.
+    alias : Union[Hashable, List[Hashable], None]
+        The alias or aliases to register the implementation under.
+        Keyword-only argument; required.
+
+    Returns
+    -------
+    Callable[[T], T]
+        A decorator that creates an implementation from a class.
+
+
+    Example Usage
+    -------------
+    >>> @implements(Interface1, Interface2, alias="ALIAS")
+    ... class MyImplementation:
+    ...     # Static value member
+    ...     a: str = 'A'
+    ...     # Non-dataset Evaluatable member
+    ...     b: str = Option('B')
+    ...     # Explicit dataset member
+    ...     @dataset
+    ...     def c() -> str:
+    ...         return 'C'
+    ...     # Implicit dataset member
+    ...     def d() -> str:
+    ...         return 'D'
+    """
+    if alias is None:
+        raise ValueError(
+            "The @implements decorator requires at least one alias to be provided."
+        )
+    aliases = tuple(alias) if isinstance(alias, list) else (alias,)
+
+    def wrapper(cls):
+        return Implementation(
+            cls.__name__, cls.__bases__, dict(cls.__dict__), interfaces, aliases
+        )
+
+    return wrapper
+
+
+class Interface(type):
+    """Metaclass for interfaces.
+
+    Interfaces are classes that define a set of members that must be implemented by
+    its implementations. The interface is defined by the members of the class, which
+    are abstract datasets. The interface must have a dispatch, which is an evaluatable
+    that returns a key that is used to determine which implementation to use.
+
+    Members of an interface are defined by the class attributes and annotations. Members
+    can be defined implicitly using a type annotation, or explicitly using the
+    :code:`@abstractdataset` decorator. Members can also have a default implementation.
+    Usually this is done by using the :code:`@dataset` decorator, but any class attribute
+    that is an Evaluatable can be used as a default implementation.
+
+    Normally, rather than using this metaclass directly, the :code:`@interface` decorator
+    should be used to create an interface.
+    """
+
+    _dispatch: Evaluatable[Hashable]
+    _members: Dict[str, Dataset]
+
+    def __new__(mcs, name, bases, namespace, dispatch: Evaluatable[Hashable]):
+        namespace["_dispatch"] = dispatch
+        return super().__new__(mcs, name, bases, namespace)
+
+    def __init__(
+        cls,
+        name: str,
+        bases: Tuple[type, ...],
+        dct: Dict[str, Any],
+        dispatch: Evaluatable[Hashable],
+    ):
+        super().__init__(name, bases, dct)
+
+        for name, typ in dct.get("__annotations__", {}).items():
+            if name.startswith("_") or name in dct:
+                continue
+
+            def _abstractdataset():
+                pass  # pragma: no cover
+
+            _abstractdataset.__qualname__ = f"{cls.__name__}.{name}"
+            _abstractdataset.__name__ = name
+            setattr(cls, name, abstractdataset(_abstractdataset, dispatch=dispatch))
+
+        for key, val in dct.items():
+            if key.startswith("_"):
+                continue
+
+            if isinstance(val, staticmethod):
+                val = val.__func__
+                setattr(cls, key, val)
+
+            if isinstance(val, FunctionType):
+                setattr(cls, key, dataset(val, dispatch=dispatch))
+            elif isinstance(val, Dataset):
+                val.set_dispatch(dispatch)
+            else:
+
+                def _dataset(v=Evaluatable.ensure(val)):
+                    return v
+
+                _dataset.__qualname__ = f"{cls.__qualname__}.{key}"
+                _dataset.__name__ = key
+                setattr(cls, key, dataset(_dataset, dispatch=dispatch))
+
+    def __setattr__(self, key, value):
+        if key in ("implementation",):
+            raise AttributeError(f"Cannot create a member called {key} on Interface.")
+
+        super().__setattr__(key, value)
+
+    def implementation(cls, alias: Union[Hashable, List[Hashable]]) -> Callable[[T], T]:
+        """Decorator for implementing a single interface.
+
+        This is shorthand for using the :code:`@implements` decorator with a single interface.
+
+        Arguments
+        ---------
+        alias : Union[Hashable, List[Hashable]]
+            The alias or aliases to register the implementation under.
+        """
+        if isinstance(alias, type):
+            raise TypeError(
+                f"@{cls.__name__}.implementation requires that at least one alias be provided, "
+                f"but a type was provided. Did you forget to provide an alias? "
+                f"(e.g. @{cls.__name__}.implementation instead of "
+                f'@{cls.__name__}.implementation("ALIAS"))?'
+            )
+
+        return implements(cls, alias=alias)
+
+    def __repr__(self):
+        return f"<Interface {self.__module__}.{self.__qualname__}>"
+
+
+class Implementation(type):
+    """Metaclass for interface implementations.
+
+    Implementations are classes that implement one or more interfaces. Implementations
+    are defined by the members of the class, which are datasets or other Evaluatables.
+    When the implementation is created, the members are added as overloads to the
+    corresponding members of the interfaces. If a member is not implemented, an error
+    is raised.
+
+    Normally, rather than using this metaclass directly, the :code:`@implements` decorator
+    or the :code:`@MyInterface.implementation` decorator should be used.
+    """
+
+    _interfaces: Tuple[Interface, ...]
+
+    def __new__(
+        mcs,
+        name,
+        bases,
+        namespace,
+        interfaces: Tuple[Interface, ...],
+        aliases: Tuple[Hashable, ...],
+    ):
+        namespace["_interfaces"] = interfaces
+        return super().__new__(mcs, name, bases, namespace)
+
+    def __init__(
+        cls,
+        name,
+        bases,
+        dct,
+        interfaces: Tuple[Interface, ...],
+        aliases: Tuple[Hashable, ...],
+    ):
+        super().__init__(name, bases, dct)
+
+        members = _get_members(interfaces)
+        overloads = _build_overloads(dct, members, interfaces)
+
+        for key, member_list in members.items():
+            overload = overloads.get(key)
+
+            for member in member_list:
+                if member.is_abstract and overload is None:
+                    raise TypeError(f"No implementation provided for {member}.")
+
+            if overload is not None:
+                for member in member_list:
+                    for alias in aliases:
+                        member.register(alias, overload)
+                setattr(cls, key, overload)
+
+    def __repr__(self):
+        return (
+            f"<Implementation {self.__module__}.{self.__qualname__} of "
+            f'{", ".join(i.__qualname__ for i in self._interfaces)}>'
+        )
+
+
+def _get_members(interfaces: Tuple[Interface, ...]) -> Dict[str, List[Dataset]]:
+    members: Dict[str, List[Dataset]] = {}
+
+    for interface_ in interfaces:
+        for name, member in interface_.__dict__.items():
+            if not name.startswith("_") and isinstance(member, Dataset):
+                members.setdefault(name, []).append(member)
+
+    return members
+
+
+def _build_overloads(
+    dct: Dict[str, Any], members: Dict[str, List[Dataset]], interfaces
+) -> Dict[str, Evaluatable]:
+    overloads: Dict[str, Evaluatable] = {}
+
+    for key, val in dct.items():
+        if key.startswith("_"):
+            continue
+
+        if key not in members:
+            raise TypeError(
+                f"No interface in {interfaces} has a member named {key} to overload."
+            )
+
+        if isinstance(val, staticmethod):
+            val = val.__func__
+
+        overload: Evaluatable
+        if isinstance(val, FunctionType):
+            overload = FunctionApplication.lift(val)
+        elif not isinstance(val, Evaluatable):
+            overload = Value(val)
+        else:
+            overload = val
+
+        overloads[key] = overload
+
+    return overloads
+
+
+class _ImplDecoProto(Protocol):
+    def __call__(self, alias: Hashable, *aliases: Hashable) -> Callable[[T], T]:
+        ...
diff --git a/labrea/interfaces.py b/labrea/interfaces.py
deleted file mode 100644
index 615f020..0000000
--- a/labrea/interfaces.py
+++ /dev/null
@@ -1,305 +0,0 @@
-import typing
-from types import FunctionType
-from typing import Any, Dict, Optional, Tuple, Type, Union
-
-from ._aliases import default_alias, default_aliases
-from .datasets import Dataset, Overload, abstractdataset, dataset
-from .options import Option
-from .types import Alias, Evaluatable, MultiAlias, Value
-
-
-def _is_member(key: str):
-    return not key.startswith("_") and key not in ("implementation",)
-
-
-class ImplementationDecorator:
-    def __get__(self, instance, owner):
-        def wrapper_builder(*args, **outer_kwargs):
-            """Class decorator for implementing interfaces.
-
-            Can either by used as :code:`MyInterface.implementation` or
-            :code:`Interface.implementation(Interface1, Interface2)` to
-            implement multiple interfaces at once.
-
-            See :func:`interface` for example usage.
-            """
-
-            def implementation_wrapper(cls: Optional[Type] = None, **inner_kwargs):
-                kwargs = {**outer_kwargs, **inner_kwargs}
-                interfaces = (*args, *kwargs.pop("interfaces", tuple()))
-
-                if cls is None:
-                    return wrapper_builder(*interfaces, **kwargs)
-
-                return Implementation(
-                    cls.__name__,
-                    tuple(base for base in cls.__bases__ if base is not object),
-                    {
-                        **cls.__dict__,
-                        **{
-                            "__qualname__": cls.__qualname__,
-                        },
-                    },
-                    interfaces=interfaces,
-                    **kwargs,
-                )
-
-            return implementation_wrapper
-
-        if instance is None:
-            return wrapper_builder
-        else:
-            return wrapper_builder(instance)
-
-
-class Interface(type):
-    """An Interface is a collection of datasets with related implementations.
-
-    Interfaces are used to define a set of datasets that are related to each
-    other. An interface is defined by a class that inherits from Interface
-    (or is decorated with the :func:`interface` decorator). Interfaces are
-    implemented using :code:`@MyInterface.implementation` or
-    :code:`@Interface.implementation(Interface1, Interface2)` to implement
-    multiple interfaces at once.
-
-    A valid implementation of an interface must implement all of abstract
-    datasets defined by the interface. Any datasets that are not abstract
-    will be inherited from the interface if they are not implemented by the
-    implementation.
-    """
-
-    _members: Dict[str, Dataset]
-
-    def __init__(cls, name, bases, dct):
-        cls._members = {}
-        cls.__name__ = name
-
-        dispatch = getattr(cls, "_dispatch", None)
-        dispatch = dispatch or f"LABREA.IMPLEMENTATIONS.{default_alias(cls)}"
-        if isinstance(dispatch, str):
-            dispatch = Option(dispatch)
-        cls._dispatch = dispatch
-
-        for name, typ in getattr(cls, "__annotations__", {}).items():
-            if hasattr(cls, name):
-                continue
-
-            @abstractdataset(dispatch=dispatch)
-            def _dataset() -> typ:
-                return NotImplemented  # pragma: nocover
-
-            _dataset.__name__ = f"{cls.__qualname__}.{name}"
-
-            setattr(cls, name, _dataset)
-
-        for name, val in cls.__dict__.items():
-            if not _is_member(name):
-                continue
-
-            typ = getattr(cls, "__annotations__", {}).get(name, Any)
-
-            if isinstance(val, staticmethod):
-                val = val.__get__(cls)
-
-            if not isinstance(val, Evaluatable):
-                val = Value(val)
-
-            if not isinstance(val, Dataset):
-
-                @dataset(dispatch=dispatch)
-                def _dataset(dep: typ = val) -> typ:
-                    return dep
-
-                _dataset.__name__ = f"{cls.__qualname__}.{name}"
-
-                val = _dataset
-            else:
-                val._overloads.option = cls._dispatch
-
-            setattr(cls, name, val)
-
-        super().__init__(cls.__name__, bases, dct)
-
-    def __setattr__(cls, key: str, value: Any):
-        if _is_member(key):
-            cls._members[key] = value
-        else:
-            super().__setattr__(key, value)
-
-    def __getattribute__(cls, key: str):
-        if _is_member(key):
-            try:
-                return cls._members[key]
-            except KeyError as e:
-                raise AttributeError(*e.args) from e
-        else:
-            return super().__getattribute__(key)
-
-    def __repr__(cls):
-        return f"<Interface {cls.__qualname__}>"
-
-    implementation = ImplementationDecorator()
-
-
-class InterfaceFactory:
-    """Factory function for creating interfaces.
-
-    This is normally used as a decorator on a class to create an interface
-    from the class.
-
-    Parameters
-    ----------
-    dispatch : str | Evaluatable[str], optional
-        How to dispath to the correct implementation of the interface.
-
-
-    Example Usage
-    -------------
-    >>> from labrea import interface, abstractdataset, dataset, Option
-    >>> @interface(dispatch='MY_INTERFACE.IMPLEMENTATION')
-    ... class MyInterface:
-    ...     @staticmethod  # Not required, but will avoid IDE warnings
-    ...     @abstractdataset
-    ...     def my_abstract_dataset() -> str:
-    ...         return NotImplemented
-    ...
-    ...     @staticmethod
-    ...     @dataset
-    ...     def my_dataset(y: str = Option('Y')) -> str:
-    ...         return y
-    ...
-    >>> @MyInterface.implementation
-    ... class MyImplementation:
-    ...     @staticmethod
-    ...     def my_abstract_dataset(x: str = Option('X')) -> str:
-    ...         return x
-    ...
-    >>> options = {
-    ...     'X': 'x', 'Y': 'y',
-    ...     'MY_INTERFACE': {'IMPLEMENTATION': 'MY_IMPLEMENTATION'}
-    ... }
-    >>> print(MyInterface.my_abstract_dataset(options))  # x
-    >>> print(MyInterface.my_dataset(options))  # y
-
-    See Also
-    --------
-    :class:`Interface`
-    """
-
-    _dispatch: Optional[Union[str, Evaluatable[str]]]
-
-    def __init__(self, dispatch: Optional[Union[str, Evaluatable[str]]] = None):
-        self._dispatch = dispatch
-
-    @typing.overload
-    def __call__(self, cls: Type, /) -> Interface:
-        ...  # pragma: nocover
-
-    @typing.overload
-    def __call__(self, /, **kwargs: Any) -> "InterfaceFactory":
-        ...  # pragma: nocover
-
-    def __call__(
-        self,
-        cls: Optional[Type] = None,
-        /,
-        *,
-        dispatch: Optional[Union[str, Evaluatable[str]]] = None,
-        **kwargs,
-    ) -> Union[Interface, "InterfaceFactory"]:
-        factory = self
-
-        if dispatch is not None:
-            factory = factory.dispatch(dispatch)
-
-        if cls is not None:
-            return factory.wrap(cls)
-        else:
-            return factory
-
-    def wrap(self, cls: Type) -> Interface:
-        return Interface(
-            cls.__name__,
-            tuple(base for base in cls.__bases__ if base is not object),
-            {
-                **cls.__dict__,
-                **{"__qualname__": cls.__qualname__, "_dispatch": self._dispatch},
-            },
-        )
-
-    @staticmethod
-    def dispatch(dispatch: Union[str, Evaluatable[str]]):
-        return InterfaceFactory(dispatch=dispatch)
-
-
-interface = InterfaceFactory()
-
-
-class Implementation(type):
-    _interfaces: Tuple[Interface, ...]
-
-    def __new__(mcs, *args, **kwargs):
-        return super().__new__(mcs, *args)
-
-    def __init__(
-        cls,
-        *args,
-        interfaces: Tuple[Interface, ...] = tuple(),
-        alias: Optional[Alias] = None,
-        aliases: Optional[MultiAlias] = None,
-        **kwargs,
-    ):
-        if not (isinstance(interfaces, tuple) and len(interfaces)):
-            raise TypeError("Implementations must implement at least one Interface.")
-
-        cls._interfaces = interfaces
-
-        aliases = aliases or []
-        if alias is not None:
-            aliases.append(alias)
-
-        if not aliases:
-            aliases = default_aliases(cls)
-
-        for _interface in interfaces:
-            for name_, member in _interface._members.items():
-                overload = getattr(cls, name_, ...)
-                if overload is ...:
-                    if member.is_abstract:
-                        raise TypeError(
-                            f"Implementers of {_interface} " f"must implement {name_}"
-                        )
-                    else:
-                        for alias in aliases:
-                            member.register(alias, member.default)
-                        continue
-
-                if isinstance(overload, staticmethod):
-                    overload = overload.__get__(cls, None)
-
-                if isinstance(overload, FunctionType):
-                    overload = Overload(overload)
-                elif isinstance(overload, Overload):
-                    pass
-                elif isinstance(overload, Dataset):
-                    if overload.is_abstract:
-                        raise TypeError(
-                            "Implementations may not contain abstract " "datasets."
-                        )
-
-                    overload = overload.default
-                else:
-                    if not isinstance(overload, Evaluatable):
-                        overload = Value(overload)
-
-                    def _overload(dep=overload):
-                        return dep
-
-                    overload = Overload(_overload)
-
-                for alias in aliases:
-                    member.register(alias, overload)
-
-                setattr(cls, name_, overload)
-
-        super().__init__(*args)
diff --git a/labrea/iterable.py b/labrea/iterable.py
new file mode 100644
index 0000000..8866ba6
--- /dev/null
+++ b/labrea/iterable.py
@@ -0,0 +1,188 @@
+import itertools
+from typing import Dict, Iterable, Optional, Set, Tuple, TypeVar
+
+from confectioner.templating import set_dotted_key
+
+from .exceptions import EvaluationError
+from .option import WithOptions
+from .types import JSON, Evaluatable, MaybeEvaluatable, Options
+
+A = TypeVar("A", covariant=True)
+
+
+class Iter(Evaluatable[Iterable[A]]):
+    """A class representing multiple evaluatables as an iterable.
+
+    This is used to lazily evaluate multiple evaluatables in a single
+    operation. The result is an iterable of the evaluated values.
+
+    Arguments
+    ----------
+    *evaluatables : MaybeEvaluatable[A]
+        The evaluatables to evaluate.
+
+
+    Example Usage
+    -------------
+    >>> from labrea import Iter, Option
+    >>> i = Iter(Option('A.X'), Option('B.Y'))
+    >>> for value in i({'A': {'X': 1}, 'B': {'Y': 2}}):
+    ...     print(value)
+    1
+    2
+    """
+
+    evaluatables: Iterable[Evaluatable[A]]
+
+    def __init__(self, *evaluatables: Evaluatable[A]):
+        self.evaluatables = tuple(Evaluatable.ensure(e) for e in evaluatables)
+
+    def evaluate(self, options: Options) -> Iterable[A]:
+        """Evaluate the evaluatables and return an iterable of the results."""
+        return (evaluatable.evaluate(options) for evaluatable in self.evaluatables)
+
+    def validate(self, options: Options) -> None:
+        """Validate that all evaluatables can be evaluated."""
+        for evaluatable in self.evaluatables:
+            evaluatable.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        """Return the option keys required by the evaluatables."""
+        return {
+            key
+            for evaluatable in self.evaluatables
+            for key in evaluatable.keys(options)
+        }
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the option keys required by the evaluatables."""
+        return {
+            explanation
+            for evaluatable in self.evaluatables
+            for explanation in evaluatable.explain(options)
+        }
+
+    def __repr__(self) -> str:
+        return f"Iter({', '.join(map(repr, self.evaluatables))})"
+
+
+class Map(Evaluatable[Iterable[Tuple[Dict[str, JSON], A]]]):
+    """A class that represents the same evaluatable repeated over multiple options.
+
+    This is used to evaluate a single evaluatable multiple times with different
+    options. The result is an iterable of tuples, where the first element is the
+    options used to evaluate the evaluatable, and the second element is the
+    evaluated value. This is the Labrea equivalent of a for loop.
+
+    Arguments
+    ---------
+    evaluatable : Evaluatable[A]
+        The evaluatable to evaluate.
+    iterables : Dict[str, MaybeEvaluatable[Iterable[JSON]]]
+        A dictionary mapping option keys to iterables of options. The keys
+        can be dotted to represent nested options.
+
+
+    Example Usage
+    -------------
+    >>> from labrea import Map, Option, dataset
+    >>> @dataset
+    ... def hypotenuse(a: int = Option('A'), b: int = Option('B')) -> float:
+    ...     return (a ** 2 + b ** 2) ** 0.5
+    >>>
+    >>> hypotenuses = Map(hypotenuse, {'A': Option('X'), 'B': Option('Y')})
+    >>> for options, value in hypotenuses({'X': [3, 4], 'Y': [4, 5]}):
+    ...     print(options, value)
+    {'A': 3, 'B': 4} 5.0
+    {'A': 3, 'B': 5} 5.830951894845301
+    {'A': 4, 'B': 4} 5.656854249492381
+    {'A': 4, 'B': 5} 6.403124237432848
+    """
+
+    evaluatable: Evaluatable[A]
+    iterables: Dict[str, Evaluatable[Iterable[JSON]]]
+
+    def __init__(
+        self,
+        evaluatable: Evaluatable[A],
+        iterables: Dict[str, MaybeEvaluatable[Iterable[JSON]]],
+    ) -> None:
+        self.evaluatable = evaluatable
+        self.iterables = {
+            key: Evaluatable.ensure(value) for key, value in iterables.items()
+        }
+
+    def evaluate(self, options: Options) -> Iterable[Tuple[Dict[str, JSON], A]]:
+        """Evaluate the evaluatable with each set of options."""
+        return self._iter(options).evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        """Validate that both the option iterables and the evaluatable can be evaluated."""
+        self._iter(options).validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        """Return the option keys required by the evaluatable and the option iterables"""
+        return set().union(
+            self._iter(options).keys(options),
+            *(iterable.keys(options) for iterable in self.iterables.values()),
+        )
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the option keys required by the evaluatable and the option iterables"""
+        try:
+            return set().union(
+                self._iter(options or {}).explain(options),
+                *(iterable.explain(options) for iterable in self.iterables.values()),
+            )
+        except EvaluationError:
+            return (
+                self.evaluatable.explain(options) - self.iterables.keys()
+            ) | set().union(
+                *(iterable.explain(options) for iterable in self.iterables.values())
+            )
+
+    def _iter(
+        self, options: Options
+    ) -> Evaluatable[Iterable[Tuple[Dict[str, JSON], A]]]:
+        return Iter(
+            *(
+                Iter(
+                    Iter(*option_tuples).apply(dict),  # type: ignore
+                    WithOptions(  # type: ignore
+                        self.evaluatable, self._create_option_set(*option_tuples)
+                    ),
+                ).apply(lambda x: tuple(x))
+                for option_tuples in self._iterate_over_options(options)
+            )
+        )
+
+    @staticmethod
+    def _create_option_set(*args: Tuple[str, JSON]) -> Options:
+        options: Dict[str, JSON] = {}
+        for key, value in args:
+            set_dotted_key(key, value, options)
+
+        return options
+
+    def _iterate_over_options(
+        self, options: Options
+    ) -> Tuple[Tuple[Tuple[str, JSON], ...], ...]:
+        return tuple(
+            tuple(zip(self.iterables.keys(), values))
+            for values in itertools.product(
+                *(iterable.evaluate(options) for iterable in self.iterables.values())
+            )
+        )
+
+    def __repr__(self) -> str:
+        return f"Iterate({self.evaluatable!r}, {self.iterables!r})"
+
+    @property
+    def values(self) -> Evaluatable[Iterable[A]]:
+        """Strip the options from the results.
+
+        This is a convenience method that returns an evaluatable that
+        returns the second element of the tuples returned by the Map
+        evaluatable.
+        """
+        return self.apply(lambda items: (item[1] for item in items))
diff --git a/labrea/logging.py b/labrea/logging.py
new file mode 100644
index 0000000..939ec51
--- /dev/null
+++ b/labrea/logging.py
@@ -0,0 +1,196 @@
+import logging
+from typing import Optional, Set, TypeVar
+
+from . import runtime
+from .computation import Effect
+from .option import Option
+from .runtime import Request
+from .types import Evaluatable, Options
+
+A = TypeVar("A")
+
+
+class LogRequest(Request[None]):
+    """A request to log a message.
+
+    Arguments
+    ---------
+    level : int
+        The logging level (see the :mod:`logging` module).
+    name : str
+        The name of the logger; usually the module name.
+    msg : str
+        The message to log.
+    options : Options
+        The options used during evaluation
+    """
+
+    level: int
+    name: str
+    msg: str
+    options: Options
+
+    def __init__(self, level: int, name: str, msg: str, options: Options):
+        self.options = options
+        self.level = level
+        self.name = name
+        self.msg = msg
+
+
+def CRITICAL(name: str, msg: str, options: Options) -> None:
+    """Log a message at the CRITICAL level."""
+    return LogRequest(logging.CRITICAL, name, msg, options).run()
+
+
+def ERROR(name: str, msg: str, options: Options) -> None:
+    """Log a message at the ERROR level."""
+    return LogRequest(logging.ERROR, name, msg, options).run()
+
+
+def WARNING(name: str, msg: str, options: Options) -> None:
+    """Log a message at the WARNING level."""
+    return LogRequest(logging.WARNING, name, msg, options).run()
+
+
+def INFO(name: str, msg: str, options: Options) -> None:
+    """Log a message at the INFO level."""
+    return LogRequest(logging.INFO, name, msg, options).run()
+
+
+def DEBUG(name: str, msg: str, options: Options) -> None:
+    """Log a message at the DEBUG level."""
+    return LogRequest(logging.DEBUG, name, msg, options).run()
+
+
+def _disabled_logging_handler(request: LogRequest) -> None:
+    pass
+
+
+@LogRequest.handle
+def _builtin_logging_handler(request: LogRequest) -> None:
+    if Option("LABREA.LOGGING.DISABLED", False)(request.options):
+        return _disabled_logging_handler(request)
+
+    logging.getLogger(request.name).log(request.level, request.msg)
+
+
+def disabled() -> runtime.Runtime:
+    """Return a runtime that disables logging.
+
+    Can be used as a context manager to disable logging for the duration of the context block.
+
+
+    Example Usage
+    -------------
+    >>> import labrea.logging
+    >>> with labrea.logging.disabled():
+    ...     pass
+    """
+    return runtime.handle(LogRequest, _disabled_logging_handler)
+
+
+class LogEffect(Effect):
+    """An effect that logs a message.
+
+    Arguments
+    ---------
+    level : int
+        The logging level (see the :mod:`logging` module).
+    name : str
+        The name of the logger; usually the module name.
+    msg : str
+        The message to log.
+    """
+
+    level: int
+    name: str
+    msg: str
+
+    def __init__(self, level: int, name: str, msg: str):
+        self.level = level
+        self.name = name
+        self.msg = msg
+
+    def transform(self, value: None, options: Optional[Options] = None) -> None:
+        """Log the message."""
+        return LogRequest(self.level, self.name, self.msg, options or {}).run()
+
+    def validate(self, options: Options) -> None:
+        """Always validates"""
+        pass
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return an empty set, as no options are required."""
+        return set()
+
+
+class Logged(Evaluatable[A]):
+    """An Evaluatable that logs a message before (or after) evaluating another Evaluatable.
+
+    Arguments
+    ---------
+    evaluatable : Evaluatable[A]
+        The Evaluatable to evaluate.
+    level : int
+        The logging level (see the :mod:`logging` module).
+    name : str
+        The name of the logger; usually the module name.
+    msg : str
+        The message to log.
+    log_first : bool, optional
+        Whether to log the message before or after evaluating the Evaluatable. Default is True.
+    """
+
+    evaluatable: Evaluatable[A]
+    level: int
+    name: str
+    msg: str
+    log_first: bool
+
+    def __init__(
+        self,
+        evaluatable: Evaluatable[A],
+        level: int,
+        name: str,
+        msg: str,
+        log_first: bool = True,
+    ):
+        self.evaluatable = evaluatable
+        self.level = level
+        self.name = name
+        self.msg = msg
+        self.log_first = log_first
+
+    def _request(self, options: Options) -> LogRequest:
+        return LogRequest(self.level, self.name, self.msg, options)
+
+    def evaluate(self, options: Options) -> A:
+        """Evaluate the Evaluatable and log the message."""
+        if self.log_first:
+            self._request(options).run()
+            return self.evaluatable.evaluate(options)
+        else:
+            value = self.evaluatable.evaluate(options)
+            self._request(options).run()
+            return value
+
+    def validate(self, options: Options) -> None:
+        """Validate the Evaluatable."""
+        self.evaluatable.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        """Return the option keys required to evaluate the Evaluatable."""
+        return self.evaluatable.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the option keys required to evaluate the Evaluatable."""
+        return self.evaluatable.explain(options)
+
+    def __repr__(self) -> str:
+        return (
+            f"Logged({self.evaluatable!r}, {self.level!r}, "
+            f"{self.name!r}, {self.msg!r})"
+            if self.log_first
+            else f"Logged({self.evaluatable!r}, {self.level!r}, "
+            f"{self.name!r}, {self.msg!r}, log_first=False)"
+        )
diff --git a/labrea/multithreading.py b/labrea/multithreading.py
deleted file mode 100644
index 0d88310..0000000
--- a/labrea/multithreading.py
+++ /dev/null
@@ -1,29 +0,0 @@
-__PARALLEL: bool = True
-
-
-def is_parallel() -> bool:
-    """Return whether multithreading is enabled"""
-    return __PARALLEL
-
-
-def enable():
-    """Enable multithreading (enabled by default).
-
-    When multithreading is enabled, the `labrea` library will attempt to
-    evaluate dataset dependencies in multiple threads. This can speed up
-    evaluation of datasets that involve significant IO operations, but can
-    also cause issues if the datasets are not thread-safe.
-    """
-    global __PARALLEL
-    __PARALLEL = True
-
-
-def disable():
-    """Disable multithreading.
-
-    When multithreading is disabled, the `labrea` library will evaluate dataset
-    dependencies in a single thread. This is useful for debugging, or when
-    datasets are not thread-safe.
-    """
-    global __PARALLEL
-    __PARALLEL = False
diff --git a/labrea/mypy/plugin.py b/labrea/mypy/plugin.py
index e99ce82..684a377 100644
--- a/labrea/mypy/plugin.py
+++ b/labrea/mypy/plugin.py
@@ -1,53 +1,56 @@
 from typing import Callable, Optional
 
 from mypy.plugin import ClassDefContext, Plugin
-from mypy.plugins.common import add_attribute_to_class  # type: ignore
-from mypy.types import Instance
+from mypy.plugins.common import Instance, add_attribute_to_class  # type: ignore
 
-DATACLASSES_DECORATOR_FULLNAME = "labrea.datasetclasses.datasetclass"
-INTERFACE_DECORATOR_FULLNAME = "labrea.interfaces.interface"
-IMPLEMENTATION_DECO_TYPE_FULLNAME = "labrea.interfaces.ImplementationDecorator"
+DATASETCLASS_DECORATOR_FULLNAME = "labrea.datasetclass.datasetclass"
+INTERFACE_DECORATOR_FULLNAME = "labrea.interface.interface"
+IMPLEMENTATION_DECO_TYPE_FULLNAME = "labrea.interface._ImplDecoProto"
+EVALUATABLE_TYPE_FULLNAME = "labrea.evaluatable.Evaluatable"
 
 
 class LabreaPlugin(Plugin):
     def get_class_decorator_hook_2(
         self, fullname: str
     ) -> Optional[Callable[[ClassDefContext], bool]]:
-        """
-        For datasetclasses using the datasetclass decorator, tell mypy that
-        there is a .isntance attribute at the class level that returns an
-        instance of the class. In reality, this just returns the class itself,
-        but allows us to do this:
-        @datasetclass
-        class MyClass:
-            x: int = Option('X')
-        @dataset
-        def my_dataset(
-            inst: MyClass = MyClass.instance
-        ) -> int:
-            return inst.x
-        """
-        if fullname == DATACLASSES_DECORATOR_FULLNAME:
-            return self.add_result_attribute_callback
         if fullname == INTERFACE_DECORATOR_FULLNAME:
-            return self.add_implementation_attribute_callback
+            return self.interface_callback
+        if fullname == DATASETCLASS_DECORATOR_FULLNAME:
+            return self.datasetclass_callback
 
         return None
 
     @staticmethod
-    def add_result_attribute_callback(ctx: ClassDefContext):
+    def interface_callback(ctx: ClassDefContext):
+        for name, attribute in ctx.cls.info.names.copy().items():
+            if name.startswith("_"):
+                continue
+            add_attribute_to_class(  # type: ignore [call-arg]
+                api=ctx.api,
+                cls=ctx.cls,
+                name=name,
+                typ=ctx.api.named_type_or_none(  # type: ignore [arg-type]
+                    EVALUATABLE_TYPE_FULLNAME
+                ),
+                overwrite_existing=True,
+            )
+
         add_attribute_to_class(
-            api=ctx.api, cls=ctx.cls, name="result", typ=Instance(ctx.cls.info, [])
+            api=ctx.api,
+            cls=ctx.cls,
+            name="implementation",
+            typ=ctx.api.named_type_or_none(  # type: ignore [arg-type]
+                IMPLEMENTATION_DECO_TYPE_FULLNAME
+            ),
         )
+
         return True
 
     @staticmethod
-    def add_implementation_attribute_callback(ctx: ClassDefContext):
-        typ = ctx.api.named_type_or_none(IMPLEMENTATION_DECO_TYPE_FULLNAME)
-        if typ is not None:
-            add_attribute_to_class(
-                api=ctx.api, cls=ctx.cls, name="implementation", typ=typ
-            )
+    def datasetclass_callback(ctx: ClassDefContext):
+        add_attribute_to_class(
+            api=ctx.api, cls=ctx.cls, name="result", typ=Instance(ctx.cls.info, [])
+        )
         return True
 
 
diff --git a/labrea/option.py b/labrea/option.py
new file mode 100644
index 0000000..b78020c
--- /dev/null
+++ b/labrea/option.py
@@ -0,0 +1,243 @@
+from typing import Callable, Optional, Set, TypeVar
+
+from confectioner import mix
+from confectioner.templating import dotted_key_exists, get_dotted_key, resolve
+
+from ._missing import MISSING, MaybeMissing
+from .application import FunctionApplication
+from .exceptions import KeyNotFoundError
+from .template import Template
+from .types import JSON, Evaluatable, MaybeEvaluatable, Options
+
+A = TypeVar("A", covariant=True, bound=JSON)
+B = TypeVar("B", covariant=True)
+
+
+class Option(Evaluatable[A]):
+    """A class representing a single user-provided option.
+
+    Options are the singular way to provide user-input to an Evaluatable
+    object. They take a key, which is used to retrieve the value from the
+    options dictionary. If the key does not exist, a default value can be
+    provided.
+
+    Arguments
+    ----------
+    key : str
+        The key to retrieve from the options dictionary. This key can be
+        nested using the standard :code:`{NESTED.KEY}` syntax from
+        confectioner.
+    default : MaybeEvaluatable[A]
+        The default value to use if the key does not exist in the options
+        dictionary. If the default value is an Evaluatable, it is evaluated
+        using the options dictionary. If the default value is not an
+        Evaluatable, it is returned as-is. If the default value is a string,
+        it is treated as a :code:`Template` and evaluated using the options
+        dictionary.
+    default_factory : Callable[[], A]
+        A factory function that returns the default value to use if the key
+        does not exist in the options dictionary. This is an alternative to
+        providing a default value directly.
+    doc : str
+        The docstring for the option.
+
+
+    Example Usage
+    -------------
+    >>> from labrea import Option
+    >>> o = Option('A.X', default='foo')
+    >>> o()
+    'foo'
+    >>> o({'A': {'X': 'bar'}})
+    'bar'
+    """
+
+    key: str
+    default: MaybeMissing[Evaluatable[A]]
+
+    def __init__(
+        self,
+        key: str,
+        default: MaybeMissing[MaybeEvaluatable[A]] = MISSING,
+        default_factory: MaybeMissing[Callable[[], A]] = MISSING,
+        doc: str = "",
+    ) -> None:
+        self.key = key
+        if isinstance(default, str):
+            self.default = Template(default)  # type: ignore [assignment]
+        elif default is not MISSING:
+            self.default = Evaluatable.ensure(default)
+        elif default_factory is not MISSING:
+            self.default = FunctionApplication(default_factory)
+        else:
+            self.default = MISSING
+
+        self.__doc__ = doc
+
+    def evaluate(self, options: Options) -> A:
+        """Retrieves the key from the options dictionary.
+
+        If the key does not exist, the default key is returned. If no default
+        key is provided, an error is raised. If the default key is an
+        Evaluatable, it is evaluated using the options dictionary. If the
+        default key is not an Evaluatable, it is returned as-is.
+        """
+        try:
+            value = get_dotted_key(self.key, options)
+            return resolve(value, options)
+        except KeyError:
+            if self.default is MISSING:
+                raise KeyNotFoundError(self.key, self)
+            return self.default.evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        """Validates that the key exists in the options dictionary.
+
+        If the key does not exist, the default key is validated. If the
+        default key is an Evaluatable, it is validated using the options
+        dictionary. If the default key is not an Evaluatable, it is ignored.
+        """
+        if dotted_key_exists(self.key, options):
+            _ = self.keys(options)
+        elif self.default is not MISSING:
+            self.default.validate(options)
+        else:
+            raise KeyNotFoundError(self.key, self)
+
+    def keys(self, options: Options) -> Set[str]:
+        """Returns the keys required by the option.
+
+        Usually this is just the provided key, but if the default value is a
+        string, the keys required by the template are also returned. Similarly,
+        if the default value is an Evaluatable, the keys required by the
+        Evaluatable are also returned.
+        """
+        if dotted_key_exists(self.key, options):
+            value = get_dotted_key(self.key, options)
+            if isinstance(value, str):
+                return {self.key} | Template(value).keys(options)
+            else:
+                return {self.key}
+        elif self.default is not MISSING:
+            return self.default.keys(options)
+        else:
+            raise KeyNotFoundError(self.key, self)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Returns the keys required by the option."""
+        options = options or {}
+        if dotted_key_exists(self.key, options):
+            value = get_dotted_key(self.key, options)
+            if isinstance(value, str):
+                return {self.key} | Template(value).explain(options)
+            else:
+                return {self.key}
+        elif self.default is not MISSING:
+            return self.default.explain(options)
+        else:
+            return {self.key}
+
+    def __repr__(self) -> str:
+        return (
+            f"Option({self.key!r}, default={self.default!r})"
+            if self.default is not MISSING
+            else f"Option({self.key!r})"
+        )
+
+
+class WithOptions(Evaluatable[B]):
+    """A class that wraps an Evaluatable object and provides default options.
+
+    This class is used to provide default options to an Evaluatable object. The
+    default options are mixed with the provided options. The default options
+    can be forced to take precedence over the provided options (default) or
+    the provided options can take precedence over the default options.
+
+    Arguments
+    ----------
+    evaluatable : Evaluatable[B]
+        The evaluatable object to wrap.
+    options : Options
+        The default options to provide to the evaluatable object.
+    force : bool, optional
+        If True, the default options take precedence over the provided options.
+        If False, the provided options take precedence over the default options.
+        Default is True.
+    """
+
+    evaluatable: Evaluatable[B]
+    options: Options
+    force: bool
+
+    def __init__(
+        self, evaluatable: Evaluatable[B], options: Options, force: bool = True
+    ) -> None:
+        self.evaluatable = evaluatable
+        self.options = options
+        self.force = force
+
+    def _options(self, options: Options) -> Options:
+        return (
+            mix(options, self.options)  # type: ignore
+            if self.force
+            else mix(self.options, options)  # type: ignore
+        )
+
+    def evaluate(self, options: Options) -> B:
+        """Evaluate the wrapped Evaluatable object with the provided options."""
+        return self.evaluatable.evaluate(self._options(options))
+
+    def validate(self, options: Options) -> None:
+        """Validate the wrapped Evaluatable object with the provided options."""
+        self.evaluatable.validate(self._options(options))
+
+    def keys(self, options: Options) -> Set[str]:
+        """Return the keys required by the wrapped Evaluatable object."""
+        return {
+            key
+            for key in self.evaluatable.keys(self._options(options))
+            if not (
+                dotted_key_exists(key, self.options)
+                and (self.force or not dotted_key_exists(key, options))
+            )
+        }
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the explanation for the wrapped Evaluatable object."""
+        options = options or {}
+        return {
+            key
+            for key in self.evaluatable.explain(self._options(options))
+            if not (
+                dotted_key_exists(key, self.options)
+                and (self.force or not dotted_key_exists(key, options))
+            )
+        }
+
+    def __repr__(self) -> str:
+        return (
+            f"WithOptions({self.evaluatable!r}, {self.options!r})"
+            if self.force
+            else f"WithDefaultOptions({self.evaluatable!r}, {self.options!r})"
+        )
+
+
+def WithDefaultOptions(evaluatable: Evaluatable[B], options: Options) -> WithOptions[B]:
+    """Wrap an Evaluatable object with default options.
+
+    This is a convenience function for creating a WithOptions object
+    with :code:`force=False`.
+
+    Arguments
+    ----------
+    evaluatable : Evaluatable[B]
+        The evaluatable object to wrap.
+    options : Options
+        The default options to provide to the evaluatable object.
+
+    Returns
+    -------
+    WithOptions[B]
+        The wrapped evaluatable object with default options.
+    """
+    return WithOptions(evaluatable, options, force=False)
diff --git a/labrea/options.py b/labrea/options.py
deleted file mode 100644
index da7f1cd..0000000
--- a/labrea/options.py
+++ /dev/null
@@ -1,135 +0,0 @@
-from typing import Callable, Set, TypeVar, Union
-
-from confectioner.templating import dotted_key_exists, get_dotted_key, resolve
-
-from .template import Template
-from .types import Evaluatable, EvaluationError, JSONDict, ValidationError, Value
-
-A = TypeVar("A")
-
-
-MaybeEvaluatableA = Union[A, Evaluatable[A]]
-
-
-class Unreachable(Evaluatable[A]):
-    def evaluate(self, options: JSONDict) -> A:
-        raise EvaluationError("UNREACHABLE")  # pragma: nocover
-
-    def validate(self, options: JSONDict) -> None:
-        raise ValidationError("UNREACHABLE")  # pragma: nocover
-
-    def keys(self, options: JSONDict) -> Set[str]:
-        return set()  # pragma: nocover
-
-
-_UNREACHABLE: Evaluatable = Unreachable()
-
-
-class Option(Evaluatable[A]):
-    """Retrieves a single entry from the options dictionary.
-
-    If the key does not exist, the default value is returned. If no default
-    value is provided, an error is raised. If the default value is an
-    Evaluatable, it is evaluated using the options dictionary. If the
-    default value is not an Evaluatable, it is returned as-is. A
-    default_factory can be provided instead of a default value.
-
-    Parameters
-    ----------
-    key : str
-        The key to retrieve from the options dictionary.
-    default : Evaluatable[A] | A, optional
-        The default value to return if the key does not exist
-        in the options dictionary
-    default_factory : Callable[[], Evaluatable[A] | A], optional
-        A function that returns the default value to return if the key
-        does not exist in the options dictionary.
-    doc : str, optional
-        The docstring for the Option object
-    """
-
-    key: str
-    default: Evaluatable[A]
-    default_factory: Callable[[], MaybeEvaluatableA] = lambda: _UNREACHABLE
-
-    def __init__(
-        self,
-        key: str,
-        default: MaybeEvaluatableA = _UNREACHABLE,
-        default_factory: Callable[[], MaybeEvaluatableA] = lambda: _UNREACHABLE,
-        doc: str = "",
-    ):
-        if default is _UNREACHABLE:
-            default = default_factory()
-
-        if isinstance(default, str):
-            default = Template(default)
-        elif not isinstance(default, Evaluatable):
-            default = Value(default)
-
-        self.key = key
-        self.default = default  # type: ignore [assignment]
-        self.default_factory = default_factory
-        self.__doc__ = doc
-
-    def evaluate(self, options: JSONDict) -> A:
-        """Retrieves the value from the options dictionary.
-
-        If the key does not exist, the default value is returned. If no default
-        value is provided, an error is raised. If the default value is an
-        Evaluatable, it is evaluated using the options dictionary. If the
-        default value is not an Evaluatable, it is returned as-is.
-        """
-        try:
-            value = get_dotted_key(self.key, options)
-            if self.resolving(options):
-                return resolve(value, options)
-            else:
-                return value
-        except KeyError:
-            if not self.has_default:
-                raise EvaluationError(self.key)
-            return self.default.evaluate(options)
-
-    def validate(self, options: JSONDict) -> None:
-        """
-        Validates that the key exists in the options dictionary, or that
-        a default value is provided and can be validated against the options
-        dictionary.
-        """
-        if not self.has_default and not dotted_key_exists(self.key, dict(options)):
-            raise ValidationError(self.key)
-
-        if self.resolving(options):
-            try:
-                resolve(get_dotted_key(self.key, options), options)
-            except KeyError as e:
-                if self.has_default:
-                    self.default.validate(options)
-                else:
-                    raise ValidationError(self.key) from e
-
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Returns the keys that this object depends on.
-
-        This method returns the key that this object depends on, if it exists
-        in the options dictionary. If the key does not exist, the default keys
-        are returned.
-        """
-        return (
-            {self.key}
-            if dotted_key_exists(self.key, dict(options))
-            else self.default.keys(options)
-        )
-
-    @property
-    def has_default(self):
-        return self.default is not _UNREACHABLE
-
-    @staticmethod
-    def resolving(options: JSONDict) -> bool:
-        return (
-            options.get("LABREA", {})  # type: ignore
-            .get("OPTIONS", {})
-            .get("RESOLVE", True)
-        )
diff --git a/labrea/overload.py b/labrea/overload.py
new file mode 100644
index 0000000..681a978
--- /dev/null
+++ b/labrea/overload.py
@@ -0,0 +1,98 @@
+import sys
+
+if sys.version_info < (3, 10):
+    from typing_extensions import ParamSpec
+else:
+    from typing import ParamSpec
+
+import threading
+from typing import Dict, Hashable, Optional, Set, TypeVar
+
+from ._missing import MISSING, MaybeMissing
+from .conditional import switch
+from .types import Evaluatable, MaybeEvaluatable, Options
+
+A = TypeVar("A", covariant=True)
+P = ParamSpec("P")
+
+
+class Overloaded(Evaluatable[A]):
+    """A class representing multiple implementations of an Evaluatable.
+
+    This class generalizes the idea of dataset overloads. It takes a dispatch,
+    which is an evaluatable (like an Option) that returns a key, and a lookup
+    dictionary, which maps keys to evaluatables. When the overloaded object is
+    evaluated, it uses the dispatch to determine which evaluatable to use, and
+    then evaluates that evaluatable. An optional default evaluatable can be
+    provided, which is used if the dispatch key is not found in the lookup
+    dictionary, or if the dispatch cannot be evaluated.
+
+    Arguments
+    ----------
+    dispatch : Evaluatable[Hashable]
+        The evaluatable that determines which implementation to use.
+    lookup : Dict[Hashable, Evaluatable[A]], optional
+        A dictionary mapping keys to evaluatables.
+    default : MaybeMissing[MaybeEvaluatable[A]], optional
+        The default evaluatable to use if the dispatch key is not found in the
+        lookup dictionary. If this is not provided, an exception will be raised
+        if the dispatch key is not found.
+    """
+
+    dispatch: Evaluatable[Hashable]
+    lookup: Dict[Hashable, Evaluatable[A]]
+    default: MaybeMissing[Evaluatable[A]]
+    _lock: threading.Lock
+
+    def __init__(
+        self,
+        dispatch: Evaluatable[Hashable],
+        lookup: Optional[Dict[Hashable, Evaluatable[A]]],
+        default: MaybeMissing[MaybeEvaluatable[A]] = MISSING,
+    ):
+        self.dispatch = dispatch
+        self.lookup = lookup or {}
+        self.default = (
+            Evaluatable.ensure(default) if default is not MISSING else default
+        )
+        self._lock = threading.Lock()
+
+    def evaluate(self, options: Options) -> A:
+        """Evaluate the dispatch, and then evaluate the selected implementation."""
+        return self.switch.evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        """Validate the dispatch and the selected implementation."""
+        self.switch.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        """Return the option keys required by the dispatch and the selected implementation.""" ""
+        return self.switch.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the option keys required by the dispatch and the selected implementation."""
+        return self.switch.explain(options)
+
+    def __repr__(self) -> str:
+        if self.default is MISSING:
+            return f"Overloaded({self.dispatch!r}, {self.lookup!r})"
+
+        return f"Overloaded({self.dispatch!r}, {self.lookup!r}, {self.default!r})"
+
+    def register(self, key: Hashable, value: Evaluatable[A]) -> None:
+        """Register a new implementation with the overloaded object.
+
+        Arguments
+        ----------
+        key : Hashable
+            The key for the implementation.
+        value : Evaluatable[A]
+            The implementation to register.
+        """
+        with self._lock:
+            self.lookup = {**self.lookup, key: value}
+
+    @property
+    def switch(self) -> Evaluatable[A]:
+        """Return the switch evaluatable that determines which implementation to use."""
+        return switch(self.dispatch, self.lookup, default=self.default)
diff --git a/labrea/pipeline.py b/labrea/pipeline.py
new file mode 100644
index 0000000..e1d5451
--- /dev/null
+++ b/labrea/pipeline.py
@@ -0,0 +1,235 @@
+import sys
+
+if sys.version_info < (3, 10):
+    from typing_extensions import Concatenate, ParamSpec
+else:
+    from typing import Concatenate, ParamSpec
+
+from typing import (
+    Any,
+    Callable,
+    Generic,
+    Iterable,
+    Iterator,
+    Optional,
+    Set,
+    TypeVar,
+    cast,
+    overload,
+)
+
+from .application import PartialApplication
+from .types import Evaluatable, MaybeEvaluatable, Options, Transformation, Value
+
+A = TypeVar("A")
+B = TypeVar("B", covariant=True)
+C = TypeVar("C")
+P = ParamSpec("P")
+
+
+class PipelineStep(Evaluatable[Callable[[A], B]], Transformation[A, B]):
+    """A class representing a single step in a pipeline.
+
+    A pipeline step is a single transformation that can be applied to a value.
+    Steps can be composed into pipelines using the :code:`+` operator.
+
+    This class should probably not be used directly. Instead, use the :code:`pipeline_step`
+    """
+
+    step: Evaluatable[Callable[[A], B]]
+    _name: Optional[str]
+
+    def __init__(
+        self, step: Evaluatable[Callable[[A], B]], _name: Optional[str] = None
+    ) -> None:
+        self.step = step
+        self._name = _name
+
+    def evaluate(self, options: Options) -> Callable[[A], B]:
+        """Evaluate the pipeline step, returning a function that applies the transformation."""
+        return self.step.evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        """Validate the pipeline step."""
+        self.step.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        """Return the option keys required by the pipeline step."""
+        return self.step.keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the option keys required by the pipeline step."""
+        return self.step.explain(options)
+
+    def transform(self, value: A, options: Optional[Options] = None) -> B:
+        """Transform a value using the pipeline step.
+
+        Arguments
+        ---------
+        value : A
+            The value to transform.
+        options : Optional[Options]
+            The options to use when transforming the value.
+
+        Returns
+        -------
+        B
+            The transformed value.
+        """
+        return self(options)(value)
+
+    def __repr__(self) -> str:
+        return f"<PipelineStep {self._name or repr(self.step)}>"
+
+    def __add__(self, other: MaybeEvaluatable[Callable[[B], C]]) -> "Pipeline[A, C]":
+        base: Pipeline[A, B] = Pipeline(self)
+        return base + other
+
+    def __eq__(self, other: Any) -> bool:
+        return isinstance(other, PipelineStep) and self.step == other.step
+
+
+class _Identity(Generic[A], PipelineStep[A, A]):
+    def __new__(cls) -> "_Identity[A]":
+        return super().__new__(cls)
+
+    def __init__(self) -> None:
+        super().__init__(Value(lambda x: x), "Identity")
+
+
+Identity: _Identity = _Identity()
+
+
+class Pipeline(
+    Evaluatable[Callable[[A], B]],
+    Iterable[Evaluatable[Callable[[Any], Any]]],
+    Transformation[A, B],
+):
+    """A class representing a pipeline of transformations.
+
+    A pipeline is a sequence of transformations that can be applied to a value.
+    Pipelines are implemented as a linked list of PipelineSteps, where each step
+    is a single transformation that can be applied to a value. Pipelines, like
+    PipelineSteps, can be composed using the :code:`+` operator.
+
+    This class should probably not be used directly. Instead, use the :code:`pipeline_step`
+    and compose pipelines using the :code:`+` operator.
+    """
+
+    tail: PipelineStep[Any, B]
+    rest: Optional["Pipeline[A, Any]"]
+
+    @overload
+    def __new__(cls) -> "Pipeline[A, A]":
+        ...  # pragma: nocover
+
+    @overload
+    def __new__(cls, tail: PipelineStep[B, C]) -> "Pipeline[B, C]":
+        ...  # pragma: nocover
+
+    @overload
+    def __new__(
+        cls, tail: PipelineStep[B, C], rest: "Pipeline[A, B]"
+    ) -> "Pipeline[A, C]":
+        ...  # pragma: nocover
+
+    def __new__(
+        cls,
+        tail: PipelineStep[B, C] = Identity,
+        rest: Optional["Pipeline[A, B]"] = None,
+    ):
+        return super().__new__(cls)
+
+    def __init__(
+        self,
+        tail: PipelineStep[Any, B] = Identity,
+        rest: Optional["Pipeline[A, Any]"] = None,
+    ) -> None:
+        if rest is not None and rest.empty:
+            rest = None
+
+        self.tail = tail
+        self.rest = rest
+
+    def evaluate(self, options: Options) -> Callable[[A], B]:
+        """Evaluate the pipeline, returning a function that applies all transformations."""
+        tail = self.tail.evaluate(options)
+        rest = self.rest.evaluate(options) if self.rest else lambda x: x
+        return lambda x: tail(rest(x))
+
+    def validate(self, options: Options) -> None:
+        """Validate the pipeline."""
+        self.tail.validate(options)
+        if self.rest:
+            self.rest.validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        """Return the option keys required by the pipeline."""
+        return self.tail.keys(options) | (
+            self.rest.keys(options) if self.rest else set()
+        )
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the option keys required by the pipeline."""
+        return self.tail.explain(options) | (
+            self.rest.explain(options) if self.rest else set()
+        )
+
+    def transform(self, value: A, options: Optional[Options] = None) -> B:
+        """Transform a value using the pipeline."""
+        return self(options)(value)
+
+    def __repr__(self) -> str:
+        if self.empty:
+            return "<Pipeline Identity>"
+        return f'<Pipeline {" + ".join(map(repr, self))}>'
+
+    def __add__(self, other: MaybeEvaluatable[Callable[[B], C]]) -> "Pipeline[A, C]":
+        if isinstance(other, PipelineStep):
+            return Pipeline(other, self)
+        elif isinstance(other, Pipeline):
+            if other.empty:
+                return cast(Pipeline[A, C], self)
+            elif other.rest is None:
+                return Pipeline(other.tail, self)
+            return (self + other.rest) + other.tail
+        else:
+            return Pipeline(PipelineStep(Evaluatable.ensure(other)), self)
+
+    def __iter__(self) -> Iterator[Evaluatable[Callable[[Any], Any]]]:
+        if self.rest is not None:
+            yield from self.rest
+        yield self.tail
+
+    @property
+    def empty(self) -> bool:
+        return self.tail == Identity and self.rest is None
+
+
+def pipeline_step(func: Callable[Concatenate[A, P], B]) -> "PipelineStep[A, B]":
+    """Create a pipeline step from a function.
+
+    This is the primary way to create a pipeline step. The function should be used
+    as a decorator on the function that will be used as the transformation. The
+    function should take at least one argument. The first argument will be the value
+    to transform; the remaining arguments will be the parameters for the transformation,
+    and should have default values similar to a dataset definition.
+
+
+    Example Usage
+    -------------
+    >>> @pipeline_step
+    ... def add(x: int, y: int = Option('AMOUNT', 1)) -> int:
+    ...     return x + y
+    >>>
+    >>> @pipeline_step
+    ... def multiply(x: int, y: int = Option('FACTOR', 2)) -> int:
+    ...     return x * y
+    >>>
+    >>> pipeline = add + multiply
+    >>> pipeline.transform(1)
+    4
+    >>> pipeline.transform(1, {'AMOUNT': 2, 'FACTOR': 3})
+    9
+    """
+    return PipelineStep(PartialApplication.lift(func), getattr(func, "__name__", None))
diff --git a/labrea/pipelines.py b/labrea/pipelines.py
deleted file mode 100644
index 318eb6b..0000000
--- a/labrea/pipelines.py
+++ /dev/null
@@ -1,155 +0,0 @@
-import functools
-import inspect
-import sys
-import warnings
-from typing import Callable, Generic, Optional, TypeVar
-
-from functional_pypelines.core import Pipeline
-from functional_pypelines.validator import (
-    FAILURE,
-    SUCCESS,
-    ValidationResult,
-    ValidatorPipeline,
-)
-
-from .collections import DatasetDict
-from .types import Evaluatable, JSONDict, ValidationError, Value
-
-if sys.version_info < (3, 10):
-    from typing_extensions import Concatenate, ParamSpec
-else:
-    from typing import Concatenate, ParamSpec
-
-
-A = TypeVar("A")
-B = TypeVar("B")
-P = ParamSpec("P")
-
-
-# -----------------------------------------------------------------------------
-# Core of Labrea Pypeline
-# -----------------------------------------------------------------------------
-class LabreaPipelineData(Generic[A]):
-    value: A
-    options: JSONDict
-
-    def __init__(self, value: A, options: Optional[JSONDict] = None):
-        self.value = value
-        self.options = options or {}
-
-    def __eq__(self, other):
-        if not isinstance(other, LabreaPipelineData):
-            return False
-
-        return (self.value, self.options) == (other.value, other.options)
-
-    def __repr__(self):
-        return (
-            f"{self.__class__.__name__}("
-            f"{repr(self.value)}, "
-            f"options={repr(self.options)})"
-        )
-
-
-@Pipeline.step
-def _unwrap_pipeline_data(data: LabreaPipelineData):
-    return data.value
-
-
-class LabreaPipeline(Pipeline[LabreaPipelineData[A], LabreaPipelineData[B]]):
-    """Subclass of functional_pypelines.core.Pipeline for use with Labrea"""
-
-    @staticmethod
-    def default_data() -> LabreaPipelineData[None]:
-        return LabreaPipelineData(None)
-
-    @classmethod
-    def step(cls, f: Callable[Concatenate[A, P], B]) -> "LabreaPipeline[A, B]":
-        """Decorator to create a new LabreaPipeline step from a function
-
-        LabreaPipeline steps are created similarly to Datasets, but with
-        a single first argument with no default value. The first argument
-        is the value of the previous step in the pipeline. All other arguments
-        must have default values, which will be evaluated using the options
-        dictionary passed to the pipeline.
-
-        Example Usage
-        -------------
-        >>> from labrea import LabreaPipeline, Option
-        >>> @LabreaPipeline.step
-        ... def add_n(x: int, n: int = Option('N')) -> int:
-        ...    return x + n
-        >>>
-        >>> @LabreaPipeline
-        ... def multiply_by_m(x: int, m: int = Option('M')) -> int:
-        ...    return x * m
-        >>>
-        >>> pipeline = add_n >> multiply_by_m
-        >>> print(pipeline(1, options={'N': 2, 'M': 3})) # 9
-        """
-        signature = inspect.signature(f)
-        for i, (key, val) in enumerate(signature.parameters.items()):
-            if i == 0 and val.default is not signature.empty:
-                warnings.warn(
-                    f"Argument {key} has default value {val.default} that "
-                    f"that will be ignored",
-                    stacklevel=2,
-                )
-            elif i > 0 and val.default is signature.empty:
-                raise TypeError(f"Argument {key} must have a default value")
-        kwargs = DatasetDict(
-            {
-                key: (
-                    val.default
-                    if isinstance(val.default, Evaluatable)
-                    else Value(val.default)
-                )
-                for key, val in list(signature.parameters.items())[1:]
-            }
-        )
-
-        @functools.wraps(f)
-        def _step(data: LabreaPipelineData[A]) -> LabreaPipelineData[B]:
-            return LabreaPipelineData(
-                f(
-                    data.value,  # type: ignore [arg-type]
-                    **kwargs.evaluate(data.options),
-                ),
-                data.options,
-            )
-
-        setattr(_step, "_labrea_kwargs", kwargs)
-
-        return cls(_step)
-
-    @classmethod
-    def wrap(cls, *args, **kwargs) -> "LabreaPipelineData":
-        if len(args) and isinstance(args[0], LabreaPipelineData):
-            return args[0]
-        elif len(args) == 0 and "value" not in kwargs:
-            return LabreaPipelineData(None, **kwargs)
-
-        return LabreaPipelineData(*args, **kwargs)
-
-    @property
-    def base_validator(self) -> ValidatorPipeline:
-        return validator
-
-    unwrap = _unwrap_pipeline_data
-
-
-# -----------------------------------------------------------------------------
-# Validator Steps
-# -----------------------------------------------------------------------------
-@ValidatorPipeline.step
-def validator(pipeline: LabreaPipeline, data: LabreaPipelineData) -> ValidationResult:
-    for step in pipeline:
-        try:
-            step_kwargs: DatasetDict = getattr(
-                step, "_labrea_kwargs", DatasetDict({})  # type: ignore [arg-type]
-            )
-            step_kwargs.validate(data.options)
-        except ValidationError as e:
-            return FAILURE(e.args[0])
-
-    return SUCCESS
diff --git a/labrea/runtime.py b/labrea/runtime.py
new file mode 100644
index 0000000..237af5f
--- /dev/null
+++ b/labrea/runtime.py
@@ -0,0 +1,245 @@
+import sys
+
+if sys.version_info < (3, 11):
+    from typing_extensions import Self
+else:
+    from typing import Self
+
+import threading
+from typing import Callable, Dict, Generic, Mapping, Optional, Type, TypeVar, Union
+
+from .types import Options
+
+A = TypeVar("A", covariant=True)
+R = TypeVar("R", covariant=True, bound="Request")
+
+
+lock = threading.Lock()
+
+
+Handler = Callable[[R], A]
+
+
+class Request(Generic[A]):
+    """Base class for all requests.
+
+    Requests are objects that are passed to the runtime to be handled by a
+    handler. The handler is responsible for processing the request and returning
+    a result. Requests are used to represent side effects using data, and allow
+    the runtime to execute the side effects in a controlled manner.
+    """
+
+    options: Options
+
+    def run(self) -> A:
+        """Runs the request using the current runtime, returning the result.
+
+        This method should not be overridden.
+        """
+        return current_runtime().run(self)
+
+    @classmethod
+    def handle(cls, handler: Handler[Self, A]) -> Handler[Self, A]:
+        """Decorator to register a default handler for a request type.
+
+        This method should not be overridden.
+
+        Arguments
+        ----------
+        handler : Handler[Self, A]
+            The handler to register for the request type.
+
+        Returns
+        -------
+        Handler[Self, A]
+            The handler that was registered.
+
+
+        Example Usage
+        -------------
+        >>> class PrintRequest(Request[None]):
+        ...     msg: str
+        ...     def __init__(self, msg: str, options: Options) -> None:
+        ...         self.msg = msg
+        ...         self.options = options
+        >>>
+        >>> @MyRequest.handle
+        ... def print_handler(request: MyRequest) -> str:
+        ...     print(request.msg)
+        >>>
+        >>> PrintRequest("Hello, world!", {}).run()
+        Hello, world!
+        """
+        handle_by_default(cls, handler)
+        return handler
+
+
+_DEFAULT_HANDLERS: Dict[Type[Request], Handler] = {}
+
+
+class Runtime:
+    """A class that manages the execution of requests via handlers.
+
+    Runtimes are used to execute side effects in a controlled manner. They
+    maintain a mapping of request types to handlers, and execute the appropriate
+    handler for each request.
+
+    Can be used as a context manager, which sets the current runtime for the
+    duration of the context block for the current thread.
+
+    Arguments
+    ----------
+    handlers : Optional[Mapping[Type[Request], Handler]]
+        A mapping of request types to handlers. If provided, these handlers will
+        be used in place of the default handlers.
+    """
+
+    handlers: Mapping[Type[Request], Handler]
+    previous: Optional["Runtime"]
+
+    def __init__(
+        self, handlers: Optional[Mapping[Type[Request], Handler]] = None
+    ) -> None:
+        self.handlers = {**_DEFAULT_HANDLERS, **(handlers or {})}
+        self.previous = None
+
+    def handle(
+        self,
+        request: Union[Type[R], Mapping[Type[Request], Handler]],
+        handler: Optional[Handler[R, A]] = None,
+    ) -> "Runtime":
+        """Creates a new runtime with the provided request type and handler.
+
+        This method returns a new runtime with the provided request type and
+        handler registered. The new runtime will inherit the handlers from the
+        current runtime, and will override any handlers for the provided request
+        type.
+
+        Arguments
+        ----------
+        request : Union[Type[R], Mapping[Type[Request], Handler]]
+            The request type to handle, or a mapping of request types to handlers.
+        handler : Optional[Handler[R, A]]
+            The handler to register for the request type.
+
+        Returns
+        -------
+        Runtime
+            A new runtime with the provided request type and handler registered.
+
+        Raises
+        ------
+        TypeError
+            If the request is not a request type or a mapping of request types to
+            handlers.
+        """
+        if isinstance(request, Mapping):
+            return Runtime({**self.handlers, **request})
+        elif isinstance(request, type) and callable(handler):
+            return Runtime({**self.handlers, request: handler})  # type: ignore
+
+        raise TypeError(
+            "Runtime.handle() requires either a request type and handler, "
+            "or a mapping of request types to handlers."
+        )
+
+    def run(self, request: Request[A]) -> A:
+        """Runs the provided request using the registered handler.
+
+        This is not meant to be called directly. Instead, use the `run()` method
+        on the request object.
+
+        Arguments
+        ----------
+        request : Request[A]
+            The request to run.
+
+        Returns
+        -------
+        A
+            The result of running the request with the correct handler.
+        """
+
+        try:
+            handler = self.handlers[type(request)]
+        except KeyError as e:
+            raise TypeError(
+                f"No handler for request type {type(request).__qualname__}"
+            ) from e
+
+        return handler(request)
+
+    def __enter__(self):
+        with lock:
+            self.previous = _RUNTIMES.get(threading.current_thread())
+            _RUNTIMES[threading.current_thread()] = self
+            return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        with lock:
+            _RUNTIMES[threading.current_thread()] = self.previous
+            self.previous = None
+
+
+_RUNTIMES: Dict[threading.Thread, Runtime] = {}
+
+
+def current_runtime() -> Runtime:
+    """Returns the current runtime for the current thread."""
+    with lock:
+        return _RUNTIMES.setdefault(threading.current_thread(), Runtime())
+
+
+def handle(
+    request: Union[Type[R], Mapping[Type[Request], Handler]],
+    handler: Optional[Handler[R, A]] = None,
+) -> Runtime:
+    """Alias for current_runtime().handle(request, handler).
+
+    This is useful when using the runtime as a context manager.
+
+    Arguments
+    ----------
+    request : Union[Type[R], Mapping[Type[Request], Handler]]
+        The request type to handle, or a mapping of request types to handlers.
+    handler : Optional[Handler[R, A]]
+        The handler to register for the request type.
+
+    Returns
+    -------
+    Runtime
+        A new runtime with the provided request type and handler registered
+
+
+    Example Usage
+    -------------
+    >>> with handle(MyRequest, my_handler):
+    ...     MyRequest("Hello, world!", {}).run()
+    """
+    return current_runtime().handle(request, handler)
+
+
+def handle_by_default(request: Type[R], handler: Handler[R, A]) -> None:
+    """Registers a default handler for a request type.
+
+    Arguments
+    ----------
+    request : Type[R]
+        The request type to handle.
+    handler : Handler[R, A]
+        The handler to register for the request type.
+    """
+    with lock:
+        _DEFAULT_HANDLERS[request] = handler
+
+
+def inherit(parent: threading.Thread) -> None:
+    """Inherit the runtime from a parent thread.
+
+    Arguments
+    ----------
+    parent : threading.Thread
+        The parent thread to inherit the runtime from.
+    """
+    with lock:
+        _RUNTIMES[threading.current_thread()] = _RUNTIMES.get(parent, Runtime())
diff --git a/labrea/switch.py b/labrea/switch.py
deleted file mode 100644
index 4d7d65b..0000000
--- a/labrea/switch.py
+++ /dev/null
@@ -1,133 +0,0 @@
-from typing import Dict, Set, TypeVar, Union
-
-from .options import _UNREACHABLE, Option
-from .types import (
-    Evaluatable,
-    EvaluationError,
-    JSONDict,
-    JSONType,
-    ValidationError,
-    Value,
-)
-
-A = TypeVar("A")
-
-
-class Switch(Evaluatable[A]):
-    """A switch statement that evaluates to one of several Evaluatables
-
-    Takes an Evaluatable that evaluates to a value, and a dict of values to
-    lookup. If the value is in the dict, evaluates to the corresponding
-    Evaluatable. If the value is not in the dict, evaluates to the default
-    Evaluatable (if provided). If no default is provided, raises an
-    EvaluationError.
-    """
-
-    option: Evaluatable
-    lookup: Dict[JSONType, Evaluatable[A]]
-    default: Evaluatable[A]
-
-    def __init__(
-        self,
-        option: Union[str, Evaluatable[JSONType]],
-        lookup: Dict[JSONType, Union[Evaluatable[A], A]],
-        default: Evaluatable[A] = _UNREACHABLE,
-    ):
-        """Create a new Switch Evaluatable
-
-        Parameters
-        ----------
-        option : Union[str, Evaluatable[JSONType]]
-            The Evaluatable that evaluates to the value to lookup.
-            If a string is provided, it will be wrapped in an Option.
-        lookup : Dict[JSONType, Union[Evaluatable[A], A]]
-            The dict of values to lookup. If a value is not in the dict, the
-            default will be used.
-        default : Evaluatable[A]
-            The default Evaluatable to use if the value is not in the dict.
-            If not provided, an EvaluationError will be raised if the value is
-            not in the lookup dict.
-        """
-        self.option = option if isinstance(option, Evaluatable) else Option(option)
-        self.lookup = {
-            key: val if isinstance(val, Evaluatable) else Value(val)
-            for key, val in lookup.items()
-        }
-        self.default = default
-
-    def get_dispatch(self, options: JSONDict) -> Evaluatable[A]:
-        try:
-            option_val = self.option.evaluate(options)
-        except EvaluationError:
-            option_val = "{NONE}"
-
-        if not isinstance(option_val, (str, int, float, bool)):
-            raise EvaluationError(
-                self.option,
-                option_val,
-                f"{self.option} has value {option_val} of type "
-                f"{type(option_val)}, but it must be of type string, int, "
-                f"float, or bool.",
-            )
-
-        dispatch = self.lookup.get(option_val, self.default)
-
-        if dispatch is _UNREACHABLE:
-            raise EvaluationError(
-                self.option,
-                option_val,
-                f"{self.option} has value {option_val}, but it "
-                f"must take a value in {list(self.lookup.keys())}.",
-            )
-
-        return dispatch
-
-    def evaluate(self, options: JSONDict) -> A:
-        """Evaluate the switch statement
-
-        Determines which entry in the lookup dict to use, and evaluates it.
-
-        See Also
-        --------
-        labrea.types.Evaluatable.evaluate
-        """
-        dispatch = self.get_dispatch(options)
-        return dispatch.evaluate(options)
-
-    def validate(self, options: JSONDict) -> None:
-        """Validate the switch statement
-
-        Determines which entry in the lookup dict to use, and validates it.
-
-        See Also
-        --------
-        labrea.types.Evaluatable.validate
-        """
-        try:
-            dispatch = self.get_dispatch(options)
-        except EvaluationError as err:
-            raise ValidationError(*err.args) from err
-
-        dispatch.validate(options)
-
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Return the keys that the switch statement depends on
-
-        Determines which entry in the lookup dict to use, and returns the keys
-        that it depends on.
-
-        See Also
-        --------
-        labrea.types.Evaluatable.keys
-        """
-        dispatch = self.get_dispatch(options)
-
-        keys = dispatch.keys(options).copy()
-        if dispatch is not self.default:
-            keys.update(self.option.keys(options))
-
-        return keys
-
-    @property
-    def has_default(self):
-        return self.default is not _UNREACHABLE
diff --git a/labrea/template.py b/labrea/template.py
index b6fc3d2..4a1d28b 100644
--- a/labrea/template.py
+++ b/labrea/template.py
@@ -1,11 +1,12 @@
 import re
 import warnings
-from typing import Any, Dict, Set
+from typing import Any, Dict, Optional, Set
 
 from confectioner import mix
-from confectioner.templating import dotted_key_exists, find_template_keys, resolve
+from confectioner.templating import find_template_keys, resolve
 
-from .types import Evaluatable, EvaluationError, JSONDict, ValidationError, Value
+from .exceptions import KeyNotFoundError
+from .types import Evaluatable, Options, Value
 
 TEMPLATE_PARAM = re.compile(r"^:[a-zA-Z_][a-zA-Z0-9_]*:$")
 
@@ -20,14 +21,14 @@ class Template(Evaluatable[str]):
     the template using the standard :code:`{NESTED.KEY}` syntax from
     confectioner.
 
-    Parameters
+    Arguments
     ----------
     template : str
         The template string to evaluate
     kwargs : Any
         The parameters to use when evaluating the template. Each parameter
         can be any Evaluatable. If a parameter is not an Evaluatable, it is
-        used as a constant value.
+        used as a constant key.
 
     Raises
     ------
@@ -43,8 +44,8 @@ class Template(Evaluatable[str]):
     ...     return b
     >>>
     >>> t = Template('{A.X} {:b:}', b=b_dataset)
-    >>> t({'A': {'X': 'Hello'}, 'B': 'World!'})  # 'Hello World!'
-
+    >>> t({'A': {'X': 'Hello'}, 'B': 'World!'})
+    'Hello World!'
     """
 
     template: str
@@ -77,35 +78,65 @@ def __init__(self, template: str, **kwargs):
                 f"{template}"
             )
 
-    def evaluate(self, options: JSONDict) -> str:
+    def evaluate(self, options: Options) -> str:
         """Evaluates the template using the options dictionary."""
         params = {f":{key}:": val.evaluate(options) for key, val in self.params.items()}
 
         try:
             return str(resolve(self.template, mix(options, params)))  # type: ignore
         except KeyError as e:
-            raise EvaluationError(
-                f"Missing option {e.args[0]} for {self.template}"
-            ) from e
+            raise KeyNotFoundError((*e.args, "UNKNOWN")[0], self) from e
 
-    def validate(self, options: JSONDict) -> None:
+    def validate(self, options: Options) -> None:
         """Validates that the template can be evaluated using the options."""
+        from .option import Option
+
         for val in self.params.values():
             val.validate(options)
 
         for key in find_template_keys(self.template):
             if TEMPLATE_PARAM.match(key):
                 continue
-            if not dotted_key_exists(key, options):
-                raise ValidationError(f"Missing option {key} for {self.template}")
+            try:
+                Option(key).validate(options)
+            except KeyNotFoundError as e:
+                raise KeyNotFoundError(e.key, self) from e
+
+    def keys(self, options: Options) -> Set[str]:
+        """Returns the keys that this object depends on."""
+        from .option import Option
+
+        keys = set().union(*(value.keys(options) for value in self.params.values()))
+        for key in find_template_keys(self.template):
+            if TEMPLATE_PARAM.match(key):
+                continue
+            try:
+                keys.update(Option(key).keys(options))
+            except KeyNotFoundError as e:
+                raise KeyNotFoundError(e.key, self) from e
+
+        return keys
 
-    def keys(self, options: JSONDict) -> Set[str]:
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
         """Returns the keys that this object depends on."""
-        return set.union(
-            *(value.keys(options) for value in self.params.values()),
-            {
-                key
-                for key in find_template_keys(self.template)
-                if not TEMPLATE_PARAM.match(key)
-            },
-        )
+        from .option import Option
+
+        options = options or {}
+        keys = set().union(*(value.explain(options) for value in self.params.values()))
+        for key in find_template_keys(self.template):
+            if TEMPLATE_PARAM.match(key):
+                continue
+
+            keys.update(Option(key).explain(options))
+
+        return keys
+
+    def __repr__(self) -> str:
+        if self.params:
+            return (
+                f"Template({self.template!r}, "
+                f"{', '.join(f'{key}={value!r}' for key, value in self.params.items())}"
+                f")"
+            )
+
+        return f"Template({self.template!r})"
diff --git a/labrea/types.py b/labrea/types.py
index 0fab936..44fe624 100644
--- a/labrea/types.py
+++ b/labrea/types.py
@@ -1,47 +1,59 @@
-import sys
+import hashlib
+import json
+from abc import ABC, abstractmethod
 from copy import deepcopy
-from dataclasses import dataclass
-from typing import Callable, List, Mapping, Optional, Set, TypeVar, Union
+from typing import (
+    Callable,
+    Generic,
+    List,
+    Mapping,
+    Optional,
+    Protocol,
+    Set,
+    TypeVar,
+    Union,
+    overload,
+)
 
-# Support Python < 3.8
-if sys.version_info >= (3, 8):  # pragma: nocover
-    from typing import Protocol, runtime_checkable
-else:  # pragma: nocover
-    from typing_extensions import Protocol, runtime_checkable
+from confectioner.templating import get_dotted_key
 
+from .exceptions import EvaluationError, InsufficientInformationError
 
-JSONScalar = Union[int, float, bool, str, None]
-JSONType = Union[JSONScalar, "JSONDict", List["JSONType"]]  # type: ignore
-JSONDict = Mapping[str, JSONType]  # type: ignore
-
-
-Alias = JSONScalar
-MultiAlias = List[Alias]
+JSONScalar = Union[str, int, float, bool, None]
+JSON = Union[JSONScalar, Mapping[str, "JSON"], List["JSON"]]
+Options = Mapping[str, JSON]
 
 
 A = TypeVar("A", covariant=True)
 B = TypeVar("B", covariant=True)
+R = TypeVar("R", covariant=True)
+T = TypeVar("T", contravariant=True)
+X = TypeVar("X")
 
 
-@runtime_checkable
-class Validatable(Protocol):
-    """A protocol for objects that can be validated.
+class Transformation(Protocol[T, R]):
+    """A protocol for objects that can transform values using an options dictionary."""
 
-    This protocol is used to validate objects that are not yet evaluated.
-    """
+    def transform(self, value: T, options: Optional[Options] = None) -> R:
+        raise NotImplementedError  # pragma: nocover
 
-    def validate(self, options: JSONDict) -> None:
+
+class Validatable(ABC):
+    """Abstract base class for objects that can be validated against an options dictionary."""
+
+    @abstractmethod
+    def validate(self, options: Options) -> None:
         """Validate the object.
 
-        Parameters
+        Arguments
         ----------
-        options : JSONDict
+        options : Options
             The options dictionary to validate against.
 
         Raises
         ------
-        ValidationError
-            If the object is invalid.
+        KeyNotFoundError
+            If a required key is not found in the options dictionary.
 
         Notes
         -----
@@ -50,13 +62,21 @@ def validate(self, options: JSONDict) -> None:
         """
         raise NotImplementedError  # pragma: nocover
 
-    def keys(self, options: JSONDict) -> Set[str]:
+
+class Cacheable(ABC):
+    """Abstract base class for objects that can be cached."""
+
+    @abstractmethod
+    def keys(self, options: Options) -> Set[str]:
         """
-        Return the keys that this object depends on.
+        Return the keys from the Options that this object depends on.
+
+        This should only return the keys that are present in the options dictionary.
+        If a key is not present but is required, a KeyNotFoundError should be raised.
 
-        Parameters
+        Arguments
         ----------
-        options : JSONDict
+        options : Options
             The options dictionary to validate against.
 
         Returns
@@ -64,6 +84,11 @@ def keys(self, options: JSONDict) -> Set[str]:
         Set[str]
             The keys that this object depends on.
 
+        Raises
+        ------
+        KeyNotFoundError
+            If a required key is not found in the options dictionary.
+
         Notes
         -----
         This method should be called recursively on all child objects where
@@ -71,26 +96,72 @@ def keys(self, options: JSONDict) -> Set[str]:
         """
         raise NotImplementedError  # pragma: nocover
 
+    def fingerprint(self, options: Options) -> bytes:
+        """Return a fingerprint, which is a unique identifier for a given evaluation."""
+        fingerprint = hashlib.blake2b(digest_size=64)
+
+        for key in sorted(self.keys(options)):
+            fingerprint.update(key.encode())
+            fingerprint.update(
+                json.dumps(get_dotted_key(key, options), sort_keys=True).encode()
+            )
+
+        return fingerprint.digest()
+
+
+class Explainable(ABC):
+    """Abstract base class for objects that can explain themselves."""
+
+    @abstractmethod
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return all keys that this object depends on.
+
+        This should return all keys that this object depends on, including those that are not
+        present in the options dictionary. If the keys required cannot be determined, an
+        InsufficientInformationError should be raised.
+
+        Arguments
+        ----------
+        options : Options
+            The options dictionary to validate against.
+
+        Returns
+        -------
+        Set[str]
+            The keys that this object depends on.
+
+        Raises
+        ------
+        InsufficientInformationError
+            If the keys required cannot be determined.
+
+        Notes
+        -----
+        This method should be called recursively on all child objects where
+        applicable.
+        """
+
 
-@runtime_checkable
-class Evaluatable(Validatable, Protocol[A]):
-    """A protocol for objects that can be evaluated.
+class Evaluatable(Generic[A], Cacheable, Explainable, Validatable, ABC):
+    """Abstract base class for objects that can be evaluated with an Options dictionary.
 
-    This protocol is used to define a common interface for objects that can be
+    This ABC is used to define a common interface for objects that can be
     evaluated using an options dictionary. Datasets, Options, and other types
-    defined in this library implement this protocol.
+    defined in this library implement subclass this ABC. This allows for
+    polymorphic behavior when evaluating objects, and allows for third-party
+    extensions to be created that can be used within the labrea framework.
     """
 
-    def __call__(self, options: JSONDict) -> A:
+    def __call__(self, options: Optional[Options] = None) -> A:
         """Evaluate the object.
 
-        Any object that explicitly inherits from Evaluatble can be called as a
-        function
+        An alias for the evaluate method, but can be called with no arguments.
 
-        Parameters
+        Arguments
         ----------
-        options : JSONDict
-            The options dictionary to evaluate against.
+        options : Optional[Options]
+            The options dictionary to evaluate against. An empty dictionary is
+            used if no options are provided
 
         Returns
         -------
@@ -99,17 +170,18 @@ def __call__(self, options: JSONDict) -> A:
 
         Raises
         ------
-        EvaluationError
-            If the object cannot be evaluated.
+        KeyNotFoundError
+            If a required key is not found in the options dictionary.
         """
-        return self.evaluate(options)
+        return self.evaluate(options or {})
 
-    def evaluate(self, options: JSONDict) -> A:
+    @abstractmethod
+    def evaluate(self, options: Options) -> A:
         """Evaluate the object.
 
-        Parameters
+        Arguments
         ----------
-        options : JSONDict
+        options : Options
             The options dictionary to evaluate against.
 
         Returns
@@ -129,79 +201,288 @@ def evaluate(self, options: JSONDict) -> A:
         """
         raise NotImplementedError  # pragma: nocover
 
+    @abstractmethod
+    def __repr__(self) -> str:
+        """Return a string representation of the object.
+
+        Returns
+        -------
+        str
+            A string representation of the object.
+        """
+        raise NotImplementedError  # pragma: nocover
+
+    @staticmethod
+    def unit(value: T) -> "Value[T]":
+        """Wrap a value in a Value object.
+
+        This method is used to wrap a value in a Value object. This is useful
+        when you want to treat a value as an Evaluatable.
+
+        Arguments
+        ----------
+        value : A
+            The value to wrap.
+
+        Returns
+        -------
+        Value[A]
+            The wrapped value.
+        """
+        return Value(value)
+
+    @overload
+    @staticmethod
+    def ensure(value: "Evaluatable[T]") -> "Evaluatable[T]":
+        ...
+
+    @overload
+    @staticmethod
+    def ensure(value: T) -> "Evaluatable[T]":
+        ...
+
+    @staticmethod
+    def ensure(value: "MaybeEvaluatable[T]") -> "Evaluatable[T]":
+        """Ensure that a value is an Evaluatable.
+
+        This method is used to ensure that a value is an Evaluatable. If the
+        value is already an Evaluatable, it is returned as is. If the value is
+        not an Evaluatable, it is wrapped in a Value object.
+
+        Arguments
+        ----------
+        value : MaybeEvaluatable[A]
+            The value to ensure is an Evaluatable.
+
+        Returns
+        -------
+        Evaluatable[A]
+            The value as an Evaluatable.
+        """
+        if isinstance(value, Evaluatable):
+            return value
+        return Value(value)
+
+    def apply(self, func: "MaybeEvaluatable[Callable[[A], B]]") -> "Evaluatable[B]":
+        """Lazy application of a function to the result of evaluating the object.
+
+        This method is used to apply a function to the result of evaluating the
+        object. The function is not evaluated until the object is evaluated.
+        Equivalently can use the :code:`>>` operator.
+
+        Arguments
+        ----------
+        func : MaybeEvaluatable[Callable[[A], B]]
+            The function to apply to the result of evaluating the object. Either a
+            function of one argument, or an Evaluatable that evaluates to a function
+            of one argument.
+
+        Returns
+        -------
+        Evaluatable[B]
+            A new evaluatable, that when evaluated, applies the function to the
+            result of evaluating the source object.
+
+
+        Example Usage
+        -------------
+        >>> from labrea import Option
+        >>>
+        >>> Option('A').apply(str.upper)({'A': 'foo'})
+        'FOO'
+        >>>
+        >>> (Option('B') >> str.upper)({'B': 'bar'})
+        'BAR'
+        """
+        if not isinstance(func, Evaluatable) and not callable(func):
+            raise TypeError(f"Cannot apply object of type ({type(func)})")
+
+        return Apply(self, self.ensure(func))
+
+    def bind(self, func: Callable[[A], "Evaluatable[B]"]) -> "Evaluatable[B]":
+        """Lazy bind a function to the result of evaluating the object.
+
+        This method is used to bind a function to the result of evaluating the
+        object. The function should take the result of evaluating the object,
+        and return an Evaluatable. This is useful for when you want to use the
+        result of evaluating the object to determine the next step in the
+        evaluation process.
+
+        Arguments
+        ----------
+        func : Callable[[A], Evaluatable[B]]
+            The function to bind to the result of evaluating the object.
+
+        Returns
+        -------
+        Evaluatable[B]
+            A new evaluatable, that when evaluated, binds the function to the
+            result of evaluating the source object.
+
+        Example Usage
+        -------------
+        >>> from labrea import Option
+        >>>
+        >>> x_if_a_neg_else_y = Option('A').bind(lambda a: Option('X') if a < 0 else Option('Y'))
+        >>> x_if_a_neg_else_y({'A': -1, 'X': 'foo', 'Y': 'bar'})
+        'foo'
+        >>> x_if_a_neg_else_y({'A': 1, 'X': 'foo', 'Y': 'bar'})
+        'bar'
+        """
+        if not callable(func):
+            raise TypeError(f"Cannot bind object of type ({type(func)})")
+        return Bind(self, func)
+
     @property
     def result(self) -> A:
-        """Dummy property to allow for type checking.
+        """Dummy property used to appease type checkers.
 
-        This property is used to allow for type checking of the return value of
-        an Evaluatable. At runtime, it simply returns the Evaluatable itself.
+        When creating a dataset, type checkers will complain that the default
+        values are not of the correct type. This property is used to tell the
+        type checker that the default value is of the correct type.
         """
         return self  # type: ignore
 
+    def __rshift__(
+        self, other: "MaybeEvaluatable[Callable[[A], B]]"
+    ) -> "Evaluatable[B]":
+        return self.apply(other)
 
-Callback = Callable[[A, JSONDict], Optional[B]]
-Callback.__doc__ = """A callback function
-
-Callbacks will be used to extend the behavior of an Evaluatable, either by
-running a computation before or after the .evaluate() method is called.
-"""
 
-
-@dataclass
 class Value(Evaluatable[A]):
     """Simple wrapper for a plain value.
 
     This class is used to wrap a value that is not an Evaluatable and make it
     an Evaluatable.
 
-    Parameters
+    Arguments
     ----------
     value : A
-        The value to wrap.
+        The key to wrap.
     """
 
     value: A
 
-    def evaluate(self, options: JSONDict) -> A:
-        """Evaluate the object.
+    def __init__(self, value: A) -> None:
+        self.value = value
 
-        Returns the value that was wrapped. If possible, the value is deep
-        copied.
-        """
+    def evaluate(self, options: Options) -> A:
+        """Return the wrapped value."""
         try:
             return deepcopy(self.value)
         except Exception:  # noqa: E722
             return self.value
 
-    def validate(self, options: JSONDict) -> None:
-        """Validate the object.
-
-        This method does nothing, as the value can always be evaluated.
-        """
+    def validate(self, options: Options) -> None:
+        """Always passes validation."""
         pass
 
-    def keys(self, options: JSONDict) -> Set[str]:
-        """Return the keys that this object depends on.
+    def keys(self, options: Options) -> Set[str]:
+        """Return an empty set, as this object does not depend on any keys."""
+        return set()
 
-        This method returns an empty set, as the value does not depend on any
-        keys.
-        """
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return an empty set, as this object does not depend on any keys."""
         return set()
 
+    def __repr__(self) -> str:
+        return f"Value({self.value!r})"
+
+    def __eq__(self, other):
+        return isinstance(other, Value) and self.value == other.value
+
+
+class Apply(Generic[A, B], Evaluatable[B]):
+    """A class representing the application of a function to the result of evaluating an object.
+
+    This class is used to apply a function to the result of evaluating an object.
+    Apply objects are not usually created directly, instead the :code:`apply` method
+    is used on an Evaluatable object.
+    """
+
+    evaluatable: Evaluatable[A]
+    func: Evaluatable[Callable[[A], B]]
+
+    def __init__(
+        self, evaluatable: Evaluatable[A], func: Evaluatable[Callable[[A], B]]
+    ) -> None:
+        self.evaluatable = evaluatable
+        self.func = func
+
+    def evaluate(self, options: Options) -> B:
+        """Apply the function to the result of evaluating the object.""" ""
+        return self.func(options)(self.evaluatable(options))
+
+    def validate(self, options: Options) -> None:
+        """Validate the source object and the function to apply to it."""
+        self.evaluatable.validate(options)
+        self.func.validate(options)
 
-class EvaluationError(ValueError):
-    """Error raised when an object cannot be evaluated."""
+    def keys(self, options: Options) -> Set[str]:
+        """Return the keys the source object and the function depend on."""
+        return self.evaluatable.keys(options) | self.func.keys(options)
 
-    pass  # pragma: nocover
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the keys the source object and the function depend on."""
+        return self.evaluatable.explain(options) | self.func.explain(options)
 
+    def __repr__(self) -> str:
+        return f"{self.evaluatable!r}.apply({self.func!r})"
 
-class ValidationError(KeyError):
-    """Error raised when an object cannot be validated.
 
-    This error is raised when an object cannot be validated against an options
-    dictionary. This means that we can statically determine that the object
-    cannot be evaluated, usually due to a missing key in the options
-    dictionary.
+class Bind(Generic[A, B], Evaluatable[B]):
+    """A class representing the binding of a function to the result of evaluating an object.
+
+    This class is used to bind a function to the result of evaluating an object.
+    Bind objects are not usually created directly, instead the :code:`bind` method
+    is used on an Evaluatable object.
     """
 
-    pass  # pragma: nocover
+    evaluatable: Evaluatable[A]
+    func: Callable[[A], Evaluatable[B]]
+
+    def __init__(
+        self, evaluatable: Evaluatable[A], func: Callable[[A], Evaluatable[B]]
+    ) -> None:
+        self.evaluatable = evaluatable
+        self.func = func
+
+    def evaluate(self, options: Options) -> B:
+        """Bind the function to the result of evaluating the object."""
+        return self.func(self.evaluatable(options)).evaluate(options)
+
+    def validate(self, options: Options) -> None:
+        """Validate the source object and the function"""
+        self.evaluatable.validate(options)
+        self.func(self.evaluatable(options)).validate(options)
+
+    def keys(self, options: Options) -> Set[str]:
+        """Return the keys the source object, function, and result depend on.
+
+        Because the function is bound to the result of evaluating the source object,
+        the source object must be evaluated to determine the keys that the function's
+        result depends on.
+        """
+        return self.evaluatable.keys(options) | self.func(
+            self.evaluatable(options)
+        ).keys(options)
+
+    def explain(self, options: Optional[Options] = None) -> Set[str]:
+        """Return the keys the source object, function, and result depend on.
+
+        Because the function is bound to the result of evaluating the source object,
+        the source object must be evaluated to determine the keys that the function's
+        result depends on.
+        """
+        try:
+            return self.evaluatable.explain(options) | self.func(
+                self.evaluatable(options)
+            ).explain(options)
+        except EvaluationError as e:
+            raise InsufficientInformationError(f"Cannot explain {self}", self) from e
+
+    def __repr__(self) -> str:
+        return f"{self.evaluatable!r}.bind({self.func!r})"
+
+
+MaybeEvaluatable = Union[Evaluatable[X], X]
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 0000000..dac7fa5
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,2 @@
+[mypy]
+plugins = labrea/mypy/plugin.py
diff --git a/pyproject.toml b/pyproject.toml
index 54309be..1255de8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,9 +26,8 @@ classifiers = [
     "Programming Language :: Python",
 ]
 dependencies = [
-    "typing_extensions>=4.0; python_version<\"3.10\"",
+    "typing_extensions>=4.0; python_version<\"3.11\"",
     "confectioner>=1.0,<2.0",
-    "functional-pypelines>=1.0,<2.0",
 ]
 dynamic = ["version"]
 
@@ -75,6 +74,9 @@ testpaths = [
     "tests/"
 ]
 
+[tool.mypy]
+plugins = ["labrea.mypy.plugin"]
+
 [tool.black]
 line-length = 88
 include = '\.pyi?$'


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index fc0d495..e69de29 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +0,0 @@
-import pathlib
-import sys
-
-sys.path.insert(0, str(pathlib.Path.cwd()))
diff --git a/tests/coverage.svg b/tests/coverage.svg
deleted file mode 100644
index 3438732..0000000
--- a/tests/coverage.svg
+++ /dev/null
@@ -1,21 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="99" height="20">
-    <linearGradient id="b" x2="0" y2="100%">
-        <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
-        <stop offset="1" stop-opacity=".1"/>
-    </linearGradient>
-    <mask id="a">
-        <rect width="99" height="20" rx="3" fill="#fff"/>
-    </mask>
-    <g mask="url(#a)">
-        <path fill="#555" d="M0 0h63v20H0z"/>
-        <path fill="#4c1" d="M63 0h36v20H63z"/>
-        <path fill="url(#b)" d="M0 0h99v20H0z"/>
-    </g>
-    <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
-        <text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
-        <text x="31.5" y="14">coverage</text>
-        <text x="80" y="15" fill="#010101" fill-opacity=".3">97%</text>
-        <text x="80" y="14">97%</text>
-    </g>
-</svg>
diff --git a/tests/ext/__init__.py b/tests/ext/__init__.py
deleted file mode 100644
index 91a16ed..0000000
--- a/tests/ext/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from . import datasets, interfaces
-
-__all__ = ["datasets", "interfaces"]
diff --git a/tests/ext/datasets.py b/tests/ext/datasets.py
deleted file mode 100644
index 68efaec..0000000
--- a/tests/ext/datasets.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from labrea import Option
-from tests.pkg.datasets import abstract, basic, dispatch
-
-
-@basic.overload
-def basic_overload(bsc: bool = Option("BASIC", False)) -> bool:
-    return bsc
-
-
-@dispatch.overload(alias="custom_alias_dispatch_overload")
-def dispatch_overload() -> str:
-    return "overload"
-
-
-@abstract.overload
-def abstract_overload() -> bool:
-    return True
diff --git a/tests/ext/interfaces.py b/tests/ext/interfaces.py
deleted file mode 100644
index 2874a9a..0000000
--- a/tests/ext/interfaces.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from labrea import dataset
-from tests.pkg.interfaces import Interface1
-
-
-@Interface1.implementation
-class Int1Imp:
-    @staticmethod
-    def a() -> int:
-        return 1
-
-    @staticmethod
-    @dataset
-    def c() -> str:
-        return "overload"
diff --git a/tests/pkg/__init__.py b/tests/pkg/__init__.py
deleted file mode 100644
index 91a16ed..0000000
--- a/tests/pkg/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from . import datasets, interfaces
-
-__all__ = ["datasets", "interfaces"]
diff --git a/tests/pkg/datasets.py b/tests/pkg/datasets.py
deleted file mode 100644
index 613eea5..0000000
--- a/tests/pkg/datasets.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from labrea import Option, abstractdataset, dataset
-
-
-@dataset
-def basic(bsc: bool = Option("BASIC", True)) -> bool:
-    return bsc
-
-
-@dataset(dispatch="CUSTOM_DISPATCH")
-def dispatch() -> str:
-    return "default"
-
-
-@abstractdataset
-def abstract():
-    ...
diff --git a/tests/pkg/interfaces.py b/tests/pkg/interfaces.py
deleted file mode 100644
index e46a204..0000000
--- a/tests/pkg/interfaces.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from labrea import Option, dataset, interface
-
-
-@interface
-class Interface1:
-    a: int
-
-    @staticmethod
-    @dataset
-    def b(bb: str = Option("B")) -> str:
-        return bb
-
-    @staticmethod
-    @dataset
-    def c() -> str:
-        return "default"
diff --git a/tests/requirements.txt b/tests/requirements.txt
deleted file mode 100644
index f416386..0000000
--- a/tests/requirements.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-coverage
-coverage-badge
-pytest
-pytest-console-scripts
-pytest-cov
-pytest-env
diff --git a/tests/test_application.py b/tests/test_application.py
new file mode 100644
index 0000000..e5f3c13
--- /dev/null
+++ b/tests/test_application.py
@@ -0,0 +1,114 @@
+from labrea import Option
+from labrea.application import FunctionApplication, PartialApplication
+from labrea.exceptions import KeyNotFoundError
+import pytest
+
+
+def add(x: float, y: float) -> float:
+    return x + y
+
+
+def test_application():
+    a = Option('A')
+    b = Option('B')
+
+    app = FunctionApplication(add, a, b)
+
+    assert app.evaluate({'A': 1, 'B': 2}) == 3
+    with pytest.raises(KeyNotFoundError):
+        app.evaluate({'A': 1})
+
+    app.validate({'A': 1, 'B': 2})
+    with pytest.raises(KeyNotFoundError):
+        app.validate({'A': 1})
+
+    assert app.keys({'A': 1, 'B': 2}) == {'A', 'B'}
+    with pytest.raises(KeyNotFoundError):
+        app.keys({'A': 1})
+
+    assert app.explain() == {'A', 'B'}
+
+    assert repr(app) == "FunctionApplication(add, Option('A'), Option('B'))"
+
+
+def test_lift():
+    def good(a: float = Option('A'), b: float = Option('B')) -> float:
+        return a + b
+
+    def bad(a: float, b: float) -> float:
+        return a + b
+
+    assert FunctionApplication.lift(good).evaluate({'A': 1, 'B': 2}) == 3
+
+    with pytest.raises(TypeError):
+        FunctionApplication.lift(bad)
+
+    assert FunctionApplication.lift(bad, a=Option('A'), b=Option('B')).evaluate({'A': 1, 'B': 2}) == 3
+
+    @FunctionApplication.lift
+    def good_deco(a: float = Option('A'), b: float = Option('B')) -> float:
+        return a + b
+
+    assert good_deco.evaluate({'A': 1, 'B': 2}) == 3
+
+    @FunctionApplication.lift(a=Option('A'), b=Option('B'))
+    def good_deco_kwargs(a: float, b: float = Option('X')) -> float:
+        return a + b
+
+    assert good_deco_kwargs.evaluate({'A': 1, 'B': 2}) == 3
+
+
+def test_partial_application():
+    b = Option('B')
+
+    app = PartialApplication(add, b)
+
+    assert app.evaluate({'B': 2})(1) == 3
+    with pytest.raises(KeyNotFoundError):
+        app.evaluate({})
+
+    app.validate({'B': 2})
+    with pytest.raises(KeyNotFoundError):
+        app.validate({})
+
+    assert app.keys({'B': 2}) == {'B'}
+    with pytest.raises(KeyNotFoundError):
+        app.keys({})
+
+    assert app.explain() == {'B'}
+
+    assert repr(app) == "PartialApplication(add, Option('B'))"
+
+
+def test_lift_partial():
+    def good(a: float, b: float = Option('B')) -> float:
+        return a + b
+
+    def bad(a: float, b: float) -> float:
+        return a + b
+
+    def bad2(a: float = Option('A'), b: float = Option('B')) -> float:
+        return a + b
+
+    assert PartialApplication.lift(good).evaluate({'B': 2})(1) == 3
+
+    with pytest.raises(TypeError):
+        PartialApplication.lift(bad)
+
+    with pytest.raises(TypeError):
+        PartialApplication.lift(bad2)
+
+    assert PartialApplication.lift(bad, b=Option('B')).evaluate({'B': 2})(1) == 3
+
+    @PartialApplication.lift
+    def good_deco(a: float, b: float = Option('B')) -> float:
+        return a + b
+
+    assert good_deco.evaluate({'B': 2})(1) == 3
+
+    @PartialApplication.lift(b=Option('B'))
+    def good_deco_kwargs(a: float, b: float = Option('X')) -> float:
+        return a + b
+
+    assert good_deco_kwargs.evaluate({'B': 2})(1) == 3
+
diff --git a/tests/test_arguments.py b/tests/test_arguments.py
new file mode 100644
index 0000000..1a028df
--- /dev/null
+++ b/tests/test_arguments.py
@@ -0,0 +1,62 @@
+import pytest
+
+from labrea.arguments import Arguments, EvaluatableArgs, EvaluatableKwargs, arguments
+from labrea.exceptions import KeyNotFoundError
+from labrea.option import Option
+
+
+def test_arguments():
+    assert repr(Arguments(1, 2, 3)) == "Arguments(1, 2, 3)"
+    assert repr(Arguments(a=1, b=2, c=3)) == "Arguments(a=1, b=2, c=3)"
+    assert repr(Arguments(1, b=2)) == "Arguments(1, b=2)"
+    assert repr(Arguments()) == "Arguments()"
+
+    assert Arguments(1, b=2) == Arguments(1, b=2)
+    assert Arguments(1, b=2) != Arguments(1, b=3)
+    assert Arguments(1, b=2) != object()
+
+
+
+def test_evaluatable_args():
+    args = EvaluatableArgs(Option('A'), Option('B'), Option('C'))
+
+    assert args.evaluate({'A': 1, 'B': 2, 'C': 3}) == (1, 2, 3)
+    args.validate({'A': 1, 'B': 2, 'C': 3})
+    with pytest.raises(KeyNotFoundError):
+        args.validate({'A': 1, 'B': 2})
+    assert args.keys({'A': 1, 'B': 2, 'C': 3}) == {'A', 'B', 'C'}
+    with pytest.raises(KeyNotFoundError):
+        args.keys({'A': 1, 'B': 2})
+    assert args.explain() == {'A', 'B', 'C'}
+
+    assert repr(args) == "EvaluatableArgs(Option('A'), Option('B'), Option('C'))"
+
+
+def test_evaluatable_kwargs():
+    kwargs = EvaluatableKwargs(a=Option('A'), b=Option('B'), c=Option('C'))
+
+    assert kwargs.evaluate({'A': 1, 'B': 2, 'C': 3}) == {'a': 1, 'b': 2, 'c': 3}
+    kwargs.validate({'A': 1, 'B': 2, 'C': 3})
+    with pytest.raises(KeyNotFoundError):
+        kwargs.validate({'A': 1, 'B': 2})
+    assert kwargs.keys({'A': 1, 'B': 2, 'C': 3}) == {'A', 'B', 'C'}
+    with pytest.raises(KeyNotFoundError):
+        kwargs.keys({'A': 1, 'B': 2})
+    assert kwargs.explain() == {'A', 'B', 'C'}
+
+    assert repr(kwargs) == "EvaluatableKwargs(a=Option('A'), b=Option('B'), c=Option('C'))"
+
+
+def test_evaluatable_arguments():
+    args = arguments(Option('A'), b=Option('B'))
+
+    assert args.evaluate({'A': 1, 'B': 2}) == Arguments(1, b=2)
+    args.validate({'A': 1, 'B': 2})
+    with pytest.raises(KeyNotFoundError):
+        args.validate({'A': 1})
+    assert args.keys({'A': 1, 'B': 2}) == {'A', 'B'}
+    with pytest.raises(KeyNotFoundError):
+        args.keys({'A': 1})
+    assert args.explain() == {'A', 'B'}
+
+    assert repr(args) == "EvaluatableParameters(Option('A'), b=Option('B'))"
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 0000000..6701b6e
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,65 @@
+from typing import List
+import uuid
+
+import pytest
+
+from confectioner.templating import set_dotted_key
+
+from labrea.application import FunctionApplication
+from labrea.cache import cached, NoCache
+from labrea.option import Option
+import labrea.cache
+
+
+def test_cached():
+    uuid4 = FunctionApplication(uuid.uuid4)
+    cached_uuid4 = cached(uuid4)
+
+    assert uuid4() != uuid4()
+    assert cached_uuid4() == cached_uuid4()
+
+
+def test_nocache():
+    uuid4 = FunctionApplication(uuid.uuid4)
+    cached_uuid4 = cached(uuid4, NoCache())
+
+    assert uuid4() != uuid4()
+    assert cached_uuid4() != cached_uuid4()
+
+
+def test_cached_decorator():
+    @cached
+    @FunctionApplication.lift
+    def uuid4_list(n: int = Option('N')) -> List[uuid.UUID]:
+        return [uuid.uuid4() for _ in range(n)]
+
+    assert uuid4_list({'N': 3}) == uuid4_list({'N': 3})
+    assert uuid4_list({'N': 3}) != uuid4_list({'N': 4})
+
+    @cached(NoCache())
+    @FunctionApplication.lift
+    def uuid4_list(n: int = Option('N')) -> List[uuid.UUID]:
+        return [uuid.uuid4() for _ in range(n)]
+
+
+@pytest.mark.parametrize('method', ['ctx', 'LABREA.CACHE.DISABLED', 'LABREA.CACHE.DISABLE'])
+def test_disable_cache(method):
+    uuid4 = cached(FunctionApplication(uuid.uuid4))
+
+    a = uuid4()
+    b = uuid4()
+
+    if method == 'ctx':
+        with labrea.cache.disabled():
+            c = uuid4()
+            d = uuid4()
+    else:
+        options = {}
+        set_dotted_key(method, True, options)
+        c = uuid4(options)
+        d = uuid4(options)
+
+    e = uuid4()
+
+    assert a == b == e
+    assert a != c and c != d
diff --git a/tests/test_coalesce.py b/tests/test_coalesce.py
new file mode 100644
index 0000000..d618816
--- /dev/null
+++ b/tests/test_coalesce.py
@@ -0,0 +1,36 @@
+from labrea.coalesce import Coalesce
+from labrea.option import Option
+from labrea.exceptions import KeyNotFoundError
+import pytest
+
+
+def test_coalesce():
+    a = Option('A')
+    b = Option('B')
+
+    c = Coalesce(a, b)
+
+    assert c({'A': 'Hello'}) == 'Hello'
+    assert c({'B': 'World!'}) == 'World!'
+    assert c({'A': 'Hello', 'B': 'World!'}) == 'Hello'
+    assert c({'B': 'World!', 'C': 'foo'}) == 'World!'
+
+    with pytest.raises(KeyNotFoundError):
+        c({})
+
+    with pytest.raises(KeyNotFoundError):
+        c.validate({})
+
+    with pytest.raises(KeyNotFoundError):
+        c.keys({})
+
+    assert c.explain() == {'B'}
+
+
+def test_repr():
+    assert repr(Coalesce(Option('A'), Option('B'))) == "Coalesce(Option('A'), Option('B'))"
+
+
+def test_init():
+    with pytest.raises(TypeError):
+        Coalesce()
diff --git a/tests/test_collections.py b/tests/test_collections.py
new file mode 100644
index 0000000..35d613b
--- /dev/null
+++ b/tests/test_collections.py
@@ -0,0 +1,23 @@
+from labrea.collections import (
+    evaluatable_dict,
+    evaluatable_list,
+    evaluatable_set,
+    evaluatable_tuple,
+)
+from labrea.option import Option
+
+
+def test_dict():
+    assert evaluatable_dict({'a': Option('A'), 'b': 2}).evaluate({'A': 1}) == {'a': 1, 'b': 2}
+
+
+def test_list():
+    assert evaluatable_list(Option('A'), 2).evaluate({'A': 1}) == [1, 2]
+
+
+def test_set():
+    assert evaluatable_set(Option('A'), 2).evaluate({'A': 1}) == {1, 2}
+
+
+def test_tuple():
+    assert evaluatable_tuple(Option('A'), 2).evaluate({'A': 1}) == (1, 2)
\ No newline at end of file
diff --git a/tests/test_computation.py b/tests/test_computation.py
new file mode 100644
index 0000000..8b3a971
--- /dev/null
+++ b/tests/test_computation.py
@@ -0,0 +1,59 @@
+import pytest
+
+from labrea.computation import Computation, ChainedEffect, CallbackEffect
+from labrea.exceptions import EvaluationError
+from labrea.option import Option
+from labrea.pipeline import pipeline_step
+
+
+def test_callback_effect():
+    store = None
+
+    @pipeline_step
+    def store_value_add_a(value: int, a: int = Option('A')) -> None:
+        nonlocal store
+        store = value + a
+
+    effect = CallbackEffect(store_value_add_a)
+
+    assert effect.transform(1, {'A': 1}) is None
+    assert store == 2
+    effect.validate({'A': 1})
+    assert effect.explain() == {'A'}
+    assert repr(effect) == f"CallbackEffect({store_value_add_a!r})"
+
+    with pytest.raises(EvaluationError):
+        effect.transform(1, {})
+    with pytest.raises(EvaluationError):
+        effect.validate({})
+
+
+def test_chained_effect():
+    a = lambda x: None
+    b = lambda x: None
+
+    effect = ChainedEffect(CallbackEffect(a), CallbackEffect(b))
+
+    assert effect.transform(1) is None
+    effect.validate({})
+    assert effect.explain() == set()
+    assert repr(effect) == f"ChainedEffect(CallbackEffect(Value({a!r})), CallbackEffect(Value({b!r})))"
+
+
+def test_computation():
+    store = None
+
+    @pipeline_step
+    def store_value_add_a(value: int, a: int = Option('A')) -> None:
+        nonlocal store
+        store = value + a
+
+    comp = Computation(Option('A'), CallbackEffect(store_value_add_a))
+
+    assert comp.evaluate({'A': 1}) == 1
+    assert store == 2
+    comp.validate({'A': 1})
+    assert comp.keys({'A': 1}) == {'A'}
+    assert comp.explain() == {'A'}
+
+    assert repr(comp) == f"Computation(Option('A'), CallbackEffect({store_value_add_a!r}))"
diff --git a/tests/test_conditional.py b/tests/test_conditional.py
new file mode 100644
index 0000000..9880478
--- /dev/null
+++ b/tests/test_conditional.py
@@ -0,0 +1,133 @@
+from labrea.conditional import switch, SwitchError, case, CaseWhenError
+from labrea.exceptions import KeyNotFoundError, InsufficientInformationError
+from labrea.option import Option
+import pytest
+
+
+def test_switch():
+    s = switch(Option('A'), {'X': 42, 'Y': Option('Z')})
+
+    assert s({'A': 'X'}) == 42
+    assert s({'A': 'Y', 'Z': 43}) == 43
+    with pytest.raises(KeyNotFoundError):
+        s.evaluate({})
+    with pytest.raises(KeyNotFoundError):
+        s.evaluate({'A': 'Y'})
+    with pytest.raises(SwitchError):
+        s.evaluate({'A': 'Z'})
+
+    s.validate({'A': 'X'})
+    s.validate({'A': 'Y', 'Z': 43})
+    with pytest.raises(KeyNotFoundError):
+        s.validate({})
+    with pytest.raises(KeyNotFoundError):
+        s.validate({'A': 'Y'})
+    with pytest.raises(SwitchError):
+        s.validate({'A': 'Z'})
+
+    assert s.keys({'A': 'X'}) == {'A'}
+    assert s.keys({'A': 'Y', 'Z': 43}) == {'A', 'Z'}
+    with pytest.raises(KeyNotFoundError):
+        s.keys({})
+    with pytest.raises(KeyNotFoundError):
+        s.keys({'A': 'Y'})
+    with pytest.raises(SwitchError):
+        s.keys({'A': 'Z'})
+
+    assert s.explain({'A': 'X'}) == {'A'}
+    assert s.explain({'A': 'Y', 'Z': 43}) == {'A', 'Z'}
+    with pytest.raises(InsufficientInformationError):
+        s.explain({})
+    assert s.explain({'A': 'Y'}) == {'A', 'Z'}
+    with pytest.raises(InsufficientInformationError):
+        assert s.explain({'A': 'Z'}) == {'A'}
+
+    assert repr(s) == "switch(Option('A'), {'X': Value(42), 'Y': Option('Z')})"
+    assert (
+        repr(switch(Option('A'), {'X': 42, 'Y': Option('Z')}, 43)) ==
+        "switch(Option('A'), {'X': Value(42), 'Y': Option('Z')}, Value(43))"
+    )
+
+    assert switch('A', {'X': 1})({'A': 'X'}) == 1
+
+
+def test_case_when():
+    c = case(Option('A')).when(
+        lambda x: x == 'X',
+        42
+    ).when(
+        lambda x: x == 'Y',
+        Option('Z')
+    ).otherwise(43)
+
+    assert c({'A': 'X'}) == 42
+    assert c({'A': 'Y', 'Z': 44}) == 44
+    assert c({'A': 'Z'}) == 43
+    with pytest.raises(KeyNotFoundError):
+        c.evaluate({})
+    with pytest.raises(KeyNotFoundError):
+        c.evaluate({'A': 'Y'})
+
+    c.validate({'A': 'X'})
+    c.validate({'A': 'Y', 'Z': 44})
+    c.validate({'A': 'Z'})
+    with pytest.raises(KeyNotFoundError):
+        c.validate({})
+    with pytest.raises(KeyNotFoundError):
+        c.validate({'A': 'Y'})
+    c.validate({'A': 'Z'})
+
+    assert c.keys({'A': 'X'}) == {'A'}
+    assert c.keys({'A': 'Y', 'Z': 44}) == {'A', 'Z'}
+    assert c.keys({'A': 'Z'}) == {'A'}
+    with pytest.raises(KeyNotFoundError):
+        c.keys({})
+    with pytest.raises(KeyNotFoundError):
+        c.keys({'A': 'Y'})
+    assert c.keys({'A': 'Z'}) == {'A'}
+
+    assert c.explain({'A': 'X'}) == {'A'}
+    assert c.explain({'A': 'Y', 'Z': 44}) == {'A', 'Z'}
+    assert c.explain({'A': 'Z'}) == {'A'}
+    with pytest.raises(InsufficientInformationError):
+        c.explain({})
+    assert c.explain({'A': 'Y'}) == {'A', 'Z'}
+    assert c.explain({'A': 'Z'}) == {'A'}
+
+
+def test_case_when_no_default():
+    c = case(Option('A')).when(
+        lambda x: x == 'X',
+        42
+    ).when(
+        lambda x: x == 'Y',
+        Option('Z')
+    )
+
+    assert c({'A': 'X'}) == 42
+    assert c({'A': 'Y', 'Z': 44}) == 44
+    with pytest.raises(CaseWhenError):
+        c({'A': 'Z'})
+    with pytest.raises(KeyNotFoundError):
+        c.evaluate({})
+
+    c.validate({'A': 'X'})
+    c.validate({'A': 'Y', 'Z': 44})
+    with pytest.raises(CaseWhenError):
+        c.validate({'A': 'Z'})
+    with pytest.raises(KeyNotFoundError):
+        c.validate({})
+
+    assert c.keys({'A': 'X'}) == {'A'}
+    assert c.keys({'A': 'Y', 'Z': 44}) == {'A', 'Z'}
+    with pytest.raises(CaseWhenError):
+        c.keys({'A': 'Z'})
+    with pytest.raises(KeyNotFoundError):
+        c.keys({})
+
+    assert c.explain({'A': 'X'}) == {'A'}
+    assert c.explain({'A': 'Y', 'Z': 44}) == {'A', 'Z'}
+    with pytest.raises(InsufficientInformationError):
+        c.explain({'A': 'Z'})
+    with pytest.raises(InsufficientInformationError):
+        c.explain({})
diff --git a/tests/test_dataset.py b/tests/test_dataset.py
new file mode 100644
index 0000000..39dbfc5
--- /dev/null
+++ b/tests/test_dataset.py
@@ -0,0 +1,284 @@
+import logging
+import pytest
+import uuid
+
+from labrea.computation import CallbackEffect
+from labrea.dataset import dataset, abstractdataset
+from labrea.exceptions import EvaluationError
+from labrea.logging import LogRequest
+from labrea.option import Option
+import labrea.runtime
+
+
+def test_dataset():
+    @dataset
+    def add(a: int = Option('A'), b: int = Option('B')) -> int:
+        return a + b
+
+    assert add.evaluate({'A': 1, 'B': 2}) == 3
+    add.validate({'A': 1, 'B': 2})
+    assert add.keys({'A': 1, 'B': 2}) == {'A', 'B'}
+    assert add.explain() == {'A', 'B'}
+
+
+def test_overloads():
+    @dataset(dispatch='X')
+    def combine(a: int = Option('A'), b: int = Option('B')) -> int:
+        return a + b
+
+    @combine.overload(alias='Y')
+    def multiply(a: int = Option('C'), b: int = Option('D')) -> int:
+        return a * b
+
+    assert combine.evaluate({'A': 1, 'B': 2}) == 3
+    combine.validate({'A': 1, 'B': 2})
+    assert combine.keys({'A': 1, 'B': 2}) == {'A', 'B'}
+    assert combine.explain() == {'A', 'B'}
+
+    assert multiply.evaluate({'C': 1, 'D': 2}) == 2
+    multiply.validate({'C': 1, 'D': 2})
+    assert multiply.keys({'C': 1, 'D': 2}) == {'C', 'D'}
+    assert multiply.explain() == {'C', 'D'}
+
+    assert combine.evaluate({'X': 'Y', 'C': 1, 'D': 2}) == 2
+    combine.validate({'X': 'Y', 'C': 1, 'D': 2})
+    assert combine.keys({'X': 'Y', 'C': 1, 'D': 2}) == {'X', 'C', 'D'}
+    assert combine.explain({'X': 'Y'}) == {'X', 'C', 'D'}
+
+    assert combine.evaluate({'X': 'Z', 'A': 1, 'B': 2}) == 3
+    combine.validate({'X': 'Z', 'A': 1, 'B': 2})
+    assert combine.keys({'X': 'Z', 'A': 1, 'B': 2}) == {'A', 'B'}
+    assert combine.explain({'X': 'Z'}) == {'A', 'B'}
+
+
+def test_overload_no_dispatch():
+    @dataset
+    def x():
+        pass
+
+    with pytest.raises(ValueError):
+        @x.overload('A')
+        def y():
+            pass
+
+
+def test_abstract():
+    with pytest.raises(ValueError):
+        @abstractdataset
+        def x() -> int:
+            pass
+
+    @abstractdataset(dispatch='A')
+    def x() -> int:
+        pass
+
+    @x.overload('B')
+    def y() -> int:
+        return 1
+
+    with pytest.raises(EvaluationError):
+        x()
+
+    assert x.evaluate({'A': 'B'}) == 1
+
+
+def test_repr():
+    def add(a: int = Option('A'), b: int = Option('B')) -> int:
+        return a + b
+
+    assert repr(dataset(add)) == f"<Dataset {add.__qualname__}>"
+
+
+def test_cache():
+    @dataset
+    def uuid4() -> uuid.UUID:
+        return uuid.uuid4()
+
+    assert uuid4() == uuid4()
+
+
+def test_nocache():
+    @dataset.nocache
+    def uuid4() -> uuid.UUID:
+        return uuid.uuid4()
+
+    assert uuid4() != uuid4()
+
+
+def test_effect():
+    store = None
+
+    def set_store(value):
+        nonlocal store
+        store = value
+        return value
+
+    @dataset.nocache(effects=[CallbackEffect(set_store)])
+    def uuid4() -> uuid.UUID:
+        return uuid.uuid4()
+
+    result = uuid4()
+    assert store == result
+
+    @dataset.nocache(effects=[set_store])
+    def uuid4() -> uuid.UUID:
+        return uuid.uuid4()
+
+    result = uuid4()
+    assert store == result
+
+    @dataset.nocache
+    def uuid4() -> uuid.UUID:
+        return uuid.uuid4()
+
+    uuid4.add_effect(CallbackEffect(set_store))
+    result = uuid4()
+    assert store == result
+
+    result = uuid4()
+    assert store == result
+
+    @dataset.nocache(effects=[set_store])
+    def uuid4() -> uuid.UUID:
+        return uuid.uuid4()
+
+    result1 = uuid4()
+    assert store == result1
+
+    uuid4.disable_effects()
+    result2 = uuid4()
+    assert store != result2
+
+    uuid4.enable_effects()
+    result3 = uuid4()
+    assert store == result3
+
+    result4 = uuid4({'LABREA': {'EFFECTS': {'DISABLED': True}}})
+    assert store != result4
+
+
+def test_set_dispatch():
+    @dataset
+    def x() -> int:
+        return 1
+
+    x.set_dispatch(Option('A'))
+
+    @x.overload('A')
+    def y() -> int:
+        return 2
+
+    assert x() == 1
+    assert x.evaluate({'A': 'A'}) == 2
+
+
+def test_where():
+    def add(x: int, y: int) -> int:
+        return x + y
+
+    add = dataset.where(x=Option('X'), y=Option('Y')).wrap(add)
+
+    assert add.evaluate({'X': 1, 'Y': 2}) == 3
+    add.validate({'X': 1, 'Y': 2})
+    assert add.keys({'X': 1, 'Y': 2}) == {'X', 'Y'}
+    assert add.explain() == {'X', 'Y'}
+
+
+def test_force_options():
+    @dataset(options={'A': 1})
+    def x(a: int = Option('A'), b: int = Option('B')) -> int:
+        return a + b
+
+    assert x({'B': 2}) == x({'A': 2, 'B': 2}) == 3
+    x.validate({'B': 2})
+    assert x.keys({'B': 2}) == {'B'}
+    assert x.explain() == {'B'}
+
+    with pytest.raises(EvaluationError):
+        x()
+    with pytest.raises(EvaluationError):
+        x.validate({})
+    with pytest.raises(EvaluationError):
+        x.keys({})
+
+
+def test_default_options():
+    @dataset(default_options={'A': 1})
+    def x(a: int = Option('A'), b: int = Option('B')) -> int:
+        return a + b
+
+    assert x({'B': 2}) == 3
+    x.validate({'B': 2})
+    assert x.keys({'B': 2}) == {'B'}
+    assert x.explain() == {'B'}
+
+    assert x({'A': 2, 'B': 2}) == 4
+    x.validate({'A': 2, 'B': 2})
+    assert x.keys({'A': 2, 'B': 2}) == {'A', 'B'}
+    assert x.explain({'A': 2}) == {'A', 'B'}
+
+    with pytest.raises(EvaluationError):
+        x()
+    with pytest.raises(EvaluationError):
+        x.validate({})
+    with pytest.raises(EvaluationError):
+        x.keys({})
+
+
+def test_default():
+    @dataset(dispatch='X')
+    def x() -> str:
+        return 'x'
+
+    @x.overload('Y')
+    def y() -> str:
+        return 'y'
+
+    assert x.default({'X': 'Y'}) == 'x'
+
+
+def test_with_options():
+    @dataset
+    def x(a: int = Option('A'), b: int = Option('B')) -> int:
+        return a + b
+
+    x1 = x.with_options({'A': 1})
+    x2 = x.with_options({'A': 2})
+
+    assert isinstance(x1, type(x))
+
+    assert x1({'B': 2}) == 3
+    assert x2({'B': 2}) == 4
+
+
+def test_with_default_options():
+    @dataset
+    def x(a: int = Option('A'), b: int = Option('B')) -> int:
+        return a + b
+
+    x1 = x.with_default_options({'A': 1})
+
+    assert isinstance(x1, type(x))
+
+    assert x1({'B': 2}) == 3
+    assert x1({'A': 2, 'B': 2}) == 4
+
+
+def test_logging():
+    @dataset
+    def x() -> int:
+        return 1
+
+    handler_run = False
+
+    def test_logging_handler(request: LogRequest) -> None:
+        nonlocal handler_run
+        handler_run = True
+        assert request.level == logging.INFO
+        assert request.name == x.__module__
+        assert request.msg == f'Labrea: Evaluating {x!r}'
+
+    with labrea.runtime.handle(LogRequest, test_logging_handler):
+        x()
+
+    assert handler_run
diff --git a/tests/test_datasetclass.py b/tests/test_datasetclass.py
new file mode 100644
index 0000000..cc0da5f
--- /dev/null
+++ b/tests/test_datasetclass.py
@@ -0,0 +1,83 @@
+import pytest
+
+from labrea import datasetclass, Option
+from labrea.exceptions import KeyNotFoundError
+
+
+@datasetclass
+class X:
+    a: int = Option('A')
+    b: bool = True
+    c: str = Option('C').apply(str)
+
+
+@datasetclass
+class X2:
+    a: int = Option('A')
+    b: bool = True
+    c: str = Option('C').apply(str)
+
+
+def test_evaluate():
+    x = X({'A': 1, 'C': 3})
+
+    assert x.a == 1
+    assert x.b is True
+    assert x.c == '3'
+
+    with pytest.raises(KeyNotFoundError) as exc_info:
+        X({'A': 1})
+        assert exc_info.value.key == 'C'
+
+
+def test_validate():
+    X.validate({'A': 1, 'C': 3})
+
+    with pytest.raises(KeyNotFoundError) as exc_info:
+        X.validate({'A': 1})
+        assert exc_info.value.key == 'C'
+
+
+def test_keys():
+    assert X.keys({'A': 1, 'C': 3}) == {'A', 'C'}
+
+    with pytest.raises(KeyNotFoundError) as exc_info:
+        X.keys({'A': 1})
+        assert exc_info.value.key == 'C'
+
+
+def test_explain():
+    assert X.explain() == X.explain({'A': 1, 'C': 3}) == {'A', 'C'}
+
+
+def test_repr():
+    assert repr(X) == '<DatasetClass X>'
+    opts = {'A': 1, 'C': 3}
+    assert repr(X(opts)) == f'X({opts!r})'
+
+
+def test_missing_default():
+    with pytest.raises(ValueError):
+        @datasetclass
+        class Y:
+            a: int = Option('A')
+            b: bool
+            c: str = Option('C').apply(str)
+
+
+def test_eq():
+    assert X({'A': 1, 'C': 3}) == X({'A': 1, 'C': 3})
+    assert X({'A': 1, 'C': 3}) != X({'A': 1, 'C': 4})
+    assert X({'A': 1, 'C': 3}) != X2({'A': 1, 'C': 3})
+
+
+def test_inheritance():
+    @datasetclass
+    class Y(X):
+        d: float = Option('D')
+
+    y = Y({'A': 1, 'C': 3, 'D': 4.0})
+    assert y.a == 1
+    assert y.b is True
+    assert y.c == '3'
+    assert y.d == 4.0
diff --git a/tests/test_evaluatable.py b/tests/test_evaluatable.py
new file mode 100644
index 0000000..250b3f0
--- /dev/null
+++ b/tests/test_evaluatable.py
@@ -0,0 +1,97 @@
+import pytest
+
+from labrea.types import Evaluatable, Value
+from labrea.exceptions import EvaluationError, KeyNotFoundError
+from labrea.option import Option
+
+
+def test_value():
+    value = Value(42)
+    assert value.evaluate({}) == value() == 42
+    assert value.validate({}) is None
+    assert value.keys({}) == set()
+    assert value.explain() == value.explain({}) == set()
+    assert repr(value) == "Value(42)"
+    assert value == value
+    assert value != Value(43)
+
+    class Uncopyable:
+        def __deepcopy__(self, memo):
+            raise NotImplementedError('Cannot copy this.')
+
+    uncopyable = Uncopyable()
+
+    assert Value(uncopyable).evaluate({}) is uncopyable
+
+
+def test_unit():
+    assert Evaluatable.unit(1)() == 1
+
+
+@pytest.mark.parametrize(
+    'wrapper,method',
+    [
+        (wrapper, method)
+        for wrapper in (Value, lambda x: x)
+        for method in ('apply', 'rshift')
+    ]
+)
+def test_apply(wrapper, method):
+    value = Value(42)
+
+    def incr(x):
+        return x + 1
+
+    if method == 'apply':
+        apply = value.apply(wrapper(incr))
+    else:
+        apply = value >> wrapper(incr)
+
+    assert apply.evaluate({}) == 43
+    assert apply.validate({}) is None
+    assert apply.keys({}) == set()
+    assert apply.explain() == set()
+    assert repr(apply) == f"Value(42).apply(Value({repr(incr)}))"
+
+
+def test_bind():
+    value = Value(42)
+
+    def incr(x):
+        return Value(x + 1)
+
+    bind = value.bind(incr)
+    assert bind.evaluate({}) == 43
+    assert bind.validate({}) is None
+    assert bind.keys({}) == set()
+    assert bind.explain() == set()
+    assert repr(bind) == f"Value(42).bind({repr(incr)})"
+
+
+def test_type_error():
+    with pytest.raises(TypeError):
+        Value(42).bind(42)
+
+    with pytest.raises(TypeError):
+        Value(42).apply(42)
+
+
+def test_error_str():
+    assert str(EvaluationError('message', Value(1))) == "Originating in Value(1) | message"
+    assert str(KeyNotFoundError('key', Value(1))) == "Originating in Value(1) | Key 'key' not found"
+
+
+def test_fingerprint():
+    value = Value(42)
+    option = Option('A')
+
+    assert value.fingerprint({}) == value.fingerprint({'A': 1})
+    assert value.fingerprint({'A': 1}) != option.fingerprint({'A': 1})
+    assert option.fingerprint({'A': 1}) == option.fingerprint({'A': 1})
+    assert option.fingerprint({'A': 1}) != option.fingerprint({'A': 2})
+    assert option.fingerprint({'A': 1}) == option.fingerprint({'A': 1, 'V': 2})
+
+
+def test_result():
+    option = Option('A')
+    assert option.result is option
diff --git a/tests/test_functions.py b/tests/test_functions.py
new file mode 100644
index 0000000..58b1366
--- /dev/null
+++ b/tests/test_functions.py
@@ -0,0 +1,22 @@
+from labrea.option import Option
+import labrea.functions as lf
+
+
+def test_map():
+    m = Option('A') >> lf.map(str) >> list
+
+    assert m({'A': [1, 2]}) == ['1', '2']
+
+
+def test_filter():
+    f = Option('A') >> lf.filter(lambda x: x % 2 == 0) >> list
+
+    assert f({'A': [1, 2, 3, 4]}) == [2, 4]
+
+
+def test_reduce():
+    r1 = Option('A') >> lf.reduce(lambda x, y: x + y)
+    r2 = Option('A') >> lf.reduce(lambda x, y: x + y, 10)
+
+    assert r1({'A': [1, 2, 3, 4]}) == 10
+    assert r2({'A': [1, 2, 3, 4]}) == 20
diff --git a/tests/test_interface.py b/tests/test_interface.py
new file mode 100644
index 0000000..a948f29
--- /dev/null
+++ b/tests/test_interface.py
@@ -0,0 +1,120 @@
+import pytest
+
+from labrea.interface import interface, implements
+from labrea.option import Option
+from labrea.dataset import dataset
+from labrea.exceptions import EvaluationError
+
+
+@interface('DISPATCH')
+class MyInterface:
+    a: str
+
+    def b() -> str:
+        return 'b'
+
+    @staticmethod
+    @dataset
+    def c() -> str:
+        return 'c'
+
+    d: str = Option('D', 'd')
+
+
+@interface('DISPATCH_2')
+class MyInterface2:
+    e = 'e'
+
+
+def test_no_implementation():
+    with pytest.raises(EvaluationError):
+        MyInterface.a()
+
+    assert MyInterface.b() == 'b'
+    assert MyInterface.c() == 'c'
+    assert MyInterface.d() == 'd'
+
+
+def test_good_implementation():
+    @MyInterface.implementation('GOOD')
+    class Good:
+        a = 'a'
+
+    assert MyInterface.a({'DISPATCH': 'GOOD'}) == 'a'
+    assert MyInterface.b({'DISPATCH': 'GOOD'}) == 'b'
+    assert MyInterface.c({'DISPATCH': 'GOOD'}) == 'c'
+    assert MyInterface.d({'DISPATCH': 'GOOD'}) == 'd'
+
+
+def test_bad_implementation():
+    with pytest.raises(TypeError):
+        @MyInterface.implementation('BAD')
+        class Bad:
+            pass
+
+    with pytest.raises(TypeError):
+        @MyInterface.implementation('BAD')
+        class Bad:
+            a = 'a'
+            f = 'f'
+
+
+def test_override():
+    @MyInterface.implementation('OVERRIDE')
+    class Good:
+        a = 'A'
+
+        def b():
+            return 'B'
+
+        @staticmethod
+        @dataset
+        def c():
+            return 'C'
+
+        d = Option('D', 'D')
+
+    assert MyInterface.a({'DISPATCH': 'OVERRIDE'}) == 'A'
+    assert MyInterface.b({'DISPATCH': 'OVERRIDE'}) == 'B'
+    assert MyInterface.c({'DISPATCH': 'OVERRIDE'}) == 'C'
+    assert MyInterface.d({'DISPATCH': 'OVERRIDE'}) == 'D'
+
+
+def test_multi_implementation():
+    @implements(MyInterface, MyInterface2, alias='MULTI')
+    class Multi:
+        a = 'a'
+
+    assert MyInterface.a({'DISPATCH': 'MULTI'}) == 'a'
+    assert MyInterface.b({'DISPATCH': 'MULTI'}) == 'b'
+    assert MyInterface.c({'DISPATCH': 'MULTI'}) == 'c'
+    assert MyInterface.d({'DISPATCH': 'MULTI'}) == 'd'
+    assert MyInterface2.e({'DISPATCH_2': 'MULTI'}) == 'e'
+
+
+def test_missing_args():
+    with pytest.raises(TypeError):
+        @interface
+        class A:
+            pass
+
+    with pytest.raises(TypeError):
+        @MyInterface.implementation
+        class A:
+            pass
+
+
+def test_invalid_member():
+    with pytest.raises(AttributeError):
+        @interface('A')
+        class A:
+            implementation = 'implementation'
+
+
+def test_repr():
+    @MyInterface.implementation('A')
+    class A:
+        a: int = 1
+
+    assert repr(MyInterface) == '<Interface test_interface.MyInterface>'
+    assert repr(A) == '<Implementation test_interface.A of MyInterface>'
diff --git a/tests/test_iterable.py b/tests/test_iterable.py
new file mode 100644
index 0000000..2a795ad
--- /dev/null
+++ b/tests/test_iterable.py
@@ -0,0 +1,51 @@
+import pytest
+
+from labrea.iterable import Iter, Map
+from labrea.conditional import switch
+from labrea.exceptions import EvaluationError, InsufficientInformationError
+from labrea.option import Option
+
+
+def test_iter():
+    i = Iter(Option('A'), 2)
+
+    assert i.apply(list).evaluate({'A': 1}) == [1, 2]
+    i.validate({'A': 1})
+    assert i.keys({'A': 1}) == {'A'}
+    assert i.explain() == {'A'}
+
+    with pytest.raises(EvaluationError):
+        i.apply(list).evaluate({})
+    with pytest.raises(EvaluationError):
+        i.validate({})
+    with pytest.raises(EvaluationError):
+        i.keys({})
+
+    assert repr(i) == "Iter(Option('A'), Value(2))"
+
+
+def test_iterate():
+    i = Map(Option('A'), {'A': Option('B')})
+
+    assert i.apply(list).evaluate({'B': [1, 2]}) == [({'A': 1}, 1), ({'A': 2}, 2)]
+    assert i.values.apply(list).evaluate({'B': [1, 2]}) == [1, 2]
+    i.validate({'B': [1, 2]})
+    assert i.keys({'B': [1, 2]}) == {'B'}
+    assert i.explain({'B': [1, 2]}) == {'B'}
+
+    with pytest.raises(EvaluationError):
+        i.apply(list).evaluate({})
+    with pytest.raises(EvaluationError):
+        i.validate({})
+    with pytest.raises(EvaluationError):
+        i.keys({})
+    assert i.explain() == {'B'}
+
+    assert repr(i) == "Iterate(Option('A'), {'A': Option('B')})"
+
+
+def test_iterate_explain_insufficient_information():
+    i = Map(switch(Option('A'), {'X': 1}), {'A': Option('B')})
+
+    with pytest.raises(InsufficientInformationError):
+        i.explain()
diff --git a/tests/test_missing.py b/tests/test_missing.py
new file mode 100644
index 0000000..a6bf85e
--- /dev/null
+++ b/tests/test_missing.py
@@ -0,0 +1,5 @@
+from labrea._missing import MISSING
+
+
+def test_missing():
+    assert repr(MISSING) == "MISSING"
diff --git a/tests/test_option.py b/tests/test_option.py
new file mode 100644
index 0000000..4a298ce
--- /dev/null
+++ b/tests/test_option.py
@@ -0,0 +1,190 @@
+from labrea.exceptions import KeyNotFoundError
+from labrea.option import Option, WithOptions, WithDefaultOptions
+from labrea.template import Template
+import pytest
+
+
+def test_basic():
+    option = Option('A')
+    options = {'A': 42}
+
+    assert option.evaluate(options) == 42
+    option.validate(options)
+    assert option.keys(options) == {'A'}
+    assert option.explain(options) == {'A'}
+    assert option.explain() == {'A'}
+
+
+def test_multiple_provided():
+    option = Option('A')
+    options = {'A': 42, 'V': 43}
+
+    assert option.evaluate(options) == 42
+    option.validate(options)
+    assert option.keys(options) == {'A'}
+    assert option.explain(options) == {'A'}
+
+
+def test_missing():
+    option = Option('A')
+    options = {'V': 42}
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.evaluate(options)
+        assert excinfo.value.key == 'A'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.validate(options)
+        assert excinfo.value.key == 'A'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.keys(options)
+        assert excinfo.value.key == 'A'
+
+    assert option.explain(options) == {'A'}
+
+
+def test_default():
+    option = Option('A', default=42)
+    missing = {}
+    provided = {'A': 43}
+
+    assert option.evaluate(missing) == 42
+    option.validate(missing)
+    assert option.keys(missing) == set()
+    assert option.explain(missing) == set()
+
+    assert option.evaluate(provided) == 43
+    option.validate(provided)
+    assert option.keys(provided) == {'A'}
+    assert option.explain(provided) == {'A'}
+
+
+def test_default_factory():
+    option = Option('A', default_factory=lambda: 42)
+    missing = {}
+    provided = {'A': 43}
+
+    assert option.evaluate(missing) == 42
+    option.validate(missing)
+    assert option.keys(missing) == set()
+    assert option.explain(missing) == set()
+
+    assert option.evaluate(provided) == 43
+    option.validate(provided)
+    assert option.keys(provided) == {'A'}
+    assert option.explain(provided) == {'A'}
+
+
+def test_evaluatable_default():
+    option = Option('A', default=Option('V'))
+    options = {'V': 42}
+
+    assert option.evaluate(options) == 42
+    option.validate(options)
+    assert option.keys(options) == {'V'}
+    assert option.explain(options) == {'V'}
+    assert option.explain() == {'V'}
+
+
+def test_nested():
+    option = Option('A.V')
+    options = {'A': {'V': 42}}
+
+    assert option.evaluate(options) == 42
+    option.validate(options)
+    assert option.keys(options) == {'A.V'}
+    assert option.explain(options) == {'A.V'}
+
+
+def test_nested_missing():
+    option = Option('A.V')
+    options = {'A': {}}
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.evaluate(options)
+        assert excinfo.value.key == 'A.V'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.validate(options)
+        assert excinfo.value.key == 'A.V'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.keys(options)
+        assert excinfo.value.key == 'A.V'
+
+    assert option.explain(options) == {'A.V'}
+
+
+def test_template_default():
+    option = Option('A', default='{V}.{C}')
+    assert isinstance(option.default, Template)
+
+
+def test_confectioner_templating():
+    option = Option('A')
+    present = {'A': '{V} {C}!', 'V': 'Hello', 'C': 'World'}
+    missing = {'A': '{V} {C}!', 'V': 'Hello'}
+
+    assert option.evaluate(present) == 'Hello World!'
+    option.validate(present)
+    assert option.keys(present) == {'A', 'V', 'C'}
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.evaluate(missing)
+        assert excinfo.value.key == 'C'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.validate(missing)
+        assert excinfo.value.key == 'C'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        option.keys(missing)
+        assert excinfo.value.key == 'C'
+
+    assert option.explain(present) == {'A', 'V', 'C'}
+    assert option.explain(missing) == {'A', 'V', 'C'}
+    assert option.explain() == {'A'}
+
+
+def test_repr():
+    assert repr(Option('A')) == "Option('A')"
+    assert repr(Option('A', default=42)) == "Option('A', default=Value(42))"
+    assert repr(Option('A', default='{V}.{C}')) == f"Option('A', default={repr(Template('{V}.{C}'))})"
+    assert repr(Option('A', doc='Hello, World!')) == "Option('A')"
+
+
+def test_with_options():
+    w = WithOptions(Option('A'), {'A': 42})
+
+    assert w.evaluate({}) == 42
+    assert w.evaluate({'A': 43}) == 42
+
+    w.validate({})
+    w.validate({'A': 43})
+
+    assert w.keys({}) == set()
+    assert w.keys({'A': 43}) == set()
+
+    assert w.explain({}) == set()
+    assert w.explain({'A': 43}) == set()
+
+    assert repr(w) == "WithOptions(Option('A'), {'A': 42})"
+
+
+def test_with_default_options():
+    w = WithDefaultOptions(Option('A'), {'A': 42})
+
+    assert w.evaluate({}) == 42
+    assert w.evaluate({'A': 43}) == 43
+
+    w.validate({})
+    w.validate({'A': 43})
+
+    assert w.keys({}) == set()
+    assert w.keys({'A': 43}) == {'A'}
+
+    assert w.explain({}) == set()
+    assert w.explain({'A': 43}) == {'A'}
+
+    assert repr(w) == "WithDefaultOptions(Option('A'), {'A': 42})"
diff --git a/tests/test_overload.py b/tests/test_overload.py
new file mode 100644
index 0000000..2ac6016
--- /dev/null
+++ b/tests/test_overload.py
@@ -0,0 +1,44 @@
+import pytest
+
+from labrea.application import FunctionApplication
+from labrea import Value
+from labrea.exceptions import KeyNotFoundError
+from labrea.option import Option
+from labrea.overload import Overloaded
+
+
+def test_overloaded():
+    @FunctionApplication.lift
+    def add(x: int = Option('X'), y: int = Option('Y')) -> int:
+        return x + y
+
+    overloads = Overloaded(Option('A'), {}, default=add)
+
+    @FunctionApplication.lift
+    def dummy_add():
+        return 0
+
+    overloads.register('dummy', dummy_add)
+
+    assert overloads.evaluate({'X': 1, 'Y': 2}) == 3
+    assert overloads.evaluate({'A': 'dummy'}) == 0
+
+    overloads.validate({'X': 1, 'Y': 2})
+    overloads.validate({'A': 'dummy'})
+    with pytest.raises(KeyNotFoundError):
+        overloads.validate({'X': 1})
+
+    assert overloads.keys({'X': 1, 'Y': 2}) == {'X', 'Y'}
+    assert overloads.keys({'A': 'dummy'}) == {'A'}
+    with pytest.raises(KeyNotFoundError):
+        overloads.keys({'X': 1})
+
+    assert overloads.explain({'X': 1, 'Y': 2}) == {'X', 'Y'}
+    assert overloads.explain({'A': 'dummy'}) == {'A'}
+    assert overloads.explain() == {'X', 'Y'}
+
+
+def test_repr():
+    assert repr(Overloaded(Option('A'), {'X': Value(42)})) == "Overloaded(Option('A'), {'X': Value(42)})"
+    assert repr(Overloaded(Option('A'), {'X': Value(42)}, default=43)) == "Overloaded(Option('A'), {'X': Value(42)}, Value(43))"
+
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
new file mode 100644
index 0000000..eaab81d
--- /dev/null
+++ b/tests/test_pipeline.py
@@ -0,0 +1,68 @@
+import pytest
+from typing import List
+
+from labrea import pipeline_step, Option, Value
+from labrea.exceptions import EvaluationError
+from labrea.pipeline import Pipeline, PipelineStep
+
+
+@pipeline_step
+def incr(x: float) -> float:
+    return x + 1
+
+
+@pipeline_step
+def repeat(x: float, n: int = Option('N')) -> List[float]:
+    return [x for _ in range(n)]
+
+
+incr_then_repeat = incr + repeat
+
+
+def test_types():
+    assert isinstance(incr, PipelineStep)
+    assert isinstance(repeat, PipelineStep)
+    assert isinstance(incr_then_repeat, Pipeline)
+
+
+def test_pipeline_valid():
+    assert incr_then_repeat.evaluate({'N': 3})(1) == [2, 2, 2]
+    incr_then_repeat.validate({'N': 3})
+    assert incr_then_repeat.keys({'N': 3}) == {'N'}
+    assert incr_then_repeat.explain({'N': 3}) == {'N'}
+
+
+def test_pipeline_invalid():
+    with pytest.raises(EvaluationError):
+        incr_then_repeat.evaluate({})
+    with pytest.raises(EvaluationError):
+        incr_then_repeat.validate({})
+    with pytest.raises(EvaluationError):
+        incr_then_repeat.keys({})
+    assert incr_then_repeat.explain() == {'N'}
+
+
+def test_repr():
+    assert repr(incr) == '<PipelineStep incr>'
+    assert repr(incr_then_repeat) == '<Pipeline <PipelineStep incr> + <PipelineStep repeat>>'
+    assert repr(Pipeline()) == '<Pipeline Identity>'
+
+
+def test_iter():
+    assert list(incr_then_repeat) == [incr, repeat]
+
+
+def test_add():
+    def decr(x: float) -> float:
+        return x - 1
+
+    assert list(incr + repeat) == [incr, repeat]
+    assert list(incr + repeat + decr) == [incr, repeat, PipelineStep(Value(decr))]
+    assert list(incr_then_repeat + incr_then_repeat) == [incr, repeat, incr, repeat]
+    assert list(Pipeline() + incr) == list(incr + Pipeline()) == [incr]
+
+
+def test_transformation():
+    assert incr.transform(1) == 2
+    assert list(repeat.transform(1, {'N': 3})) == [1, 1, 1]
+    assert list(incr_then_repeat.transform(1, {'N': 3})) == [2, 2, 2]
diff --git a/tests/test_runtime.py b/tests/test_runtime.py
new file mode 100644
index 0000000..163c032
--- /dev/null
+++ b/tests/test_runtime.py
@@ -0,0 +1,71 @@
+import pytest
+import threading
+from labrea.runtime import Request, handle, inherit
+
+
+class UnhandledRequest(Request):
+    def __init__(self, value):
+        self.value = value
+
+
+class EchoRequest(Request[str]):
+    message: str
+
+    def __init__(self, message: str):
+        self.message = message
+
+
+@EchoRequest.handle
+def echo(request: EchoRequest) -> str:
+    return request.message
+
+
+def void(request) -> None:
+    pass
+
+
+def test_request():
+    assert EchoRequest("hello").run() == "hello"
+
+    with pytest.raises(TypeError):
+        UnhandledRequest("world").run()
+
+
+def test_handle():
+    assert EchoRequest("hello").run() == "hello"
+
+    with handle(EchoRequest, void):
+        assert EchoRequest("hello").run() is None
+
+    with handle(EchoRequest, void):
+        with handle(EchoRequest, echo):
+            assert EchoRequest("hello").run() is "hello"
+
+    with handle({EchoRequest: void}):
+        assert EchoRequest("hello").run() is None
+
+    with handle({EchoRequest: void, UnhandledRequest: void}):
+        assert EchoRequest("hello").run() is None
+        assert UnhandledRequest("world").run() is None
+
+    assert EchoRequest("hello").run() == "hello"
+
+    with pytest.raises(TypeError):
+        with handle(42, void):
+            pass
+
+    with pytest.raises(TypeError):
+        with handle(EchoRequest, 42):
+            pass
+
+
+def test_inherit():
+    def worker(parent: threading.Thread, request: Request):
+        inherit(parent)
+        return request.run()
+
+    with pytest.raises(TypeError):
+        threading.Thread(target=worker, args=(threading.current_thread(), UnhandledRequest("hello"))).run()
+
+    with handle(UnhandledRequest, void):
+        threading.Thread(target=worker, args=(threading.current_thread(), UnhandledRequest("hello"))).run()
diff --git a/tests/test_template.py b/tests/test_template.py
new file mode 100644
index 0000000..5fd11a9
--- /dev/null
+++ b/tests/test_template.py
@@ -0,0 +1,71 @@
+from labrea.template import Template
+from labrea.exceptions import KeyNotFoundError
+import pytest
+
+
+def test_basic():
+    template = Template('{GREETING}, {:name:}!', name='World')
+    options = {'GREETING': 'Hello'}
+
+    assert template(options) == 'Hello, World!'
+    template.validate(options)
+    assert template.keys(options) == {'GREETING'}
+    assert template.explain(options) == {'GREETING'}
+
+
+def test_malformed():
+    with pytest.raises(ValueError):
+        Template('{GREETING} {:name:}')
+
+    with pytest.warns(UserWarning):
+        Template('{GREETING}', name='World')
+
+
+def test_missing():
+    template = Template('{GREETING}, {:name:}!', name='World')
+    options = {}
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        template(options)
+        assert excinfo.value.key == 'GREETING'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        template.validate(options)
+        assert excinfo.value.key == 'GREETING'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        template.keys(options)
+        assert excinfo.value.key == 'GREETING'
+
+    assert template.explain(options) == {'GREETING'}
+
+
+def test_confectioner_templating():
+    template = Template('{A} {B}{:punc:}', punc='!')
+    present = {'A': '{C}', 'B': '{D}', 'C': 'Hello', 'D': 'World'}
+    missing = {'A': '{C}', 'B': '{D}', 'C': 'Hello'}
+
+    assert template(present) == 'Hello World!'
+    template.validate(present)
+    assert template.keys(present) == {'A', 'B', 'C', 'D'}
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        template(missing)
+        assert excinfo.value.key == 'D'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        template.validate(missing)
+        assert excinfo.value.key == 'D'
+
+    with pytest.raises(KeyNotFoundError) as excinfo:
+        template.keys(missing)
+        assert excinfo.value.key == 'D'
+
+    assert template.explain(present) == {'A', 'B', 'C', 'D'}
+    assert template.explain(missing) == {'A', 'B', 'C', 'D'}
+    assert template.explain() == {'A', 'B'}
+
+
+def test_repr():
+    assert repr(Template('{GREETING}, {:name:}!', name='World')) == "Template('{GREETING}, {:name:}!', name=Value('World'))"
+    assert repr(Template('{GREETING}')) == "Template('{GREETING}')"
diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py
deleted file mode 100644
index 3136c25..0000000
--- a/tests/unit/test_cache.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import uuid
-
-from labrea import abstractdataset, dataset
-from labrea.cache import NoCache
-
-
-@dataset
-def cached():
-    return str(uuid.uuid4())
-
-
-@dataset.nocache
-def not_cached():
-    return str(uuid.uuid4())
-
-
-@abstractdataset
-def abstract():
-    ...
-
-
-@abstract.overload.nocache
-def overload():
-    pass
-
-
-not_cached_2 = cached.nocache
-
-
-def test_cache():
-    assert cached({}) == cached({})
-    assert not_cached({}) != not_cached({})
-    assert not_cached_2({}) != not_cached_2({})
-
-    assert isinstance(overload._cache, NoCache)
diff --git a/tests/unit/test_coalesce.py b/tests/unit/test_coalesce.py
deleted file mode 100644
index 73097dd..0000000
--- a/tests/unit/test_coalesce.py
+++ /dev/null
@@ -1,36 +0,0 @@
-import uuid
-
-import pytest
-
-from labrea import Coalesce, Option
-from labrea.types import EvaluationError, ValidationError
-
-
-def test_no_default():
-    coalesce = Coalesce(Option("A"), Option("B"), Option("C"))
-
-    assert coalesce({"A": 1}) == 1
-    assert coalesce({"B": 2}) == 2
-    assert coalesce({"C": 3}) == 3
-    assert coalesce({"A": 1, "C": 3}) == 1
-    with pytest.raises(EvaluationError):
-        coalesce({})
-
-    coalesce.validate({"A": 1})
-    with pytest.raises(ValidationError):
-        coalesce.validate({})
-
-    assert coalesce.keys({"A": 1}) == {"A"}
-    assert coalesce.keys({"A": 1, "B": 2}) == {"A"}
-    assert coalesce.keys({}) == set()
-
-
-def test_with_default():
-    default = uuid.uuid4()
-    coalesce = Coalesce(Option("A"), default)
-
-    assert coalesce({"A": 1}) == 1
-    assert coalesce({}) == default
-
-    coalesce.validate({"A": 1})
-    coalesce.validate({})
diff --git a/tests/unit/test_datasetclasses.py b/tests/unit/test_datasetclasses.py
deleted file mode 100644
index 672d7d1..0000000
--- a/tests/unit/test_datasetclasses.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import pytest
-
-from labrea import Field, datasetclass
-
-
-def test_bad_annotation():
-    with pytest.raises(AttributeError):
-
-        @datasetclass
-        class Bad:
-            x: int
-
-
-def test_auto_field():
-    @datasetclass
-    class AutoField:
-        x: int = 1
-
-    assert AutoField.x == Field(1)
diff --git a/tests/unit/test_datasets.py b/tests/unit/test_datasets.py
deleted file mode 100644
index edf4109..0000000
--- a/tests/unit/test_datasets.py
+++ /dev/null
@@ -1,312 +0,0 @@
-import random
-
-import pytest
-
-import tests.ext.datasets
-import tests.pkg.datasets
-from labrea import (
-    DatasetDict,
-    DatasetList,
-    Field,
-    Option,
-    Switch,
-    abstractdataset,
-    dataset,
-    datasetclass,
-)
-from labrea.cache import NoCache
-from labrea.datasets import Overload
-from labrea.types import EvaluationError, ValidationError, Value
-
-
-@datasetclass
-class DSC:
-    val = Option("DSC.VAL")
-    const = Field(0)
-
-    def __eq__(self, other):
-        return self.val == other
-
-
-@dataset
-def dependency(dependent_input=Option("DEPENDENT.INPUT")):
-    return dependent_input
-
-
-unwrapped_value = []
-
-
-@dataset
-def tst_dataset(
-    x=Option("X", 1),
-    y=Switch("Y", {True: 1, False: 2}),
-    dsc=DSC,
-    dsl=DatasetList(Option("LIST.A"), Option("LIST.B")),
-    dsd=DatasetDict({"a": Option("DICT.A"), "b": Option("DICT.B")}),
-    val=Value("value"),
-    lst=unwrapped_value,
-):
-    return x, y, dsc, dsl, dsd, val, lst
-
-
-config = {
-    "X": random.random(),
-    "Y": random.choice([True, False]),
-    "DSC": {"VAL": random.random()},
-    "LIST": {
-        "A": random.random(),
-        "B": random.random(),
-    },
-    "DICT": {
-        "A": random.random(),
-        "B": random.random(),
-    },
-}
-
-
-def test_dataset():
-    x, y, dsc, dsl, dsd, val, lst = tst_dataset(config)
-
-    assert x == config["X"]
-    assert 1 if config["Y"] else 2
-    assert dsc == config["DSC"]["VAL"]
-    assert dsc.const == 0
-    assert dsl == list(config["LIST"].values())
-    assert dsd == {key.lower(): val for key, val in config["DICT"].items()}
-    assert lst == unwrapped_value and lst is not unwrapped_value
-
-
-def test_validate():
-    with pytest.raises(ValidationError):
-        tst_dataset.validate({})
-
-    good_config = {k: v for k, v in config.items() if k != "X"}
-    tst_dataset.validate(good_config)
-
-    bad_config = {k: v for k, v in good_config.items() if k != "Y"}
-    with pytest.raises(ValidationError):
-        tst_dataset.validate(bad_config)
-
-    with pytest.raises(EvaluationError):
-        tst_dataset.evaluate(bad_config)
-
-
-def test_warning():
-    @dataset
-    def x():
-        return 1
-
-    with pytest.warns(UserWarning):
-
-        @dataset
-        def y():
-            return x
-
-
-def test_no_default():
-    with pytest.raises(TypeError):
-
-        @dataset
-        def x(y):
-            pass
-
-
-def test_where():
-    @dataset.where(a=Option("Y"))
-    def x(a=Option("A")):
-        return a
-
-    assert x.evaluate({"Y": 1}) == 1
-    assert x.where(a=Option("Z")).evaluate({"Z": 2}) == 2
-
-    with pytest.raises(TypeError):
-        tests.pkg.datasets.abstract.where(x=1)
-
-
-def test_overloads():
-    overloads = {
-        "LABREA": {
-            "IMPLEMENTATIONS": {
-                "tests": {
-                    "pkg": {
-                        "datasets": {
-                            "basic": "tests.ext.datasets.basic_overload",
-                            "abstract": "tests.ext.datasets.abstract_overload",
-                        }
-                    }
-                }
-            },
-            "REQUIRE": ["tests.ext"],
-        },
-        "CUSTOM_DISPATCH": "custom_alias_dispatch_overload",
-    }
-
-    assert tests.pkg.datasets.basic.evaluate({})
-    assert not tests.pkg.datasets.basic.evaluate(overloads)
-
-    assert tests.pkg.datasets.dispatch.evaluate({}) == "default"
-    assert tests.pkg.datasets.dispatch.evaluate(overloads) == "overload"
-
-    with pytest.raises(EvaluationError):
-        tests.pkg.datasets.abstract.evaluate({})
-
-    assert tests.pkg.datasets.abstract.evaluate(overloads)
-
-    bad_overload = {"CUSTOM_DISPATCH": "does.not.exist"}
-
-    with pytest.warns(UserWarning):
-        tests.pkg.datasets.dispatch.evaluate(bad_overload)
-
-
-def test_repr():
-    assert repr(tests.pkg.datasets.basic) == "<Dataset tests.pkg.datasets.basic>"
-    assert (
-        repr(tests.ext.datasets.basic_overload)
-        == "<Overload basic_overload of <Dataset tests.pkg.datasets.basic>>"
-    )
-
-    def x():
-        pass
-
-    assert repr(Overload(x)) == f"<Unbound Overload {x.__qualname__}>"
-
-
-def test_decorator_args():
-    @dataset(dispatch="DISPATCH", where={"x": Option("X")}, cache=NoCache)
-    def y(x=1):
-        return x
-
-    @y.overload(where={"x": Option("XX")}, cache=NoCache, alias="y_overload")
-    def y_overload(x=1):
-        return x
-
-    assert isinstance(y.default._cache, NoCache)
-    assert isinstance(y_overload._cache, NoCache)
-
-    assert y.evaluate({"X": 10}) == 10
-    assert y.evaluate({"XX": 20, "DISPATCH": "y_overload"}) == 20
-
-
-def test_abstract_dataset_warnings():
-    with pytest.warns(Warning):
-
-        @abstractdataset.nocache
-        def x():
-            pass
-
-    with pytest.warns(Warning):
-
-        @abstractdataset.where(y=Option("X"))
-        def z(y=1):
-            return y
-
-
-def test_complex_dispatch():
-    @dataset
-    def custom_dispatch() -> str:
-        return "overload"
-
-    @abstractdataset.dispatch(dispatch=custom_dispatch)
-    def abstract():
-        ...
-
-    @abstract.overload(alias="overload")
-    def overload():
-        return "overload"
-
-    assert abstract({}) == "overload"
-
-
-def test_custom_callback():
-    store = []
-
-    @dataset(callbacks=[lambda val, opts: store.append(val)])
-    def x():
-        return 1
-
-    x.evaluate({})
-    x.evaluate({})
-
-    assert store == [1]  # Only called once because of cache
-
-    store = []
-
-    @dataset.nocache(callbacks=[lambda val, opts: store.append(val)])
-    def y():
-        return 1
-
-    y.evaluate({})
-    y.evaluate({})
-
-    assert store == [1, 1]  # Called every time because of no cache
-
-
-def test_add_callback():
-    store = []
-
-    @dataset
-    def x():
-        return 1
-
-    x.add_callbacks(lambda val, opts: store.append(val))
-
-    x.evaluate({})
-    x.evaluate({})
-
-    assert store == [1]  # Called only once
-
-
-def test_scoped_options_deco():
-    @dataset
-    def x(a: int = Option("A")):
-        return a
-
-    @dataset(options={"A": 2})
-    def y(x: int = x, b: int = Option("B")) -> int:
-        return x + b
-
-    assert y({"B": 3}) == 5
-    assert y({"A": 3, "B": 3}) == 5
-
-
-def test_with_options():
-    @dataset
-    def x(a: int = Option("A")):
-        return a
-
-    @dataset
-    def y(x: int = x, b: int = Option("B")) -> int:
-        return x + b
-
-    z = y.with_options({"A": 2})
-
-    assert z({"B": 3}) == 5
-    assert z({"A": 3, "B": 3}) == 5
-
-
-def test_with_cache():
-    @dataset
-    def rand():
-        return random.random()
-
-    assert rand.evaluate({}) == rand.evaluate({})
-
-    rand_no_cache = rand.with_cache(NoCache)
-
-    assert rand_no_cache.evaluate({}) != rand_no_cache.evaluate({})
-
-    rand.set_cache(NoCache)
-
-    assert rand.evaluate({}) != rand.evaluate({})
-
-
-def test_distinct_callbacks():
-    @dataset
-    def x():
-        return 1
-
-    @dataset
-    def y():
-        return 2
-
-    assert x._callbacks is not y._callbacks
diff --git a/tests/unit/test_interfaces.py b/tests/unit/test_interfaces.py
deleted file mode 100644
index 53683e3..0000000
--- a/tests/unit/test_interfaces.py
+++ /dev/null
@@ -1,85 +0,0 @@
-import pytest
-
-import tests.ext.interfaces
-import tests.pkg.interfaces
-from labrea import Interface, Option, interface
-from labrea.types import EvaluationError
-
-
-def test_base():
-    config = {"B": "B"}
-
-    with pytest.raises(EvaluationError):
-        _ = tests.pkg.interfaces.Interface1.a(config)
-
-    assert tests.pkg.interfaces.Interface1.b.evaluate(config) == "B"
-    assert tests.pkg.interfaces.Interface1.c.evaluate(config) == "default"
-
-
-def test_imp():
-    config = {
-        "B": "B",
-        "LABREA": {
-            "IMPLEMENTATIONS": {
-                "tests": {
-                    "pkg": {
-                        "interfaces": {"Interface1": "tests.ext.interfaces.Int1Imp"}
-                    }
-                }
-            },
-            "REQUIRES": ["tests.ext"],
-        },
-    }
-
-    assert tests.pkg.interfaces.Interface1.a.evaluate(config) == 1
-    assert tests.pkg.interfaces.Interface1.b.evaluate(config) == "B"
-    assert tests.pkg.interfaces.Interface1.c.evaluate(config) == "overload"
-
-
-def test_multi():
-    config = {"A": "A", "B": "B", "LABREA": {"IMPLEMENTATIONS": {"A": "X", "B": "XX"}}}
-
-    @interface.dispatch("LABREA.IMPLEMENTATIONS.A")
-    class A:
-        a: int
-
-    @interface.dispatch("LABREA.IMPLEMENTATIONS.B")
-    class B:
-        b: int
-
-    with pytest.raises(TypeError):
-
-        @Interface.implementation(A, B)
-        class W:
-            a = Option("A")
-
-    @Interface.implementation(A, B, aliases=["X", "XX"])
-    class X:
-        a = Option("A")
-        b = Option("B")
-
-    assert A.a(config) == "A"
-    assert B.b(config) == "B"
-
-
-def test_other_evaluatable():
-    @interface(dispatch="INT")
-    class Int:
-        a: int = Option("A")
-        b: int = 2
-
-    @Int.implementation(alias="Imp")
-    class Imp:
-        a: int = 3
-
-    assert Int.a({"A": 1}) == 1
-    assert Int.b({}) == 2
-    assert Int.a({"INT": "Imp"}) == 3
-
-
-def test_no_interfaces():
-    with pytest.raises(TypeError):
-
-        @Interface.implementation()
-        class X:
-            pass
diff --git a/tests/unit/test_multithreading.py b/tests/unit/test_multithreading.py
deleted file mode 100644
index b114598..0000000
--- a/tests/unit/test_multithreading.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import threading
-
-import labrea.multithreading
-from labrea import dataset
-
-
-@dataset.nocache
-def inner():
-    return threading.currentThread()
-
-
-@dataset.nocache
-def outer(inr=inner):
-    return inr
-
-
-def test_multithreading():
-    assert labrea.multithreading.__PARALLEL
-
-    assert outer({}) is not threading.currentThread()
-
-    labrea.multithreading.disable()
-    assert not labrea.multithreading.__PARALLEL
-
-    assert outer({}) is threading.currentThread()
-
-    labrea.multithreading.enable()
-    assert labrea.multithreading.__PARALLEL
diff --git a/tests/unit/test_options.py b/tests/unit/test_options.py
deleted file mode 100644
index 2663d4d..0000000
--- a/tests/unit/test_options.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import pytest
-
-from labrea import Option
-from labrea.types import ValidationError
-
-
-def test_templating():
-    opt = Option("C", "{A.X} {B}")
-
-    assert opt({"A": {"X": "Hello"}, "B": "World!"}) == "Hello World!"
-    assert opt({"C": "{D} {E}", "D": "foo", "E": "bar"}) == "foo bar"
-    assert (
-        opt(
-            {
-                "C": "{D} {E}",
-                "D": "foo",
-                "E": "bar",
-                "LABREA": {"OPTIONS": {"RESOLVE": False}},
-            }
-        )
-        == "{D} {E}"
-    )
-
-
-def test_validate():
-    with pytest.raises(ValidationError):
-        Option("A.B").validate({"A": {"X": 1}})
-
-    with pytest.raises(ValidationError):
-        Option("A").validate({"A": "{X}"})
-
-    with pytest.raises(ValidationError):
-        Option("A", "{B}").validate({})
-
-    Option("A", "{B}").validate({"B": 1})
-
-    Option("A").validate({"A": "{X}", "LABREA": {"OPTIONS": {"RESOLVE": False}}})
-
-
-def test_docstring():
-    assert Option("A").__doc__ == ""
-    assert Option("A", doc="Hello, World!").__doc__ == "Hello, World!"
diff --git a/tests/unit/test_pipelines.py b/tests/unit/test_pipelines.py
deleted file mode 100644
index f249d5c..0000000
--- a/tests/unit/test_pipelines.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import pytest
-from functional_pypelines.validator import ValidationError
-
-from labrea import LabreaPipeline, Option, dataset
-from labrea.pipelines import validator
-from labrea.types import EvaluationError
-
-
-@dataset
-def double_a(a: int = Option("A").result) -> int:
-    return a * 2
-
-
-@LabreaPipeline.step
-def add_double_a(x: int, dbl_a: int = double_a.result) -> int:
-    return x + dbl_a
-
-
-@LabreaPipeline.step
-def divide(x: int, divisor: int = Option("DIVISOR").result) -> float:
-    return x / divisor
-
-
-pipeline = add_double_a >> divide >> LabreaPipeline.unwrap
-
-
-def test_valid_input():
-    options = {"A": 4, "DIVISOR": 3}
-    validator.validate(pipeline, 1, options)
-    assert pipeline(1, options) == 3
-
-
-def test_invalid_input():
-    with pytest.raises(ValidationError):
-        validator.validate(pipeline, 1, {})
-    with pytest.raises(ValidationError):
-        validator.validate(pipeline, 1, {"A": 4})
-    with pytest.raises(ValidationError):
-        validator.validate(pipeline, 1, {"DIVISOR": 3})
-    with pytest.raises(EvaluationError):
-        pipeline(1, {})
-
-
-def test_warning():
-    with pytest.warns():
-
-        @LabreaPipeline.step
-        def default_argument(x: int = 1, y: int = 2) -> int:
-            return x + y
-
-
-def test_no_value():
-    @LabreaPipeline.step
-    def no_default_value(_, x: int = Option("X").result):
-        return x
-
-    assert no_default_value(options={"X": 1}).value == 1
diff --git a/tests/unit/test_switch.py b/tests/unit/test_switch.py
deleted file mode 100644
index f64b33c..0000000
--- a/tests/unit/test_switch.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import pytest
-
-from labrea import Switch
-from labrea.types import EvaluationError
-
-
-def test_dispatch():
-    switch = Switch("X", {1: 1})
-
-    with pytest.raises(EvaluationError):
-        switch.evaluate({"X": []})
-
-
-def test_has_default_prop():
-    assert not Switch("X", {1: 1}).has_default
-    assert Switch("X", {1: 1}, 10).has_default
diff --git a/tests/unit/test_template.py b/tests/unit/test_template.py
deleted file mode 100644
index 0226ba9..0000000
--- a/tests/unit/test_template.py
+++ /dev/null
@@ -1,47 +0,0 @@
-import pytest
-
-from labrea import Option, Template, dataset
-from labrea.types import EvaluationError, ValidationError
-
-
-@dataset
-def b(b_: str = Option("B")) -> str:
-    return b_
-
-
-t = Template("{A.X} {:b:}", b=b)
-
-
-def test_evaluate():
-    assert t({"A": {"X": "Hello"}, "B": "World!"}) == "Hello World!"
-
-    with pytest.raises(EvaluationError):
-        t({"A": {"X": "Hello"}})
-
-    with pytest.raises(EvaluationError):
-        t({"B": "World!"})
-
-
-def test_validate():
-    t.validate({"A": {"X": "Hello"}, "B": "World!"})
-
-    with pytest.raises(ValidationError):
-        t.validate({"A": {"X": "Hello"}})
-
-    with pytest.raises(ValidationError):
-        t.validate({"B": "World!"})
-
-
-def test_keys():
-    assert t.keys({"A": {"X": "Hello"}, "B": "World!"}) == {"A.X", "B"}
-
-
-def test_invalid_template():
-    with pytest.raises(ValueError):
-        Template("{A.X} {:b:}")
-
-    with pytest.raises(ValueError):
-        Template("{A.X} {:b:}", a="Unused")
-
-    with pytest.warns(UserWarning):
-        Template("{A.X} {:b:}", a="Unused", b=b)
diff --git a/tests/unit/test_value.py b/tests/unit/test_value.py
deleted file mode 100644
index a4f211c..0000000
--- a/tests/unit/test_value.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from labrea.types import Value
-
-
-def test_uncopyable():
-    class Uncopyable:
-        def __deepcopy__(self, memo):
-            raise NotImplementedError("Cannot copy this.")
-
-    uncopyable = Uncopyable()
-
-    assert Value(uncopyable).evaluate({}) is uncopyable