[shuffle] Move utils to new structure.

The name `core` was decided fit better with the base language for the
IR. This CL moves the `core/` folder to `utils/` and moves the files
into the correct subdirectories. The build targets and namespaces are
not updated as part of the move and will be fixed up in later CLs.

Bug: tint:1988
Change-Id: I1fc4414c86b28e1669af2d2d07340ecfdd9ba681
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/142361
Reviewed-by: Ben Clayton <bclayton@google.com>
Kokoro: Kokoro <noreply+kokoro@google.com>
diff --git a/src/tint/utils/cli/cli.cc b/src/tint/utils/cli/cli.cc
new file mode 100644
index 0000000..72ff0d6
--- /dev/null
+++ b/src/tint/utils/cli/cli.cc
@@ -0,0 +1,182 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/cli/cli.h"
+
+#include <algorithm>
+#include <sstream>
+#include <utility>
+
+#include "src/tint/utils/containers/hashset.h"
+#include "src/tint/utils/containers/transform.h"
+#include "src/tint/utils/text/string.h"
+
+namespace tint::utils::cli {
+
+Option::~Option() = default;
+
+void OptionSet::ShowHelp(std::ostream& s_out) {
+    utils::Vector<const Option*, 32> sorted_options;
+    for (auto* opt : options.Objects()) {
+        sorted_options.Push(opt);
+    }
+    sorted_options.Sort([](const Option* a, const Option* b) { return a->Name() < b->Name(); });
+
+    struct CmdInfo {
+        std::string left;
+        std::string right;
+    };
+    utils::Vector<CmdInfo, 64> cmd_infos;
+
+    for (auto* opt : sorted_options) {
+        {
+            std::stringstream left, right;
+            left << "--" << opt->Name();
+            if (auto param = opt->Parameter(); !param.empty()) {
+                left << " <" << param << ">";
+            }
+            right << opt->Description();
+            if (auto def = opt->DefaultValue(); !def.empty()) {
+                right << "\ndefault: " << def;
+            }
+            cmd_infos.Push({left.str(), right.str()});
+        }
+        if (auto alias = opt->Alias(); !alias.empty()) {
+            std::stringstream left, right;
+            left << "--" << alias;
+            right << "alias for --" << opt->Name();
+            cmd_infos.Push({left.str(), right.str()});
+        }
+        if (auto sn = opt->ShortName(); !sn.empty()) {
+            std::stringstream left, right;
+            left << " -" << sn;
+            right << "short name for --" << opt->Name();
+            cmd_infos.Push({left.str(), right.str()});
+        }
+    }
+
+    const size_t kMaxRightOffset = 30;
+
+    // Measure
+    size_t left_width = 0;
+    for (auto& cmd_info : cmd_infos) {
+        for (auto line : utils::Split(cmd_info.left, "\n")) {
+            if (line.length() < kMaxRightOffset) {
+                left_width = std::max(left_width, line.length());
+            }
+        }
+    }
+
+    // Print
+    left_width = std::min(left_width, kMaxRightOffset);
+
+    auto pad = [&](size_t n) {
+        while (n--) {
+            s_out << " ";
+        }
+    };
+
+    for (auto& cmd_info : cmd_infos) {
+        auto left_lines = utils::Split(cmd_info.left, "\n");
+        auto right_lines = utils::Split(cmd_info.right, "\n");
+
+        size_t num_lines = std::max(left_lines.Length(), right_lines.Length());
+        for (size_t i = 0; i < num_lines; i++) {
+            bool has_left = (i < left_lines.Length()) && !left_lines[i].empty();
+            bool has_right = (i < right_lines.Length()) && !right_lines[i].empty();
+            if (has_left) {
+                s_out << left_lines[i];
+                if (has_right) {
+                    if (left_lines[i].length() > left_width) {
+                        // Left exceeds column width.
+                        // Insert a new line and indent to the right
+                        s_out << std::endl;
+                        pad(left_width);
+                    } else {
+                        pad(left_width - left_lines[i].length());
+                    }
+                }
+            } else if (has_right) {
+                pad(left_width);
+            }
+            if (has_right) {
+                s_out << "  " << right_lines[i];
+            }
+            s_out << std::endl;
+        }
+    }
+}
+
+Result<OptionSet::Unconsumed> OptionSet::Parse(std::ostream& s_err,
+                                               utils::VectorRef<std::string_view> arguments_raw) {
+    // Build a map of name to option, and set defaults
+    utils::Hashmap<std::string, Option*, 32> options_by_name;
+    for (auto* opt : options.Objects()) {
+        opt->SetDefault();
+        for (auto name : {opt->Name(), opt->Alias(), opt->ShortName()}) {
+            if (!name.empty() && !options_by_name.Add(name, opt)) {
+                s_err << "multiple options with name '" << name << "'" << std::endl;
+                return Failure;
+            }
+        }
+    }
+
+    // Canonicalize arguments by splitting '--foo=x' into '--foo' 'x'.
+    std::deque<std::string_view> arguments;
+    for (auto arg : arguments_raw) {
+        if (HasPrefix(arg, "-")) {
+            if (auto eq_idx = arg.find("="); eq_idx != std::string_view::npos) {
+                arguments.push_back(arg.substr(0, eq_idx));
+                arguments.push_back(arg.substr(eq_idx + 1));
+                continue;
+            }
+        }
+        arguments.push_back(arg);
+    }
+
+    utils::Hashset<Option*, 8> options_parsed;
+
+    Unconsumed unconsumed;
+    while (!arguments.empty()) {
+        auto arg = std::move(arguments.front());
+        arguments.pop_front();
+        auto name = TrimLeft(arg, [](char c) { return c == '-'; });
+        if (arg == name || name.length() == 0) {
+            unconsumed.Push(arg);
+            continue;
+        }
+        if (auto opt = options_by_name.Find(name)) {
+            if (auto err = (*opt)->Parse(arguments); !err.empty()) {
+                s_err << err << std::endl;
+                return Failure;
+            }
+        } else {
+            s_err << "unknown flag: " << arg << std::endl;
+            auto names = options_by_name.Keys();
+            auto alternatives =
+                Transform(names, [&](const std::string& s) { return std::string_view(s); });
+            utils::StringStream ss;
+            utils::SuggestAlternativeOptions opts;
+            opts.prefix = "--";
+            opts.list_possible_values = false;
+            SuggestAlternatives(arg, alternatives.Slice(), ss, opts);
+            s_err << ss.str();
+            return Failure;
+        }
+    }
+
+    return unconsumed;
+}
+
+}  // namespace tint::utils::cli
diff --git a/src/tint/utils/cli/cli.h b/src/tint/utils/cli/cli.h
new file mode 100644
index 0000000..1f14fb2
--- /dev/null
+++ b/src/tint/utils/cli/cli.h
@@ -0,0 +1,410 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CLI_CLI_H_
+#define SRC_TINT_UTILS_CLI_CLI_H_
+
+#include <deque>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "src/tint/utils/containers/vector.h"
+#include "src/tint/utils/macros/compiler.h"
+#include "src/tint/utils/memory/block_allocator.h"
+#include "src/tint/utils/result/result.h"
+#include "src/tint/utils/text/parse_num.h"
+#include "src/tint/utils/text/string.h"
+
+namespace tint::utils::cli {
+
+/// Alias is a fluent-constructor helper for Options
+struct Alias {
+    /// The alias to apply to an Option
+    std::string value;
+
+    /// @param option the option to apply the alias to
+    template <typename T>
+    void Apply(T& option) {
+        option.alias = value;
+    }
+};
+
+/// ShortName is a fluent-constructor helper for Options
+struct ShortName {
+    /// The short-name to apply to an Option
+    std::string value;
+
+    /// @param option the option to apply the short name to
+    template <typename T>
+    void Apply(T& option) {
+        option.short_name = value;
+    }
+};
+
+/// Parameter is a fluent-constructor helper for Options
+struct Parameter {
+    /// The parameter name to apply to an Option
+    std::string value;
+
+    /// @param option the option to apply the parameter name to
+    template <typename T>
+    void Apply(T& option) {
+        option.parameter = value;
+    }
+};
+
+/// Default is a fluent-constructor helper for Options
+template <typename T>
+struct Default {
+    /// The default value to apply to an Option
+    T value;
+
+    /// @param option the option to apply the default value to
+    template <typename O>
+    void Apply(O& option) {
+        option.default_value = value;
+    }
+};
+
+/// Deduction guide for Default
+template <typename T>
+Default(T) -> Default<T>;
+
+/// Option is the base class for all command line options
+class Option {
+  public:
+    /// An alias to std::string, used to hold error messages.
+    using Error = std::string;
+
+    /// Destructor
+    virtual ~Option();
+
+    /// @return the name of the option, without any leading hyphens.
+    /// Example: 'help'
+    virtual std::string Name() const = 0;
+
+    /// @return the alias name of the option, without any leading hyphens. (optional)
+    /// Example: 'flag'
+    virtual std::string Alias() const = 0;
+
+    /// @return the shorter name of the option, without any leading hyphens. (optional)
+    /// Example: 'h'
+    virtual std::string ShortName() const = 0;
+
+    /// @return a string describing the parameter that the option expects.
+    /// Empty represents no expected parameter.
+    virtual std::string Parameter() const = 0;
+
+    /// @return a description of the option.
+    /// Example: 'shows this message'
+    virtual std::string Description() const = 0;
+
+    /// @return the default value of the option, or an empty string if there is no default value.
+    virtual std::string DefaultValue() const = 0;
+
+    /// Sets the option value to the default (called before arguments are parsed)
+    virtual void SetDefault() = 0;
+
+    /// Parses the option's arguments from the list of command line arguments, removing the consumed
+    /// arguments before returning. @p arguments will have already had the option's name consumed
+    /// before calling.
+    /// @param arguments the queue of unprocessed arguments. Parse() may take from the front of @p
+    /// arguments.
+    /// @return empty Error if successfully parsed, otherwise an error string.
+    virtual Error Parse(std::deque<std::string_view>& arguments) = 0;
+
+  protected:
+    /// An empty string, used to represent no-error.
+    static constexpr const char* Success = "";
+
+    /// @param expected the expected value(s) for the option
+    /// @return an Error message for a missing argument
+    Error ErrMissingArgument(std::string expected) const {
+        Error err = "missing value for option '--" + Name() + "'";
+        if (!expected.empty()) {
+            err += "Expected: " + expected;
+        }
+        return err;
+    }
+
+    /// @param got the argument value provided
+    /// @param reason the reason the argument is invalid (optional)
+    /// @return an Error message for an invalid argument
+    Error ErrInvalidArgument(std::string_view got, std::string reason) const {
+        Error err = "invalid value '" + std::string(got) + "' for option '--" + Name() + "'";
+        if (!reason.empty()) {
+            err += "\n" + reason;
+        }
+        return err;
+    }
+};
+
+/// OptionSet is a set of Options, which can parse the command line arguments.
+class OptionSet {
+  public:
+    /// Unconsumed is a list of unconsumed command line arguments
+    using Unconsumed = utils::Vector<std::string_view, 8>;
+
+    /// Constructs and returns a new Option to be owned by the OptionSet
+    /// @tparam T the Option type
+    /// @tparam ARGS the constructor argument types
+    /// @param args the constructor arguments
+    /// @return the constructed Option
+    template <typename T, typename... ARGS>
+    T& Add(ARGS&&... args) {
+        return *options.Create<T>(std::forward<ARGS>(args)...);
+    }
+
+    /// Prints to @p out the description of all the command line options.
+    /// @param out the output stream
+    void ShowHelp(std::ostream& out);
+
+    /// Parses all the options in @p options.
+    /// @param err the error stream
+    /// @param arguments the command line arguments, excluding the initial executable name
+    /// @return a Result holding a list of arguments that were not consumed as options
+    Result<Unconsumed> Parse(std::ostream& err, utils::VectorRef<std::string_view> arguments);
+
+  private:
+    /// The list of options to parse
+    utils::BlockAllocator<Option, 1024> options;
+};
+
+/// ValueOption is an option that accepts a single value
+template <typename T>
+class ValueOption : public Option {
+    static constexpr bool is_bool = std::is_same_v<T, bool>;
+    static constexpr bool is_number =
+        !is_bool && (std::is_integral_v<T> || std::is_floating_point_v<T>);
+    static constexpr bool is_string = std::is_same_v<T, std::string>;
+    static_assert(is_bool || is_number || is_string, "unsupported data type");
+
+  public:
+    /// The name of the option, without any leading hyphens.
+    std::string name;
+    /// The alias name of the option, without any leading hyphens.
+    std::string alias;
+    /// The shorter name of the option, without any leading hyphens.
+    std::string short_name;
+    /// A description of the option.
+    std::string description;
+    /// The default value.
+    std::optional<T> default_value;
+    /// The option value. Populated with Parse().
+    std::optional<T> value;
+    /// A string describing the name of the option's value.
+    std::string parameter = "value";
+
+    /// Constructor
+    ValueOption() = default;
+
+    /// Constructor
+    /// @param option_name the option name
+    /// @param option_description the option description
+    /// @param settings a number of fluent-constructor values that configure the option
+    /// @see ShortName, Parameter, Default
+    template <typename... SETTINGS>
+    ValueOption(std::string option_name, std::string option_description, SETTINGS&&... settings)
+        : name(std::move(option_name)), description(std::move(option_description)) {
+        (settings.Apply(*this), ...);
+    }
+
+    std::string Name() const override { return name; }
+
+    std::string Alias() const override { return alias; }
+
+    std::string ShortName() const override { return short_name; }
+
+    std::string Parameter() const override { return parameter; }
+
+    std::string Description() const override { return description; }
+
+    std::string DefaultValue() const override {
+        return default_value.has_value() ? ToString(*default_value) : "";
+    }
+
+    void SetDefault() override { value = default_value; }
+
+    Error Parse(std::deque<std::string_view>& arguments) override {
+        TINT_BEGIN_DISABLE_WARNING(UNREACHABLE_CODE);
+
+        if (arguments.empty()) {
+            if constexpr (is_bool) {
+                // Treat as flag (--blah)
+                value = true;
+                return Success;
+            } else {
+                return ErrMissingArgument(parameter);
+            }
+        }
+
+        auto arg = arguments.front();
+
+        if constexpr (is_number) {
+            auto result = ParseNumber<T>(arg);
+            if (result) {
+                value = result.Get();
+                arguments.pop_front();
+                return Success;
+            }
+            if (result.Failure() == ParseNumberError::kResultOutOfRange) {
+                return ErrInvalidArgument(arg, "value out of range");
+            }
+            return ErrInvalidArgument(arg, "failed to parse value");
+        } else if constexpr (is_string) {
+            value = arg;
+            arguments.pop_front();
+            return Success;
+        } else if constexpr (is_bool) {
+            if (arg == "true") {
+                value = true;
+                arguments.pop_front();
+                return Success;
+            }
+            if (arg == "false") {
+                value = false;
+                arguments.pop_front();
+                return Success;
+            }
+            // Next argument is assumed to be another option, or unconsumed argument.
+            // Treat as flag (--blah)
+            value = true;
+            return Success;
+        }
+
+        TINT_END_DISABLE_WARNING(UNREACHABLE_CODE);
+    }
+};
+
+/// BoolOption is an alias to ValueOption<bool>
+using BoolOption = ValueOption<bool>;
+
+/// StringOption is an alias to ValueOption<std::string>
+using StringOption = ValueOption<std::string>;
+
+/// EnumName is a pair of enum value and name.
+/// @tparam ENUM the enum type
+template <typename ENUM>
+struct EnumName {
+    /// Constructor
+    EnumName() = default;
+
+    /// Constructor
+    /// @param v the enum value
+    /// @param n the name of the enum value
+    EnumName(ENUM v, std::string n) : value(v), name(std::move(n)) {}
+
+    /// the enum value
+    ENUM value;
+    /// the name of the enum value
+    std::string name;
+};
+
+/// Deduction guide for EnumName
+template <typename ENUM>
+EnumName(ENUM, std::string) -> EnumName<ENUM>;
+
+/// EnumOption is an option that accepts an enumerator of values
+template <typename ENUM>
+class EnumOption : public Option {
+  public:
+    /// The name of the option, without any leading hyphens.
+    std::string name;
+    /// The alias name of the option, without any leading hyphens.
+    std::string alias;
+    /// The shorter name of the option, without any leading hyphens.
+    std::string short_name;
+    /// A description of the option.
+    std::string description;
+    /// The enum options as a pair of enum value to name
+    utils::Vector<EnumName<ENUM>, 8> enum_names;
+    /// The default value.
+    std::optional<ENUM> default_value;
+    /// The option value. Populated with Parse().
+    std::optional<ENUM> value;
+
+    /// Constructor
+    EnumOption() = default;
+
+    /// Constructor
+    /// @param option_name the option name
+    /// @param option_description the option description
+    /// @param names The enum options as a pair of enum value to name
+    /// @param settings a number of fluent-constructor values that configure the option
+    /// @see ShortName, Parameter, Default
+    template <typename... SETTINGS>
+    EnumOption(std::string option_name,
+               std::string option_description,
+               utils::VectorRef<EnumName<ENUM>> names,
+               SETTINGS&&... settings)
+        : name(std::move(option_name)),
+          description(std::move(option_description)),
+          enum_names(std::move(names)) {
+        (settings.Apply(*this), ...);
+    }
+
+    std::string Name() const override { return name; }
+
+    std::string ShortName() const override { return short_name; }
+
+    std::string Alias() const override { return alias; }
+
+    std::string Parameter() const override { return PossibleValues("|"); }
+
+    std::string Description() const override { return description; }
+
+    std::string DefaultValue() const override {
+        for (auto& enum_name : enum_names) {
+            if (enum_name.value == default_value) {
+                return enum_name.name;
+            }
+        }
+        return "";
+    }
+
+    void SetDefault() override { value = default_value; }
+
+    Error Parse(std::deque<std::string_view>& arguments) override {
+        if (arguments.empty()) {
+            return ErrMissingArgument("one of: " + PossibleValues(", "));
+        }
+        auto& arg = arguments.front();
+        for (auto& enum_name : enum_names) {
+            if (enum_name.name == arg) {
+                value = enum_name.value;
+                arguments.pop_front();
+                return Success;
+            }
+        }
+        return ErrInvalidArgument(arg, "Must be one of: " + PossibleValues(", "));
+    }
+
+    /// @param delimiter the delimiter between each enum option
+    /// @returns the accepted enum names delimited with @p delimiter
+    std::string PossibleValues(std::string delimiter) const {
+        std::string out;
+        for (auto& enum_name : enum_names) {
+            if (!out.empty()) {
+                out += delimiter;
+            }
+            out += enum_name.name;
+        }
+        return out;
+    }
+};
+
+}  // namespace tint::utils::cli
+
+#endif  // SRC_TINT_UTILS_CLI_CLI_H_
diff --git a/src/tint/utils/cli/cli_test.cc b/src/tint/utils/cli/cli_test.cc
new file mode 100644
index 0000000..6b8fe4e
--- /dev/null
+++ b/src/tint/utils/cli/cli_test.cc
@@ -0,0 +1,299 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/cli/cli.h"
+
+#include <sstream>
+
+#include "gmock/gmock.h"
+#include "src/tint/utils/text/string.h"
+
+#include "src/tint/utils/containers/transform.h"  // Used by ToStringList()
+
+namespace tint::utils::cli {
+namespace {
+
+// Workaround for https://github.com/google/googletest/issues/3081
+// Remove when using C++20
+template <size_t N>
+utils::Vector<std::string, N> ToStringList(const utils::Vector<std::string_view, N>& views) {
+    return Transform(views, [](std::string_view view) { return std::string(view); });
+}
+
+using CLITest = testing::Test;
+
+TEST_F(CLITest, ShowHelp_ValueWithParameter) {
+    OptionSet opts;
+    opts.Add<ValueOption<int>>("my_option", "sets the awesome value");
+
+    std::stringstream out;
+    out << std::endl;
+    opts.ShowHelp(out);
+    EXPECT_EQ(out.str(), R"(
+--my_option <value>  sets the awesome value
+)");
+}
+
+TEST_F(CLITest, ShowHelp_ValueWithAlias) {
+    OptionSet opts;
+    opts.Add<ValueOption<int>>("my_option", "sets the awesome value", Alias{"alias"});
+
+    std::stringstream out;
+    out << std::endl;
+    opts.ShowHelp(out);
+    EXPECT_EQ(out.str(), R"(
+--my_option <value>  sets the awesome value
+--alias              alias for --my_option
+)");
+}
+TEST_F(CLITest, ShowHelp_ValueWithShortName) {
+    OptionSet opts;
+    opts.Add<ValueOption<int>>("my_option", "sets the awesome value", ShortName{"a"});
+
+    std::stringstream out;
+    out << std::endl;
+    opts.ShowHelp(out);
+    EXPECT_EQ(out.str(), R"(
+--my_option <value>  sets the awesome value
+ -a                  short name for --my_option
+)");
+}
+
+TEST_F(CLITest, ShowHelp_MultilineDesc) {
+    OptionSet opts;
+    opts.Add<ValueOption<int>>("an-option", R"(this is a
+multi-line description
+for an option
+)");
+
+    std::stringstream out;
+    out << std::endl;
+    opts.ShowHelp(out);
+    EXPECT_EQ(out.str(), R"(
+--an-option <value>  this is a
+                     multi-line description
+                     for an option
+
+)");
+}
+
+TEST_F(CLITest, ShowHelp_LongName) {
+    OptionSet opts;
+    opts.Add<ValueOption<int>>("an-option-with-a-really-really-long-name",
+                               "this is an option that has a silly long name", ShortName{"a"});
+
+    std::stringstream out;
+    out << std::endl;
+    opts.ShowHelp(out);
+    EXPECT_EQ(out.str(), R"(
+--an-option-with-a-really-really-long-name <value>
+     this is an option that has a silly long name
+ -a  short name for --an-option-with-a-really-really-long-name
+)");
+}
+
+TEST_F(CLITest, ShowHelp_EnumValue) {
+    enum class E { X, Y, Z };
+
+    OptionSet opts;
+    opts.Add<EnumOption<E>>("my_enum_option", "sets the awesome value",
+                            utils::Vector{
+                                EnumName(E::X, "X"),
+                                EnumName(E::Y, "Y"),
+                                EnumName(E::Z, "Z"),
+                            });
+
+    std::stringstream out;
+    out << std::endl;
+    opts.ShowHelp(out);
+    EXPECT_EQ(out.str(), R"(
+--my_enum_option <X|Y|Z>  sets the awesome value
+)");
+}
+
+TEST_F(CLITest, ShowHelp_MixedValues) {
+    enum class E { X, Y, Z };
+
+    OptionSet opts;
+
+    opts.Add<ValueOption<int>>("option-a", "an integer");
+    opts.Add<BoolOption>("option-b", "a boolean");
+    opts.Add<EnumOption<E>>("option-c", "sets the awesome value",
+                            utils::Vector{
+                                EnumName(E::X, "X"),
+                                EnumName(E::Y, "Y"),
+                                EnumName(E::Z, "Z"),
+                            });
+
+    std::stringstream out;
+    out << std::endl;
+    opts.ShowHelp(out);
+    EXPECT_EQ(out.str(), R"(
+--option-a <value>  an integer
+--option-b <value>  a boolean
+--option-c <X|Y|Z>  sets the awesome value
+)");
+}
+
+TEST_F(CLITest, ParseBool_Flag) {
+    OptionSet opts;
+    auto& opt = opts.Add<BoolOption>("my_option", "a boolean value");
+
+    std::stringstream err;
+    auto res = opts.Parse(err, Split("--my_option unconsumed", " "));
+    ASSERT_TRUE(res) << err.str();
+    EXPECT_TRUE(err.str().empty());
+    EXPECT_THAT(ToStringList(res.Get()), testing::ElementsAre("unconsumed"));
+    EXPECT_EQ(opt.value, true);
+}
+
+TEST_F(CLITest, ParseBool_ExplicitTrue) {
+    OptionSet opts;
+    auto& opt = opts.Add<BoolOption>("my_option", "a boolean value");
+
+    std::stringstream err;
+    auto res = opts.Parse(err, Split("--my_option true", " "));
+    ASSERT_TRUE(res) << err.str();
+    EXPECT_TRUE(err.str().empty());
+    EXPECT_THAT(ToStringList(res.Get()), testing::ElementsAre());
+    EXPECT_EQ(opt.value, true);
+}
+
+TEST_F(CLITest, ParseBool_ExplicitFalse) {
+    OptionSet opts;
+    auto& opt = opts.Add<BoolOption>("my_option", "a boolean value", Default{true});
+
+    std::stringstream err;
+    auto res = opts.Parse(err, Split("--my_option false", " "));
+    ASSERT_TRUE(res) << err.str();
+    EXPECT_TRUE(err.str().empty());
+    EXPECT_THAT(ToStringList(res.Get()), testing::ElementsAre());
+    EXPECT_EQ(opt.value, false);
+}
+
+TEST_F(CLITest, ParseInt) {
+    OptionSet opts;
+    auto& opt = opts.Add<ValueOption<int>>("my_option", "an integer value");
+
+    std::stringstream err;
+    auto res = opts.Parse(err, Split("--my_option 42", " "));
+    ASSERT_TRUE(res) << err.str();
+    EXPECT_TRUE(err.str().empty());
+    EXPECT_THAT(ToStringList(res.Get()), testing::ElementsAre());
+    EXPECT_EQ(opt.value, 42);
+}
+
+TEST_F(CLITest, ParseUint64) {
+    OptionSet opts;
+    auto& opt = opts.Add<ValueOption<uint64_t>>("my_option", "a uint64_t value");
+
+    std::stringstream err;
+    auto res = opts.Parse(err, Split("--my_option 1000000", " "));
+    ASSERT_TRUE(res) << err.str();
+    EXPECT_TRUE(err.str().empty());
+    EXPECT_THAT(ToStringList(res.Get()), testing::ElementsAre());
+    EXPECT_EQ(opt.value, 1000000);
+}
+
+TEST_F(CLITest, ParseFloat) {
+    OptionSet opts;
+    auto& opt = opts.Add<ValueOption<float>>("my_option", "a float value");
+
+    std::stringstream err;
+    auto res = opts.Parse(err, Split("--my_option 1.25", " "));
+    ASSERT_TRUE(res) << err.str();
+    EXPECT_TRUE(err.str().empty());
+    EXPECT_THAT(ToStringList(res.Get()), testing::ElementsAre());
+    EXPECT_EQ(opt.value, 1.25f);
+}
+
+TEST_F(CLITest, ParseString) {
+    OptionSet opts;
+    auto& opt = opts.Add<StringOption>("my_option", "a string value");
+
+    std::stringstream err;
+    auto res = opts.Parse(err, Split("--my_option blah", " "));
+    ASSERT_TRUE(res) << err.str();
+    EXPECT_TRUE(err.str().empty());
+    EXPECT_THAT(ToStringList(res.Get()), testing::ElementsAre());
+    EXPECT_EQ(opt.value, "blah");
+}
+
+TEST_F(CLITest, ParseEnum) {
+    enum class E { X, Y, Z };
+
+    OptionSet opts;
+    auto& opt = opts.Add<EnumOption<E>>("my_option", "sets the awesome value",
+                                        utils::Vector{
+                                            EnumName(E::X, "X"),
+                                            EnumName(E::Y, "Y"),
+                                            EnumName(E::Z, "Z"),
+                                        });
+    std::stringstream err;
+    auto res = opts.Parse(err, Split("--my_option Y", " "));
+    ASSERT_TRUE(res) << err.str();
+    EXPECT_TRUE(err.str().empty());
+    EXPECT_THAT(ToStringList(res.Get()), testing::ElementsAre());
+    EXPECT_EQ(opt.value, E::Y);
+}
+
+TEST_F(CLITest, ParseShortName) {
+    OptionSet opts;
+    auto& opt = opts.Add<ValueOption<int>>("my_option", "an integer value", ShortName{"o"});
+
+    std::stringstream err;
+    auto res = opts.Parse(err, Split("-o 42", " "));
+    ASSERT_TRUE(res) << err.str();
+    EXPECT_TRUE(err.str().empty());
+    EXPECT_THAT(ToStringList(res.Get()), testing::ElementsAre());
+    EXPECT_EQ(opt.value, 42);
+}
+
+TEST_F(CLITest, ParseUnconsumed) {
+    OptionSet opts;
+    auto& opt = opts.Add<ValueOption<int32_t>>("my_option", "a int32_t value");
+
+    std::stringstream err;
+    auto res = opts.Parse(err, Split("abc --my_option -123 def", " "));
+    ASSERT_TRUE(res) << err.str();
+    EXPECT_TRUE(err.str().empty());
+    EXPECT_THAT(ToStringList(res.Get()), testing::ElementsAre("abc", "def"));
+    EXPECT_EQ(opt.value, -123);
+}
+
+TEST_F(CLITest, ParseUsingEquals) {
+    OptionSet opts;
+    auto& opt = opts.Add<ValueOption<int>>("my_option", "an int value");
+
+    std::stringstream err;
+    auto res = opts.Parse(err, Split("--my_option=123", " "));
+    ASSERT_TRUE(res) << err.str();
+    EXPECT_TRUE(err.str().empty());
+    EXPECT_THAT(ToStringList(res.Get()), testing::ElementsAre());
+    EXPECT_EQ(opt.value, 123);
+}
+
+TEST_F(CLITest, SetValueToDefault) {
+    OptionSet opts;
+    auto& opt = opts.Add<BoolOption>("my_option", "a boolean value", Default{true});
+
+    std::stringstream err;
+    auto res = opts.Parse(err, utils::Empty);
+    ASSERT_TRUE(res) << err.str();
+    EXPECT_TRUE(err.str().empty());
+    EXPECT_EQ(opt.value, true);
+}
+
+}  // namespace
+}  // namespace tint::utils::cli
diff --git a/src/tint/utils/command/command.h b/src/tint/utils/command/command.h
new file mode 100644
index 0000000..3406ffc
--- /dev/null
+++ b/src/tint/utils/command/command.h
@@ -0,0 +1,82 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_COMMAND_COMMAND_H_
+#define SRC_TINT_UTILS_COMMAND_COMMAND_H_
+
+#include <string>
+#include <utility>
+
+namespace tint::utils {
+
+/// Command is a helper used by tests for executing a process with a number of
+/// arguments and an optional stdin string, and then collecting and returning
+/// the process's stdout and stderr output as strings.
+class Command {
+  public:
+    /// Output holds the output of the process
+    struct Output {
+        /// stdout from the process
+        std::string out;
+        /// stderr from the process
+        std::string err;
+        /// process error code
+        int error_code = 0;
+    };
+
+    /// Constructor
+    /// @param path path to the executable
+    explicit Command(const std::string& path);
+
+    /// Looks for an executable with the given name in the current working
+    /// directory, and if not found there, in each of the directories in the
+    /// `PATH` environment variable.
+    /// @param executable the executable name
+    /// @returns a Command which will return true for Found() if the executable
+    /// was found.
+    static Command LookPath(const std::string& executable);
+
+    /// @return true if the executable exists at the path provided to the
+    /// constructor
+    bool Found() const;
+
+    /// @returns the path of the command
+    const std::string& Path() const { return path_; }
+
+    /// Invokes the command with the given argument strings, blocking until the
+    /// process has returned.
+    /// @param args the string arguments to pass to the process
+    /// @returns the process output
+    template <typename... ARGS>
+    Output operator()(ARGS... args) const {
+        return Exec({std::forward<ARGS>(args)...});
+    }
+
+    /// Exec invokes the command with the given argument strings, blocking until
+    /// the process has returned.
+    /// @param args the string arguments to pass to the process
+    /// @returns the process output
+    Output Exec(std::initializer_list<std::string> args) const;
+
+    /// @param input the input data to pipe to the process's stdin
+    void SetInput(const std::string& input) { input_ = input; }
+
+  private:
+    std::string const path_;
+    std::string input_;
+};
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_COMMAND_COMMAND_H_
diff --git a/src/tint/utils/command/command_other.cc b/src/tint/utils/command/command_other.cc
new file mode 100644
index 0000000..f500482
--- /dev/null
+++ b/src/tint/utils/command/command_other.cc
@@ -0,0 +1,35 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/command/command.h"
+
+namespace tint::utils {
+
+Command::Command(const std::string&) {}
+
+Command Command::LookPath(const std::string&) {
+    return Command("");
+}
+
+bool Command::Found() const {
+    return false;
+}
+
+Command::Output Command::Exec(std::initializer_list<std::string>) const {
+    Output out;
+    out.err = "Command not supported by this target";
+    return out;
+}
+
+}  // namespace tint::utils
diff --git a/src/tint/utils/command/command_posix.cc b/src/tint/utils/command/command_posix.cc
new file mode 100644
index 0000000..a615cc6
--- /dev/null
+++ b/src/tint/utils/command/command_posix.cc
@@ -0,0 +1,264 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/command/command.h"
+
+#include <sys/poll.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+#include <unistd.h>
+#include <sstream>
+#include <vector>
+
+namespace tint::utils {
+
+namespace {
+
+/// File is a simple wrapper around a POSIX file descriptor
+class File {
+    constexpr static const int kClosed = -1;
+
+  public:
+    /// Constructor
+    File() : handle_(kClosed) {}
+
+    /// Constructor
+    explicit File(int handle) : handle_(handle) {}
+
+    /// Destructor
+    ~File() { Close(); }
+
+    /// Move assignment operator
+    File& operator=(File&& rhs) {
+        Close();
+        handle_ = rhs.handle_;
+        rhs.handle_ = kClosed;
+        return *this;
+    }
+
+    /// Closes the file (if it wasn't already closed)
+    void Close() {
+        if (handle_ != kClosed) {
+            close(handle_);
+        }
+        handle_ = kClosed;
+    }
+
+    /// @returns the file handle
+    operator int() { return handle_; }
+
+    /// @returns true if the file is not closed
+    operator bool() { return handle_ != kClosed; }
+
+  private:
+    File(const File&) = delete;
+    File& operator=(const File&) = delete;
+
+    int handle_ = kClosed;
+};
+
+/// Pipe is a simple wrapper around a POSIX pipe() function
+class Pipe {
+  public:
+    /// Constructs the pipe
+    Pipe() {
+        int pipes[2] = {};
+        if (pipe(pipes) == 0) {
+            read = File(pipes[0]);
+            write = File(pipes[1]);
+        }
+    }
+
+    /// Closes both the read and write files (if they're not already closed)
+    void Close() {
+        read.Close();
+        write.Close();
+    }
+
+    /// @returns true if the pipe has an open read or write file
+    operator bool() { return read || write; }
+
+    /// The reader end of the pipe
+    File read;
+
+    /// The writer end of the pipe
+    File write;
+};
+
+bool ExecutableExists(const std::string& path) {
+    struct stat s {};
+    if (stat(path.c_str(), &s) != 0) {
+        return false;
+    }
+    return s.st_mode & S_IXUSR;
+}
+
+std::string FindExecutable(const std::string& name) {
+    if (ExecutableExists(name)) {
+        return name;
+    }
+    if (name.find("/") == std::string::npos) {
+        auto* path_env = getenv("PATH");
+        if (!path_env) {
+            return "";
+        }
+        std::istringstream path{path_env};
+        std::string dir;
+        while (getline(path, dir, ':')) {
+            auto test = dir + "/" + name;
+            if (ExecutableExists(test)) {
+                return test;
+            }
+        }
+    }
+    return "";
+}
+
+}  // namespace
+
+Command::Command(const std::string& path) : path_(path) {}
+
+Command Command::LookPath(const std::string& executable) {
+    return Command(FindExecutable(executable));
+}
+
+bool Command::Found() const {
+    return ExecutableExists(path_);
+}
+
+Command::Output Command::Exec(std::initializer_list<std::string> arguments) const {
+    if (!Found()) {
+        Output out;
+        out.err = "Executable not found";
+        return out;
+    }
+
+    // Pipes used for piping std[in,out,err] to / from the target process.
+    Pipe stdin_pipe;
+    Pipe stdout_pipe;
+    Pipe stderr_pipe;
+
+    if (!stdin_pipe || !stdout_pipe || !stderr_pipe) {
+        Output output;
+        output.err = "Command::Exec(): Failed to create pipes";
+        return output;
+    }
+
+    // execv() and friends replace the current process image with the target
+    // process image. To keep process that called this function going, we need to
+    // fork() this process into a child and parent process.
+    //
+    // The child process is responsible for hooking up the pipes to
+    // std[in,out,err]_pipes to STD[IN,OUT,ERR]_FILENO and then calling execv() to
+    // run the target command.
+    //
+    // The parent process is responsible for feeding any input to the stdin_pipe
+    // and collecting output from the std[out,err]_pipes.
+
+    int child_id = fork();
+    if (child_id < 0) {
+        Output output;
+        output.err = "Command::Exec(): fork() failed";
+        return output;
+    }
+
+    if (child_id > 0) {
+        // fork() - parent
+
+        // Close the stdout and stderr writer pipes.
+        // This is required for getting poll() POLLHUP events.
+        stdout_pipe.write.Close();
+        stderr_pipe.write.Close();
+
+        // Write the input to the child process
+        if (!input_.empty()) {
+            ssize_t n = write(stdin_pipe.write, input_.data(), input_.size());
+            if (n != static_cast<ssize_t>(input_.size())) {
+                Output output;
+                output.err = "Command::Exec(): write() for stdin failed";
+                return output;
+            }
+        }
+        stdin_pipe.write.Close();
+
+        // Accumulate the stdout and stderr output from the child process
+        pollfd poll_fds[2];
+        poll_fds[0].fd = stdout_pipe.read;
+        poll_fds[0].events = POLLIN;
+        poll_fds[1].fd = stderr_pipe.read;
+        poll_fds[1].events = POLLIN;
+
+        Output output;
+        bool stdout_open = true;
+        bool stderr_open = true;
+        while (stdout_open || stderr_open) {
+            if (poll(poll_fds, 2, -1) < 0) {
+                break;
+            }
+            char buf[256];
+            if (poll_fds[0].revents & POLLIN) {
+                auto n = read(stdout_pipe.read, buf, sizeof(buf));
+                if (n > 0) {
+                    output.out += std::string(buf, buf + n);
+                }
+            }
+            if (poll_fds[0].revents & POLLHUP) {
+                stdout_open = false;
+            }
+            if (poll_fds[1].revents & POLLIN) {
+                auto n = read(stderr_pipe.read, buf, sizeof(buf));
+                if (n > 0) {
+                    output.err += std::string(buf, buf + n);
+                }
+            }
+            if (poll_fds[1].revents & POLLHUP) {
+                stderr_open = false;
+            }
+        }
+
+        // Get the resulting error code
+        waitpid(child_id, &output.error_code, 0);
+
+        return output;
+    } else {
+        // fork() - child
+
+        // Redirect the stdin, stdout, stderr pipes for the execv process
+        if ((dup2(stdin_pipe.read, STDIN_FILENO) == -1) ||
+            (dup2(stdout_pipe.write, STDOUT_FILENO) == -1) ||
+            (dup2(stderr_pipe.write, STDERR_FILENO) == -1)) {
+            fprintf(stderr, "Command::Exec(): Failed to redirect pipes");
+            exit(errno);
+        }
+
+        // Close the pipes, once redirected above, we're now done with them.
+        stdin_pipe.Close();
+        stdout_pipe.Close();
+        stderr_pipe.Close();
+
+        // Run target executable
+        std::vector<const char*> args;
+        args.emplace_back(path_.c_str());
+        for (auto& arg : arguments) {
+            if (!arg.empty()) {
+                args.emplace_back(arg.c_str());
+            }
+        }
+        args.emplace_back(nullptr);
+        auto res = execv(path_.c_str(), const_cast<char* const*>(args.data()));
+        exit(res);
+    }
+}
+
+}  // namespace tint::utils
diff --git a/src/tint/utils/command/command_test.cc b/src/tint/utils/command/command_test.cc
new file mode 100644
index 0000000..a362243
--- /dev/null
+++ b/src/tint/utils/command/command_test.cc
@@ -0,0 +1,90 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/command/command.h"
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+#ifdef _WIN32
+
+TEST(CommandTest, Echo) {
+    auto cmd = Command::LookPath("cmd");
+    if (!cmd.Found()) {
+        GTEST_SKIP() << "cmd not found on PATH";
+    }
+
+    auto res = cmd("/C", "echo", "hello world");
+    EXPECT_EQ(res.error_code, 0);
+    EXPECT_EQ(res.out, "hello world\r\n");
+    EXPECT_EQ(res.err, "");
+}
+
+#else
+
+TEST(CommandTest, Echo) {
+    auto cmd = Command::LookPath("echo");
+    if (!cmd.Found()) {
+        GTEST_SKIP() << "echo not found on PATH";
+    }
+
+    auto res = cmd("hello world");
+    EXPECT_EQ(res.error_code, 0);
+    EXPECT_EQ(res.out, "hello world\n");
+    EXPECT_EQ(res.err, "");
+}
+
+TEST(CommandTest, Cat) {
+    auto cmd = Command::LookPath("cat");
+    if (!cmd.Found()) {
+        GTEST_SKIP() << "cat not found on PATH";
+    }
+
+    cmd.SetInput("hello world");
+    auto res = cmd();
+    EXPECT_EQ(res.error_code, 0);
+    EXPECT_EQ(res.out, "hello world");
+    EXPECT_EQ(res.err, "");
+}
+
+TEST(CommandTest, True) {
+    auto cmd = Command::LookPath("true");
+    if (!cmd.Found()) {
+        GTEST_SKIP() << "true not found on PATH";
+    }
+
+    auto res = cmd();
+    EXPECT_EQ(res.error_code, 0);
+    EXPECT_EQ(res.out, "");
+    EXPECT_EQ(res.err, "");
+}
+
+TEST(CommandTest, False) {
+    auto cmd = Command::LookPath("false");
+    if (!cmd.Found()) {
+        GTEST_SKIP() << "false not found on PATH";
+    }
+
+    auto res = cmd();
+    EXPECT_NE(res.error_code, 0);
+    EXPECT_EQ(res.out, "");
+    EXPECT_EQ(res.err, "");
+}
+
+#endif
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/command/command_windows.cc b/src/tint/utils/command/command_windows.cc
new file mode 100644
index 0000000..cbd749f
--- /dev/null
+++ b/src/tint/utils/command/command_windows.cc
@@ -0,0 +1,272 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/command/command.h"
+
+#define WIN32_LEAN_AND_MEAN 1
+#include <Windows.h>
+#include <dbghelp.h>
+#include <string>
+
+#include "src/tint/utils/macros/defer.h"
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint::utils {
+
+namespace {
+
+/// Handle is a simple wrapper around the Win32 HANDLE
+class Handle {
+  public:
+    /// Constructor
+    Handle() : handle_(nullptr) {}
+
+    /// Constructor
+    explicit Handle(HANDLE handle) : handle_(handle) {}
+
+    /// Destructor
+    ~Handle() { Close(); }
+
+    /// Move assignment operator
+    Handle& operator=(Handle&& rhs) {
+        Close();
+        handle_ = rhs.handle_;
+        rhs.handle_ = nullptr;
+        return *this;
+    }
+
+    /// Closes the handle (if it wasn't already closed)
+    void Close() {
+        if (handle_) {
+            CloseHandle(handle_);
+        }
+        handle_ = nullptr;
+    }
+
+    /// @returns the handle
+    operator HANDLE() { return handle_; }
+
+    /// @returns true if the handle is not invalid
+    operator bool() { return handle_ != nullptr; }
+
+  private:
+    Handle(const Handle&) = delete;
+    Handle& operator=(const Handle&) = delete;
+
+    HANDLE handle_ = nullptr;
+};
+
+/// Pipe is a simple wrapper around a Win32 CreatePipe() function
+class Pipe {
+  public:
+    /// Constructs the pipe
+    explicit Pipe(bool for_read) {
+        SECURITY_ATTRIBUTES sa;
+        sa.nLength = sizeof(SECURITY_ATTRIBUTES);
+        sa.bInheritHandle = TRUE;
+        sa.lpSecurityDescriptor = nullptr;
+
+        HANDLE hread;
+        HANDLE hwrite;
+        if (CreatePipe(&hread, &hwrite, &sa, 0)) {
+            read = Handle(hread);
+            write = Handle(hwrite);
+            // Ensure the read handle to the pipe is not inherited
+            if (!SetHandleInformation(for_read ? read : write, HANDLE_FLAG_INHERIT, 0)) {
+                read.Close();
+                write.Close();
+            }
+        }
+    }
+
+    /// @returns true if the pipe has an open read or write file
+    operator bool() { return read || write; }
+
+    /// The reader end of the pipe
+    Handle read;
+
+    /// The writer end of the pipe
+    Handle write;
+};
+
+/// Queries whether the file at the given path is an executable or DLL.
+bool ExecutableExists(const std::string& path) {
+    auto file = Handle(CreateFileA(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr,
+                                   OPEN_EXISTING, FILE_ATTRIBUTE_READONLY, nullptr));
+    if (!file) {
+        return false;
+    }
+
+    auto map = Handle(CreateFileMappingA(file, nullptr, PAGE_READONLY, 0, 0, nullptr));
+    if (map == INVALID_HANDLE_VALUE) {
+        return false;
+    }
+
+    void* addr_header = MapViewOfFileEx(map, FILE_MAP_READ, 0, 0, 0, nullptr);
+
+    // Dynamically obtain the address of, and call ImageNtHeader. This is done to avoid tint.exe
+    // needing to statically link Dbghelp.lib.
+    static auto* dbg_help = LoadLibraryA("Dbghelp.dll");  // Leaks, but who cares?
+    if (dbg_help) {
+        if (FARPROC proc = GetProcAddress(dbg_help, "ImageNtHeader")) {
+            using ImageNtHeaderPtr = decltype(&ImageNtHeader);
+            auto* image_nt_header = reinterpret_cast<ImageNtHeaderPtr>(proc)(addr_header);
+            return image_nt_header != nullptr;
+        }
+    }
+
+    // Couldn't call ImageNtHeader, assume it is executable
+    return false;
+}
+
+std::string FindExecutable(const std::string& name) {
+    if (ExecutableExists(name)) {
+        return name;
+    }
+    if (ExecutableExists(name + ".exe")) {
+        return name + ".exe";
+    }
+    if (name.find("/") == std::string::npos && name.find("\\") == std::string::npos) {
+        char* path_env = nullptr;
+        size_t path_env_len = 0;
+        if (_dupenv_s(&path_env, &path_env_len, "PATH")) {
+            return "";
+        }
+        std::istringstream path{path_env};
+        free(path_env);
+        std::string dir;
+        while (getline(path, dir, ';')) {
+            auto test = dir + "\\" + name;
+            if (ExecutableExists(test)) {
+                return test;
+            }
+            if (ExecutableExists(test + ".exe")) {
+                return test + ".exe";
+            }
+        }
+    }
+    return "";
+}
+
+}  // namespace
+
+Command::Command(const std::string& path) : path_(path) {}
+
+Command Command::LookPath(const std::string& executable) {
+    return Command(FindExecutable(executable));
+}
+
+bool Command::Found() const {
+    return ExecutableExists(path_);
+}
+
+Command::Output Command::Exec(std::initializer_list<std::string> arguments) const {
+    Pipe stdout_pipe(true);
+    Pipe stderr_pipe(true);
+    Pipe stdin_pipe(false);
+    if (!stdin_pipe || !stdout_pipe || !stderr_pipe) {
+        Output output;
+        output.err = "Command::Exec(): Failed to create pipes";
+        return output;
+    }
+
+    if (!input_.empty()) {
+        if (!WriteFile(stdin_pipe.write, input_.data(), input_.size(), nullptr, nullptr)) {
+            Output output;
+            output.err = "Command::Exec() Failed to write stdin";
+            return output;
+        }
+    }
+    stdin_pipe.write.Close();
+
+    STARTUPINFOA si{};
+    si.cb = sizeof(si);
+    si.dwFlags |= STARTF_USESTDHANDLES;
+    si.hStdOutput = stdout_pipe.write;
+    si.hStdError = stderr_pipe.write;
+    si.hStdInput = stdin_pipe.read;
+
+    utils::StringStream args;
+    args << path_;
+    for (auto& arg : arguments) {
+        if (!arg.empty()) {
+            args << " " << arg;
+        }
+    }
+
+    PROCESS_INFORMATION pi{};
+    if (!CreateProcessA(nullptr,                                // No module name (use command line)
+                        const_cast<LPSTR>(args.str().c_str()),  // Command line
+                        nullptr,                                // Process handle not inheritable
+                        nullptr,                                // Thread handle not inheritable
+                        TRUE,                                   // Handles are inherited
+                        0,                                      // No creation flags
+                        nullptr,                                // Use parent's environment block
+                        nullptr,                                // Use parent's starting directory
+                        &si,                                    // Pointer to STARTUPINFO structure
+                        &pi)) {  // Pointer to PROCESS_INFORMATION structure
+        Output out;
+        out.err = "Command::Exec() CreateProcess('" + args.str() + "') failed";
+        out.error_code = 1;
+        return out;
+    }
+
+    stdin_pipe.read.Close();
+    stdout_pipe.write.Close();
+    stderr_pipe.write.Close();
+
+    struct StreamReadThreadArgs {
+        HANDLE stream;
+        std::string output;
+    };
+
+    auto stream_read_thread = [](LPVOID user) -> DWORD {
+        auto* thread_args = reinterpret_cast<StreamReadThreadArgs*>(user);
+        DWORD n = 0;
+        char buf[256];
+        while (ReadFile(thread_args->stream, buf, sizeof(buf), &n, NULL)) {
+            auto s = std::string(buf, buf + n);
+            thread_args->output += std::string(buf, buf + n);
+        }
+        return 0;
+    };
+
+    StreamReadThreadArgs stdout_read_args{stdout_pipe.read, {}};
+    auto* stdout_read_thread =
+        ::CreateThread(nullptr, 0, stream_read_thread, &stdout_read_args, 0, nullptr);
+
+    StreamReadThreadArgs stderr_read_args{stderr_pipe.read, {}};
+    auto* stderr_read_thread =
+        ::CreateThread(nullptr, 0, stream_read_thread, &stderr_read_args, 0, nullptr);
+
+    HANDLE handles[] = {pi.hProcess, stdout_read_thread, stderr_read_thread};
+    constexpr DWORD num_handles = sizeof(handles) / sizeof(handles[0]);
+
+    Output output;
+
+    auto res = WaitForMultipleObjects(num_handles, handles, /* wait_all = */ TRUE, INFINITE);
+    if (res >= WAIT_OBJECT_0 && res < WAIT_OBJECT_0 + num_handles) {
+        output.out = stdout_read_args.output;
+        output.err = stderr_read_args.output;
+        DWORD exit_code = 0;
+        GetExitCodeProcess(pi.hProcess, &exit_code);
+        output.error_code = static_cast<int>(exit_code);
+    } else {
+        output.err = "Command::Exec() WaitForMultipleObjects() returned " + std::to_string(res);
+    }
+
+    return output;
+}
+
+}  // namespace tint::utils
diff --git a/src/tint/utils/containers/bitset.h b/src/tint/utils/containers/bitset.h
new file mode 100644
index 0000000..de8eb58
--- /dev/null
+++ b/src/tint/utils/containers/bitset.h
@@ -0,0 +1,121 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_BITSET_H_
+#define SRC_TINT_UTILS_CONTAINERS_BITSET_H_
+
+#include <stdint.h>
+
+#include "src/tint/utils/containers/vector.h"
+
+namespace tint::utils {
+
+/// Bitset is a dynamically sized, vector of bits, packed into integer words.
+/// Bits can be individually read and written using the index operator.
+///
+/// Bitset will fit at least `N` bits internally before spilling to heap allocations.
+template <size_t N = 0>
+class Bitset {
+    /// The integer word type used to hold the bits
+    using Word = size_t;
+    /// Number of bits per word
+    static constexpr size_t kWordBits = sizeof(Word) * 8;
+
+    /// Number of words required to hold the number of bits
+    static constexpr size_t NumWords(size_t num_bits) {
+        return ((num_bits + kWordBits - 1) / kWordBits);
+    }
+
+  public:
+    /// Constructor
+    Bitset() = default;
+
+    /// Destructor
+    ~Bitset() = default;
+
+    /// Accessor for a single bit
+    struct Bit {
+        /// The word that contains the bit
+        Word& word;
+        /// A word with a single bit set, which masks the targetted bit
+        Word const mask;
+
+        /// Assignment operator
+        /// @param value the new value for the bit
+        /// @returns this Bit so calls can be chained
+        const Bit& operator=(bool value) const {
+            if (value) {
+                word = word | mask;
+            } else {
+                word = word & ~mask;
+            }
+            return *this;
+        }
+
+        /// Conversion operator
+        /// @returns the bit value
+        operator bool() const { return (word & mask) != 0; }
+    };
+
+    /// @param new_len the new size of the bitmap, in bits.
+    void Resize(size_t new_len) {
+        vec_.Resize(NumWords(new_len));
+
+        // Clear any potentially set bits that are in the top part of the word
+        if (size_t high_bit = new_len % kWordBits; high_bit > 0) {
+            vec_.Back() &= (static_cast<Word>(1) << high_bit) - 1;
+        }
+
+        len_ = new_len;
+    }
+
+    /// @return the number of bits in the bitset.
+    size_t Length() const { return len_; }
+
+    /// Index operator
+    /// @param index the index of the bit to access
+    /// @return the accessor for the indexed bit
+    Bit operator[](size_t index) {
+        auto& word = vec_[index / kWordBits];
+        auto mask = static_cast<Word>(1) << (index % kWordBits);
+        return Bit{word, mask};
+    }
+
+    /// Const index operator
+    /// @param index the index of the bit to access
+    /// @return bool value of the indexed bit
+    bool operator[](size_t index) const {
+        const auto& word = vec_[index / kWordBits];
+        auto mask = static_cast<Word>(1) << (index % kWordBits);
+        return word & mask;
+    }
+
+    /// @returns true iff the all bits are unset (0)
+    bool AllBitsZero() const {
+        for (auto word : vec_) {
+            if (word) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+  private:
+    Vector<size_t, NumWords(N)> vec_;
+    size_t len_ = 0;
+};
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_BITSET_H_
diff --git a/src/tint/utils/containers/bitset_test.cc b/src/tint/utils/containers/bitset_test.cc
new file mode 100644
index 0000000..70de877
--- /dev/null
+++ b/src/tint/utils/containers/bitset_test.cc
@@ -0,0 +1,145 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/containers/bitset.h"
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(Bitset, Length) {
+    Bitset<8> bits;
+    EXPECT_EQ(bits.Length(), 0u);
+    bits.Resize(100u);
+    EXPECT_EQ(bits.Length(), 100u);
+}
+
+TEST(Bitset, AllBitsZero) {
+    Bitset<8> bits;
+    EXPECT_TRUE(bits.AllBitsZero());
+
+    bits.Resize(4u);
+    EXPECT_TRUE(bits.AllBitsZero());
+
+    bits.Resize(100u);
+    EXPECT_TRUE(bits.AllBitsZero());
+
+    bits[63] = true;
+    EXPECT_FALSE(bits.AllBitsZero());
+
+    bits.Resize(60);
+    EXPECT_TRUE(bits.AllBitsZero());
+
+    bits.Resize(64);
+    EXPECT_TRUE(bits.AllBitsZero());
+
+    bits[4] = true;
+    EXPECT_FALSE(bits.AllBitsZero());
+
+    bits.Resize(8);
+    EXPECT_FALSE(bits.AllBitsZero());
+}
+
+TEST(Bitset, InitCleared_NoSpill) {
+    Bitset<256> bits;
+    bits.Resize(256);
+    for (size_t i = 0; i < 256; i++) {
+        EXPECT_FALSE(bits[i]);
+    }
+}
+
+TEST(Bitset, InitCleared_Spill) {
+    Bitset<64> bits;
+    bits.Resize(256);
+    for (size_t i = 0; i < 256; i++) {
+        EXPECT_FALSE(bits[i]);
+    }
+}
+
+TEST(Bitset, ReadWrite_NoSpill) {
+    Bitset<256> bits;
+    bits.Resize(256);
+    for (size_t i = 0; i < 256; i++) {
+        bits[i] = (i & 0x2) == 0;
+    }
+    for (size_t i = 0; i < 256; i++) {
+        EXPECT_EQ(bits[i], (i & 0x2) == 0);
+    }
+}
+
+TEST(Bitset, ReadWrite_Spill) {
+    Bitset<64> bits;
+    bits.Resize(256);
+    for (size_t i = 0; i < 256; i++) {
+        bits[i] = (i & 0x2) == 0;
+    }
+    for (size_t i = 0; i < 256; i++) {
+        EXPECT_EQ(bits[i], (i & 0x2) == 0);
+    }
+}
+
+TEST(Bitset, ShinkGrowAlignedClears_NoSpill) {
+    Bitset<256> bits;
+    bits.Resize(256);
+    for (size_t i = 0; i < 256; i++) {
+        bits[i] = true;
+    }
+    bits.Resize(64);
+    bits.Resize(256);
+    for (size_t i = 0; i < 256; i++) {
+        EXPECT_EQ(bits[i], i < 64u);
+    }
+}
+
+TEST(Bitset, ShinkGrowAlignedClears_Spill) {
+    Bitset<64> bits;
+    bits.Resize(256);
+    for (size_t i = 0; i < 256; i++) {
+        bits[i] = true;
+    }
+    bits.Resize(64);
+    bits.Resize(256);
+    for (size_t i = 0; i < 256; i++) {
+        EXPECT_EQ(bits[i], i < 64u);
+    }
+}
+TEST(Bitset, ShinkGrowMisalignedClears_NoSpill) {
+    Bitset<256> bits;
+    bits.Resize(256);
+    for (size_t i = 0; i < 256; i++) {
+        bits[i] = true;
+    }
+    bits.Resize(42);
+    bits.Resize(256);
+    for (size_t i = 0; i < 256; i++) {
+        EXPECT_EQ(bits[i], i < 42u);
+    }
+}
+
+TEST(Bitset, ShinkGrowMisalignedClears_Spill) {
+    Bitset<64> bits;
+    bits.Resize(256);
+    for (size_t i = 0; i < 256; i++) {
+        bits[i] = true;
+    }
+    bits.Resize(42);
+    bits.Resize(256);
+    for (size_t i = 0; i < 256; i++) {
+        EXPECT_EQ(bits[i], i < 42u);
+    }
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/containers/enum_set.h b/src/tint/utils/containers/enum_set.h
new file mode 100644
index 0000000..e8729629
--- /dev/null
+++ b/src/tint/utils/containers/enum_set.h
@@ -0,0 +1,261 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_ENUM_SET_H_
+#define SRC_TINT_UTILS_CONTAINERS_ENUM_SET_H_
+
+#include <cstdint>
+#include <functional>
+#include <type_traits>
+#include <utility>
+
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint::utils {
+
+/// EnumSet is a set of enum values.
+/// @note As the EnumSet is backed by a single uint64_t value, it can only hold
+/// enum values in the range [0 .. 63].
+template <typename ENUM>
+struct EnumSet {
+  public:
+    /// Enum is the enum type this EnumSet wraps
+    using Enum = ENUM;
+
+    /// Constructor. Initializes the EnumSet with zero.
+    constexpr EnumSet() = default;
+
+    /// Copy constructor.
+    /// @param s the EnumSet to copy
+    constexpr EnumSet(const EnumSet& s) = default;
+
+    /// Constructor. Initializes the EnumSet with the given values.
+    /// @param values the enumerator values to construct the EnumSet with
+    template <typename... VALUES>
+    explicit constexpr EnumSet(VALUES... values) : set(Union(values...)) {}
+
+    /// Copy assignment operator.
+    /// @param set the EnumSet to assign to this set
+    /// @returns this EnumSet so calls can be chained
+    inline EnumSet& operator=(const EnumSet& set) = default;
+
+    /// Copy assignment operator.
+    /// @param e the enum value
+    /// @returns this EnumSet so calls can be chained
+    inline EnumSet& operator=(Enum e) { return *this = EnumSet{e}; }
+
+    /// Adds all the given values to this set
+    /// @param values the values to add
+    /// @returns this EnumSet so calls can be chained
+    template <typename... VALUES>
+    inline EnumSet& Add(VALUES... values) {
+        return Add(EnumSet(std::forward<VALUES>(values)...));
+    }
+
+    /// Removes all the given values from this set
+    /// @param values the values to remove
+    /// @returns this EnumSet so calls can be chained
+    template <typename... VALUES>
+    inline EnumSet& Remove(VALUES... values) {
+        return Remove(EnumSet(std::forward<VALUES>(values)...));
+    }
+
+    /// Adds all of @p s to this set
+    /// @param s the enum value
+    /// @returns this EnumSet so calls can be chained
+    inline EnumSet& Add(EnumSet s) { return (*this = *this + s); }
+
+    /// Removes all of @p s from this set
+    /// @param s the enum value
+    /// @returns this EnumSet so calls can be chained
+    inline EnumSet& Remove(EnumSet s) { return (*this = *this - s); }
+
+    /// Adds or removes @p e to the set
+    /// @param e the enum value
+    /// @param add if true the enum value is added, otherwise removed
+    /// @returns this EnumSet so calls can be chained
+    inline EnumSet& Set(Enum e, bool add = true) { return add ? Add(e) : Remove(e); }
+
+    /// @param e the enum value
+    /// @returns a copy of this EnumSet with @p e added
+    inline EnumSet operator+(Enum e) const {
+        EnumSet out;
+        out.set = set | Bit(e);
+        return out;
+    }
+
+    /// @param e the enum value
+    /// @returns a copy of this EnumSet with @p e removed
+    inline EnumSet operator-(Enum e) const {
+        EnumSet out;
+        out.set = set & ~Bit(e);
+        return out;
+    }
+
+    /// @param s the other set
+    /// @returns the union of this EnumSet with @p s (`this` ∪ @p s)
+    inline EnumSet operator+(EnumSet s) const {
+        EnumSet out;
+        out.set = set | s.set;
+        return out;
+    }
+
+    /// @param s the other set
+    /// @returns the set of entries found in this but not in s (`this` \ @p s)
+    inline EnumSet operator-(EnumSet s) const {
+        EnumSet out;
+        out.set = set & ~s.set;
+        return out;
+    }
+
+    /// @param s the other set
+    /// @returns the intersection of this EnumSet with s (`this` ∩ @p s)
+    inline EnumSet operator&(EnumSet s) const {
+        EnumSet out;
+        out.set = set & s.set;
+        return out;
+    }
+
+    /// @param e the enum value
+    /// @return true if the set contains @p e
+    inline bool Contains(Enum e) const { return (set & Bit(e)) != 0; }
+
+    /// @return true if the set is empty
+    inline bool Empty() const { return set == 0; }
+
+    /// Equality operator
+    /// @param rhs the other EnumSet to compare this to
+    /// @return true if this EnumSet is equal to @p rhs
+    inline bool operator==(EnumSet rhs) const { return set == rhs.set; }
+
+    /// Inequality operator
+    /// @param rhs the other EnumSet to compare this to
+    /// @return true if this EnumSet is not equal to @p rhs
+    inline bool operator!=(EnumSet rhs) const { return set != rhs.set; }
+
+    /// Equality operator
+    /// @param rhs the enum to compare this to
+    /// @return true if this EnumSet only contains @p rhs
+    inline bool operator==(Enum rhs) const { return set == Bit(rhs); }
+
+    /// Inequality operator
+    /// @param rhs the enum to compare this to
+    /// @return false if this EnumSet only contains @p rhs
+    inline bool operator!=(Enum rhs) const { return set != Bit(rhs); }
+
+    /// @return the underlying value for the EnumSet
+    inline uint64_t Value() const { return set; }
+
+    /// Iterator provides read-only, unidirectional iterator over the enums of an
+    /// EnumSet.
+    class Iterator {
+        static constexpr int8_t kEnd = 63;
+
+        Iterator(uint64_t s, int8_t b) : set(s), pos(b) {}
+
+        /// Make the constructor accessible to the EnumSet.
+        friend struct EnumSet;
+
+      public:
+        /// @return the Enum value at this point in the iterator
+        Enum operator*() const { return static_cast<Enum>(pos); }
+
+        /// Increments the iterator
+        /// @returns this iterator
+        Iterator& operator++() {
+            while (pos < kEnd) {
+                pos++;
+                if (set & (static_cast<uint64_t>(1) << static_cast<uint64_t>(pos))) {
+                    break;
+                }
+            }
+            return *this;
+        }
+
+        /// Equality operator
+        /// @param rhs the Iterator to compare this to
+        /// @return true if the two iterators are equal
+        bool operator==(const Iterator& rhs) const { return set == rhs.set && pos == rhs.pos; }
+
+        /// Inequality operator
+        /// @param rhs the Iterator to compare this to
+        /// @return true if the two iterators are different
+        bool operator!=(const Iterator& rhs) const { return !(*this == rhs); }
+
+      private:
+        const uint64_t set;
+        int8_t pos;
+    };
+
+    /// @returns an read-only iterator to the beginning of the set
+    Iterator begin() const {
+        auto it = Iterator{set, -1};
+        ++it;  // Move to first set bit
+        return it;
+    }
+
+    /// @returns an iterator to the beginning of the set
+    Iterator end() const { return Iterator{set, Iterator::kEnd}; }
+
+  private:
+    static constexpr uint64_t Bit(Enum value) {
+        return static_cast<uint64_t>(1) << static_cast<uint64_t>(value);
+    }
+
+    static constexpr uint64_t Union() { return 0; }
+
+    template <typename FIRST, typename... VALUES>
+    static constexpr uint64_t Union(FIRST first, VALUES... values) {
+        return Bit(first) | Union(values...);
+    }
+
+    uint64_t set = 0;
+};
+
+/// Writes the EnumSet to the stream.
+/// @param out the stream to write to
+/// @param set the EnumSet to write
+/// @returns out so calls can be chained
+template <typename ENUM>
+inline utils::StringStream& operator<<(utils::StringStream& out, EnumSet<ENUM> set) {
+    out << "{";
+    bool first = true;
+    for (auto e : set) {
+        if (!first) {
+            out << ", ";
+        }
+        first = false;
+        out << e;
+    }
+    return out << "}";
+}
+
+}  // namespace tint::utils
+
+namespace std {
+
+/// Custom std::hash specialization for tint::utils::EnumSet<T>
+template <typename T>
+class hash<tint::utils::EnumSet<T>> {
+  public:
+    /// @param e the EnumSet to create a hash for
+    /// @return the hash value
+    inline std::size_t operator()(const tint::utils::EnumSet<T>& e) const {
+        return std::hash<uint64_t>()(e.Value());
+    }
+};
+
+}  // namespace std
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_ENUM_SET_H_
diff --git a/src/tint/utils/containers/enum_set_test.cc b/src/tint/utils/containers/enum_set_test.cc
new file mode 100644
index 0000000..32303c8
--- /dev/null
+++ b/src/tint/utils/containers/enum_set_test.cc
@@ -0,0 +1,254 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/containers/enum_set.h"
+
+#include <vector>
+
+#include "gmock/gmock.h"
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint::utils {
+namespace {
+
+using ::testing::ElementsAre;
+
+enum class E { A = 0, B = 3, C = 7 };
+
+utils::StringStream& operator<<(utils::StringStream& out, E e) {
+    switch (e) {
+        case E::A:
+            return out << "A";
+        case E::B:
+            return out << "B";
+        case E::C:
+            return out << "C";
+    }
+    return out << "E(" << static_cast<uint32_t>(e) << ")";
+}
+
+TEST(EnumSetTest, ConstructEmpty) {
+    EnumSet<E> set;
+    EXPECT_FALSE(set.Contains(E::A));
+    EXPECT_FALSE(set.Contains(E::B));
+    EXPECT_FALSE(set.Contains(E::C));
+    EXPECT_TRUE(set.Empty());
+}
+
+TEST(EnumSetTest, ConstructWithSingle) {
+    EnumSet<E> set(E::B);
+    EXPECT_FALSE(set.Contains(E::A));
+    EXPECT_TRUE(set.Contains(E::B));
+    EXPECT_FALSE(set.Contains(E::C));
+    EXPECT_FALSE(set.Empty());
+}
+
+TEST(EnumSetTest, ConstructWithMultiple) {
+    EnumSet<E> set(E::A, E::C);
+    EXPECT_TRUE(set.Contains(E::A));
+    EXPECT_FALSE(set.Contains(E::B));
+    EXPECT_TRUE(set.Contains(E::C));
+    EXPECT_FALSE(set.Empty());
+}
+
+TEST(EnumSetTest, AssignSet) {
+    EnumSet<E> set;
+    set = EnumSet<E>(E::A, E::C);
+    EXPECT_TRUE(set.Contains(E::A));
+    EXPECT_FALSE(set.Contains(E::B));
+    EXPECT_TRUE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, AssignEnum) {
+    EnumSet<E> set(E::A);
+    set = E::B;
+    EXPECT_FALSE(set.Contains(E::A));
+    EXPECT_TRUE(set.Contains(E::B));
+    EXPECT_FALSE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, AddEnum) {
+    EnumSet<E> set;
+    set.Add(E::B);
+    EXPECT_FALSE(set.Contains(E::A));
+    EXPECT_TRUE(set.Contains(E::B));
+    EXPECT_FALSE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, RemoveEnum) {
+    EnumSet<E> set(E::A, E::B);
+    set.Remove(E::B);
+    EXPECT_TRUE(set.Contains(E::A));
+    EXPECT_FALSE(set.Contains(E::B));
+    EXPECT_FALSE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, AddEnums) {
+    EnumSet<E> set;
+    set.Add(E::B, E::C);
+    EXPECT_FALSE(set.Contains(E::A));
+    EXPECT_TRUE(set.Contains(E::B));
+    EXPECT_TRUE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, RemoveEnums) {
+    EnumSet<E> set(E::A, E::B);
+    set.Remove(E::C, E::B);
+    EXPECT_TRUE(set.Contains(E::A));
+    EXPECT_FALSE(set.Contains(E::B));
+    EXPECT_FALSE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, AddEnumSet) {
+    EnumSet<E> set;
+    set.Add(EnumSet<E>{E::B, E::C});
+    EXPECT_FALSE(set.Contains(E::A));
+    EXPECT_TRUE(set.Contains(E::B));
+    EXPECT_TRUE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, RemoveEnumSet) {
+    EnumSet<E> set(E::A, E::B);
+    set.Remove(EnumSet<E>{E::B, E::C});
+    EXPECT_TRUE(set.Contains(E::A));
+    EXPECT_FALSE(set.Contains(E::B));
+    EXPECT_FALSE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, Set) {
+    EnumSet<E> set;
+    set.Set(E::B);
+    EXPECT_FALSE(set.Contains(E::A));
+    EXPECT_TRUE(set.Contains(E::B));
+    EXPECT_FALSE(set.Contains(E::C));
+
+    set.Set(E::B, false);
+    EXPECT_FALSE(set.Contains(E::A));
+    EXPECT_FALSE(set.Contains(E::B));
+    EXPECT_FALSE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, OperatorPlusEnum) {
+    EnumSet<E> set = EnumSet<E>{E::B} + E::C;
+    EXPECT_FALSE(set.Contains(E::A));
+    EXPECT_TRUE(set.Contains(E::B));
+    EXPECT_TRUE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, OperatorMinusEnum) {
+    EnumSet<E> set = EnumSet<E>{E::A, E::B} - E::B;
+    EXPECT_TRUE(set.Contains(E::A));
+    EXPECT_FALSE(set.Contains(E::B));
+    EXPECT_FALSE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, OperatorPlusSet) {
+    EnumSet<E> set = EnumSet<E>{E::B} + EnumSet<E>{E::B, E::C};
+    EXPECT_FALSE(set.Contains(E::A));
+    EXPECT_TRUE(set.Contains(E::B));
+    EXPECT_TRUE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, OperatorMinusSet) {
+    EnumSet<E> set = EnumSet<E>{E::A, E::B} - EnumSet<E>{E::B, E::C};
+    EXPECT_TRUE(set.Contains(E::A));
+    EXPECT_FALSE(set.Contains(E::B));
+    EXPECT_FALSE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, OperatorAnd) {
+    EnumSet<E> set = EnumSet<E>{E::A, E::B} & EnumSet<E>{E::B, E::C};
+    EXPECT_FALSE(set.Contains(E::A));
+    EXPECT_TRUE(set.Contains(E::B));
+    EXPECT_FALSE(set.Contains(E::C));
+}
+
+TEST(EnumSetTest, EqualitySet) {
+    EXPECT_TRUE(EnumSet<E>(E::A, E::B) == EnumSet<E>(E::A, E::B));
+    EXPECT_FALSE(EnumSet<E>(E::A, E::B) == EnumSet<E>(E::A, E::C));
+}
+
+TEST(EnumSetTest, InequalitySet) {
+    EXPECT_FALSE(EnumSet<E>(E::A, E::B) != EnumSet<E>(E::A, E::B));
+    EXPECT_TRUE(EnumSet<E>(E::A, E::B) != EnumSet<E>(E::A, E::C));
+}
+
+TEST(EnumSetTest, EqualityEnum) {
+    EXPECT_TRUE(EnumSet<E>(E::A) == E::A);
+    EXPECT_FALSE(EnumSet<E>(E::B) == E::A);
+    EXPECT_FALSE(EnumSet<E>(E::B) == E::C);
+    EXPECT_FALSE(EnumSet<E>(E::A, E::B) == E::A);
+    EXPECT_FALSE(EnumSet<E>(E::A, E::B) == E::B);
+    EXPECT_FALSE(EnumSet<E>(E::A, E::B) == E::C);
+}
+
+TEST(EnumSetTest, InequalityEnum) {
+    EXPECT_FALSE(EnumSet<E>(E::A) != E::A);
+    EXPECT_TRUE(EnumSet<E>(E::B) != E::A);
+    EXPECT_TRUE(EnumSet<E>(E::B) != E::C);
+    EXPECT_TRUE(EnumSet<E>(E::A, E::B) != E::A);
+    EXPECT_TRUE(EnumSet<E>(E::A, E::B) != E::B);
+    EXPECT_TRUE(EnumSet<E>(E::A, E::B) != E::C);
+}
+
+TEST(EnumSetTest, Hash) {
+    auto hash = [&](EnumSet<E> s) { return std::hash<EnumSet<E>>()(s); };
+    EXPECT_EQ(hash(EnumSet<E>(E::A, E::B)), hash(EnumSet<E>(E::A, E::B)));
+}
+
+TEST(EnumSetTest, Value) {
+    EXPECT_EQ(EnumSet<E>().Value(), 0u);
+    EXPECT_EQ(EnumSet<E>(E::A).Value(), 1u);
+    EXPECT_EQ(EnumSet<E>(E::B).Value(), 8u);
+    EXPECT_EQ(EnumSet<E>(E::C).Value(), 128u);
+    EXPECT_EQ(EnumSet<E>(E::A, E::C).Value(), 129u);
+}
+
+TEST(EnumSetTest, Iterator) {
+    auto set = EnumSet<E>(E::C, E::A);
+
+    auto it = set.begin();
+    EXPECT_EQ(*it, E::A);
+    EXPECT_NE(it, set.end());
+    ++it;
+    EXPECT_EQ(*it, E::C);
+    EXPECT_NE(it, set.end());
+    ++it;
+    EXPECT_EQ(it, set.end());
+}
+
+TEST(EnumSetTest, IteratorEmpty) {
+    auto set = EnumSet<E>();
+    EXPECT_EQ(set.begin(), set.end());
+}
+
+TEST(EnumSetTest, Loop) {
+    auto set = EnumSet<E>(E::C, E::A);
+
+    std::vector<E> seen;
+    for (auto e : set) {
+        seen.emplace_back(e);
+    }
+
+    EXPECT_THAT(seen, ElementsAre(E::A, E::C));
+}
+
+TEST(EnumSetTest, Ostream) {
+    utils::StringStream ss;
+    ss << EnumSet<E>(E::A, E::C);
+    EXPECT_EQ(ss.str(), "{A, C}");
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/containers/hashmap.h b/src/tint/utils/containers/hashmap.h
new file mode 100644
index 0000000..daa006e
--- /dev/null
+++ b/src/tint/utils/containers/hashmap.h
@@ -0,0 +1,290 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_HASHMAP_H_
+#define SRC_TINT_UTILS_CONTAINERS_HASHMAP_H_
+
+#include <functional>
+#include <optional>
+#include <utility>
+
+#include "src/tint/utils/containers/hashmap_base.h"
+#include "src/tint/utils/containers/vector.h"
+#include "src/tint/utils/debug/debug.h"
+#include "src/tint/utils/math/hash.h"
+
+namespace tint::utils {
+
+/// An unordered map that uses a robin-hood hashing algorithm.
+template <typename KEY,
+          typename VALUE,
+          size_t N,
+          typename HASH = Hasher<KEY>,
+          typename EQUAL = EqualTo<KEY>>
+class Hashmap : public HashmapBase<KEY, VALUE, N, HASH, EQUAL> {
+    using Base = HashmapBase<KEY, VALUE, N, HASH, EQUAL>;
+    using PutMode = typename Base::PutMode;
+
+    template <typename T>
+    using ReferenceKeyType = traits::CharArrayToCharPtr<std::remove_reference_t<T>>;
+
+  public:
+    /// The key type
+    using Key = KEY;
+    /// The value type
+    using Value = VALUE;
+    /// The key-value type for a map entry
+    using Entry = KeyValue<Key, Value>;
+
+    /// Result of Add()
+    using AddResult = typename Base::PutResult;
+
+    /// Reference is returned by Hashmap::Find(), and performs dynamic Hashmap lookups.
+    /// The value returned by the Reference reflects the current state of the Hashmap, and so the
+    /// referenced value may change, or transition between valid or invalid based on the current
+    /// state of the Hashmap.
+    template <bool IS_CONST, typename K>
+    class ReferenceT {
+        /// `const Value` if IS_CONST, or `Value` if !IS_CONST
+        using T = std::conditional_t<IS_CONST, const Value, Value>;
+
+        /// `const Hashmap` if IS_CONST, or `Hashmap` if !IS_CONST
+        using Map = std::conditional_t<IS_CONST, const Hashmap, Hashmap>;
+
+      public:
+        /// @returns true if the reference is valid.
+        operator bool() const { return Get() != nullptr; }
+
+        /// @returns the pointer to the Value, or nullptr if the reference is invalid.
+        operator T*() const { return Get(); }
+
+        /// @returns the pointer to the Value
+        /// @warning if the Hashmap does not contain a value for the reference, then this will
+        /// trigger a TINT_ASSERT, or invalid pointer dereference.
+        T* operator->() const {
+            auto* hashmap_reference_lookup = Get();
+            TINT_ASSERT(Utils, hashmap_reference_lookup != nullptr);
+            return hashmap_reference_lookup;
+        }
+
+        /// @returns the pointer to the Value, or nullptr if the reference is invalid.
+        T* Get() const {
+            auto generation = map_.Generation();
+            if (generation_ != generation) {
+                cached_ = map_.Lookup(key_);
+                generation_ = generation;
+            }
+            return cached_;
+        }
+
+      private:
+        friend Hashmap;
+
+        /// Constructor
+        template <typename K_ARG>
+        ReferenceT(Map& map, K_ARG&& key)
+            : map_(map),
+              key_(std::forward<K_ARG>(key)),
+              cached_(nullptr),
+              generation_(map.Generation() - 1) {}
+
+        /// Constructor
+        template <typename K_ARG>
+        ReferenceT(Map& map, K_ARG&& key, T* value)
+            : map_(map),
+              key_(std::forward<K_ARG>(key)),
+              cached_(value),
+              generation_(map.Generation()) {}
+
+        Map& map_;
+        const K key_;
+        mutable T* cached_ = nullptr;
+        mutable size_t generation_ = 0;
+    };
+
+    /// A mutable reference returned by Find()
+    template <typename K>
+    using Reference = ReferenceT</*IS_CONST*/ false, K>;
+
+    /// An immutable reference returned by Find()
+    template <typename K>
+    using ConstReference = ReferenceT</*IS_CONST*/ true, K>;
+
+    /// Adds a value to the map, if the map does not already contain an entry with the key @p key.
+    /// @param key the entry key.
+    /// @param value the value of the entry to add to the map.
+    /// @returns A AddResult describing the result of the add
+    template <typename K, typename V>
+    AddResult Add(K&& key, V&& value) {
+        return this->template Put<PutMode::kAdd>(std::forward<K>(key), std::forward<V>(value));
+    }
+
+    /// Adds a new entry to the map, replacing any entry that has a key equal to @p key.
+    /// @param key the entry key.
+    /// @param value the value of the entry to add to the map.
+    /// @returns A AddResult describing the result of the replace
+    template <typename K, typename V>
+    AddResult Replace(K&& key, V&& value) {
+        return this->template Put<PutMode::kReplace>(std::forward<K>(key), std::forward<V>(value));
+    }
+
+    /// @param key the key to search for.
+    /// @returns the value of the entry that is equal to `value`, or no value if the entry was not
+    ///          found.
+    template <typename K>
+    std::optional<Value> Get(K&& key) const {
+        if (auto [found, index] = this->IndexOf(key); found) {
+            return this->slots_[index].entry->value;
+        }
+        return std::nullopt;
+    }
+
+    /// Searches for an entry with the given key, adding and returning the result of calling
+    /// @p create if the entry was not found.
+    /// @note: Before calling `create`, the map will insert a zero-initialized value for the given
+    /// key, which will be replaced with the value returned by @p create. If @p create adds an entry
+    /// with @p key to this map, it will be replaced.
+    /// @param key the entry's key value to search for.
+    /// @param create the create function to call if the map does not contain the key.
+    /// @returns the value of the entry.
+    template <typename K, typename CREATE>
+    Value& GetOrCreate(K&& key, CREATE&& create) {
+        auto res = Add(std::forward<K>(key), Value{});
+        if (res.action == MapAction::kAdded) {
+            // Store the map generation before calling create()
+            auto generation = this->Generation();
+            // Call create(), which might modify this map.
+            auto value = create();
+            // Was this map mutated?
+            if (this->Generation() == generation) {
+                // Calling create() did not touch the map. No need to lookup again.
+                *res.value = std::move(value);
+            } else {
+                // Calling create() modified the map. Need to insert again.
+                res = Replace(key, std::move(value));
+            }
+        }
+        return *res.value;
+    }
+
+    /// Searches for an entry with the given key value, adding and returning a newly created
+    /// zero-initialized value if the entry was not found.
+    /// @param key the entry's key value to search for.
+    /// @returns the value of the entry.
+    template <typename K>
+    auto GetOrZero(K&& key) {
+        auto res = Add(std::forward<K>(key), Value{});
+        return Reference<ReferenceKeyType<K>>(*this, key, res.value);
+    }
+
+    /// @param key the key to search for.
+    /// @returns a reference to the entry that is equal to the given value.
+    template <typename K>
+    auto Find(K&& key) {
+        return Reference<ReferenceKeyType<K>>(*this, std::forward<K>(key));
+    }
+
+    /// @param key the key to search for.
+    /// @returns a reference to the entry that is equal to the given value.
+    template <typename K>
+    auto Find(K&& key) const {
+        return ConstReference<ReferenceKeyType<K>>(*this, std::forward<K>(key));
+    }
+
+    /// @returns the keys of the map as a vector.
+    /// @note the order of the returned vector is non-deterministic between compilers.
+    template <size_t N2 = N>
+    Vector<Key, N2> Keys() const {
+        Vector<Key, N2> out;
+        out.Reserve(this->Count());
+        for (auto it : *this) {
+            out.Push(it.key);
+        }
+        return out;
+    }
+
+    /// @returns the values of the map as a vector
+    /// @note the order of the returned vector is non-deterministic between compilers.
+    template <size_t N2 = N>
+    Vector<Value, N2> Values() const {
+        Vector<Value, N2> out;
+        out.Reserve(this->Count());
+        for (auto it : *this) {
+            out.Push(it.value);
+        }
+        return out;
+    }
+
+    /// Equality operator
+    /// @param other the other Hashmap to compare this Hashmap to
+    /// @returns true if this Hashmap has the same key and value pairs as @p other
+    template <typename K, typename V, size_t N2>
+    bool operator==(const Hashmap<K, V, N2>& other) const {
+        if (this->Count() != other.Count()) {
+            return false;
+        }
+        for (auto it : *this) {
+            auto other_val = other.Find(it.key);
+            if (!other_val || it.value != *other_val) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /// Inequality operator
+    /// @param other the other Hashmap to compare this Hashmap to
+    /// @returns false if this Hashmap has the same key and value pairs as @p other
+    template <typename K, typename V, size_t N2>
+    bool operator!=(const Hashmap<K, V, N2>& other) const {
+        return !(*this == other);
+    }
+
+  private:
+    template <typename K>
+    Value* Lookup(K&& key) {
+        if (auto [found, index] = this->IndexOf(key); found) {
+            return &this->slots_[index].entry->value;
+        }
+        return nullptr;
+    }
+
+    template <typename K>
+    const Value* Lookup(K&& key) const {
+        if (auto [found, index] = this->IndexOf(key); found) {
+            return &this->slots_[index].entry->value;
+        }
+        return nullptr;
+    }
+};
+
+/// Hasher specialization for Hashmap
+template <typename K, typename V, size_t N, typename HASH, typename EQUAL>
+struct Hasher<Hashmap<K, V, N, HASH, EQUAL>> {
+    /// @param map the Hashmap to hash
+    /// @returns a hash of the map
+    size_t operator()(const Hashmap<K, V, N, HASH, EQUAL>& map) const {
+        auto hash = Hash(map.Count());
+        for (auto it : map) {
+            // Use an XOR to ensure that the non-deterministic ordering of the map still produces
+            // the same hash value for the same entries.
+            hash ^= Hash(it.key) * 31 + Hash(it.value);
+        }
+        return hash;
+    }
+};
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_HASHMAP_H_
diff --git a/src/tint/utils/containers/hashmap_base.h b/src/tint/utils/containers/hashmap_base.h
new file mode 100644
index 0000000..9d20197
--- /dev/null
+++ b/src/tint/utils/containers/hashmap_base.h
@@ -0,0 +1,636 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_HASHMAP_BASE_H_
+#define SRC_TINT_UTILS_CONTAINERS_HASHMAP_BASE_H_
+
+#include <algorithm>
+#include <functional>
+#include <optional>
+#include <tuple>
+#include <utility>
+
+#include "src/tint/utils/containers/vector.h"
+#include "src/tint/utils/debug/debug.h"
+#include "src/tint/utils/math/hash.h"
+
+#define TINT_ASSERT_ITERATORS_NOT_INVALIDATED
+
+namespace tint::utils {
+
+/// Action taken by a map mutation
+enum class MapAction {
+    /// A new entry was added to the map
+    kAdded,
+    /// A existing entry in the map was replaced
+    kReplaced,
+    /// No action was taken as the map already contained an entry with the given key
+    kKeptExisting,
+};
+
+/// KeyValue is a key-value pair.
+template <typename KEY, typename VALUE>
+struct KeyValue {
+    /// The key type
+    using Key = KEY;
+    /// The value type
+    using Value = VALUE;
+
+    /// The key
+    Key key;
+
+    /// The value
+    Value value;
+
+    /// Equality operator
+    /// @param other the RHS of the operator
+    /// @returns true if both the key and value of this KeyValue are equal to the key and value
+    /// of @p other
+    template <typename K, typename V>
+    bool operator==(const KeyValue<K, V>& other) const {
+        return key == other.key && value == other.value;
+    }
+
+    /// Inequality operator
+    /// @param other the RHS of the operator
+    /// @returns true if either the key and value of this KeyValue are not equal to the key and
+    /// value of @p other
+    template <typename K, typename V>
+    bool operator!=(const KeyValue<K, V>& other) const {
+        return *this != other;
+    }
+};
+
+/// KeyValueRef is a pair of references to a key and value.
+/// #key is always a const reference.
+/// #value is always a const reference if @tparam VALUE_IS_CONST is true, otherwise a non-const
+/// reference.
+template <typename KEY, typename VALUE, bool VALUE_IS_CONST>
+struct KeyValueRef {
+    /// The reference to key type
+    using KeyRef = const KEY&;
+    /// The reference to value type
+    using ValueRef = std::conditional_t<VALUE_IS_CONST, const VALUE&, VALUE&>;
+
+    /// The reference to the key
+    KeyRef key;
+
+    /// The reference to the value
+    ValueRef value;
+
+    /// @returns a KeyValue<KEY, VALUE> with the referenced key and value
+    operator KeyValue<KEY, VALUE>() const { return {key, value}; }
+};
+
+/// Writes the KeyValue to the stream.
+/// @param out the stream to write to
+/// @param key_value the KeyValue to write
+/// @returns out so calls can be chained
+template <typename KEY, typename VALUE>
+utils::StringStream& operator<<(utils::StringStream& out, const KeyValue<KEY, VALUE>& key_value) {
+    return out << "[" << key_value.key << ": " << key_value.value << "]";
+}
+
+/// A base class for Hashmap and Hashset that uses a robin-hood hashing algorithm.
+/// @see the fantastic tutorial: https://programming.guide/robin-hood-hashing.html
+template <typename KEY,
+          typename VALUE,
+          size_t N,
+          typename HASH = Hasher<KEY>,
+          typename EQUAL = EqualTo<KEY>>
+class HashmapBase {
+    static constexpr bool ValueIsVoid = std::is_same_v<VALUE, void>;
+
+  public:
+    /// The key type
+    using Key = KEY;
+    /// The value type
+    using Value = VALUE;
+    /// The entry type for the map.
+    /// This is:
+    /// - Key when Value is void (used by Hashset)
+    /// - KeyValue<Key, Value> when Value is not void (used by Hashmap)
+    using Entry = std::conditional_t<ValueIsVoid, Key, KeyValue<Key, Value>>;
+
+    /// A reference to an entry in the map.
+    /// This is:
+    /// - const Key& when Value is void (used by Hashset)
+    /// - KeyValueRef<Key, Value> when Value is not void (used by Hashmap)
+    template <bool IS_CONST>
+    using EntryRef = std::conditional_t<
+        ValueIsVoid,
+        const Key&,
+        KeyValueRef<Key, std::conditional_t<ValueIsVoid, bool, Value>, IS_CONST>>;
+
+    /// STL-friendly alias to Entry. Used by gmock.
+    using value_type = Entry;
+
+  private:
+    /// @returns the key from an entry
+    static const Key& KeyOf(const Entry& entry) {
+        if constexpr (ValueIsVoid) {
+            return entry;
+        } else {
+            return entry.key;
+        }
+    }
+
+    /// @returns a pointer to the value from an entry.
+    static Value* ValueOf(Entry& entry) {
+        if constexpr (ValueIsVoid) {
+            return nullptr;  // Hashset only has keys
+        } else {
+            return &entry.value;
+        }
+    }
+
+    /// A slot is a single entry in the underlying vector.
+    /// A slot can either be empty or filled with a value. If the slot is empty, #hash and #distance
+    /// will be zero.
+    struct Slot {
+        template <typename K>
+        bool Equals(size_t key_hash, K&& key) const {
+            return key_hash == hash && EQUAL()(std::forward<K>(key), KeyOf(*entry));
+        }
+
+        /// The slot value. If this does not contain a value, then the slot is vacant.
+        std::optional<Entry> entry;
+        /// The precomputed hash of value.
+        size_t hash = 0;
+        size_t distance = 0;
+    };
+
+    /// The target length of the underlying vector length in relation to the number of entries in
+    /// the map, expressed as a percentage. For example a value of `150` would mean there would be
+    /// at least 50% more slots than the number of map entries.
+    static constexpr size_t kRehashFactor = 150;
+
+    /// @returns the target slot vector size to hold `n` map entries.
+    static constexpr size_t NumSlots(size_t count) { return (count * kRehashFactor) / 100; }
+
+    /// The fixed-size slot vector length, based on N and kRehashFactor.
+    static constexpr size_t kNumFixedSlots = NumSlots(N);
+
+    /// The minimum number of slots for the map.
+    static constexpr size_t kMinSlots = std::max<size_t>(kNumFixedSlots, 4);
+
+  public:
+    /// Iterator for entries in the map.
+    /// Iterators are invalidated if the map is modified.
+    template <bool IS_CONST>
+    class IteratorT {
+      public:
+        /// @returns the value pointed to by this iterator
+        EntryRef<IS_CONST> operator->() const {
+#ifdef TINT_ASSERT_ITERATORS_NOT_INVALIDATED
+            TINT_ASSERT(Utils, map.Generation() == initial_generation &&
+                                   "iterator invalidated by container modification");
+#endif
+            return *this;
+        }
+
+        /// @returns a reference to the value at the iterator
+        EntryRef<IS_CONST> operator*() const {
+#ifdef TINT_ASSERT_ITERATORS_NOT_INVALIDATED
+            TINT_ASSERT(Utils, map.Generation() == initial_generation &&
+                                   "iterator invalidated by container modification");
+#endif
+            auto& ref = current->entry.value();
+            if constexpr (ValueIsVoid) {
+                return ref;
+            } else {
+                return {ref.key, ref.value};
+            }
+        }
+
+        /// Increments the iterator
+        /// @returns this iterator
+        IteratorT& operator++() {
+#ifdef TINT_ASSERT_ITERATORS_NOT_INVALIDATED
+            TINT_ASSERT(Utils, map.Generation() == initial_generation &&
+                                   "iterator invalidated by container modification");
+#endif
+            if (current == end) {
+                return *this;
+            }
+            current++;
+            SkipToNextValue();
+            return *this;
+        }
+
+        /// Equality operator
+        /// @param other the other iterator to compare this iterator to
+        /// @returns true if this iterator is equal to other
+        bool operator==(const IteratorT& other) const {
+#ifdef TINT_ASSERT_ITERATORS_NOT_INVALIDATED
+            TINT_ASSERT(Utils, map.Generation() == initial_generation &&
+                                   "iterator invalidated by container modification");
+#endif
+            return current == other.current;
+        }
+
+        /// Inequality operator
+        /// @param other the other iterator to compare this iterator to
+        /// @returns true if this iterator is not equal to other
+        bool operator!=(const IteratorT& other) const {
+#ifdef TINT_ASSERT_ITERATORS_NOT_INVALIDATED
+            TINT_ASSERT(Utils, map.Generation() == initial_generation &&
+                                   "iterator invalidated by container modification");
+#endif
+            return current != other.current;
+        }
+
+      private:
+        /// Friend class
+        friend class HashmapBase;
+
+        using SLOT = std::conditional_t<IS_CONST, const Slot, Slot>;
+
+        IteratorT(SLOT* c, SLOT* e, [[maybe_unused]] const HashmapBase& m)
+            : current(c),
+              end(e)
+#ifdef TINT_ASSERT_ITERATORS_NOT_INVALIDATED
+              ,
+              map(m),
+              initial_generation(m.Generation())
+#endif
+        {
+            SkipToNextValue();
+        }
+
+        /// Moves the iterator forward, stopping at the next slot that is not empty.
+        void SkipToNextValue() {
+            while (current != end && !current->entry.has_value()) {
+                current++;
+            }
+        }
+
+        SLOT* current;  /// The slot the iterator is pointing to
+        SLOT* end;      /// One past the last slot in the map
+
+#ifdef TINT_ASSERT_ITERATORS_NOT_INVALIDATED
+        const HashmapBase& map;     /// The hashmap that is being iterated over.
+        size_t initial_generation;  /// The generation ID when the iterator was created.
+#endif
+    };
+
+    /// An immutable key and mutable value iterator
+    using Iterator = IteratorT</*IS_CONST*/ false>;
+
+    /// An immutable key and value iterator
+    using ConstIterator = IteratorT</*IS_CONST*/ true>;
+
+    /// Constructor
+    HashmapBase() { slots_.Resize(kMinSlots); }
+
+    /// Copy constructor
+    /// @param other the other HashmapBase to copy
+    HashmapBase(const HashmapBase& other) = default;
+
+    /// Move constructor
+    /// @param other the other HashmapBase to move
+    HashmapBase(HashmapBase&& other) = default;
+
+    /// Destructor
+    ~HashmapBase() { Clear(); }
+
+    /// Copy-assignment operator
+    /// @param other the other HashmapBase to copy
+    /// @returns this so calls can be chained
+    HashmapBase& operator=(const HashmapBase& other) = default;
+
+    /// Move-assignment operator
+    /// @param other the other HashmapBase to move
+    /// @returns this so calls can be chained
+    HashmapBase& operator=(HashmapBase&& other) = default;
+
+    /// Removes all entries from the map.
+    void Clear() {
+        slots_.Clear();  // Destructs all entries
+        slots_.Resize(kMinSlots);
+        count_ = 0;
+        generation_++;
+    }
+
+    /// Removes an entry from the map.
+    /// @param key the entry key.
+    /// @returns true if an entry was removed.
+    bool Remove(const Key& key) {
+        const auto [found, start] = IndexOf(key);
+        if (!found) {
+            return false;
+        }
+
+        // Shuffle the entries backwards until we either find a free slot, or a slot that has zero
+        // distance.
+        Slot* prev = nullptr;
+
+        const auto count = slots_.Length();
+        for (size_t distance = 0, index = start; distance < count; distance++) {
+            auto& slot = slots_[index];
+            if (prev) {
+                // note: `distance == 0` also includes empty slots.
+                if (slot.distance == 0) {
+                    // Clear the previous slot, and stop shuffling.
+                    *prev = {};
+                    break;
+                }
+                // Shuffle the slot backwards.
+                prev->entry = std::move(slot.entry);
+                prev->hash = slot.hash;
+                prev->distance = slot.distance - 1;
+            }
+            prev = &slot;
+
+            index = (index == count - 1) ? 0 : index + 1;
+        }
+
+        // Entry was removed.
+        count_--;
+        generation_++;
+
+        return true;
+    }
+
+    /// Checks whether an entry exists in the map
+    /// @param key the key to search for.
+    /// @returns true if the map contains an entry with the given value.
+    bool Contains(const Key& key) const {
+        const auto [found, _] = IndexOf(key);
+        return found;
+    }
+
+    /// Pre-allocates memory so that the map can hold at least `capacity` entries.
+    /// @param capacity the new capacity of the map.
+    void Reserve(size_t capacity) {
+        // Calculate the number of slots required to hold `capacity` entries.
+        const size_t num_slots = std::max(NumSlots(capacity), kMinSlots);
+        if (slots_.Length() >= num_slots) {
+            // Already have enough slots.
+            return;
+        }
+
+        // Move all the values out of the map and into a vector.
+        Vector<Entry, N> entries;
+        entries.Reserve(count_);
+        for (auto& slot : slots_) {
+            if (slot.entry.has_value()) {
+                entries.Push(std::move(slot.entry.value()));
+            }
+        }
+
+        // Clear the map, grow the number of slots.
+        Clear();
+        slots_.Resize(num_slots);
+
+        // As the number of slots has grown, the slot indices will have changed from before, so
+        // re-add all the entries back into the map.
+        for (auto& entry : entries) {
+            if constexpr (ValueIsVoid) {
+                struct NoValue {};
+                Put<PutMode::kAdd>(std::move(entry), NoValue{});
+            } else {
+                Put<PutMode::kAdd>(std::move(entry.key), std::move(entry.value));
+            }
+        }
+    }
+
+    /// @returns the number of entries in the map.
+    size_t Count() const { return count_; }
+
+    /// @returns true if the map contains no entries.
+    bool IsEmpty() const { return count_ == 0; }
+
+    /// @returns a monotonic counter which is incremented whenever the map is mutated.
+    size_t Generation() const { return generation_; }
+
+    /// @returns an immutable iterator to the start of the map.
+    ConstIterator begin() const { return ConstIterator{slots_.begin(), slots_.end(), *this}; }
+
+    /// @returns an immutable iterator to the end of the map.
+    ConstIterator end() const { return ConstIterator{slots_.end(), slots_.end(), *this}; }
+
+    /// @returns an iterator to the start of the map.
+    Iterator begin() { return Iterator{slots_.begin(), slots_.end(), *this}; }
+
+    /// @returns an iterator to the end of the map.
+    Iterator end() { return Iterator{slots_.end(), slots_.end(), *this}; }
+
+    /// A debug function for checking that the map is in good health.
+    /// Asserts if the map is corrupted.
+    void ValidateIntegrity() const {
+        size_t num_alive = 0;
+        for (size_t slot_idx = 0; slot_idx < slots_.Length(); slot_idx++) {
+            const auto& slot = slots_[slot_idx];
+            if (slot.entry.has_value()) {
+                num_alive++;
+                auto const [index, hash] = Hash(KeyOf(*slot.entry));
+                TINT_ASSERT(Utils, hash == slot.hash);
+                TINT_ASSERT(Utils, slot_idx == Wrap(index + slot.distance));
+            }
+        }
+        TINT_ASSERT(Utils, num_alive == count_);
+    }
+
+  protected:
+    /// The behaviour of Put() when an entry already exists with the given key.
+    enum class PutMode {
+        /// Do not replace existing entries with the new value.
+        kAdd,
+        /// Replace existing entries with the new value.
+        kReplace,
+    };
+
+    /// Result of Put()
+    struct PutResult {
+        /// Whether the insert replaced or added a new entry to the map.
+        MapAction action = MapAction::kAdded;
+        /// A pointer to the inserted entry value.
+        Value* value = nullptr;
+
+        /// @returns true if the entry was added to the map, or an existing entry was replaced.
+        operator bool() const { return action != MapAction::kKeptExisting; }
+    };
+
+    /// The common implementation for Add() and Replace()
+    /// @param key the key of the entry to add to the map.
+    /// @param value the value of the entry to add to the map.
+    /// @returns A PutResult describing the result of the insertion
+    template <PutMode MODE, typename K, typename V>
+    PutResult Put(K&& key, V&& value) {
+        // Ensure the map can fit a new entry
+        if (ShouldRehash(count_ + 1)) {
+            Reserve((count_ + 1) * 2);
+        }
+
+        const auto hash = Hash(key);
+
+        auto make_entry = [&] {
+            if constexpr (ValueIsVoid) {
+                return std::forward<K>(key);
+            } else {
+                return Entry{std::forward<K>(key), std::forward<V>(value)};
+            }
+        };
+
+        const auto count = slots_.Length();
+        for (size_t distance = 0, index = hash.scan_start; distance < count; distance++) {
+            auto& slot = slots_[index];
+            if (!slot.entry.has_value()) {
+                // Found an empty slot.
+                // Place value directly into the slot, and we're done.
+                slot.entry.emplace(make_entry());
+                slot.hash = hash.code;
+                slot.distance = distance;
+                count_++;
+                generation_++;
+                return PutResult{MapAction::kAdded, ValueOf(*slot.entry)};
+            }
+
+            // Slot has an entry
+
+            if (slot.Equals(hash.code, key)) {
+                // Slot is equal to value. Replace or preserve?
+                if constexpr (MODE == PutMode::kReplace) {
+                    slot.entry = make_entry();
+                    generation_++;
+                    return PutResult{MapAction::kReplaced, ValueOf(*slot.entry)};
+                } else {
+                    return PutResult{MapAction::kKeptExisting, ValueOf(*slot.entry)};
+                }
+            }
+
+            if (slot.distance < distance) {
+                // Existing slot has a closer distance than the value we're attempting to insert.
+                // Steal from the rich!
+                // Move the current slot to a temporary (evicted), and put the value into the slot.
+                Slot evicted{make_entry(), hash.code, distance};
+                std::swap(evicted, slot);
+
+                // Find a new home for the evicted slot.
+                evicted.distance++;  // We've already swapped at index.
+                InsertShuffle(Wrap(index + 1), std::move(evicted));
+
+                count_++;
+                generation_++;
+                return PutResult{MapAction::kAdded, ValueOf(*slot.entry)};
+            }
+
+            index = (index == count - 1) ? 0 : index + 1;
+        }
+
+        tint::diag::List diags;
+        TINT_ICE(Utils, diags) << "HashmapBase::Put() looped entire map without finding a slot";
+        return PutResult{};
+    }
+
+    /// HashResult is the return value of Hash()
+    struct HashResult {
+        /// The target (zero-distance) slot index for the key.
+        size_t scan_start;
+        /// The calculated hash code of the key.
+        size_t code;
+    };
+
+    /// @param key the key to hash
+    /// @returns a tuple holding the target slot index for the given value, and the hash of the
+    /// value, respectively.
+    template <typename K>
+    HashResult Hash(K&& key) const {
+        size_t hash = HASH()(std::forward<K>(key));
+        size_t index = Wrap(hash);
+        return {index, hash};
+    }
+
+    /// Looks for the key in the map.
+    /// @param key the key to search for.
+    /// @returns a tuple holding a boolean representing whether the key was found in the map, and
+    /// if found, the index of the slot that holds the key.
+    template <typename K>
+    std::tuple<bool, size_t> IndexOf(K&& key) const {
+        const auto hash = Hash(key);
+        const auto count = slots_.Length();
+        for (size_t distance = 0, index = hash.scan_start; distance < count; distance++) {
+            auto& slot = slots_[index];
+            if (!slot.entry.has_value()) {
+                return {/* found */ false, /* index */ 0};
+            }
+            if (slot.Equals(hash.code, key)) {
+                return {/* found */ true, index};
+            }
+            if (slot.distance < distance) {
+                // If the slot distance is less than the current probe distance, then the slot
+                // must be for entry that has an index that comes after key. In this situation,
+                // we know that the map does not contain the key, as it would have been found
+                // before this slot. The "Lookup" section of
+                // https://programming.guide/robin-hood-hashing.html suggests that the condition
+                // should inverted, but this is wrong.
+                return {/* found */ false, /* index */ 0};
+            }
+            index = (index == count - 1) ? 0 : index + 1;
+        }
+
+        tint::diag::List diags;
+        TINT_ICE(Utils, diags) << "HashmapBase::IndexOf() looped entire map without finding a slot";
+        return {/* found */ false, /* index */ 0};
+    }
+
+    /// Shuffles slots for an insertion that has been placed one slot before `start`.
+    /// @param start the index of the first slot to start shuffling.
+    /// @param evicted the slot content that was evicted for the insertion.
+    void InsertShuffle(size_t start, Slot&& evicted) {
+        const auto count = slots_.Length();
+        for (size_t distance = 0, index = start; distance < count; distance++) {
+            auto& slot = slots_[index];
+
+            if (!slot.entry.has_value()) {
+                // Empty slot found for evicted.
+                slot = std::move(evicted);
+                return;  //  We're done.
+            }
+
+            if (slot.distance < evicted.distance) {
+                // Occupied slot has shorter distance to evicted.
+                // Swap slot and evicted.
+                std::swap(slot, evicted);
+            }
+
+            // evicted moves further from the target slot...
+            evicted.distance++;
+
+            index = (index == count - 1) ? 0 : index + 1;
+        }
+    }
+
+    /// @param count the number of new entries in the map
+    /// @returns true if the map should grow the slot vector, and rehash the items.
+    bool ShouldRehash(size_t count) const { return NumSlots(count) > slots_.Length(); }
+
+    /// @param index an input value
+    /// @returns the input value modulo the number of slots.
+    size_t Wrap(size_t index) const { return index % slots_.Length(); }
+
+    /// The vector of slots. The vector length is equal to its capacity.
+    Vector<Slot, kNumFixedSlots> slots_;
+
+    /// The number of entries in the map.
+    size_t count_ = 0;
+
+    /// Counter that's incremented with each modification to the map.
+    size_t generation_ = 0;
+};
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_HASHMAP_BASE_H_
diff --git a/src/tint/utils/containers/hashmap_test.cc b/src/tint/utils/containers/hashmap_test.cc
new file mode 100644
index 0000000..52f2970
--- /dev/null
+++ b/src/tint/utils/containers/hashmap_test.cc
@@ -0,0 +1,537 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/containers/hashmap.h"
+
+#include <array>
+#include <random>
+#include <string>
+#include <tuple>
+#include <unordered_map>
+
+#include "gmock/gmock.h"
+
+namespace tint::utils {
+namespace {
+
+constexpr std::array kPrimes{
+    2,   3,   5,   7,   11,  13,  17,  19,  23,  29,  31,  37,  41,  43,  47,  53,
+    59,  61,  67,  71,  73,  79,  83,  89,  97,  101, 103, 107, 109, 113, 127, 131,
+    137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, 223,
+    227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 293, 307, 311,
+    313, 317, 331, 337, 347, 349, 353, 359, 367, 373, 379, 383, 389, 397, 401, 409,
+};
+
+TEST(Hashmap, Empty) {
+    Hashmap<std::string, int, 8> map;
+    EXPECT_EQ(map.Count(), 0u);
+}
+
+TEST(Hashmap, AddRemove) {
+    Hashmap<std::string, std::string, 8> map;
+    EXPECT_TRUE(map.Add("hello", "world"));
+    EXPECT_EQ(map.Get("hello"), "world");
+    EXPECT_EQ(map.Count(), 1u);
+    EXPECT_TRUE(map.Contains("hello"));
+    EXPECT_FALSE(map.Contains("world"));
+    EXPECT_FALSE(map.Add("hello", "cat"));
+    EXPECT_EQ(map.Count(), 1u);
+    EXPECT_TRUE(map.Remove("hello"));
+    EXPECT_EQ(map.Count(), 0u);
+    EXPECT_FALSE(map.Contains("hello"));
+    EXPECT_FALSE(map.Contains("world"));
+}
+
+TEST(Hashmap, ReplaceRemove) {
+    Hashmap<std::string, std::string, 8> map;
+    map.Replace("hello", "world");
+    EXPECT_EQ(map.Get("hello"), "world");
+    EXPECT_EQ(map.Count(), 1u);
+    EXPECT_TRUE(map.Contains("hello"));
+    EXPECT_FALSE(map.Contains("world"));
+    map.Replace("hello", "cat");
+    EXPECT_EQ(map.Get("hello"), "cat");
+    EXPECT_EQ(map.Count(), 1u);
+    EXPECT_TRUE(map.Remove("hello"));
+    EXPECT_EQ(map.Count(), 0u);
+    EXPECT_FALSE(map.Contains("hello"));
+    EXPECT_FALSE(map.Contains("world"));
+}
+
+TEST(Hashmap, Generation) {
+    Hashmap<int, std::string, 8> map;
+    EXPECT_EQ(map.Generation(), 0u);
+    map.Add(1, "one");
+    EXPECT_EQ(map.Generation(), 1u);
+    map.Add(1, "uno");
+    EXPECT_EQ(map.Generation(), 1u);
+    map.Replace(1, "une");
+    EXPECT_EQ(map.Generation(), 2u);
+    map.Add(2, "dos");
+    EXPECT_EQ(map.Generation(), 3u);
+    map.Remove(1);
+    EXPECT_EQ(map.Generation(), 4u);
+    map.Clear();
+    EXPECT_EQ(map.Generation(), 5u);
+    map.Find(2);
+    EXPECT_EQ(map.Generation(), 5u);
+    map.Get(2);
+    EXPECT_EQ(map.Generation(), 5u);
+}
+
+TEST(Hashmap, Index) {
+    Hashmap<int, std::string, 4> map;
+    auto zero = map.Find(0);
+    EXPECT_FALSE(zero);
+
+    map.Add(3, "three");
+    auto three = map.Find(3);
+    map.Add(2, "two");
+    auto two = map.Find(2);
+    map.Add(4, "four");
+    auto four = map.Find(4);
+    map.Add(8, "eight");
+    auto eight = map.Find(8);
+
+    EXPECT_FALSE(zero);
+    ASSERT_TRUE(three);
+    ASSERT_TRUE(two);
+    ASSERT_TRUE(four);
+    ASSERT_TRUE(eight);
+
+    EXPECT_EQ(*three, "three");
+    EXPECT_EQ(*two, "two");
+    EXPECT_EQ(*four, "four");
+    EXPECT_EQ(*eight, "eight");
+
+    map.Add(0, "zero");  // Note: Find called before Add() is okay!
+
+    map.Add(5, "five");
+    auto five = map.Find(5);
+    map.Add(6, "six");
+    auto six = map.Find(6);
+    map.Add(1, "one");
+    auto one = map.Find(1);
+    map.Add(7, "seven");
+    auto seven = map.Find(7);
+
+    ASSERT_TRUE(zero);
+    ASSERT_TRUE(three);
+    ASSERT_TRUE(two);
+    ASSERT_TRUE(four);
+    ASSERT_TRUE(eight);
+    ASSERT_TRUE(five);
+    ASSERT_TRUE(six);
+    ASSERT_TRUE(one);
+    ASSERT_TRUE(seven);
+
+    EXPECT_EQ(*zero, "zero");
+    EXPECT_EQ(*three, "three");
+    EXPECT_EQ(*two, "two");
+    EXPECT_EQ(*four, "four");
+    EXPECT_EQ(*eight, "eight");
+    EXPECT_EQ(*five, "five");
+    EXPECT_EQ(*six, "six");
+    EXPECT_EQ(*one, "one");
+    EXPECT_EQ(*seven, "seven");
+
+    map.Remove(2);
+    map.Remove(8);
+    map.Remove(1);
+
+    EXPECT_FALSE(two);
+    EXPECT_FALSE(eight);
+    EXPECT_FALSE(one);
+}
+
+TEST(Hashmap, StringKeys) {
+    Hashmap<std::string, int, 4> map;
+    EXPECT_FALSE(map.Find("zero"));
+    EXPECT_FALSE(map.Find(std::string("zero")));
+    EXPECT_FALSE(map.Find(std::string_view("zero")));
+
+    map.Add("three", 3);
+    auto three_cstr = map.Find("three");
+    auto three_str = map.Find(std::string("three"));
+    auto three_sv = map.Find(std::string_view("three"));
+    map.Add(std::string("two"), 2);
+    auto two_cstr = map.Find("two");
+    auto two_str = map.Find(std::string("two"));
+    auto two_sv = map.Find(std::string_view("two"));
+    map.Add("four", 4);
+    auto four_cstr = map.Find("four");
+    auto four_str = map.Find(std::string("four"));
+    auto four_sv = map.Find(std::string_view("four"));
+    map.Add(std::string("eight"), 8);
+    auto eight_cstr = map.Find("eight");
+    auto eight_str = map.Find(std::string("eight"));
+    auto eight_sv = map.Find(std::string_view("eight"));
+
+    ASSERT_TRUE(three_cstr);
+    ASSERT_TRUE(three_str);
+    ASSERT_TRUE(three_sv);
+    ASSERT_TRUE(two_cstr);
+    ASSERT_TRUE(two_str);
+    ASSERT_TRUE(two_sv);
+    ASSERT_TRUE(four_cstr);
+    ASSERT_TRUE(four_str);
+    ASSERT_TRUE(four_sv);
+    ASSERT_TRUE(eight_cstr);
+    ASSERT_TRUE(eight_str);
+    ASSERT_TRUE(eight_sv);
+
+    EXPECT_EQ(*three_cstr, 3);
+    EXPECT_EQ(*three_str, 3);
+    EXPECT_EQ(*three_sv, 3);
+    EXPECT_EQ(*two_cstr, 2);
+    EXPECT_EQ(*two_str, 2);
+    EXPECT_EQ(*two_sv, 2);
+    EXPECT_EQ(*four_cstr, 4);
+    EXPECT_EQ(*four_str, 4);
+    EXPECT_EQ(*four_sv, 4);
+    EXPECT_EQ(*eight_cstr, 8);
+    EXPECT_EQ(*eight_str, 8);
+    EXPECT_EQ(*eight_sv, 8);
+
+    map.Add("zero", 0);  // Note: Find called before Add() is okay!
+    auto zero_cstr = map.Find("zero");
+    auto zero_str = map.Find(std::string("zero"));
+    auto zero_sv = map.Find(std::string_view("zero"));
+
+    map.Add(std::string("five"), 5);
+    auto five_cstr = map.Find("five");
+    auto five_str = map.Find(std::string("five"));
+    auto five_sv = map.Find(std::string_view("five"));
+    map.Add("six", 6);
+    auto six_cstr = map.Find("six");
+    auto six_str = map.Find(std::string("six"));
+    auto six_sv = map.Find(std::string_view("six"));
+    map.Add("one", 1);
+    auto one_cstr = map.Find("one");
+    auto one_str = map.Find(std::string("one"));
+    auto one_sv = map.Find(std::string_view("one"));
+    map.Add(std::string("seven"), 7);
+    auto seven_cstr = map.Find("seven");
+    auto seven_str = map.Find(std::string("seven"));
+    auto seven_sv = map.Find(std::string_view("seven"));
+
+    ASSERT_TRUE(zero_cstr);
+    ASSERT_TRUE(zero_str);
+    ASSERT_TRUE(zero_sv);
+    ASSERT_TRUE(three_cstr);
+    ASSERT_TRUE(three_str);
+    ASSERT_TRUE(three_sv);
+    ASSERT_TRUE(two_cstr);
+    ASSERT_TRUE(two_str);
+    ASSERT_TRUE(two_sv);
+    ASSERT_TRUE(four_cstr);
+    ASSERT_TRUE(four_str);
+    ASSERT_TRUE(four_sv);
+    ASSERT_TRUE(eight_cstr);
+    ASSERT_TRUE(eight_str);
+    ASSERT_TRUE(eight_sv);
+    ASSERT_TRUE(five_cstr);
+    ASSERT_TRUE(five_str);
+    ASSERT_TRUE(five_sv);
+    ASSERT_TRUE(six_cstr);
+    ASSERT_TRUE(six_str);
+    ASSERT_TRUE(six_sv);
+    ASSERT_TRUE(one_cstr);
+    ASSERT_TRUE(one_str);
+    ASSERT_TRUE(one_sv);
+    ASSERT_TRUE(seven_cstr);
+    ASSERT_TRUE(seven_str);
+    ASSERT_TRUE(seven_sv);
+
+    EXPECT_EQ(*zero_cstr, 0);
+    EXPECT_EQ(*zero_str, 0);
+    EXPECT_EQ(*zero_sv, 0);
+    EXPECT_EQ(*three_cstr, 3);
+    EXPECT_EQ(*three_str, 3);
+    EXPECT_EQ(*three_sv, 3);
+    EXPECT_EQ(*two_cstr, 2);
+    EXPECT_EQ(*two_str, 2);
+    EXPECT_EQ(*two_sv, 2);
+    EXPECT_EQ(*four_cstr, 4);
+    EXPECT_EQ(*four_str, 4);
+    EXPECT_EQ(*four_sv, 4);
+    EXPECT_EQ(*eight_cstr, 8);
+    EXPECT_EQ(*eight_str, 8);
+    EXPECT_EQ(*eight_sv, 8);
+    EXPECT_EQ(*five_cstr, 5);
+    EXPECT_EQ(*five_str, 5);
+    EXPECT_EQ(*five_sv, 5);
+    EXPECT_EQ(*six_cstr, 6);
+    EXPECT_EQ(*six_str, 6);
+    EXPECT_EQ(*six_sv, 6);
+    EXPECT_EQ(*one_cstr, 1);
+    EXPECT_EQ(*one_str, 1);
+    EXPECT_EQ(*one_sv, 1);
+    EXPECT_EQ(*seven_cstr, 7);
+    EXPECT_EQ(*seven_str, 7);
+    EXPECT_EQ(*seven_sv, 7);
+}
+
+TEST(Hashmap, Iterator) {
+    using Map = Hashmap<int, std::string, 8>;
+    using Entry = typename Map::Entry;
+    Map map;
+    map.Add(1, "one");
+    map.Add(4, "four");
+    map.Add(3, "three");
+    map.Add(2, "two");
+    EXPECT_THAT(map, testing::UnorderedElementsAre(Entry{1, "one"}, Entry{2, "two"},
+                                                   Entry{3, "three"}, Entry{4, "four"}));
+}
+
+TEST(Hashmap, MutableIterator) {
+    using Map = Hashmap<int, std::string, 8>;
+    using Entry = typename Map::Entry;
+    Map map;
+    map.Add(1, "one");
+    map.Add(4, "four");
+    map.Add(3, "three");
+    map.Add(2, "two");
+    for (auto pair : map) {
+        pair.value += "!";
+    }
+    EXPECT_THAT(map, testing::UnorderedElementsAre(Entry{1, "one!"}, Entry{2, "two!"},
+                                                   Entry{3, "three!"}, Entry{4, "four!"}));
+}
+
+TEST(Hashmap, KeysValues) {
+    using Map = Hashmap<int, std::string, 8>;
+    Map map;
+    map.Add(1, "one");
+    map.Add(4, "four");
+    map.Add(3, "three");
+    map.Add(2, "two");
+    EXPECT_THAT(map.Keys(), testing::UnorderedElementsAre(1, 2, 3, 4));
+    EXPECT_THAT(map.Values(), testing::UnorderedElementsAre("one", "two", "three", "four"));
+}
+
+TEST(Hashmap, AddMany) {
+    Hashmap<int, std::string, 8> map;
+    for (size_t i = 0; i < kPrimes.size(); i++) {
+        int prime = kPrimes[i];
+        ASSERT_TRUE(map.Add(prime, std::to_string(prime))) << "i: " << i;
+        ASSERT_FALSE(map.Add(prime, std::to_string(prime))) << "i: " << i;
+        ASSERT_EQ(map.Count(), i + 1);
+    }
+    ASSERT_EQ(map.Count(), kPrimes.size());
+    for (int prime : kPrimes) {
+        ASSERT_TRUE(map.Contains(prime)) << prime;
+        ASSERT_EQ(map.Get(prime), std::to_string(prime)) << prime;
+    }
+}
+
+TEST(Hashmap, GetOrCreate) {
+    Hashmap<int, std::string, 8> map;
+    std::optional<std::string> value_of_key_0_at_create;
+    EXPECT_EQ(map.GetOrCreate(0,
+                              [&] {
+                                  value_of_key_0_at_create = map.Get(0);
+                                  return "zero";
+                              }),
+              "zero");
+    EXPECT_EQ(map.Count(), 1u);
+    EXPECT_EQ(map.Get(0), "zero");
+    EXPECT_EQ(value_of_key_0_at_create, "");
+
+    bool create_called = false;
+    EXPECT_EQ(map.GetOrCreate(0,
+                              [&] {
+                                  create_called = true;
+                                  return "oh noes";
+                              }),
+              "zero");
+    EXPECT_FALSE(create_called);
+    EXPECT_EQ(map.Count(), 1u);
+    EXPECT_EQ(map.Get(0), "zero");
+
+    EXPECT_EQ(map.GetOrCreate(1, [&] { return "one"; }), "one");
+    EXPECT_EQ(map.Count(), 2u);
+    EXPECT_EQ(map.Get(1), "one");
+}
+
+TEST(Hashmap, GetOrCreate_CreateModifiesMap) {
+    Hashmap<int, std::string, 8> map;
+    EXPECT_EQ(map.GetOrCreate(0,
+                              [&] {
+                                  map.Add(3, "three");
+                                  map.Add(1, "one");
+                                  map.Add(2, "two");
+                                  return "zero";
+                              }),
+              "zero");
+    EXPECT_EQ(map.Count(), 4u);
+    EXPECT_EQ(map.Get(0), "zero");
+    EXPECT_EQ(map.Get(1), "one");
+    EXPECT_EQ(map.Get(2), "two");
+    EXPECT_EQ(map.Get(3), "three");
+
+    bool create_called = false;
+    EXPECT_EQ(map.GetOrCreate(0,
+                              [&] {
+                                  create_called = true;
+                                  return "oh noes";
+                              }),
+              "zero");
+    EXPECT_FALSE(create_called);
+    EXPECT_EQ(map.Count(), 4u);
+    EXPECT_EQ(map.Get(0), "zero");
+    EXPECT_EQ(map.Get(1), "one");
+    EXPECT_EQ(map.Get(2), "two");
+    EXPECT_EQ(map.Get(3), "three");
+
+    EXPECT_EQ(map.GetOrCreate(4,
+                              [&] {
+                                  map.Add(6, "six");
+                                  map.Add(5, "five");
+                                  map.Add(7, "seven");
+                                  return "four";
+                              }),
+              "four");
+    EXPECT_EQ(map.Count(), 8u);
+    EXPECT_EQ(map.Get(0), "zero");
+    EXPECT_EQ(map.Get(1), "one");
+    EXPECT_EQ(map.Get(2), "two");
+    EXPECT_EQ(map.Get(3), "three");
+    EXPECT_EQ(map.Get(4), "four");
+    EXPECT_EQ(map.Get(5), "five");
+    EXPECT_EQ(map.Get(6), "six");
+    EXPECT_EQ(map.Get(7), "seven");
+}
+
+TEST(Hashmap, GetOrCreate_CreateAddsSameKeyedValue) {
+    Hashmap<int, std::string, 8> map;
+    EXPECT_EQ(map.GetOrCreate(42,
+                              [&] {
+                                  map.Add(42, "should-be-replaced");
+                                  return "expected-value";
+                              }),
+              "expected-value");
+    EXPECT_EQ(map.Count(), 1u);
+    EXPECT_EQ(map.Get(42), "expected-value");
+}
+
+TEST(Hashmap, Soak) {
+    std::mt19937 rnd;
+    std::unordered_map<std::string, std::string> reference;
+    Hashmap<std::string, std::string, 8> map;
+    for (size_t i = 0; i < 1000000; i++) {
+        std::string key = std::to_string(rnd() & 64);
+        std::string value = "V" + key;
+        switch (rnd() % 7) {
+            case 0: {  // Add
+                auto expected = reference.emplace(key, value).second;
+                EXPECT_EQ(map.Add(key, value), expected) << "i:" << i;
+                EXPECT_EQ(map.Get(key), value) << "i:" << i;
+                EXPECT_TRUE(map.Contains(key)) << "i:" << i;
+                break;
+            }
+            case 1: {  // Replace
+                reference[key] = value;
+                map.Replace(key, value);
+                EXPECT_EQ(map.Get(key), value) << "i:" << i;
+                EXPECT_TRUE(map.Contains(key)) << "i:" << i;
+                break;
+            }
+            case 2: {  // Remove
+                auto expected = reference.erase(key) != 0;
+                EXPECT_EQ(map.Remove(key), expected) << "i:" << i;
+                EXPECT_FALSE(map.Get(key).has_value()) << "i:" << i;
+                EXPECT_FALSE(map.Contains(key)) << "i:" << i;
+                break;
+            }
+            case 3: {  // Contains
+                auto expected = reference.count(key) != 0;
+                EXPECT_EQ(map.Contains(key), expected) << "i:" << i;
+                break;
+            }
+            case 4: {  // Get
+                if (reference.count(key) != 0) {
+                    auto expected = reference[key];
+                    EXPECT_EQ(map.Get(key), expected) << "i:" << i;
+                } else {
+                    EXPECT_FALSE(map.Get(key).has_value()) << "i:" << i;
+                }
+                break;
+            }
+            case 5: {  // Copy / Move
+                Hashmap<std::string, std::string, 8> tmp(map);
+                map = std::move(tmp);
+                break;
+            }
+            case 6: {  // Clear
+                reference.clear();
+                map.Clear();
+                break;
+            }
+        }
+    }
+}
+
+TEST(Hashmap, EqualitySameSize) {
+    Hashmap<int, std::string, 8> a;
+    Hashmap<int, std::string, 8> b;
+    EXPECT_EQ(a, b);
+    a.Add(1, "one");
+    EXPECT_NE(a, b);
+    b.Add(2, "two");
+    EXPECT_NE(a, b);
+    a.Add(2, "two");
+    EXPECT_NE(a, b);
+    b.Add(1, "one");
+    EXPECT_EQ(a, b);
+}
+
+TEST(Hashmap, EqualityDifferentSize) {
+    Hashmap<int, std::string, 8> a;
+    Hashmap<int, std::string, 4> b;
+    EXPECT_EQ(a, b);
+    a.Add(1, "one");
+    EXPECT_NE(a, b);
+    b.Add(2, "two");
+    EXPECT_NE(a, b);
+    a.Add(2, "two");
+    EXPECT_NE(a, b);
+    b.Add(1, "one");
+    EXPECT_EQ(a, b);
+}
+
+TEST(Hashmap, HashSameSize) {
+    Hashmap<int, std::string, 8> a;
+    Hashmap<int, std::string, 8> b;
+    EXPECT_EQ(Hash(a), Hash(b));
+    a.Add(1, "one");
+    b.Add(2, "two");
+    a.Add(2, "two");
+    b.Add(1, "one");
+    EXPECT_EQ(Hash(a), Hash(b));
+}
+
+TEST(Hashmap, HashDifferentSize) {
+    Hashmap<int, std::string, 8> a;
+    Hashmap<int, std::string, 4> b;
+    EXPECT_EQ(Hash(a), Hash(b));
+    a.Add(1, "one");
+    b.Add(2, "two");
+    a.Add(2, "two");
+    b.Add(1, "one");
+    EXPECT_EQ(Hash(a), Hash(b));
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/containers/hashset.h b/src/tint/utils/containers/hashset.h
new file mode 100644
index 0000000..8ab452f
--- /dev/null
+++ b/src/tint/utils/containers/hashset.h
@@ -0,0 +1,97 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_HASHSET_H_
+#define SRC_TINT_UTILS_CONTAINERS_HASHSET_H_
+
+#include <stddef.h>
+#include <algorithm>
+#include <functional>
+#include <optional>
+#include <tuple>
+#include <utility>
+
+#include "src/tint/utils/containers/hashmap.h"
+#include "src/tint/utils/containers/vector.h"
+#include "src/tint/utils/debug/debug.h"
+
+namespace tint::utils {
+
+/// An unordered set that uses a robin-hood hashing algorithm.
+template <typename KEY, size_t N, typename HASH = Hasher<KEY>, typename EQUAL = std::equal_to<KEY>>
+class Hashset : public HashmapBase<KEY, void, N, HASH, EQUAL> {
+    using Base = HashmapBase<KEY, void, N, HASH, EQUAL>;
+    using PutMode = typename Base::PutMode;
+
+  public:
+    using Base::Base;
+
+    /// Constructor with initializer list of items
+    /// @param items the items to place into the set
+    Hashset(std::initializer_list<KEY> items) {
+        this->Reserve(items.size());
+        for (auto item : items) {
+            this->Add(item);
+        }
+    }
+
+    /// Adds a value to the set, if the set does not already contain an entry equal to `value`.
+    /// @param value the value to add to the set.
+    /// @returns true if the value was added, false if there was an existing value in the set.
+    template <typename V>
+    bool Add(V&& value) {
+        struct NoValue {};
+        return this->template Put<PutMode::kAdd>(std::forward<V>(value), NoValue{});
+    }
+
+    /// @returns the set entries of the map as a vector
+    /// @note the order of the returned vector is non-deterministic between compilers.
+    template <size_t N2 = N>
+    utils::Vector<KEY, N2> Vector() const {
+        utils::Vector<KEY, N2> out;
+        out.Reserve(this->Count());
+        for (auto& value : *this) {
+            out.Push(value);
+        }
+        return out;
+    }
+
+    /// @returns true if the predicate function returns true for any of the elements of the set
+    /// @param pred a function-like with the signature `bool(T)`
+    template <typename PREDICATE>
+    bool Any(PREDICATE&& pred) const {
+        for (const auto& it : *this) {
+            if (pred(it)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /// @returns false if the predicate function returns false for any of the elements of the set
+    /// @param pred a function-like with the signature `bool(T)`
+    template <typename PREDICATE>
+    bool All(PREDICATE&& pred) const {
+        for (const auto& it : *this) {
+            if (!pred(it)) {
+                return false;
+            }
+        }
+        return true;
+    }
+};
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_HASHSET_H_
diff --git a/src/tint/utils/containers/hashset_test.cc b/src/tint/utils/containers/hashset_test.cc
new file mode 100644
index 0000000..c9e147e
--- /dev/null
+++ b/src/tint/utils/containers/hashset_test.cc
@@ -0,0 +1,181 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/containers/hashset.h"
+
+#include <array>
+#include <random>
+#include <string>
+#include <tuple>
+#include <unordered_set>
+
+#include "gmock/gmock.h"
+#include "src/tint/utils/containers/predicates.h"
+
+namespace tint::utils {
+namespace {
+
+constexpr std::array kPrimes{
+    2,   3,   5,   7,   11,  13,  17,  19,  23,  29,  31,  37,  41,  43,  47,  53,
+    59,  61,  67,  71,  73,  79,  83,  89,  97,  101, 103, 107, 109, 113, 127, 131,
+    137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, 223,
+    227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 293, 307, 311,
+    313, 317, 331, 337, 347, 349, 353, 359, 367, 373, 379, 383, 389, 397, 401, 409,
+};
+
+TEST(Hashset, Empty) {
+    Hashset<std::string, 8> set;
+    EXPECT_EQ(set.Count(), 0u);
+}
+
+TEST(Hashset, InitializerConstructor) {
+    Hashset<int, 8> set{1, 5, 7};
+    EXPECT_EQ(set.Count(), 3u);
+    EXPECT_TRUE(set.Contains(1u));
+    EXPECT_FALSE(set.Contains(3u));
+    EXPECT_TRUE(set.Contains(5u));
+    EXPECT_TRUE(set.Contains(7u));
+    EXPECT_FALSE(set.Contains(9u));
+}
+
+TEST(Hashset, AddRemove) {
+    Hashset<std::string, 8> set;
+    EXPECT_TRUE(set.Add("hello"));
+    EXPECT_EQ(set.Count(), 1u);
+    EXPECT_TRUE(set.Contains("hello"));
+    EXPECT_FALSE(set.Contains("world"));
+    EXPECT_FALSE(set.Add("hello"));
+    EXPECT_EQ(set.Count(), 1u);
+    EXPECT_TRUE(set.Remove("hello"));
+    EXPECT_EQ(set.Count(), 0u);
+    EXPECT_FALSE(set.Contains("hello"));
+    EXPECT_FALSE(set.Contains("world"));
+}
+
+TEST(Hashset, AddMany) {
+    Hashset<int, 8> set;
+    for (size_t i = 0; i < kPrimes.size(); i++) {
+        int prime = kPrimes[i];
+        ASSERT_TRUE(set.Add(prime)) << "i: " << i;
+        ASSERT_FALSE(set.Add(prime)) << "i: " << i;
+        ASSERT_EQ(set.Count(), i + 1);
+        set.ValidateIntegrity();
+    }
+    ASSERT_EQ(set.Count(), kPrimes.size());
+    for (int prime : kPrimes) {
+        ASSERT_TRUE(set.Contains(prime)) << prime;
+    }
+}
+
+TEST(Hashset, Generation) {
+    Hashset<int, 8> set;
+    EXPECT_EQ(set.Generation(), 0u);
+    set.Add(1);
+    EXPECT_EQ(set.Generation(), 1u);
+    set.Add(1);
+    EXPECT_EQ(set.Generation(), 1u);
+    set.Add(2);
+    EXPECT_EQ(set.Generation(), 2u);
+    set.Remove(1);
+    EXPECT_EQ(set.Generation(), 3u);
+    set.Clear();
+    EXPECT_EQ(set.Generation(), 4u);
+}
+
+TEST(Hashset, Iterator) {
+    Hashset<std::string, 8> set;
+    set.Add("one");
+    set.Add("four");
+    set.Add("three");
+    set.Add("two");
+    EXPECT_THAT(set, testing::UnorderedElementsAre("one", "two", "three", "four"));
+}
+
+TEST(Hashset, Vector) {
+    Hashset<std::string, 8> set;
+    set.Add("one");
+    set.Add("four");
+    set.Add("three");
+    set.Add("two");
+    auto vec = set.Vector();
+    EXPECT_THAT(vec, testing::UnorderedElementsAre("one", "two", "three", "four"));
+}
+
+TEST(Hashset, Soak) {
+    std::mt19937 rnd;
+    std::unordered_set<std::string> reference;
+    Hashset<std::string, 8> set;
+    for (size_t i = 0; i < 1000000; i++) {
+        std::string value = std::to_string(rnd() & 0x100);
+        switch (rnd() % 5) {
+            case 0: {  // Add
+                auto expected = reference.emplace(value).second;
+                ASSERT_EQ(set.Add(value), expected) << "i: " << i;
+                ASSERT_TRUE(set.Contains(value)) << "i: " << i;
+                break;
+            }
+            case 1: {  // Remove
+                auto expected = reference.erase(value) != 0;
+                ASSERT_EQ(set.Remove(value), expected) << "i: " << i;
+                ASSERT_FALSE(set.Contains(value)) << "i: " << i;
+                break;
+            }
+            case 2: {  // Contains
+                auto expected = reference.count(value) != 0;
+                ASSERT_EQ(set.Contains(value), expected) << "i: " << i;
+                break;
+            }
+            case 3: {  // Copy / Move
+                Hashset<std::string, 8> tmp(set);
+                set = std::move(tmp);
+                break;
+            }
+            case 4: {  // Clear
+                reference.clear();
+                set.Clear();
+                break;
+            }
+        }
+        set.ValidateIntegrity();
+    }
+}
+
+TEST(HashsetTest, Any) {
+    Hashset<int, 8> set{1, 7, 5, 9};
+    EXPECT_TRUE(set.Any(Eq(1)));
+    EXPECT_FALSE(set.Any(Eq(2)));
+    EXPECT_FALSE(set.Any(Eq(3)));
+    EXPECT_FALSE(set.Any(Eq(4)));
+    EXPECT_TRUE(set.Any(Eq(5)));
+    EXPECT_FALSE(set.Any(Eq(6)));
+    EXPECT_TRUE(set.Any(Eq(7)));
+    EXPECT_FALSE(set.Any(Eq(8)));
+    EXPECT_TRUE(set.Any(Eq(9)));
+}
+
+TEST(HashsetTest, All) {
+    Hashset<int, 8> set{1, 7, 5, 9};
+    EXPECT_FALSE(set.All(Ne(1)));
+    EXPECT_TRUE(set.All(Ne(2)));
+    EXPECT_TRUE(set.All(Ne(3)));
+    EXPECT_TRUE(set.All(Ne(4)));
+    EXPECT_FALSE(set.All(Ne(5)));
+    EXPECT_TRUE(set.All(Ne(6)));
+    EXPECT_FALSE(set.All(Ne(7)));
+    EXPECT_TRUE(set.All(Ne(8)));
+    EXPECT_FALSE(set.All(Ne(9)));
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/containers/map.h b/src/tint/utils/containers/map.h
new file mode 100644
index 0000000..74a0ea8
--- /dev/null
+++ b/src/tint/utils/containers/map.h
@@ -0,0 +1,56 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_MAP_H_
+#define SRC_TINT_UTILS_CONTAINERS_MAP_H_
+
+#include <unordered_map>
+
+namespace tint::utils {
+
+/// Lookup is a utility function for fetching a value from an unordered map if
+/// it exists, otherwise returning the `if_missing` argument.
+/// @param map the unordered_map
+/// @param key the map key of the item to query
+/// @param if_missing the value to return if the map does not contain the given
+/// key. Defaults to the zero-initializer for the value type.
+/// @return the map item value, or `if_missing` if the map does not contain the
+/// given key
+template <typename K, typename V, typename H, typename C, typename KV = K>
+V Lookup(const std::unordered_map<K, V, H, C>& map, const KV& key, const V& if_missing = {}) {
+    auto it = map.find(key);
+    return it != map.end() ? it->second : if_missing;
+}
+
+/// GetOrCreate is a utility function for lazily adding to an unordered map.
+/// If the map already contains the key `key` then this is returned, otherwise
+/// `create()` is called and the result is added to the map and is returned.
+/// @param map the unordered_map
+/// @param key the map key of the item to query or add
+/// @param create a callable function-like object with the signature `V()`
+/// @return the value of the item with the given key, or the newly created item
+template <typename K, typename V, typename H, typename C, typename CREATE>
+V GetOrCreate(std::unordered_map<K, V, H, C>& map, const K& key, CREATE&& create) {
+    auto it = map.find(key);
+    if (it != map.end()) {
+        return it->second;
+    }
+    V value = create();
+    map.emplace(key, value);
+    return value;
+}
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_MAP_H_
diff --git a/src/tint/utils/containers/map_test.cc b/src/tint/utils/containers/map_test.cc
new file mode 100644
index 0000000..843421a
--- /dev/null
+++ b/src/tint/utils/containers/map_test.cc
@@ -0,0 +1,56 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/containers/map.h"
+
+#include <unordered_map>
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(Lookup, Test) {
+    std::unordered_map<int, int> map;
+    map.emplace(10, 1);
+    EXPECT_EQ(Lookup(map, 10, 0), 1);    // exists, with if_missing
+    EXPECT_EQ(Lookup(map, 10), 1);       // exists, without if_missing
+    EXPECT_EQ(Lookup(map, 20, 50), 50);  // missing, with if_missing
+    EXPECT_EQ(Lookup(map, 20), 0);       // missing, without if_missing
+}
+
+TEST(GetOrCreateTest, NewKey) {
+    std::unordered_map<int, int> map;
+    EXPECT_EQ(GetOrCreate(map, 1, [&] { return 2; }), 2);
+    EXPECT_EQ(map.size(), 1u);
+    EXPECT_EQ(map[1], 2);
+}
+
+TEST(GetOrCreateTest, ExistingKey) {
+    std::unordered_map<int, int> map;
+    map[1] = 2;
+    bool called = false;
+    EXPECT_EQ(GetOrCreate(map, 1,
+                          [&] {
+                              called = true;
+                              return -2;
+                          }),
+              2);
+    EXPECT_EQ(called, false);
+    EXPECT_EQ(map.size(), 1u);
+    EXPECT_EQ(map[1], 2);
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/containers/predicates.h b/src/tint/utils/containers/predicates.h
new file mode 100644
index 0000000..a919741
--- /dev/null
+++ b/src/tint/utils/containers/predicates.h
@@ -0,0 +1,78 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_PREDICATES_H_
+#define SRC_TINT_UTILS_CONTAINERS_PREDICATES_H_
+
+namespace tint::utils {
+
+/// @param value the value to compare against
+/// @return a function with the signature `bool(const T&)` which returns true if the argument is
+/// equal to
+/// @p value
+template <typename T>
+auto Eq(const T& value) {
+    return [value](const T& v) { return v == value; };
+}
+
+/// @param value the value to compare against
+/// @return a function with the signature `bool(const T&)` which returns true if the argument is not
+/// equal to @p value
+template <typename T>
+auto Ne(const T& value) {
+    return [value](const T& v) { return v != value; };
+}
+
+/// @param value the value to compare against
+/// @return a function with the signature `bool(const T&)` which returns true if the argument is
+/// greater than @p value
+template <typename T>
+auto Gt(const T& value) {
+    return [value](const T& v) { return v > value; };
+}
+
+/// @param value the value to compare against
+/// @return a function with the signature `bool(const T&)` which returns true if the argument is
+/// less than
+/// @p value
+template <typename T>
+auto Lt(const T& value) {
+    return [value](const T& v) { return v < value; };
+}
+
+/// @param value the value to compare against
+/// @return a function with the signature `bool(const T&)` which returns true if the argument is
+/// greater or equal to @p value
+template <typename T>
+auto Ge(const T& value) {
+    return [value](const T& v) { return v >= value; };
+}
+
+/// @param value the value to compare against
+/// @return a function with the signature `bool(const T&)` which returns true if the argument is
+/// less than or equal to @p value
+template <typename T>
+auto Le(const T& value) {
+    return [value](const T& v) { return v <= value; };
+}
+
+/// @param ptr the pointer
+/// @return true if the pointer argument is null.
+static inline bool IsNull(const void* ptr) {
+    return ptr == nullptr;
+}
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_PREDICATES_H_
diff --git a/src/tint/utils/containers/predicates_test.cc b/src/tint/utils/containers/predicates_test.cc
new file mode 100644
index 0000000..cf685b2
--- /dev/null
+++ b/src/tint/utils/containers/predicates_test.cc
@@ -0,0 +1,83 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/containers/predicates.h"
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(PredicatesTest, Eq) {
+    auto pred = Eq(3);
+    EXPECT_FALSE(pred(1));
+    EXPECT_FALSE(pred(2));
+    EXPECT_TRUE(pred(3));
+    EXPECT_FALSE(pred(4));
+    EXPECT_FALSE(pred(5));
+}
+
+TEST(PredicatesTest, Ne) {
+    auto pred = Ne(3);
+    EXPECT_TRUE(pred(1));
+    EXPECT_TRUE(pred(2));
+    EXPECT_FALSE(pred(3));
+    EXPECT_TRUE(pred(4));
+    EXPECT_TRUE(pred(5));
+}
+
+TEST(PredicatesTest, Gt) {
+    auto pred = Gt(3);
+    EXPECT_FALSE(pred(1));
+    EXPECT_FALSE(pred(2));
+    EXPECT_FALSE(pred(3));
+    EXPECT_TRUE(pred(4));
+    EXPECT_TRUE(pred(5));
+}
+
+TEST(PredicatesTest, Lt) {
+    auto pred = Lt(3);
+    EXPECT_TRUE(pred(1));
+    EXPECT_TRUE(pred(2));
+    EXPECT_FALSE(pred(3));
+    EXPECT_FALSE(pred(4));
+    EXPECT_FALSE(pred(5));
+}
+
+TEST(PredicatesTest, Ge) {
+    auto pred = Ge(3);
+    EXPECT_FALSE(pred(1));
+    EXPECT_FALSE(pred(2));
+    EXPECT_TRUE(pred(3));
+    EXPECT_TRUE(pred(4));
+    EXPECT_TRUE(pred(5));
+}
+
+TEST(PredicatesTest, Le) {
+    auto pred = Le(3);
+    EXPECT_TRUE(pred(1));
+    EXPECT_TRUE(pred(2));
+    EXPECT_TRUE(pred(3));
+    EXPECT_FALSE(pred(4));
+    EXPECT_FALSE(pred(5));
+}
+
+TEST(PredicatesTest, IsNull) {
+    int i = 1;
+    EXPECT_TRUE(IsNull(nullptr));
+    EXPECT_FALSE(IsNull(&i));
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/containers/reverse.h b/src/tint/utils/containers/reverse.h
new file mode 100644
index 0000000..bb85a9e
--- /dev/null
+++ b/src/tint/utils/containers/reverse.h
@@ -0,0 +1,62 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_REVERSE_H_
+#define SRC_TINT_UTILS_CONTAINERS_REVERSE_H_
+
+#include <iterator>
+
+namespace tint::utils {
+
+namespace detail {
+/// Used by utils::Reverse to hold the underlying iterable.
+/// begin(ReverseIterable<T>) and end(ReverseIterable<T>) are automatically
+/// called for range-for loops, via argument-dependent lookup.
+/// See https://en.cppreference.com/w/cpp/language/range-for
+template <typename T>
+struct ReverseIterable {
+    /// The wrapped iterable object.
+    T& iterable;
+};
+
+template <typename T>
+auto begin(ReverseIterable<T> r_it) {
+    return std::rbegin(r_it.iterable);
+}
+
+template <typename T>
+auto end(ReverseIterable<T> r_it) {
+    return std::rend(r_it.iterable);
+}
+}  // namespace detail
+
+/// Reverse returns an iterable wrapper that when used in range-for loops,
+/// performs a reverse iteration over the object `iterable`.
+/// Example:
+/// ```
+/// /* Equivalent to:
+///  * for (auto it = vec.rbegin(); i != vec.rend(); ++it) {
+///  *   auto v = *it;
+///  */
+/// for (auto v : utils::Reverse(vec)) {
+/// }
+/// ```
+template <typename T>
+detail::ReverseIterable<T> Reverse(T&& iterable) {
+    return {iterable};
+}
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_REVERSE_H_
diff --git a/src/tint/utils/containers/reverse_test.cc b/src/tint/utils/containers/reverse_test.cc
new file mode 100644
index 0000000..4ca0369
--- /dev/null
+++ b/src/tint/utils/containers/reverse_test.cc
@@ -0,0 +1,34 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/containers/reverse.h"
+
+#include <vector>
+
+#include "gmock/gmock.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(ReverseTest, Vector) {
+    std::vector<int> vec{1, 3, 5, 7, 9};
+    std::vector<int> rev;
+    for (auto v : Reverse(vec)) {
+        rev.emplace_back(v);
+    }
+    ASSERT_THAT(rev, testing::ElementsAre(9, 7, 5, 3, 1));
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/containers/scope_stack.h b/src/tint/utils/containers/scope_stack.h
new file mode 100644
index 0000000..d98f2cd
--- /dev/null
+++ b/src/tint/utils/containers/scope_stack.h
@@ -0,0 +1,84 @@
+// Copyright 2020 The Tint Authors.  //
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_SCOPE_STACK_H_
+#define SRC_TINT_UTILS_CONTAINERS_SCOPE_STACK_H_
+
+#include <utility>
+
+#include "src/tint/utils/containers/hashmap.h"
+#include "src/tint/utils/containers/vector.h"
+#include "src/tint/utils/text/symbol.h"
+
+namespace tint {
+
+/// Used to store a stack of scope information.
+/// The stack starts with a global scope which can not be popped.
+template <class K, class V>
+class ScopeStack {
+  public:
+    /// Push a new scope on to the stack
+    void Push() { stack_.Push({}); }
+
+    /// Pop the scope off the top of the stack
+    void Pop() {
+        if (stack_.Length() > 1) {
+            stack_.Pop();
+        }
+    }
+
+    /// Assigns the value into the top most scope of the stack.
+    /// @param key the key of the value
+    /// @param val the value
+    /// @returns the old value if there was an existing key at the top of the
+    /// stack, otherwise the zero initializer for type T.
+    V Set(const K& key, V val) {
+        auto& back = stack_.Back();
+        if (auto el = back.Find(key)) {
+            std::swap(val, *el);
+            return val;
+        }
+        back.Add(key, val);
+        return {};
+    }
+
+    /// Retrieves a value from the stack
+    /// @param key the key to look for
+    /// @returns the value, or the zero initializer if the value was not found
+    V Get(const K& key) const {
+        for (auto iter = stack_.rbegin(); iter != stack_.rend(); ++iter) {
+            if (auto val = iter->Find(key)) {
+                return *val;
+            }
+        }
+
+        return V{};
+    }
+
+    /// Return the top scope of the stack.
+    /// @returns the top scope of the stack
+    const utils::Hashmap<K, V, 4>& Top() const { return stack_.Back(); }
+
+    /// Clear the scope stack.
+    void Clear() {
+        stack_.Clear();
+        stack_.Push({});
+    }
+
+  private:
+    utils::Vector<utils::Hashmap<K, V, 4>, 8> stack_ = {{}};
+};
+
+}  // namespace tint
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_SCOPE_STACK_H_
diff --git a/src/tint/utils/containers/scope_stack_test.cc b/src/tint/utils/containers/scope_stack_test.cc
new file mode 100644
index 0000000..c24b7d3
--- /dev/null
+++ b/src/tint/utils/containers/scope_stack_test.cc
@@ -0,0 +1,91 @@
+// Copyright 2020 The Tint Authors.  //
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/containers/scope_stack.h"
+
+#include "gtest/gtest.h"
+#include "src/tint/lang/wgsl/program/program_builder.h"
+
+namespace tint {
+namespace {
+
+class ScopeStackTest : public ProgramBuilder, public testing::Test {};
+
+TEST_F(ScopeStackTest, Get) {
+    ScopeStack<Symbol, uint32_t> s;
+    Symbol a(1, ID(), "1");
+    Symbol b(3, ID(), "3");
+    s.Push();
+    s.Set(a, 5u);
+    s.Set(b, 10u);
+
+    EXPECT_EQ(s.Get(a), 5u);
+    EXPECT_EQ(s.Get(b), 10u);
+
+    s.Push();
+
+    s.Set(a, 15u);
+    EXPECT_EQ(s.Get(a), 15u);
+    EXPECT_EQ(s.Get(b), 10u);
+
+    s.Pop();
+    EXPECT_EQ(s.Get(a), 5u);
+    EXPECT_EQ(s.Get(b), 10u);
+}
+
+TEST_F(ScopeStackTest, Get_MissingSymbol) {
+    ScopeStack<Symbol, uint32_t> s;
+    Symbol sym(1, ID(), "1");
+    EXPECT_EQ(s.Get(sym), 0u);
+}
+
+TEST_F(ScopeStackTest, Set) {
+    ScopeStack<Symbol, uint32_t> s;
+    Symbol a(1, ID(), "1");
+    Symbol b(2, ID(), "2");
+
+    EXPECT_EQ(s.Set(a, 5u), 0u);
+    EXPECT_EQ(s.Get(a), 5u);
+
+    EXPECT_EQ(s.Set(b, 10u), 0u);
+    EXPECT_EQ(s.Get(b), 10u);
+
+    EXPECT_EQ(s.Set(a, 20u), 5u);
+    EXPECT_EQ(s.Get(a), 20u);
+
+    EXPECT_EQ(s.Set(b, 25u), 10u);
+    EXPECT_EQ(s.Get(b), 25u);
+}
+
+TEST_F(ScopeStackTest, Clear) {
+    ScopeStack<Symbol, uint32_t> s;
+    Symbol a(1, ID(), "1");
+    Symbol b(2, ID(), "2");
+
+    EXPECT_EQ(s.Set(a, 5u), 0u);
+    EXPECT_EQ(s.Get(a), 5u);
+
+    s.Push();
+
+    EXPECT_EQ(s.Set(b, 10u), 0u);
+    EXPECT_EQ(s.Get(b), 10u);
+
+    s.Push();
+
+    s.Clear();
+    EXPECT_EQ(s.Get(a), 0u);
+    EXPECT_EQ(s.Get(b), 0u);
+}
+
+}  // namespace
+}  // namespace tint
diff --git a/src/tint/utils/containers/slice.h b/src/tint/utils/containers/slice.h
new file mode 100644
index 0000000..972d4bb
--- /dev/null
+++ b/src/tint/utils/containers/slice.h
@@ -0,0 +1,258 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_SLICE_H_
+#define SRC_TINT_UTILS_CONTAINERS_SLICE_H_
+
+#include <cstdint>
+#include <iterator>
+
+#include "src/tint/utils/debug/debug.h"
+#include "src/tint/utils/memory/bitcast.h"
+#include "src/tint/utils/rtti/castable.h"
+#include "src/tint/utils/traits/traits.h"
+
+namespace tint::utils {
+
+/// A type used to indicate an empty array.
+struct EmptyType {};
+
+/// An instance of the EmptyType.
+static constexpr EmptyType Empty;
+
+/// Mode enumerator for ReinterpretSlice
+enum class ReinterpretMode {
+    /// Only upcasts of pointers are permitted
+    kSafe,
+    /// Potentially unsafe downcasts of pointers are also permitted
+    kUnsafe,
+};
+
+namespace detail {
+
+template <typename TO, typename FROM>
+static constexpr bool ConstRemoved = std::is_const_v<FROM> && !std::is_const_v<TO>;
+
+/// Private implementation of tint::utils::CanReinterpretSlice.
+/// Specialized for the case of TO equal to FROM, which is the common case, and avoids inspection of
+/// the base classes, which can be troublesome if the slice is of an incomplete type.
+template <ReinterpretMode MODE, typename TO, typename FROM>
+struct CanReinterpretSlice {
+  private:
+    using TO_EL = std::remove_pointer_t<std::decay_t<TO>>;
+    using FROM_EL = std::remove_pointer_t<std::decay_t<FROM>>;
+
+  public:
+    /// @see utils::CanReinterpretSlice
+    static constexpr bool value =
+        // const can only be applied, not removed
+        !ConstRemoved<TO, FROM> &&
+
+        // Both TO and FROM are the same type (ignoring const)
+        (std::is_same_v<std::remove_const_t<TO>, std::remove_const_t<FROM>> ||
+
+         // Both TO and FROM are pointers...
+         ((std::is_pointer_v<TO> && std::is_pointer_v<FROM>)&&
+
+          // const can only be applied to element type, not removed
+          !ConstRemoved<TO_EL, FROM_EL> &&
+
+          // Either:
+          // * Both the pointer elements are of the same type (ignoring const)
+          // * Both the pointer elements are both Castable, and MODE is kUnsafe, or FROM is of,
+          // or
+          //   derives from TO
+          (std::is_same_v<std::remove_const_t<FROM_EL>, std::remove_const_t<TO_EL>> ||
+           (IsCastable<FROM_EL, TO_EL> && (MODE == ReinterpretMode::kUnsafe ||
+                                           utils::traits::IsTypeOrDerived<FROM_EL, TO_EL>)))));
+};
+
+/// Specialization of 'CanReinterpretSlice' for when TO and FROM are equal types.
+template <typename T, ReinterpretMode MODE>
+struct CanReinterpretSlice<MODE, T, T> {
+    /// Always `true` as TO and FROM are the same type.
+    static constexpr bool value = true;
+};
+
+}  // namespace detail
+
+/// Evaluates whether a `Slice<FROM>` and be reinterpreted as a `Slice<TO>`.
+/// Slices can be reinterpreted if:
+///  * TO has the same or more 'constness' than FROM.
+///  * And either:
+///  * `FROM` and `TO` are pointers to the same type
+///  * `FROM` and `TO` are pointers to CastableBase (or derived), and the pointee type of `TO` is of
+///     the same type as, or is an ancestor of the pointee type of `FROM`.
+template <ReinterpretMode MODE, typename TO, typename FROM>
+static constexpr bool CanReinterpretSlice =
+    utils::detail::CanReinterpretSlice<MODE, TO, FROM>::value;
+
+/// A slice represents a contigious array of elements of type T.
+template <typename T>
+struct Slice {
+    /// Type of `T`.
+    using value_type = T;
+
+    /// The pointer to the first element in the slice
+    T* data = nullptr;
+
+    /// The total number of elements in the slice
+    size_t len = 0;
+
+    /// The total capacity of the backing store for the slice
+    size_t cap = 0;
+
+    /// Constructor
+    Slice() = default;
+
+    /// Constructor
+    Slice(EmptyType) {}  // NOLINT
+
+    /// Copy constructor with covariance / const conversion
+    /// @param other the vector to copy
+    /// @see CanReinterpretSlice for rules about conversion
+    template <typename U,
+              typename = std::enable_if_t<CanReinterpretSlice<ReinterpretMode::kSafe, T, U>>>
+    Slice(const Slice<U>& other) {  // NOLINT(runtime/explicit)
+        *this = other.template Reinterpret<T, ReinterpretMode::kSafe>();
+    }
+
+    /// Constructor
+    /// @param d pointer to the first element in the slice
+    /// @param l total number of elements in the slice
+    /// @param c total capacity of the backing store for the slice
+    Slice(T* d, size_t l, size_t c) : data(d), len(l), cap(c) {}
+
+    /// Constructor
+    /// @param elements c-array of elements
+    template <size_t N>
+    Slice(T (&elements)[N])  // NOLINT
+        : data(elements), len(N), cap(N) {}
+
+    /// Reinterprets this slice as `const Slice<TO>&`
+    /// @returns the reinterpreted slice
+    /// @see CanReinterpretSlice
+    template <typename TO, ReinterpretMode MODE = ReinterpretMode::kSafe>
+    const Slice<TO>& Reinterpret() const {
+        static_assert(CanReinterpretSlice<MODE, TO, T>);
+        return *Bitcast<const Slice<TO>*>(this);
+    }
+
+    /// Reinterprets this slice as `Slice<TO>&`
+    /// @returns the reinterpreted slice
+    /// @see CanReinterpretSlice
+    template <typename TO, ReinterpretMode MODE = ReinterpretMode::kSafe>
+    Slice<TO>& Reinterpret() {
+        static_assert(CanReinterpretSlice<MODE, TO, T>);
+        return *Bitcast<Slice<TO>*>(this);
+    }
+
+    /// @return true if the slice length is zero
+    bool IsEmpty() const { return len == 0; }
+
+    /// @return the length of the slice
+    size_t Length() const { return len; }
+
+    /// Create a new slice that represents an offset into this slice
+    /// @param offset the number of elements to offset
+    /// @return the new slice
+    Slice<T> Offset(size_t offset) const {
+        if (offset > len) {
+            offset = len;
+        }
+        return Slice(data + offset, len - offset, cap - offset);
+    }
+
+    /// Create a new slice that represents a truncated version of this slice
+    /// @param length the new length
+    /// @return a new slice that is truncated to `length` elements
+    Slice<T> Truncate(size_t length) const {
+        if (length > len) {
+            length = len;
+        }
+        return Slice(data, length, length);
+    }
+
+    /// Index operator
+    /// @param i the element index. Must be less than `len`.
+    /// @returns a reference to the i'th element.
+    T& operator[](size_t i) {
+        TINT_ASSERT(Utils, i < Length());
+        return data[i];
+    }
+
+    /// Index operator
+    /// @param i the element index. Must be less than `len`.
+    /// @returns a reference to the i'th element.
+    const T& operator[](size_t i) const {
+        TINT_ASSERT(Utils, i < Length());
+        return data[i];
+    }
+
+    /// @returns a reference to the first element in the vector
+    T& Front() {
+        TINT_ASSERT(Utils, !IsEmpty());
+        return data[0];
+    }
+
+    /// @returns a reference to the first element in the vector
+    const T& Front() const {
+        TINT_ASSERT(Utils, !IsEmpty());
+        return data[0];
+    }
+
+    /// @returns a reference to the last element in the vector
+    T& Back() {
+        TINT_ASSERT(Utils, !IsEmpty());
+        return data[len - 1];
+    }
+
+    /// @returns a reference to the last element in the vector
+    const T& Back() const {
+        TINT_ASSERT(Utils, !IsEmpty());
+        return data[len - 1];
+    }
+
+    /// @returns a pointer to the first element in the vector
+    T* begin() { return data; }
+
+    /// @returns a pointer to the first element in the vector
+    const T* begin() const { return data; }
+
+    /// @returns a pointer to one past the last element in the vector
+    T* end() { return data + len; }
+
+    /// @returns a pointer to one past the last element in the vector
+    const T* end() const { return data + len; }
+
+    /// @returns a reverse iterator starting with the last element in the vector
+    auto rbegin() { return std::reverse_iterator<T*>(end()); }
+
+    /// @returns a reverse iterator starting with the last element in the vector
+    auto rbegin() const { return std::reverse_iterator<const T*>(end()); }
+
+    /// @returns the end for a reverse iterator
+    auto rend() { return std::reverse_iterator<T*>(begin()); }
+
+    /// @returns the end for a reverse iterator
+    auto rend() const { return std::reverse_iterator<const T*>(begin()); }
+};
+
+/// Deduction guide for Slice from c-array
+template <typename T, size_t N>
+Slice(T (&elements)[N]) -> Slice<T>;
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_SLICE_H_
diff --git a/src/tint/utils/containers/slice_test.cc b/src/tint/utils/containers/slice_test.cc
new file mode 100644
index 0000000..139dcc5
--- /dev/null
+++ b/src/tint/utils/containers/slice_test.cc
@@ -0,0 +1,185 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/containers/slice.h"
+
+#include "gmock/gmock.h"
+
+namespace tint::utils {
+namespace {
+
+class C0 : public Castable<C0> {};
+class C1 : public Castable<C1, C0> {};
+class C2a : public Castable<C2a, C1> {};
+class C2b : public Castable<C2b, C1> {};
+
+////////////////////////////////////////////////////////////////////////////////
+// Static asserts
+////////////////////////////////////////////////////////////////////////////////
+// Non-pointer
+static_assert(CanReinterpretSlice<ReinterpretMode::kSafe, int, int>, "same type");
+static_assert(CanReinterpretSlice<ReinterpretMode::kSafe, int const, int>, "apply const");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, int, int const>, "remove const");
+
+// Non-castable pointers
+static_assert(CanReinterpretSlice<ReinterpretMode::kSafe, int* const, int*>, "apply ptr const");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, int*, int* const>, "remove ptr const");
+static_assert(CanReinterpretSlice<ReinterpretMode::kSafe, int const*, int*>, "apply el const");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, int*, int const*>, "remove el const");
+
+// Castable
+static_assert(CanReinterpretSlice<ReinterpretMode::kSafe, const C0*, C0*>, "apply const");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, C0*, const C0*>, "remove const");
+static_assert(CanReinterpretSlice<ReinterpretMode::kSafe, C0*, C1*>, "up cast");
+static_assert(CanReinterpretSlice<ReinterpretMode::kSafe, const C0*, const C1*>, "up cast");
+static_assert(CanReinterpretSlice<ReinterpretMode::kSafe, const C0*, C1*>, "up cast, apply const");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, C0*, const C1*>,
+              "up cast, remove const");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, C1*, C0*>, "down cast");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, const C1*, const C0*>, "down cast");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, const C1*, C0*>,
+              "down cast, apply const");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, C1*, const C0*>,
+              "down cast, remove const");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, const C1*, C0*>,
+              "down cast, apply const");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, C1*, const C0*>,
+              "down cast, remove const");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, C2a*, C2b*>, "sideways cast");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, const C2a*, const C2b*>,
+              "sideways cast");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, const C2a*, C2b*>,
+              "sideways cast, apply const");
+static_assert(!CanReinterpretSlice<ReinterpretMode::kSafe, C2a*, const C2b*>,
+              "sideways cast, remove const");
+
+TEST(TintSliceTest, Ctor) {
+    Slice<int> slice;
+    EXPECT_EQ(slice.data, nullptr);
+    EXPECT_EQ(slice.len, 0u);
+    EXPECT_EQ(slice.cap, 0u);
+    EXPECT_TRUE(slice.IsEmpty());
+}
+
+TEST(TintSliceTest, CtorCast) {
+    C1* elements[3];
+
+    Slice<C1*> slice_a;
+    slice_a.data = &elements[0];
+    slice_a.len = 3;
+    slice_a.cap = 3;
+
+    Slice<const C0*> slice_b(slice_a);
+    EXPECT_EQ(slice_b.data, Bitcast<const C0**>(&elements[0]));
+    EXPECT_EQ(slice_b.len, 3u);
+    EXPECT_EQ(slice_b.cap, 3u);
+    EXPECT_FALSE(slice_b.IsEmpty());
+}
+
+TEST(TintSliceTest, CtorEmpty) {
+    Slice<int> slice{Empty};
+    EXPECT_EQ(slice.data, nullptr);
+    EXPECT_EQ(slice.len, 0u);
+    EXPECT_EQ(slice.cap, 0u);
+    EXPECT_TRUE(slice.IsEmpty());
+}
+
+TEST(TintSliceTest, CtorCArray) {
+    int elements[] = {1, 2, 3};
+
+    auto slice = Slice{elements};
+    EXPECT_EQ(slice.data, elements);
+    EXPECT_EQ(slice.len, 3u);
+    EXPECT_EQ(slice.cap, 3u);
+    EXPECT_FALSE(slice.IsEmpty());
+}
+
+TEST(TintSliceTest, Index) {
+    int elements[] = {1, 2, 3};
+
+    auto slice = Slice{elements};
+    EXPECT_EQ(slice[0], 1);
+    EXPECT_EQ(slice[1], 2);
+    EXPECT_EQ(slice[2], 3);
+}
+
+TEST(TintSliceTest, Front) {
+    int elements[] = {1, 2, 3};
+    auto slice = Slice{elements};
+    EXPECT_EQ(slice.Front(), 1);
+}
+
+TEST(TintSliceTest, Back) {
+    int elements[] = {1, 2, 3};
+    auto slice = Slice{elements};
+    EXPECT_EQ(slice.Back(), 3);
+}
+
+TEST(TintSliceTest, BeginEnd) {
+    int elements[] = {1, 2, 3};
+    auto slice = Slice{elements};
+    EXPECT_THAT(slice, testing::ElementsAre(1, 2, 3));
+}
+
+TEST(TintSliceTest, ReverseBeginEnd) {
+    int elements[] = {1, 2, 3};
+    auto slice = Slice{elements};
+    size_t i = 0;
+    for (auto it = slice.rbegin(); it != slice.rend(); it++) {
+        EXPECT_EQ(*it, elements[2 - i]);
+        i++;
+    }
+}
+
+TEST(TintSliceTest, Offset) {
+    int elements[] = {1, 2, 3};
+
+    auto slice = Slice{elements};
+    auto offset = slice.Offset(1);
+    EXPECT_EQ(offset.Length(), 2u);
+    EXPECT_EQ(offset[0], 2);
+    EXPECT_EQ(offset[1], 3);
+}
+
+TEST(TintSliceTest, Offset_PastEnd) {
+    int elements[] = {1, 2, 3};
+
+    auto slice = Slice{elements};
+    auto offset = slice.Offset(4);
+    EXPECT_EQ(offset.Length(), 0u);
+}
+
+TEST(TintSliceTest, Truncate) {
+    int elements[] = {1, 2, 3};
+
+    auto slice = Slice{elements};
+    auto truncated = slice.Truncate(2);
+    EXPECT_EQ(truncated.Length(), 2u);
+    EXPECT_EQ(truncated[0], 1);
+    EXPECT_EQ(truncated[1], 2);
+}
+
+TEST(TintSliceTest, Truncate_PastEnd) {
+    int elements[] = {1, 2, 3};
+
+    auto slice = Slice{elements};
+    auto truncated = slice.Truncate(4);
+    EXPECT_EQ(truncated.Length(), 3u);
+    EXPECT_EQ(truncated[0], 1);
+    EXPECT_EQ(truncated[1], 2);
+    EXPECT_EQ(truncated[2], 3);
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/containers/transform.h b/src/tint/utils/containers/transform.h
new file mode 100644
index 0000000..4e3e686
--- /dev/null
+++ b/src/tint/utils/containers/transform.h
@@ -0,0 +1,175 @@
+// Copyright 2021 The Tint Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_TRANSFORM_H_
+#define SRC_TINT_UTILS_CONTAINERS_TRANSFORM_H_
+
+#include <algorithm>
+#include <type_traits>
+#include <utility>
+#include <vector>
+
+#include "src/tint/utils/containers/vector.h"
+#include "src/tint/utils/traits/traits.h"
+
+namespace tint::utils {
+
+/// Transform performs an element-wise transformation of a vector.
+/// @param in the input vector.
+/// @param transform the transformation function with signature: `OUT(IN)`
+/// @returns a new vector with each element of the source vector transformed by `transform`.
+template <typename IN, typename TRANSFORMER>
+auto Transform(const std::vector<IN>& in, TRANSFORMER&& transform)
+    -> std::vector<decltype(transform(in[0]))> {
+    std::vector<decltype(transform(in[0]))> result(in.size());
+    for (size_t i = 0; i < result.size(); ++i) {
+        result[i] = transform(in[i]);
+    }
+    return result;
+}
+
+/// Transform performs an element-wise transformation of a vector.
+/// @param in the input vector.
+/// @param transform the transformation function with signature: `OUT(IN, size_t)`
+/// @returns a new vector with each element of the source vector transformed by `transform`.
+template <typename IN, typename TRANSFORMER>
+auto Transform(const std::vector<IN>& in, TRANSFORMER&& transform)
+    -> std::vector<decltype(transform(in[0], 1u))> {
+    std::vector<decltype(transform(in[0], 1u))> result(in.size());
+    for (size_t i = 0; i < result.size(); ++i) {
+        result[i] = transform(in[i], i);
+    }
+    return result;
+}
+
+/// Transform performs an element-wise transformation of a vector.
+/// @param in the input vector.
+/// @param transform the transformation function with signature: `OUT(IN)`
+/// @returns a new vector with each element of the source vector transformed by `transform`.
+template <typename IN, size_t N, typename TRANSFORMER>
+auto Transform(const Vector<IN, N>& in, TRANSFORMER&& transform)
+    -> Vector<decltype(transform(in[0])), N> {
+    const auto count = in.Length();
+    Vector<decltype(transform(in[0])), N> result;
+    result.Reserve(count);
+    for (size_t i = 0; i < count; ++i) {
+        result.Push(transform(in[i]));
+    }
+    return result;
+}
+
+/// Transform performs an element-wise transformation of a vector.
+/// @param in the input vector.
+/// @param transform the transformation function with signature: `OUT(IN, size_t)`
+/// @returns a new vector with each element of the source vector transformed by `transform`.
+template <typename IN, size_t N, typename TRANSFORMER>
+auto Transform(const Vector<IN, N>& in, TRANSFORMER&& transform)
+    -> Vector<decltype(transform(in[0], 1u)), N> {
+    const auto count = in.Length();
+    Vector<decltype(transform(in[0], 1u)), N> result;
+    result.Reserve(count);
+    for (size_t i = 0; i < count; ++i) {
+        result.Push(transform(in[i], i));
+    }
+    return result;
+}
+
+/// Transform performs an element-wise transformation of a slice.
+/// @param in the input slice.
+/// @param transform the transformation function with signature: `OUT(IN)`
+/// @tparam N the small-array size of the returned Vector
+/// @returns a new vector with each element of the source vector transformed by `transform`.
+template <size_t N, typename IN, typename TRANSFORMER>
+auto Transform(Slice<IN> in, TRANSFORMER&& transform) -> Vector<decltype(transform(in[0])), N> {
+    Vector<decltype(transform(in[0])), N> result;
+    result.Reserve(in.len);
+    for (size_t i = 0; i < in.len; ++i) {
+        result.Push(transform(in[i]));
+    }
+    return result;
+}
+
+/// Transform performs an element-wise transformation of a slice.
+/// @param in the input slice.
+/// @param transform the transformation function with signature: `OUT(IN, size_t)`
+/// @tparam N the small-array size of the returned Vector
+/// @returns a new vector with each element of the source vector transformed by `transform`.
+template <size_t N, typename IN, typename TRANSFORMER>
+auto Transform(Slice<IN> in, TRANSFORMER&& transform) -> Vector<decltype(transform(in[0], 1u)), N> {
+    Vector<decltype(transform(in[0], 1u)), N> result;
+    result.Reserve(in.len);
+    for (size_t i = 0; i < in.len; ++i) {
+        result.Push(transform(in[i], i));
+    }
+    return result;
+}
+
+/// Transform performs an element-wise transformation of a vector reference.
+/// @param in the input vector.
+/// @param transform the transformation function with signature: `OUT(IN)`
+/// @tparam N the small-array size of the returned Vector
+/// @returns a new vector with each element of the source vector transformed by `transform`.
+template <size_t N, typename IN, typename TRANSFORMER>
+auto Transform(VectorRef<IN> in, TRANSFORMER&& transform) -> Vector<decltype(transform(in[0])), N> {
+    return Transform<N>(in.Slice(), std::forward<TRANSFORMER>(transform));
+}
+
+/// Transform performs an element-wise transformation of a vector reference.
+/// @param in the input vector.
+/// @param transform the transformation function with signature: `OUT(IN, size_t)`
+/// @tparam N the small-array size of the returned Vector
+/// @returns a new vector with each element of the source vector transformed by `transform`.
+template <size_t N, typename IN, typename TRANSFORMER>
+auto Transform(VectorRef<IN> in, TRANSFORMER&& transform)
+    -> Vector<decltype(transform(in[0], 1u)), N> {
+    return Transform<N>(in.Slice(), std::forward<TRANSFORMER>(transform));
+}
+
+/// TransformN performs an element-wise transformation of a vector, transforming and returning at
+/// most `n` elements.
+/// @param in the input vector.
+/// @param n the maximum number of elements to transform.
+/// @param transform the transformation function with signature: `OUT(IN)`
+/// @returns a new vector with at most n-elements of the source vector transformed by `transform`.
+template <typename IN, typename TRANSFORMER>
+auto TransformN(const std::vector<IN>& in, size_t n, TRANSFORMER&& transform)
+    -> std::vector<decltype(transform(in[0]))> {
+    const auto count = std::min(n, in.size());
+    std::vector<decltype(transform(in[0]))> result(count);
+    for (size_t i = 0; i < count; ++i) {
+        result[i] = transform(in[i]);
+    }
+    return result;
+}
+
+/// TransformN performs an element-wise transformation of a vector, transforming and returning at
+/// most `n` elements.
+/// @param in the input vector.
+/// @param n the maximum number of elements to transform.
+/// @param transform the transformation function with signature: `OUT(IN, size_t)`
+/// @returns a new vector with at most n-elements of the source vector transformed by `transform`.
+template <typename IN, typename TRANSFORMER>
+auto TransformN(const std::vector<IN>& in, size_t n, TRANSFORMER&& transform)
+    -> std::vector<decltype(transform(in[0], 1u))> {
+    const auto count = std::min(n, in.size());
+    std::vector<decltype(transform(in[0], 1u))> result(count);
+    for (size_t i = 0; i < count; ++i) {
+        result[i] = transform(in[i], i);
+    }
+    return result;
+}
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_TRANSFORM_H_
diff --git a/src/tint/utils/containers/transform_test.cc b/src/tint/utils/containers/transform_test.cc
new file mode 100644
index 0000000..aec9d78
--- /dev/null
+++ b/src/tint/utils/containers/transform_test.cc
@@ -0,0 +1,349 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/containers/transform.h"
+
+#include <string>
+#include <type_traits>
+
+#include "gmock/gmock.h"
+
+#define CHECK_ELEMENT_TYPE(vector, expected)                                   \
+    static_assert(std::is_same<decltype(vector)::value_type, expected>::value, \
+                  "unexpected result vector element type")
+
+namespace tint::utils {
+namespace {
+
+TEST(TransformTest, StdVectorEmpty) {
+    const std::vector<int> empty{};
+    {
+        auto transformed = Transform(empty, [](int) -> int {
+            [] { FAIL() << "Callback should not be called for empty vector"; }();
+            return 0;
+        });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_EQ(transformed.size(), 0u);
+    }
+    {
+        auto transformed = Transform(empty, [](int, size_t) -> int {
+            [] { FAIL() << "Callback should not be called for empty vector"; }();
+            return 0;
+        });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_EQ(transformed.size(), 0u);
+    }
+}
+
+TEST(TransformTest, StdVectorIdentity) {
+    const std::vector<int> input{1, 2, 3, 4};
+    auto transformed = Transform(input, [](int i) { return i; });
+    CHECK_ELEMENT_TYPE(transformed, int);
+    EXPECT_THAT(transformed, testing::ElementsAre(1, 2, 3, 4));
+}
+
+TEST(TransformTest, StdVectorIdentityWithIndex) {
+    const std::vector<int> input{1, 2, 3, 4};
+    auto transformed = Transform(input, [](int i, size_t) { return i; });
+    CHECK_ELEMENT_TYPE(transformed, int);
+    EXPECT_THAT(transformed, testing::ElementsAre(1, 2, 3, 4));
+}
+
+TEST(TransformTest, StdVectorIndex) {
+    const std::vector<int> input{10, 20, 30, 40};
+    {
+        auto transformed = Transform(input, [](int, size_t idx) { return idx; });
+        CHECK_ELEMENT_TYPE(transformed, size_t);
+        EXPECT_THAT(transformed, testing::ElementsAre(0u, 1u, 2u, 3u));
+    }
+}
+
+TEST(TransformTest, TransformStdVectorSameType) {
+    const std::vector<int> input{1, 2, 3, 4};
+    {
+        auto transformed = Transform(input, [](int i) { return i * 10; });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_THAT(transformed, testing::ElementsAre(10, 20, 30, 40));
+    }
+}
+
+TEST(TransformTest, TransformStdVectorDifferentType) {
+    const std::vector<int> input{1, 2, 3, 4};
+    {
+        auto transformed = Transform(input, [](int i) { return std::to_string(i); });
+        CHECK_ELEMENT_TYPE(transformed, std::string);
+        EXPECT_THAT(transformed, testing::ElementsAre("1", "2", "3", "4"));
+    }
+}
+
+TEST(TransformNTest, StdVectorEmpty) {
+    const std::vector<int> empty{};
+    {
+        auto transformed = TransformN(empty, 4u, [](int) -> int {
+            [] { FAIL() << "Callback should not be called for empty vector"; }();
+            return 0;
+        });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_EQ(transformed.size(), 0u);
+    }
+    {
+        auto transformed = TransformN(empty, 4u, [](int, size_t) -> int {
+            [] { FAIL() << "Callback should not be called for empty vector"; }();
+            return 0;
+        });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_EQ(transformed.size(), 0u);
+    }
+}
+
+TEST(TransformNTest, StdVectorIdentity) {
+    const std::vector<int> input{1, 2, 3, 4};
+    {
+        auto transformed = TransformN(input, 0u, [](int) {
+            [] { FAIL() << "Callback should not call the transform when n == 0"; }();
+            return 0;
+        });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_TRUE(transformed.empty());
+    }
+    {
+        auto transformed = TransformN(input, 2u, [](int i) { return i; });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_THAT(transformed, testing::ElementsAre(1, 2));
+    }
+    {
+        auto transformed = TransformN(input, 6u, [](int i) { return i; });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_THAT(transformed, testing::ElementsAre(1, 2, 3, 4));
+    }
+}
+
+TEST(TransformNTest, StdVectorIdentityWithIndex) {
+    const std::vector<int> input{1, 2, 3, 4};
+    {
+        auto transformed = TransformN(input, 0u, [](int, size_t) {
+            [] { FAIL() << "Callback should not call the transform when n == 0"; }();
+            return 0;
+        });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_TRUE(transformed.empty());
+    }
+    {
+        auto transformed = TransformN(input, 3u, [](int i, size_t) { return i; });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_THAT(transformed, testing::ElementsAre(1, 2, 3));
+    }
+    {
+        auto transformed = TransformN(input, 9u, [](int i, size_t) { return i; });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_THAT(transformed, testing::ElementsAre(1, 2, 3, 4));
+    }
+}
+
+TEST(TransformNTest, StdVectorIndex) {
+    const std::vector<int> input{10, 20, 30, 40};
+    {
+        auto transformed = TransformN(input, 0u, [](int, size_t) {
+            [] { FAIL() << "Callback should not call the transform when n == 0"; }();
+            return static_cast<size_t>(0);
+        });
+        CHECK_ELEMENT_TYPE(transformed, size_t);
+        EXPECT_TRUE(transformed.empty());
+    }
+    {
+        auto transformed = TransformN(input, 2u, [](int, size_t idx) { return idx; });
+        CHECK_ELEMENT_TYPE(transformed, size_t);
+        EXPECT_THAT(transformed, testing::ElementsAre(0u, 1u));
+    }
+    {
+        auto transformed = TransformN(input, 9u, [](int, size_t idx) { return idx; });
+        CHECK_ELEMENT_TYPE(transformed, size_t);
+        EXPECT_THAT(transformed, testing::ElementsAre(0u, 1u, 2u, 3u));
+    }
+}
+
+TEST(TransformNTest, StdVectorTransformSameType) {
+    const std::vector<int> input{1, 2, 3, 4};
+    {
+        auto transformed = TransformN(input, 0u, [](int, size_t) {
+            [] { FAIL() << "Callback should not call the transform when n == 0"; }();
+            return 0;
+        });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_TRUE(transformed.empty());
+    }
+    {
+        auto transformed = TransformN(input, 2u, [](int i) { return i * 10; });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_THAT(transformed, testing::ElementsAre(10, 20));
+    }
+    {
+        auto transformed = TransformN(input, 9u, [](int i) { return i * 10; });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_THAT(transformed, testing::ElementsAre(10, 20, 30, 40));
+    }
+}
+
+TEST(TransformNTest, StdVectorTransformDifferentType) {
+    const std::vector<int> input{1, 2, 3, 4};
+    {
+        auto transformed = TransformN(input, 0u, [](int) {
+            [] { FAIL() << "Callback should not call the transform when n == 0"; }();
+            return std::string();
+        });
+        CHECK_ELEMENT_TYPE(transformed, std::string);
+        EXPECT_TRUE(transformed.empty());
+    }
+    {
+        auto transformed = TransformN(input, 2u, [](int i) { return std::to_string(i); });
+        CHECK_ELEMENT_TYPE(transformed, std::string);
+        EXPECT_THAT(transformed, testing::ElementsAre("1", "2"));
+    }
+    {
+        auto transformed = TransformN(input, 9u, [](int i) { return std::to_string(i); });
+        CHECK_ELEMENT_TYPE(transformed, std::string);
+        EXPECT_THAT(transformed, testing::ElementsAre("1", "2", "3", "4"));
+    }
+}
+
+TEST(TransformTest, TintVectorEmpty) {
+    const Vector<int, 4> empty{};
+    {
+        auto transformed = Transform(empty, [](int) -> int {
+            [] { FAIL() << "Callback should not be called for empty vector"; }();
+            return 0;
+        });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_EQ(transformed.Length(), 0u);
+    }
+    {
+        auto transformed = Transform(empty, [](int, size_t) -> int {
+            [] { FAIL() << "Callback should not be called for empty vector"; }();
+            return 0;
+        });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_EQ(transformed.Length(), 0u);
+    }
+}
+
+TEST(TransformTest, TintVectorIdentity) {
+    const Vector<int, 4> input{1, 2, 3, 4};
+    auto transformed = Transform(input, [](int i) { return i; });
+    CHECK_ELEMENT_TYPE(transformed, int);
+    EXPECT_THAT(transformed, testing::ElementsAre(1, 2, 3, 4));
+}
+
+TEST(TransformTest, TintVectorIdentityWithIndex) {
+    const Vector<int, 4> input{1, 2, 3, 4};
+    auto transformed = Transform(input, [](int i, size_t) { return i; });
+    CHECK_ELEMENT_TYPE(transformed, int);
+    EXPECT_THAT(transformed, testing::ElementsAre(1, 2, 3, 4));
+}
+
+TEST(TransformTest, TintVectorIndex) {
+    const Vector<int, 4> input{10, 20, 30, 40};
+    {
+        auto transformed = Transform(input, [](int, size_t idx) { return idx; });
+        CHECK_ELEMENT_TYPE(transformed, size_t);
+        EXPECT_THAT(transformed, testing::ElementsAre(0u, 1u, 2u, 3u));
+    }
+}
+
+TEST(TransformTest, TransformTintVectorSameType) {
+    const Vector<int, 4> input{1, 2, 3, 4};
+    {
+        auto transformed = Transform(input, [](int i) { return i * 10; });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_THAT(transformed, testing::ElementsAre(10, 20, 30, 40));
+    }
+}
+
+TEST(TransformTest, TransformTintVectorDifferentType) {
+    const Vector<int, 4> input{1, 2, 3, 4};
+    {
+        auto transformed = Transform(input, [](int i) { return std::to_string(i); });
+        CHECK_ELEMENT_TYPE(transformed, std::string);
+        EXPECT_THAT(transformed, testing::ElementsAre("1", "2", "3", "4"));
+    }
+}
+
+TEST(TransformTest, VectorRefEmpty) {
+    Vector<int, 4> empty{};
+    VectorRef<int> ref(empty);
+    {
+        auto transformed = Transform<4>(ref, [](int) -> int {
+            [] { FAIL() << "Callback should not be called for empty vector"; }();
+            return 0;
+        });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_EQ(transformed.Length(), 0u);
+    }
+    {
+        auto transformed = Transform<4>(ref, [](int, size_t) -> int {
+            [] { FAIL() << "Callback should not be called for empty vector"; }();
+            return 0;
+        });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_EQ(transformed.Length(), 0u);
+    }
+}
+
+TEST(TransformTest, VectorRefIdentity) {
+    Vector<int, 4> input{1, 2, 3, 4};
+    VectorRef<int> ref(input);
+    auto transformed = Transform<8>(ref, [](int i) { return i; });
+    CHECK_ELEMENT_TYPE(transformed, int);
+    EXPECT_THAT(transformed, testing::ElementsAre(1, 2, 3, 4));
+}
+
+TEST(TransformTest, VectorRefIdentityWithIndex) {
+    Vector<int, 4> input{1, 2, 3, 4};
+    VectorRef<int> ref(input);
+    auto transformed = Transform<2>(ref, [](int i, size_t) { return i; });
+    CHECK_ELEMENT_TYPE(transformed, int);
+    EXPECT_THAT(transformed, testing::ElementsAre(1, 2, 3, 4));
+}
+
+TEST(TransformTest, VectorRefIndex) {
+    Vector<int, 4> input{10, 20, 30, 40};
+    VectorRef<int> ref(input);
+    {
+        auto transformed = Transform<4>(ref, [](int, size_t idx) { return idx; });
+        CHECK_ELEMENT_TYPE(transformed, size_t);
+        EXPECT_THAT(transformed, testing::ElementsAre(0u, 1u, 2u, 3u));
+    }
+}
+
+TEST(TransformTest, TransformVectorRefSameType) {
+    Vector<int, 4> input{1, 2, 3, 4};
+    VectorRef<int> ref(input);
+    {
+        auto transformed = Transform<4>(ref, [](int i) { return i * 10; });
+        CHECK_ELEMENT_TYPE(transformed, int);
+        EXPECT_THAT(transformed, testing::ElementsAre(10, 20, 30, 40));
+    }
+}
+
+TEST(TransformTest, TransformVectorRefDifferentType) {
+    Vector<int, 4> input{1, 2, 3, 4};
+    VectorRef<int> ref(input);
+    {
+        auto transformed = Transform<4>(ref, [](int i) { return std::to_string(i); });
+        CHECK_ELEMENT_TYPE(transformed, std::string);
+        EXPECT_THAT(transformed, testing::ElementsAre("1", "2", "3", "4"));
+    }
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/containers/unique_allocator.h b/src/tint/utils/containers/unique_allocator.h
new file mode 100644
index 0000000..d5e7e24
--- /dev/null
+++ b/src/tint/utils/containers/unique_allocator.h
@@ -0,0 +1,119 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_UNIQUE_ALLOCATOR_H_
+#define SRC_TINT_UTILS_CONTAINERS_UNIQUE_ALLOCATOR_H_
+
+#include <functional>
+#include <unordered_set>
+#include <utility>
+
+#include "src/tint/utils/memory/block_allocator.h"
+
+namespace tint::utils {
+
+/// UniqueAllocator is used to allocate unique instances of the template type
+/// `T`.
+template <typename T, typename HASH = std::hash<T>, typename EQUAL = std::equal_to<T>>
+class UniqueAllocator {
+  public:
+    /// Iterator is the type returned by begin() and end()
+    using Iterator = typename BlockAllocator<T>::ConstIterator;
+
+    /// @param args the arguments used to construct the object.
+    /// @return a pointer to an instance of `T` with the provided arguments.
+    ///         If an existing instance of `T` has been constructed, then the same
+    ///         pointer is returned.
+    template <typename TYPE = T, typename... ARGS>
+    TYPE* Get(ARGS&&... args) {
+        // Create a temporary T instance on the stack so that we can hash it, and
+        // use it for equality lookup for the std::unordered_set. If the item is not
+        // found in the set, then we create the persisted instance with the
+        // allocator.
+        TYPE key{args...};
+        auto hash = Hasher{}(key);
+        auto it = items.find(Entry{hash, &key});
+        if (it != items.end()) {
+            return static_cast<TYPE*>(it->ptr);
+        }
+        auto* ptr = allocator.template Create<TYPE>(std::forward<ARGS>(args)...);
+        items.emplace_hint(it, Entry{hash, ptr});
+        return ptr;
+    }
+
+    /// @param args the arguments used to create the temporary used for the search.
+    /// @return a pointer to an instance of `T` with the provided arguments, or nullptr if the item
+    ///         was not found.
+    template <typename TYPE = T, typename... ARGS>
+    TYPE* Find(ARGS&&... args) const {
+        // Create a temporary T instance on the stack so that we can hash it, and
+        // use it for equality lookup for the std::unordered_set.
+        TYPE key{args...};
+        auto hash = Hasher{}(key);
+        auto it = items.find(Entry{hash, &key});
+        if (it != items.end()) {
+            return static_cast<TYPE*>(it->ptr);
+        }
+        return nullptr;
+    }
+
+    /// Wrap sets this allocator to the objects created with the content of `inner`.
+    /// The allocator after Wrap is intended to temporarily extend the objects
+    /// of an existing immutable UniqueAllocator.
+    /// As the copied objects are owned by `inner`, `inner` must not be destructed
+    /// or assigned while using this allocator.
+    /// @param o the immutable UniqueAlllocator to extend
+    void Wrap(const UniqueAllocator<T, HASH, EQUAL>& o) { items = o.items; }
+
+    /// @returns an iterator to the beginning of the types
+    Iterator begin() const { return allocator.Objects().begin(); }
+    /// @returns an iterator to the end of the types
+    Iterator end() const { return allocator.Objects().end(); }
+
+  private:
+    /// The hash function
+    using Hasher = HASH;
+    /// The equality function
+    using Equality = EQUAL;
+
+    /// Entry is used as the entry to the unordered_set
+    struct Entry {
+        /// The pre-calculated hash of the entry
+        size_t hash;
+        /// The pointer to the unique object
+        T* ptr;
+    };
+    /// Comparator is the hashing and equality function used by the unordered_set
+    struct Comparator {
+        /// Hashing function
+        /// @param e the entry
+        /// @returns the hash of the entry
+        size_t operator()(Entry e) const { return e.hash; }
+
+        /// Equality function
+        /// @param a the first entry to compare
+        /// @param b the second entry to compare
+        /// @returns true if the two entries are equal
+        bool operator()(Entry a, Entry b) const { return EQUAL{}(*a.ptr, *b.ptr); }
+    };
+
+    /// The block allocator used to allocate the unique objects
+    BlockAllocator<T> allocator;
+    /// The unordered_set of unique item entries
+    std::unordered_set<Entry, Comparator, Comparator> items;
+};
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_UNIQUE_ALLOCATOR_H_
diff --git a/src/tint/utils/containers/unique_allocator_test.cc b/src/tint/utils/containers/unique_allocator_test.cc
new file mode 100644
index 0000000..a71ce98
--- /dev/null
+++ b/src/tint/utils/containers/unique_allocator_test.cc
@@ -0,0 +1,52 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/containers/unique_allocator.h"
+
+#include <string>
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(UniqueAllocator, Int) {
+    UniqueAllocator<int> a;
+    EXPECT_NE(a.Get(0), a.Get(1));
+    EXPECT_NE(a.Get(1), a.Get(2));
+    EXPECT_EQ(a.Get(0), a.Get(0));
+    EXPECT_EQ(a.Get(1), a.Get(1));
+    EXPECT_EQ(a.Get(2), a.Get(2));
+}
+
+TEST(UniqueAllocator, Float) {
+    UniqueAllocator<float> a;
+    EXPECT_NE(a.Get(0.1f), a.Get(1.1f));
+    EXPECT_NE(a.Get(1.1f), a.Get(2.1f));
+    EXPECT_EQ(a.Get(0.1f), a.Get(0.1f));
+    EXPECT_EQ(a.Get(1.1f), a.Get(1.1f));
+    EXPECT_EQ(a.Get(2.1f), a.Get(2.1f));
+}
+
+TEST(UniqueAllocator, String) {
+    UniqueAllocator<std::string> a;
+    EXPECT_NE(a.Get("x"), a.Get("y"));
+    EXPECT_NE(a.Get("z"), a.Get("w"));
+    EXPECT_EQ(a.Get("x"), a.Get("x"));
+    EXPECT_EQ(a.Get("y"), a.Get("y"));
+    EXPECT_EQ(a.Get("z"), a.Get("z"));
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/containers/unique_vector.h b/src/tint/utils/containers/unique_vector.h
new file mode 100644
index 0000000..980a06f
--- /dev/null
+++ b/src/tint/utils/containers/unique_vector.h
@@ -0,0 +1,149 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_UNIQUE_VECTOR_H_
+#define SRC_TINT_UTILS_CONTAINERS_UNIQUE_VECTOR_H_
+
+#include <cstddef>
+#include <functional>
+#include <unordered_set>
+#include <utility>
+#include <vector>
+
+#include "src/tint/utils/containers/hashset.h"
+#include "src/tint/utils/containers/vector.h"
+
+namespace tint::utils {
+
+/// UniqueVector is an ordered container that only contains unique items.
+/// Attempting to add a duplicate is a no-op.
+template <typename T, size_t N, typename HASH = std::hash<T>, typename EQUAL = std::equal_to<T>>
+struct UniqueVector {
+    /// STL-friendly alias to T. Used by gmock.
+    using value_type = T;
+
+    /// Constructor
+    UniqueVector() = default;
+
+    /// Constructor
+    /// @param v the vector to construct this UniqueVector with. Duplicate
+    /// elements will be removed.
+    explicit UniqueVector(std::vector<T>&& v) {
+        for (auto& el : v) {
+            Add(el);
+        }
+    }
+
+    /// Add appends the item to the end of the vector, if the vector does not
+    /// already contain the given item.
+    /// @param item the item to append to the end of the vector
+    /// @returns true if the item was added, otherwise false.
+    bool Add(const T& item) {
+        if (set.Add(item)) {
+            vector.Push(item);
+            return true;
+        }
+        return false;
+    }
+
+    /// Removes @p count elements from the vector
+    /// @param start the index of the first element to remove
+    /// @param count the number of elements to remove
+    void Erase(size_t start, size_t count = 1) {
+        for (size_t i = 0; i < count; i++) {
+            set.Remove(vector[start + i]);
+        }
+        vector.Erase(start, count);
+    }
+
+    /// @returns true if the vector contains `item`
+    /// @param item the item
+    bool Contains(const T& item) const { return set.Contains(item); }
+
+    /// @param i the index of the element to retrieve
+    /// @returns the element at the index `i`
+    T& operator[](size_t i) { return vector[i]; }
+
+    /// @param i the index of the element to retrieve
+    /// @returns the element at the index `i`
+    const T& operator[](size_t i) const { return vector[i]; }
+
+    /// @returns true if the vector is empty
+    bool IsEmpty() const { return vector.IsEmpty(); }
+
+    /// Removes all elements from the vector
+    void Clear() {
+        vector.Clear();
+        set.Clear();
+    }
+
+    /// @returns the number of items in the vector
+    size_t Length() const { return vector.Length(); }
+
+    /// @returns the pointer to the first element in the vector, or nullptr if the vector is empty.
+    const T* Data() const { return vector.IsEmpty() ? nullptr : &vector[0]; }
+
+    /// @returns an iterator to the beginning of the vector
+    auto begin() const { return vector.begin(); }
+
+    /// @returns an iterator to the end of the vector
+    auto end() const { return vector.end(); }
+
+    /// @returns an iterator to the beginning of the reversed vector
+    auto rbegin() const { return vector.rbegin(); }
+
+    /// @returns an iterator to the end of the reversed vector
+    auto rend() const { return vector.rend(); }
+
+    /// @returns a reference to the internal vector
+    operator VectorRef<T>() const { return vector; }
+
+    /// @returns the std::move()'d vector.
+    /// @note The UniqueVector must not be used after calling this method
+    VectorRef<T> Release() { return std::move(vector); }
+
+    /// Pre-allocates `count` elements in the vector and set
+    /// @param count the number of elements to pre-allocate
+    void Reserve(size_t count) {
+        vector.Reserve(count);
+        set.Reserve(count);
+    }
+
+    /// Removes the last element from the vector
+    /// @returns the popped element
+    T Pop() {
+        set.Remove(vector.Back());
+        return vector.Pop();
+    }
+
+    /// Removes the last element from the vector if it is equal to @p value
+    /// @param value the value to pop if it is at the back of the vector
+    /// @returns true if the value was popped, otherwise false
+    bool TryPop(T value) {
+        if (!vector.IsEmpty() && vector.Back() == value) {
+            set.Remove(vector.Back());
+            vector.Pop();
+            return true;
+        }
+        return false;
+    }
+
+  private:
+    Vector<T, N> vector;
+    Hashset<T, N, HASH, EQUAL> set;
+};
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_UNIQUE_VECTOR_H_
diff --git a/src/tint/utils/containers/unique_vector_test.cc b/src/tint/utils/containers/unique_vector_test.cc
new file mode 100644
index 0000000..5f50b3a
--- /dev/null
+++ b/src/tint/utils/containers/unique_vector_test.cc
@@ -0,0 +1,236 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/containers/unique_vector.h"
+
+#include <vector>
+
+#include "src/tint/utils/containers/reverse.h"
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(UniqueVectorTest, Empty) {
+    UniqueVector<int, 4> unique_vec;
+    ASSERT_EQ(unique_vec.Length(), 0u);
+    EXPECT_EQ(unique_vec.IsEmpty(), true);
+    EXPECT_EQ(unique_vec.begin(), unique_vec.end());
+}
+
+TEST(UniqueVectorTest, MoveConstructor) {
+    UniqueVector<int, 4> unique_vec(std::vector<int>{0, 3, 2, 1, 2});
+    ASSERT_EQ(unique_vec.Length(), 4u);
+    EXPECT_EQ(unique_vec.IsEmpty(), false);
+    EXPECT_EQ(unique_vec[0], 0);
+    EXPECT_EQ(unique_vec[1], 3);
+    EXPECT_EQ(unique_vec[2], 2);
+    EXPECT_EQ(unique_vec[3], 1);
+}
+
+TEST(UniqueVectorTest, AddUnique) {
+    UniqueVector<int, 4> unique_vec;
+    unique_vec.Add(0);
+    unique_vec.Add(1);
+    unique_vec.Add(2);
+    ASSERT_EQ(unique_vec.Length(), 3u);
+    EXPECT_EQ(unique_vec.IsEmpty(), false);
+    int i = 0;
+    for (auto n : unique_vec) {
+        EXPECT_EQ(n, i);
+        i++;
+    }
+    for (auto n : Reverse(unique_vec)) {
+        i--;
+        EXPECT_EQ(n, i);
+    }
+    EXPECT_EQ(unique_vec[0], 0);
+    EXPECT_EQ(unique_vec[1], 1);
+    EXPECT_EQ(unique_vec[2], 2);
+}
+
+TEST(UniqueVectorTest, AddDuplicates) {
+    UniqueVector<int, 4> unique_vec;
+    unique_vec.Add(0);
+    unique_vec.Add(0);
+    unique_vec.Add(0);
+    unique_vec.Add(1);
+    unique_vec.Add(1);
+    unique_vec.Add(2);
+    ASSERT_EQ(unique_vec.Length(), 3u);
+    EXPECT_EQ(unique_vec.IsEmpty(), false);
+    int i = 0;
+    for (auto n : unique_vec) {
+        EXPECT_EQ(n, i);
+        i++;
+    }
+    for (auto n : Reverse(unique_vec)) {
+        i--;
+        EXPECT_EQ(n, i);
+    }
+    EXPECT_EQ(unique_vec[0], 0);
+    EXPECT_EQ(unique_vec[1], 1);
+    EXPECT_EQ(unique_vec[2], 2);
+}
+
+TEST(UniqueVectorTest, Erase) {
+    UniqueVector<int, 4> unique_vec;
+    unique_vec.Add(0);
+    unique_vec.Add(3);
+    unique_vec.Add(2);
+    unique_vec.Add(5);
+    unique_vec.Add(1);
+    unique_vec.Add(6);
+    EXPECT_EQ(unique_vec.Length(), 6u);
+    EXPECT_EQ(unique_vec.IsEmpty(), false);
+
+    unique_vec.Erase(2, 2);
+
+    ASSERT_EQ(unique_vec.Length(), 4u);
+    EXPECT_EQ(unique_vec[0], 0);
+    EXPECT_EQ(unique_vec[1], 3);
+    EXPECT_EQ(unique_vec[2], 1);
+    EXPECT_EQ(unique_vec[3], 6);
+    EXPECT_TRUE(unique_vec.Contains(0));
+    EXPECT_TRUE(unique_vec.Contains(3));
+    EXPECT_FALSE(unique_vec.Contains(2));
+    EXPECT_FALSE(unique_vec.Contains(5));
+    EXPECT_TRUE(unique_vec.Contains(1));
+    EXPECT_TRUE(unique_vec.Contains(6));
+    EXPECT_EQ(unique_vec.IsEmpty(), false);
+
+    unique_vec.Erase(1);
+
+    ASSERT_EQ(unique_vec.Length(), 3u);
+    EXPECT_EQ(unique_vec[0], 0);
+    EXPECT_EQ(unique_vec[1], 1);
+    EXPECT_EQ(unique_vec[2], 6);
+    EXPECT_TRUE(unique_vec.Contains(0));
+    EXPECT_FALSE(unique_vec.Contains(3));
+    EXPECT_FALSE(unique_vec.Contains(2));
+    EXPECT_FALSE(unique_vec.Contains(5));
+    EXPECT_TRUE(unique_vec.Contains(1));
+    EXPECT_TRUE(unique_vec.Contains(6));
+    EXPECT_EQ(unique_vec.IsEmpty(), false);
+
+    unique_vec.Erase(2);
+
+    ASSERT_EQ(unique_vec.Length(), 2u);
+    EXPECT_EQ(unique_vec[0], 0);
+    EXPECT_EQ(unique_vec[1], 1);
+    EXPECT_TRUE(unique_vec.Contains(0));
+    EXPECT_FALSE(unique_vec.Contains(3));
+    EXPECT_FALSE(unique_vec.Contains(2));
+    EXPECT_FALSE(unique_vec.Contains(5));
+    EXPECT_TRUE(unique_vec.Contains(1));
+    EXPECT_FALSE(unique_vec.Contains(6));
+    EXPECT_EQ(unique_vec.IsEmpty(), false);
+
+    unique_vec.Erase(0, 2);
+
+    ASSERT_EQ(unique_vec.Length(), 0u);
+    EXPECT_FALSE(unique_vec.Contains(0));
+    EXPECT_FALSE(unique_vec.Contains(3));
+    EXPECT_FALSE(unique_vec.Contains(2));
+    EXPECT_FALSE(unique_vec.Contains(5));
+    EXPECT_FALSE(unique_vec.Contains(1));
+    EXPECT_FALSE(unique_vec.Contains(6));
+    EXPECT_EQ(unique_vec.IsEmpty(), true);
+
+    unique_vec.Add(6);
+    unique_vec.Add(0);
+    unique_vec.Add(2);
+
+    ASSERT_EQ(unique_vec.Length(), 3u);
+    EXPECT_EQ(unique_vec[0], 6);
+    EXPECT_EQ(unique_vec[1], 0);
+    EXPECT_EQ(unique_vec[2], 2);
+    EXPECT_TRUE(unique_vec.Contains(0));
+    EXPECT_FALSE(unique_vec.Contains(3));
+    EXPECT_TRUE(unique_vec.Contains(2));
+    EXPECT_FALSE(unique_vec.Contains(5));
+    EXPECT_FALSE(unique_vec.Contains(1));
+    EXPECT_TRUE(unique_vec.Contains(6));
+    EXPECT_EQ(unique_vec.IsEmpty(), false);
+}
+
+TEST(UniqueVectorTest, AsVector) {
+    UniqueVector<int, 4> unique_vec;
+    unique_vec.Add(0);
+    unique_vec.Add(0);
+    unique_vec.Add(0);
+    unique_vec.Add(1);
+    unique_vec.Add(1);
+    unique_vec.Add(2);
+
+    utils::VectorRef<int> ref = unique_vec;
+    EXPECT_EQ(ref.Length(), 3u);
+    EXPECT_EQ(unique_vec.IsEmpty(), false);
+    int i = 0;
+    for (auto n : ref) {
+        EXPECT_EQ(n, i);
+        i++;
+    }
+    for (auto n : Reverse(unique_vec)) {
+        i--;
+        EXPECT_EQ(n, i);
+    }
+}
+
+TEST(UniqueVectorTest, PopBack) {
+    UniqueVector<int, 4> unique_vec;
+    unique_vec.Add(0);
+    unique_vec.Add(2);
+    unique_vec.Add(1);
+
+    EXPECT_EQ(unique_vec.Pop(), 1);
+    ASSERT_EQ(unique_vec.Length(), 2u);
+    EXPECT_EQ(unique_vec.IsEmpty(), false);
+    EXPECT_EQ(unique_vec[0], 0);
+    EXPECT_EQ(unique_vec[1], 2);
+
+    EXPECT_EQ(unique_vec.Pop(), 2);
+    ASSERT_EQ(unique_vec.Length(), 1u);
+    EXPECT_EQ(unique_vec.IsEmpty(), false);
+    EXPECT_EQ(unique_vec[0], 0);
+
+    unique_vec.Add(1);
+
+    ASSERT_EQ(unique_vec.Length(), 2u);
+    EXPECT_EQ(unique_vec.IsEmpty(), false);
+    EXPECT_EQ(unique_vec[0], 0);
+    EXPECT_EQ(unique_vec[1], 1);
+
+    EXPECT_EQ(unique_vec.Pop(), 1);
+    ASSERT_EQ(unique_vec.Length(), 1u);
+    EXPECT_EQ(unique_vec.IsEmpty(), false);
+    EXPECT_EQ(unique_vec[0], 0);
+
+    EXPECT_EQ(unique_vec.Pop(), 0);
+    EXPECT_EQ(unique_vec.Length(), 0u);
+    EXPECT_EQ(unique_vec.IsEmpty(), true);
+}
+
+TEST(UniqueVectorTest, Data) {
+    UniqueVector<int, 4> unique_vec;
+    EXPECT_EQ(unique_vec.Data(), nullptr);
+
+    unique_vec.Add(42);
+    EXPECT_EQ(unique_vec.Data(), &unique_vec[0]);
+    EXPECT_EQ(*unique_vec.Data(), 42);
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/containers/vector.h b/src/tint/utils/containers/vector.h
new file mode 100644
index 0000000..b844e54
--- /dev/null
+++ b/src/tint/utils/containers/vector.h
@@ -0,0 +1,897 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_CONTAINERS_VECTOR_H_
+#define SRC_TINT_UTILS_CONTAINERS_VECTOR_H_
+
+#include <stddef.h>
+#include <stdint.h>
+#include <algorithm>
+#include <iterator>
+#include <new>
+#include <utility>
+#include <vector>
+
+#include "src/tint/utils/containers/slice.h"
+#include "src/tint/utils/debug/debug.h"
+#include "src/tint/utils/macros/compiler.h"
+#include "src/tint/utils/memory/bitcast.h"
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint::utils {
+
+/// Forward declarations
+template <typename>
+class VectorRef;
+template <typename>
+class VectorRef;
+
+}  // namespace tint::utils
+
+namespace tint::utils {
+
+/// Vector is a small-object-optimized, dynamically-sized vector of contigious elements of type T.
+///
+/// Vector will fit `N` elements internally before spilling to heap allocations. If `N` is greater
+/// than zero, the internal elements are stored in a 'small array' held internally by the Vector.
+///
+/// Vectors can be copied or moved.
+///
+/// Copying a vector will either copy to the 'small array' if the number of elements is equal to or
+/// less than N, otherwise elements will be copied into a new heap allocation.
+///
+/// Moving a vector will reassign ownership of the heap-allocation memory, if the source vector
+/// holds its elements in a heap allocation, otherwise a copy will be made as described above.
+///
+/// Vector is optimized for CPU performance over memory efficiency. For example:
+/// * Moving a vector that stores its elements in a heap allocation to another vector will simply
+///   assign the heap allocation, even if the target vector can hold the elements in its 'small
+///   array'. This reduces memory copying, but may incur additional memory usage.
+/// * Resizing, or popping elements from a vector that has spilled to a heap allocation does not
+///   revert back to using the 'small array'. Again, this is to reduce memory copying.
+template <typename T, size_t N>
+class Vector {
+  public:
+    /// Alias to `T*`.
+    using iterator = T*;
+    /// Alias to `const T*`.
+    using const_iterator = const T*;
+    /// Alias to `T`.
+    using value_type = T;
+    /// Value of `N`
+    static constexpr size_t static_length = N;
+
+    /// Constructor
+    Vector() = default;
+
+    /// Constructor
+    Vector(EmptyType) {}  // NOLINT(runtime/explicit)
+
+    /// Constructor
+    /// @param elements the elements to place into the vector
+    Vector(std::initializer_list<T> elements) {
+        Reserve(elements.size());
+        for (auto& el : elements) {
+            new (&impl_.slice.data[impl_.slice.len++]) T{el};
+        }
+    }
+
+    /// Copy constructor
+    /// @param other the vector to copy
+    Vector(const Vector& other) { Copy(other.impl_.slice); }
+
+    /// Move constructor
+    /// @param other the vector to move
+    Vector(Vector&& other) { MoveOrCopy(VectorRef<T>(std::move(other))); }
+
+    /// Copy constructor (differing N length)
+    /// @param other the vector to copy
+    template <size_t N2>
+    Vector(const Vector<T, N2>& other) {
+        Copy(other.impl_.slice);
+    }
+
+    /// Move constructor (differing N length)
+    /// @param other the vector to move
+    template <size_t N2>
+    Vector(Vector<T, N2>&& other) {
+        MoveOrCopy(VectorRef<T>(std::move(other)));
+    }
+
+    /// Copy constructor with covariance / const conversion
+    /// @param other the vector to copy
+    /// @see CanReinterpretSlice for rules about conversion
+    template <typename U,
+              size_t N2,
+              ReinterpretMode MODE,
+              typename = std::enable_if_t<CanReinterpretSlice<MODE, T, U>>>
+    Vector(const Vector<U, N2>& other) {  // NOLINT(runtime/explicit)
+        Copy(other.impl_.slice.template Reinterpret<T, MODE>);
+    }
+
+    /// Move constructor with covariance / const conversion
+    /// @param other the vector to move
+    /// @see CanReinterpretSlice for rules about conversion
+    template <typename U,
+              size_t N2,
+              ReinterpretMode MODE,
+              typename = std::enable_if_t<CanReinterpretSlice<MODE, T, U>>>
+    Vector(Vector<U, N2>&& other) {  // NOLINT(runtime/explicit)
+        MoveOrCopy(VectorRef<T>(std::move(other)));
+    }
+
+    /// Move constructor from a mutable vector reference
+    /// @param other the vector reference to move
+    Vector(VectorRef<T>&& other) { MoveOrCopy(std::move(other)); }  // NOLINT(runtime/explicit)
+
+    /// Copy constructor from an immutable vector reference
+    /// @param other the vector reference to copy
+    Vector(const VectorRef<T>& other) { Copy(other.slice_); }  // NOLINT(runtime/explicit)
+
+    /// Copy constructor from an immutable slice
+    /// @param other the slice to copy
+    Vector(const Slice<T>& other) { Copy(other); }  // NOLINT(runtime/explicit)
+
+    /// Destructor
+    ~Vector() { ClearAndFree(); }
+
+    /// Assignment operator
+    /// @param other the vector to copy
+    /// @returns this vector so calls can be chained
+    Vector& operator=(const Vector& other) {
+        if (&other != this) {
+            Copy(other.impl_.slice);
+        }
+        return *this;
+    }
+
+    /// Move operator
+    /// @param other the vector to move
+    /// @returns this vector so calls can be chained
+    Vector& operator=(Vector&& other) {
+        if (&other != this) {
+            MoveOrCopy(VectorRef<T>(std::move(other)));
+        }
+        return *this;
+    }
+
+    /// Assignment operator (differing N length)
+    /// @param other the vector to copy
+    /// @returns this vector so calls can be chained
+    template <size_t N2>
+    Vector& operator=(const Vector<T, N2>& other) {
+        Copy(other.impl_.slice);
+        return *this;
+    }
+
+    /// Move operator (differing N length)
+    /// @param other the vector to copy
+    /// @returns this vector so calls can be chained
+    template <size_t N2>
+    Vector& operator=(Vector<T, N2>&& other) {
+        MoveOrCopy(VectorRef<T>(std::move(other)));
+        return *this;
+    }
+
+    /// Assignment operator (differing N length)
+    /// @param other the vector reference to copy
+    /// @returns this vector so calls can be chained
+    Vector& operator=(const VectorRef<T>& other) {
+        if (&other.slice_ != &impl_.slice) {
+            Copy(other.slice_);
+        }
+        return *this;
+    }
+
+    /// Move operator (differing N length)
+    /// @param other the vector reference to copy
+    /// @returns this vector so calls can be chained
+    Vector& operator=(VectorRef<T>&& other) {
+        if (&other.slice_ != &impl_.slice) {
+            MoveOrCopy(std::move(other));
+        }
+        return *this;
+    }
+
+    /// Assignment operator for Slice
+    /// @param other the slice to copy
+    /// @returns this vector so calls can be chained
+    Vector& operator=(const Slice<T>& other) {
+        Copy(other);
+        return *this;
+    }
+
+    /// Index operator
+    /// @param i the element index. Must be less than `len`.
+    /// @returns a reference to the i'th element.
+    T& operator[](size_t i) {
+        TINT_ASSERT(Utils, i < Length());
+        return impl_.slice[i];
+    }
+
+    /// Index operator
+    /// @param i the element index. Must be less than `len`.
+    /// @returns a reference to the i'th element.
+    const T& operator[](size_t i) const {
+        TINT_ASSERT(Utils, i < Length());
+        return impl_.slice[i];
+    }
+
+    /// @return the number of elements in the vector
+    size_t Length() const { return impl_.slice.len; }
+
+    /// @return the number of elements that the vector could hold before a heap allocation needs to
+    /// be made
+    size_t Capacity() const { return impl_.slice.cap; }
+
+    /// Reserves memory to hold at least `new_cap` elements
+    /// @param new_cap the new vector capacity
+    void Reserve(size_t new_cap) {
+        if (new_cap > impl_.slice.cap) {
+            auto* old_data = impl_.slice.data;
+            impl_.Allocate(new_cap);
+            for (size_t i = 0; i < impl_.slice.len; i++) {
+                new (&impl_.slice.data[i]) T(std::move(old_data[i]));
+                old_data[i].~T();
+            }
+            impl_.Free(old_data);
+        }
+    }
+
+    /// Resizes the vector to the given length, expanding capacity if necessary.
+    /// New elements are zero-initialized
+    /// @param new_len the new vector length
+    void Resize(size_t new_len) {
+        Reserve(new_len);
+        for (size_t i = impl_.slice.len; i > new_len; i--) {  // Shrink
+            impl_.slice.data[i - 1].~T();
+        }
+        for (size_t i = impl_.slice.len; i < new_len; i++) {  // Grow
+            new (&impl_.slice.data[i]) T{};
+        }
+        impl_.slice.len = new_len;
+    }
+
+    /// Resizes the vector to the given length, expanding capacity if necessary.
+    /// @param new_len the new vector length
+    /// @param value the value to copy into the new elements
+    void Resize(size_t new_len, const T& value) {
+        Reserve(new_len);
+        for (size_t i = impl_.slice.len; i > new_len; i--) {  // Shrink
+            impl_.slice.data[i - 1].~T();
+        }
+        for (size_t i = impl_.slice.len; i < new_len; i++) {  // Grow
+            new (&impl_.slice.data[i]) T{value};
+        }
+        impl_.slice.len = new_len;
+    }
+
+    /// Copies all the elements from `other` to this vector, replacing the content of this vector.
+    /// @param other the
+    template <typename T2, size_t N2>
+    void Copy(const Vector<T2, N2>& other) {
+        Copy(other.impl_.slice);
+    }
+
+    /// Clears all elements from the vector, keeping the capacity the same.
+    void Clear() {
+        TINT_BEGIN_DISABLE_WARNING(MAYBE_UNINITIALIZED);
+        for (size_t i = 0; i < impl_.slice.len; i++) {
+            impl_.slice.data[i].~T();
+        }
+        impl_.slice.len = 0;
+        TINT_END_DISABLE_WARNING(MAYBE_UNINITIALIZED);
+    }
+
+    /// Appends a new element to the vector.
+    /// @param el the element to copy to the vector.
+    void Push(const T& el) {
+        if (impl_.slice.len >= impl_.slice.cap) {
+            Grow();
+        }
+        new (&impl_.slice.data[impl_.slice.len++]) T(el);
+    }
+
+    /// Appends a new element to the vector.
+    /// @param el the element to move to the vector.
+    void Push(T&& el) {
+        if (impl_.slice.len >= impl_.slice.cap) {
+            Grow();
+        }
+        new (&impl_.slice.data[impl_.slice.len++]) T(std::move(el));
+    }
+
+    /// Appends a new element to the vector.
+    /// @param args the arguments to pass to the element constructor.
+    template <typename... ARGS>
+    void Emplace(ARGS&&... args) {
+        if (impl_.slice.len >= impl_.slice.cap) {
+            Grow();
+        }
+        new (&impl_.slice.data[impl_.slice.len++]) T{std::forward<ARGS>(args)...};
+    }
+
+    /// Removes and returns the last element from the vector.
+    /// @returns the popped element
+    T Pop() {
+        TINT_ASSERT(Utils, !IsEmpty());
+        auto& el = impl_.slice.data[--impl_.slice.len];
+        auto val = std::move(el);
+        el.~T();
+        return val;
+    }
+
+    /// Inserts the element @p element before the element at @p before
+    /// @param before the index of the element to insert before
+    /// @param element the element to insert
+    template <typename EL>
+    void Insert(size_t before, EL&& element) {
+        TINT_ASSERT(Utils, before <= Length());
+        size_t n = Length();
+        Resize(Length() + 1);
+        // Shuffle
+        for (size_t i = n; i > before; i--) {
+            auto& src = impl_.slice.data[i - 1];
+            auto& dst = impl_.slice.data[i];
+            dst = std::move(src);
+        }
+        // Insert
+        impl_.slice.data[before] = std::forward<EL>(element);
+    }
+
+    /// Removes @p count elements from the vector
+    /// @param start the index of the first element to remove
+    /// @param count the number of elements to remove
+    void Erase(size_t start, size_t count = 1) {
+        TINT_ASSERT(Utils, start < Length());
+        TINT_ASSERT(Utils, (start + count) <= Length());
+        // Shuffle
+        for (size_t i = start + count; i < impl_.slice.len; i++) {
+            auto& src = impl_.slice.data[i];
+            auto& dst = impl_.slice.data[i - count];
+            dst = std::move(src);
+        }
+        // Pop
+        for (size_t i = 0; i < count; i++) {
+            auto& el = impl_.slice.data[--impl_.slice.len];
+            el.~T();
+        }
+    }
+
+    /// Sort sorts the vector in-place using the predicate function @p pred
+    /// @param pred a function that has the signature `bool(const T& a, const T& b)` which returns
+    /// true if `a` is ordered before `b`.
+    template <typename PREDICATE>
+    void Sort(PREDICATE&& pred) {
+        std::sort(begin(), end(), std::forward<PREDICATE>(pred));
+    }
+
+    /// Sort sorts the vector in-place using `T::operator<()`
+    void Sort() {
+        Sort([](auto& a, auto& b) { return a < b; });
+    }
+
+    /// Reverse reversed the vector in-place
+    void Reverse() {
+        size_t n = Length();
+        size_t mid = n / 2;
+        auto& self = *this;
+        for (size_t i = 0; i < mid; i++) {
+            std::swap(self[i], self[n - i - 1]);
+        }
+    }
+
+    /// @returns true if the predicate function returns true for any of the elements of the vector
+    /// @param pred a function-like with the signature `bool(T)`
+    template <typename PREDICATE>
+    bool Any(PREDICATE&& pred) const {
+        return std::any_of(begin(), end(), std::forward<PREDICATE>(pred));
+    }
+
+    /// @returns false if the predicate function returns false for any of the elements of the vector
+    /// @param pred a function-like with the signature `bool(T)`
+    template <typename PREDICATE>
+    bool All(PREDICATE&& pred) const {
+        return std::all_of(begin(), end(), std::forward<PREDICATE>(pred));
+    }
+
+    /// @returns true if the vector is empty.
+    bool IsEmpty() const { return impl_.slice.len == 0; }
+
+    /// @returns a reference to the first element in the vector
+    T& Front() { return impl_.slice.Front(); }
+
+    /// @returns a reference to the first element in the vector
+    const T& Front() const { return impl_.slice.Front(); }
+
+    /// @returns a reference to the last element in the vector
+    T& Back() { return impl_.slice.Back(); }
+
+    /// @returns a reference to the last element in the vector
+    const T& Back() const { return impl_.slice.Back(); }
+
+    /// @returns a pointer to the first element in the vector
+    T* begin() { return impl_.slice.begin(); }
+
+    /// @returns a pointer to the first element in the vector
+    const T* begin() const { return impl_.slice.begin(); }
+
+    /// @returns a pointer to one past the last element in the vector
+    T* end() { return impl_.slice.end(); }
+
+    /// @returns a pointer to one past the last element in the vector
+    const T* end() const { return impl_.slice.end(); }
+
+    /// @returns a reverse iterator starting with the last element in the vector
+    auto rbegin() { return impl_.slice.rbegin(); }
+
+    /// @returns a reverse iterator starting with the last element in the vector
+    auto rbegin() const { return impl_.slice.rbegin(); }
+
+    /// @returns the end for a reverse iterator
+    auto rend() { return impl_.slice.rend(); }
+
+    /// @returns the end for a reverse iterator
+    auto rend() const { return impl_.slice.rend(); }
+
+    /// Equality operator
+    /// @param other the other vector
+    /// @returns true if this vector is the same length as `other`, and all elements are equal.
+    template <typename T2, size_t N2>
+    bool operator==(const Vector<T2, N2>& other) const {
+        const size_t len = Length();
+        if (len != other.Length()) {
+            return false;
+        }
+        for (size_t i = 0; i < len; i++) {
+            if ((*this)[i] != other[i]) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /// Inequality operator
+    /// @param other the other vector
+    /// @returns true if this vector is not the same length as `other`, or all elements are not
+    ///          equal.
+    template <typename T2, size_t N2>
+    bool operator!=(const Vector<T2, N2>& other) const {
+        return !(*this == other);
+    }
+
+    /// @returns the internal slice of the vector
+    utils::Slice<T> Slice() { return impl_.slice; }
+
+    /// @returns the internal slice of the vector
+    utils::Slice<const T> Slice() const { return impl_.slice; }
+
+  private:
+    /// Friend class (differing specializations of this class)
+    template <typename, size_t>
+    friend class Vector;
+
+    /// Friend class
+    template <typename>
+    friend class VectorRef;
+
+    /// Friend class
+    template <typename>
+    friend class VectorRef;
+
+    template <typename... Ts>
+    void AppendVariadic(Ts&&... args) {
+        ((new (&impl_.slice.data[impl_.slice.len++]) T(std::forward<Ts>(args))), ...);
+    }
+
+    /// Expands the capacity of the vector
+    void Grow() { Reserve(std::max(impl_.slice.cap, static_cast<size_t>(1)) * 2); }
+
+    /// Moves 'other' to this vector, if possible, otherwise performs a copy.
+    void MoveOrCopy(VectorRef<T>&& other) {
+        if (other.can_move_) {
+            ClearAndFree();
+            impl_.slice = other.slice_;
+            other.slice_ = {};
+        } else {
+            Copy(other.slice_);
+        }
+    }
+
+    /// Copies all the elements from `other` to this vector, replacing the content of this vector.
+    /// @param other the
+    void Copy(const utils::Slice<T>& other) {
+        if (impl_.slice.cap < other.len) {
+            ClearAndFree();
+            impl_.Allocate(other.len);
+        } else {
+            Clear();
+        }
+
+        impl_.slice.len = other.len;
+        for (size_t i = 0; i < impl_.slice.len; i++) {
+            new (&impl_.slice.data[i]) T{other.data[i]};
+        }
+    }
+
+    /// Clears the vector, then frees the slice data.
+    void ClearAndFree() {
+        Clear();
+        impl_.Free(impl_.slice.data);
+    }
+
+    /// True if this vector uses a small array for small object optimization.
+    constexpr static bool HasSmallArray = N > 0;
+
+    /// A structure that has the same size and alignment as T.
+    /// Replacement for std::aligned_storage as this is broken on earlier versions of MSVC.
+    struct alignas(alignof(T)) TStorage {
+        /// @returns the storage reinterpreted as a T*
+        T* Get() { return Bitcast<T*>(&data[0]); }
+        /// @returns the storage reinterpreted as a T*
+        const T* Get() const { return Bitcast<const T*>(&data[0]); }
+        /// Byte array of length sizeof(T)
+        uint8_t data[sizeof(T)];
+    };
+
+    /// The internal structure for the vector with a small array.
+    struct ImplWithSmallArray {
+        TStorage small_arr[N];
+        utils::Slice<T> slice = {small_arr[0].Get(), 0, N};
+
+        /// Allocates a new vector of `T` either from #small_arr, or from the heap, then assigns the
+        /// pointer it to #slice.data, and updates #slice.cap.
+        void Allocate(size_t new_cap) {
+            if (new_cap < N) {
+                slice.data = small_arr[0].Get();
+                slice.cap = N;
+            } else {
+                slice.data = Bitcast<T*>(new TStorage[new_cap]);
+                slice.cap = new_cap;
+            }
+        }
+
+        /// Frees `data`, if not nullptr and isn't a pointer to #small_arr
+        void Free(T* data) const {
+            if (data && data != small_arr[0].Get()) {
+                delete[] Bitcast<TStorage*>(data);
+            }
+        }
+
+        /// Indicates whether the slice structure can be std::move()d.
+        /// @returns true if #slice.data does not point to #small_arr
+        bool CanMove() const { return slice.data != small_arr[0].Get(); }
+    };
+
+    /// The internal structure for the vector without a small array.
+    struct ImplWithoutSmallArray {
+        utils::Slice<T> slice = Empty;
+
+        /// Allocates a new vector of `T` and assigns it to #slice.data, and updates #slice.cap.
+        void Allocate(size_t new_cap) {
+            slice.data = Bitcast<T*>(new TStorage[new_cap]);
+            slice.cap = new_cap;
+        }
+
+        /// Frees `data`, if not nullptr.
+        void Free(T* data) const {
+            if (data) {
+                delete[] Bitcast<TStorage*>(data);
+            }
+        }
+
+        /// Indicates whether the slice structure can be std::move()d.
+        /// @returns true
+        bool CanMove() const { return true; }
+    };
+
+    /// Either a ImplWithSmallArray or ImplWithoutSmallArray based on N.
+    std::conditional_t<HasSmallArray, ImplWithSmallArray, ImplWithoutSmallArray> impl_;
+};
+
+namespace detail {
+
+/// Helper for determining the Vector element type (`T`) from the vector's constuctor arguments
+/// @tparam IS_CASTABLE true if the types of `Ts` derive from CastableBase
+/// @tparam Ts the vector constructor argument types to infer the vector element type from.
+template <bool IS_CASTABLE, typename... Ts>
+struct VectorCommonType;
+
+/// VectorCommonType specialization for non-castable types.
+template <typename... Ts>
+struct VectorCommonType</*IS_CASTABLE*/ false, Ts...> {
+    /// The common T type to use for the vector
+    using type = std::common_type_t<Ts...>;
+};
+
+/// VectorCommonType specialization for castable types.
+template <typename... Ts>
+struct VectorCommonType</*IS_CASTABLE*/ true, Ts...> {
+    /// The common Castable type (excluding pointer)
+    using common_ty = CastableCommonBase<std::remove_pointer_t<Ts>...>;
+    /// The common T type to use for the vector
+    using type = std::conditional_t<(std::is_const_v<std::remove_pointer_t<Ts>> || ...),
+                                    const common_ty*,
+                                    common_ty*>;
+};
+
+}  // namespace detail
+
+/// Helper for determining the Vector element type (`T`) from the vector's constuctor arguments
+template <typename... Ts>
+using VectorCommonType =
+    typename utils::detail::VectorCommonType<IsCastable<std::remove_pointer_t<Ts>...>, Ts...>::type;
+
+/// Deduction guide for Vector
+template <typename... Ts>
+Vector(Ts...) -> Vector<VectorCommonType<Ts...>, sizeof...(Ts)>;
+
+/// VectorRef is a weak reference to a Vector, used to pass vectors as parameters, avoiding copies
+/// between the caller and the callee, or as an non-static sized accessor on a vector. VectorRef can
+/// accept a Vector of any 'N' value, decoupling the caller's vector internal size from the callee's
+/// vector size. A VectorRef tracks the usage of moves either side of the call. If at the call site,
+/// a Vector argument is moved to a VectorRef parameter, and within the callee, the VectorRef
+/// parameter is moved to a Vector, then the Vector heap allocation will be moved. For example:
+///
+/// ```
+///     void func_a() {
+///        Vector<std::string, 4> vec;
+///        // logic to populate 'vec'.
+///        func_b(std::move(vec)); // Constructs a VectorRef tracking the move here.
+///     }
+///
+///     void func_b(VectorRef<std::string> vec_ref) {
+///        // A move was made when calling func_b, so the vector can be moved instead of copied.
+///        Vector<std::string, 2> vec(std::move(vec_ref));
+///     }
+/// ```
+///
+/// Aside from this move pattern, a VectorRef provides an immutable reference to the Vector.
+template <typename T>
+class VectorRef {
+    /// @returns an empty slice.
+    static utils::Slice<T>& EmptySlice() {
+        static utils::Slice<T> empty;
+        return empty;
+    }
+
+  public:
+    /// Type of `T`.
+    using value_type = T;
+
+    /// Constructor - empty reference
+    VectorRef() : slice_(EmptySlice()) {}
+
+    /// Constructor
+    VectorRef(EmptyType) : slice_(EmptySlice()) {}  // NOLINT(runtime/explicit)
+
+    /// Constructor from a Slice
+    /// @param slice the slice
+    VectorRef(utils::Slice<T>& slice)  // NOLINT(runtime/explicit)
+        : slice_(slice) {}
+
+    /// Constructor from a Vector
+    /// @param vector the vector to create a reference of
+    template <size_t N>
+    VectorRef(Vector<T, N>& vector)  // NOLINT(runtime/explicit)
+        : slice_(vector.impl_.slice) {}
+
+    /// Constructor from a const Vector
+    /// @param vector the vector to create a reference of
+    template <size_t N>
+    VectorRef(const Vector<T, N>& vector)  // NOLINT(runtime/explicit)
+        : slice_(const_cast<utils::Slice<T>&>(vector.impl_.slice)) {}
+
+    /// Constructor from a moved Vector
+    /// @param vector the vector being moved
+    template <size_t N>
+    VectorRef(Vector<T, N>&& vector)  // NOLINT(runtime/explicit)
+        : slice_(vector.impl_.slice), can_move_(vector.impl_.CanMove()) {}
+
+    /// Copy constructor
+    /// @param other the vector reference
+    VectorRef(const VectorRef& other) : slice_(other.slice_) {}
+
+    /// Move constructor
+    /// @param other the vector reference
+    VectorRef(VectorRef&& other) = default;
+
+    /// Copy constructor with covariance / const conversion
+    /// @param other the other vector reference
+    template <typename U,
+              typename = std::enable_if_t<CanReinterpretSlice<ReinterpretMode::kSafe, T, U>>>
+    VectorRef(const VectorRef<U>& other)  // NOLINT(runtime/explicit)
+        : slice_(other.slice_.template Reinterpret<T>()) {}
+
+    /// Move constructor with covariance / const conversion
+    /// @param other the vector reference
+    template <typename U,
+              typename = std::enable_if_t<CanReinterpretSlice<ReinterpretMode::kSafe, T, U>>>
+    VectorRef(VectorRef<U>&& other)  // NOLINT(runtime/explicit)
+        : slice_(other.slice_.template Reinterpret<T>()), can_move_(other.can_move_) {}
+
+    /// Constructor from a Vector with covariance / const conversion
+    /// @param vector the vector to create a reference of
+    /// @see CanReinterpretSlice for rules about conversion
+    template <typename U,
+              size_t N,
+              typename = std::enable_if_t<CanReinterpretSlice<ReinterpretMode::kSafe, T, U>>>
+    VectorRef(Vector<U, N>& vector)  // NOLINT(runtime/explicit)
+        : slice_(vector.impl_.slice.template Reinterpret<T>()) {}
+
+    /// Constructor from a moved Vector with covariance / const conversion
+    /// @param vector the vector to create a reference of
+    /// @see CanReinterpretSlice for rules about conversion
+    template <typename U,
+              size_t N,
+              typename = std::enable_if_t<CanReinterpretSlice<ReinterpretMode::kSafe, T, U>>>
+    VectorRef(Vector<U, N>&& vector)  // NOLINT(runtime/explicit)
+        : slice_(vector.impl_.slice.template Reinterpret<T>()), can_move_(vector.impl_.CanMove()) {}
+
+    /// Index operator
+    /// @param i the element index. Must be less than `len`.
+    /// @returns a reference to the i'th element.
+    const T& operator[](size_t i) const { return slice_[i]; }
+
+    /// @return the number of elements in the vector
+    size_t Length() const { return slice_.len; }
+
+    /// @return the number of elements that the vector could hold before a heap allocation needs to
+    /// be made
+    size_t Capacity() const { return slice_.cap; }
+
+    /// @return a reinterpretation of this VectorRef as elements of type U.
+    /// @note this is doing a reinterpret_cast of elements. It is up to the caller to ensure that
+    /// this is a safe operation.
+    template <typename U>
+    VectorRef<U> ReinterpretCast() const {
+        return {slice_.template Reinterpret<U, ReinterpretMode::kUnsafe>()};
+    }
+
+    /// @returns the internal slice of the vector
+    utils::Slice<T> Slice() { return slice_; }
+
+    /// @returns true if the vector is empty.
+    bool IsEmpty() const { return slice_.len == 0; }
+
+    /// @returns a reference to the first element in the vector
+    const T& Front() const { return slice_.Front(); }
+
+    /// @returns a reference to the last element in the vector
+    const T& Back() const { return slice_.Back(); }
+
+    /// @returns a pointer to the first element in the vector
+    const T* begin() const { return slice_.begin(); }
+
+    /// @returns a pointer to one past the last element in the vector
+    const T* end() const { return slice_.end(); }
+
+    /// @returns a reverse iterator starting with the last element in the vector
+    auto rbegin() const { return slice_.rbegin(); }
+
+    /// @returns the end for a reverse iterator
+    auto rend() const { return slice_.rend(); }
+
+  private:
+    /// Friend class
+    template <typename, size_t>
+    friend class Vector;
+
+    /// Friend class
+    template <typename>
+    friend class VectorRef;
+
+    /// Friend class
+    template <typename>
+    friend class VectorRef;
+
+    /// The slice of the vector being referenced.
+    utils::Slice<T>& slice_;
+    /// Whether the slice data is passed by r-value reference, and can be moved.
+    bool can_move_ = false;
+};
+
+/// Helper for converting a Vector to a std::vector.
+/// @note This helper exists to help code migration. Avoid if possible.
+template <typename T, size_t N>
+std::vector<T> ToStdVector(const Vector<T, N>& vector) {
+    std::vector<T> out;
+    out.reserve(vector.Length());
+    for (auto& el : vector) {
+        out.emplace_back(el);
+    }
+    return out;
+}
+
+/// Helper for converting a std::vector to a Vector.
+/// @note This helper exists to help code migration. Avoid if possible.
+template <typename T, size_t N = 0>
+Vector<T, N> ToVector(const std::vector<T>& vector) {
+    Vector<T, N> out;
+    out.Reserve(vector.size());
+    for (auto& el : vector) {
+        out.Push(el);
+    }
+    return out;
+}
+
+/// Prints the vector @p vec to @p o
+/// @param o the stream to write to
+/// @param vec the vector
+/// @return the stream so calls can be chained
+template <typename T, size_t N>
+inline StringStream& operator<<(StringStream& o, const Vector<T, N>& vec) {
+    o << "[";
+    bool first = true;
+    for (auto& el : vec) {
+        if (!first) {
+            o << ", ";
+        }
+        first = false;
+        o << el;
+    }
+    o << "]";
+    return o;
+}
+
+/// Prints the vector @p vec to @p o
+/// @param o the stream to write to
+/// @param vec the vector reference
+/// @return the stream so calls can be chained
+template <typename T>
+inline StringStream& operator<<(StringStream& o, VectorRef<T> vec) {
+    o << "[";
+    bool first = true;
+    for (auto& el : vec) {
+        if (!first) {
+            o << ", ";
+        }
+        first = false;
+        o << el;
+    }
+    o << "]";
+    return o;
+}
+
+namespace detail {
+
+/// IsVectorLike<T>::value is true if T is a utils::Vector or utils::VectorRef.
+template <typename T>
+struct IsVectorLike {
+    /// Non-specialized form of IsVectorLike defaults to false
+    static constexpr bool value = false;
+};
+
+/// IsVectorLike specialization for utils::Vector
+template <typename T, size_t N>
+struct IsVectorLike<utils::Vector<T, N>> {
+    /// True for the IsVectorLike specialization of utils::Vector
+    static constexpr bool value = true;
+};
+
+/// IsVectorLike specialization for utils::VectorRef
+template <typename T>
+struct IsVectorLike<utils::VectorRef<T>> {
+    /// True for the IsVectorLike specialization of utils::VectorRef
+    static constexpr bool value = true;
+};
+}  // namespace detail
+
+/// True if T is a Vector<T, N> or VectorRef<T>
+template <typename T>
+static constexpr bool IsVectorLike = utils::detail::IsVectorLike<T>::value;
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_CONTAINERS_VECTOR_H_
diff --git a/src/tint/utils/containers/vector_test.cc b/src/tint/utils/containers/vector_test.cc
new file mode 100644
index 0000000..0883eab
--- /dev/null
+++ b/src/tint/utils/containers/vector_test.cc
@@ -0,0 +1,2278 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/containers/vector.h"
+
+#include <string>
+#include <tuple>
+
+#include "gmock/gmock.h"
+
+#include "src/tint/utils/containers/predicates.h"
+#include "src/tint/utils/memory/bitcast.h"
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint::utils {
+namespace {
+
+class C0 : public Castable<C0> {};
+class C1 : public Castable<C1, C0> {};
+class C2a : public Castable<C2a, C1> {};
+class C2b : public Castable<C2b, C1> {};
+
+/// @returns true if the address of el is within the memory of the vector vec.
+template <typename T, size_t N, typename E>
+bool IsInternal(Vector<T, N>& vec, E& el) {
+    auto ptr = Bitcast<uintptr_t>(&el);
+    auto base = Bitcast<uintptr_t>(&vec);
+    return ptr >= base && ptr < base + sizeof(vec);
+}
+
+/// @returns true if all elements of the vector `vec` are held within the memory of `vec`.
+template <typename T, size_t N>
+bool AllInternallyHeld(Vector<T, N>& vec) {
+    for (auto& el : vec) {
+        if (!IsInternal(vec, el)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/// @returns true if all elements of the vector `vec` are held outside the memory of `vec`.
+template <typename T, size_t N>
+bool AllExternallyHeld(Vector<T, N>& vec) {
+    for (auto& el : vec) {
+        if (IsInternal(vec, el)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Static asserts
+////////////////////////////////////////////////////////////////////////////////
+static_assert(std::is_same_v<VectorCommonType<int>, int>);
+static_assert(std::is_same_v<VectorCommonType<int, int>, int>);
+static_assert(std::is_same_v<VectorCommonType<int, float>, float>);
+
+static_assert(std::is_same_v<VectorCommonType<C0*>, C0*>);
+static_assert(std::is_same_v<VectorCommonType<const C0*>, const C0*>);
+
+static_assert(std::is_same_v<VectorCommonType<C0*, C1*>, C0*>);
+static_assert(std::is_same_v<VectorCommonType<const C0*, C1*>, const C0*>);
+static_assert(std::is_same_v<VectorCommonType<C0*, const C1*>, const C0*>);
+static_assert(std::is_same_v<VectorCommonType<const C0*, const C1*>, const C0*>);
+
+static_assert(std::is_same_v<VectorCommonType<C2a*, C2b*>, C1*>);
+static_assert(std::is_same_v<VectorCommonType<const C2a*, C2b*>, const C1*>);
+static_assert(std::is_same_v<VectorCommonType<C2a*, const C2b*>, const C1*>);
+static_assert(std::is_same_v<VectorCommonType<const C2a*, const C2b*>, const C1*>);
+
+static_assert(IsVectorLike<Vector<int, 3>>);
+static_assert(IsVectorLike<VectorRef<int>>);
+static_assert(!IsVectorLike<int>);
+
+////////////////////////////////////////////////////////////////////////////////
+// TintVectorTest
+////////////////////////////////////////////////////////////////////////////////
+TEST(TintVectorTest, SmallArray_Empty) {
+    Vector<int, 2> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+}
+
+TEST(TintVectorTest, NoSmallArray) {
+    Vector<int, 0> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 0u);
+}
+
+TEST(TintVectorTest, Empty_SmallArray_Empty) {
+    Vector<int, 2> vec(Empty);
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+}
+
+TEST(TintVectorTest, Empty_NoSmallArray) {
+    Vector<int, 0> vec(Empty);
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 0u);
+}
+
+TEST(TintVectorTest, InitializerList_NoSpill) {
+    Vector<std::string, 2> vec{"one", "two"};
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "one");
+    EXPECT_EQ(vec[1], "two");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InitializerList_WithSpill) {
+    Vector<std::string, 2> vec{"one", "two", "three"};
+    EXPECT_EQ(vec.Length(), 3u);
+    EXPECT_EQ(vec.Capacity(), 3u);
+    EXPECT_EQ(vec[0], "one");
+    EXPECT_EQ(vec[1], "two");
+    EXPECT_EQ(vec[2], "three");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InitializerList_NoSmallArray) {
+    Vector<std::string, 0> vec{"one", "two"};
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "one");
+    EXPECT_EQ(vec[1], "two");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, Push_NoSmallArray) {
+    Vector<std::string, 0> vec;
+    vec.Push("one");
+    vec.Push("two");
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "one");
+    EXPECT_EQ(vec[1], "two");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, Insert) {
+    Vector<std::string, 3> vec;
+    EXPECT_THAT(vec, testing::ElementsAre());
+
+    vec.Insert(0, "six");
+    EXPECT_THAT(vec, testing::ElementsAre("six"));
+
+    vec.Insert(0, "three");
+    EXPECT_THAT(vec, testing::ElementsAre("three", "six"));
+
+    vec.Insert(1, "five");
+    EXPECT_THAT(vec, testing::ElementsAre("three", "five", "six"));
+
+    vec.Insert(0, "two");
+    EXPECT_THAT(vec, testing::ElementsAre("two", "three", "five", "six"));
+
+    vec.Insert(2, "four");
+    EXPECT_THAT(vec, testing::ElementsAre("two", "three", "four", "five", "six"));
+
+    vec.Insert(0, "one");
+    EXPECT_THAT(vec, testing::ElementsAre("one", "two", "three", "four", "five", "six"));
+}
+
+TEST(TintVectorTest, Erase_Front) {
+    Vector<std::string, 3> vec;
+    vec.Push("one");
+    vec.Push("two");
+    vec.Push("three");
+    vec.Push("four");
+    EXPECT_EQ(vec.Length(), 4u);
+
+    vec.Erase(0);
+    EXPECT_EQ(vec.Length(), 3u);
+    EXPECT_EQ(vec[0], "two");
+    EXPECT_EQ(vec[1], "three");
+    EXPECT_EQ(vec[2], "four");
+
+    vec.Erase(0, 1);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec[0], "three");
+    EXPECT_EQ(vec[1], "four");
+
+    vec.Erase(0, 2);
+    EXPECT_EQ(vec.Length(), 0u);
+}
+
+TEST(TintVectorTest, Erase_Mid) {
+    Vector<std::string, 5> vec;
+    vec.Push("one");
+    vec.Push("two");
+    vec.Push("three");
+    vec.Push("four");
+    vec.Push("five");
+    EXPECT_EQ(vec.Length(), 5u);
+
+    vec.Erase(1);
+    EXPECT_EQ(vec.Length(), 4u);
+    EXPECT_EQ(vec[0], "one");
+    EXPECT_EQ(vec[1], "three");
+    EXPECT_EQ(vec[2], "four");
+    EXPECT_EQ(vec[3], "five");
+
+    vec.Erase(1, 2);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec[0], "one");
+    EXPECT_EQ(vec[1], "five");
+}
+
+TEST(TintVectorTest, Erase_Back) {
+    Vector<std::string, 3> vec;
+    vec.Push("one");
+    vec.Push("two");
+    vec.Push("three");
+    vec.Push("four");
+    EXPECT_EQ(vec.Length(), 4u);
+
+    vec.Erase(3);
+    EXPECT_EQ(vec.Length(), 3u);
+    EXPECT_EQ(vec[0], "one");
+    EXPECT_EQ(vec[1], "two");
+    EXPECT_EQ(vec[2], "three");
+
+    vec.Erase(1, 2);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec[0], "one");
+}
+
+TEST(TintVectorTest, InferTN_1CString) {
+    auto vec = Vector{"one"};
+    static_assert(std::is_same_v<decltype(vec)::value_type, const char*>);
+    static_assert(decltype(vec)::static_length == 1u);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec.Capacity(), 1u);
+    EXPECT_STREQ(vec[0], "one");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InferTN_2CStrings) {
+    auto vec = Vector{"one", "two"};
+    static_assert(std::is_same_v<decltype(vec)::value_type, const char*>);
+    static_assert(decltype(vec)::static_length == 2u);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_STREQ(vec[0], "one");
+    EXPECT_STREQ(vec[1], "two");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InferTN_IntFloat) {
+    auto vec = Vector{1, 2.0f};
+    static_assert(std::is_same_v<decltype(vec)::value_type, float>);
+    static_assert(decltype(vec)::static_length == 2u);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], 1.0f);
+    EXPECT_EQ(vec[1], 2.0f);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InferTN_IntDoubleIntDouble) {
+    auto vec = Vector{1, 2.0, 3, 4.0};
+    static_assert(std::is_same_v<decltype(vec)::value_type, double>);
+    static_assert(decltype(vec)::static_length == 4u);
+    EXPECT_EQ(vec.Length(), 4u);
+    EXPECT_EQ(vec.Capacity(), 4u);
+    EXPECT_EQ(vec[0], 1.0);
+    EXPECT_EQ(vec[1], 2.0);
+    EXPECT_EQ(vec[2], 3.0);
+    EXPECT_EQ(vec[3], 4.0);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InferTN_C0) {
+    C0 c0;
+    auto vec = Vector{&c0};
+    static_assert(std::is_same_v<decltype(vec)::value_type, C0*>);
+    static_assert(decltype(vec)::static_length == 1u);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec.Capacity(), 1u);
+    EXPECT_EQ(vec[0], &c0);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InferTN_ConstC0) {
+    const C0 c0;
+    auto vec = Vector{&c0};
+    static_assert(std::is_same_v<decltype(vec)::value_type, const C0*>);
+    static_assert(decltype(vec)::static_length == 1u);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec.Capacity(), 1u);
+    EXPECT_EQ(vec[0], &c0);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InferTN_C0C1) {
+    C0 c0;
+    C1 c1;
+    auto vec = Vector{&c0, &c1};
+    static_assert(std::is_same_v<decltype(vec)::value_type, C0*>);
+    static_assert(decltype(vec)::static_length == 2u);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], &c0);
+    EXPECT_EQ(vec[1], &c1);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InferTN_ConstC0C1) {
+    const C0 c0;
+    C1 c1;
+    auto vec = Vector{&c0, &c1};
+    static_assert(std::is_same_v<decltype(vec)::value_type, const C0*>);
+    static_assert(decltype(vec)::static_length == 2u);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], &c0);
+    EXPECT_EQ(vec[1], &c1);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InferTN_C0ConstC1) {
+    C0 c0;
+    const C1 c1;
+    auto vec = Vector{&c0, &c1};
+    static_assert(std::is_same_v<decltype(vec)::value_type, const C0*>);
+    static_assert(decltype(vec)::static_length == 2u);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], &c0);
+    EXPECT_EQ(vec[1], &c1);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InferTN_ConstC0ConstC1) {
+    const C0 c0;
+    const C1 c1;
+    auto vec = Vector{&c0, &c1};
+    static_assert(std::is_same_v<decltype(vec)::value_type, const C0*>);
+    static_assert(decltype(vec)::static_length == 2u);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], &c0);
+    EXPECT_EQ(vec[1], &c1);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InferTN_C2aC2b) {
+    C2a c2a;
+    C2b c2b;
+    auto vec = Vector{&c2a, &c2b};
+    static_assert(std::is_same_v<decltype(vec)::value_type, C1*>);
+    static_assert(decltype(vec)::static_length == 2u);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], &c2a);
+    EXPECT_EQ(vec[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InferTN_ConstC2aC2b) {
+    const C2a c2a;
+    C2b c2b;
+    auto vec = Vector{&c2a, &c2b};
+    static_assert(std::is_same_v<decltype(vec)::value_type, const C1*>);
+    static_assert(decltype(vec)::static_length == 2u);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], &c2a);
+    EXPECT_EQ(vec[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InferTN_C2aConstC2b) {
+    C2a c2a;
+    const C2b c2b;
+    auto vec = Vector{&c2a, &c2b};
+    static_assert(std::is_same_v<decltype(vec)::value_type, const C1*>);
+    static_assert(decltype(vec)::static_length == 2u);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], &c2a);
+    EXPECT_EQ(vec[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, InferTN_ConstC2aConstC2b) {
+    const C2a c2a;
+    const C2b c2b;
+    auto vec = Vector{&c2a, &c2b};
+    static_assert(std::is_same_v<decltype(vec)::value_type, const C1*>);
+    static_assert(decltype(vec)::static_length == 2u);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], &c2a);
+    EXPECT_EQ(vec[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, CopyVector_NoSpill_N2_to_N2) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 2> vec_b(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyVector_WithSpill_N2_to_N2) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 2> vec_b(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyVector_NoSpill_N2_to_N1) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 1> vec_b(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyVector_WithSpill_N2_to_N1) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 1> vec_b(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyVector_NoSpill_N2_to_N3) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 3> vec_b(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyVector_WithSpill_N2_to_N3) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 3> vec_b(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyVector_NoMoveUpcast_NoSpill) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 2> vec_a{&c2a, &c2b};
+    Vector<C0*, 2> vec_b(vec_a);  // No move
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorTest, CopyVector_NoMoveUpcast_WithSpill) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    Vector<C0*, 2> vec_b(vec_a);  // No move
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorTest, CopyVector_NoMoveAddConst_NoSpill) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 2> vec_a{&c2a, &c2b};
+    Vector<const C1*, 2> vec_b(vec_a);  // No move
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorTest, CopyVector_NoMoveAddConst_WithSpill) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    Vector<const C1*, 2> vec_b(vec_a);  // No move
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorTest, CopyVector_NoMoveUpcastAndAddConst_NoSpill) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 2> vec_a{&c2a, &c2b};
+    Vector<const C0*, 2> vec_b(vec_a);  // No move
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorTest, CopyVector_NoMoveUpcastAndAddConst_WithSpill) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    Vector<const C0*, 2> vec_b(vec_a);  // No move
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorTest, MoveVector_NoSpill_N2_to_N2) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 2> vec_b(std::move(vec_a));
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveVector_WithSpill_N2_to_N2) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 2> vec_b(std::move(vec_a));
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveVector_NoSpill_N2_to_N1) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 1> vec_b(std::move(vec_a));
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveVector_WithSpill_N2_to_N1) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 1> vec_b(std::move(vec_a));
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveVector_NoSpill_N2_to_N3) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 3> vec_b(std::move(vec_a));
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveVector_WithSpill_N2_to_N3) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 3> vec_b(std::move(vec_a));
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveVector_Upcast_NoSpill) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 2> vec_a{&c2a, &c2b};
+    Vector<C0*, 2> vec_b(std::move(vec_a));  // Move
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorTest, MoveVector_Upcast_WithSpill) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    Vector<C0*, 2> vec_b(std::move(vec_a));  // Move
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllExternallyHeld(vec_b));  // Moved, not copied
+}
+
+TEST(TintVectorTest, MoveVector_AddConst_NoSpill) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 2> vec_a{&c2a, &c2b};
+    Vector<const C1*, 2> vec_b(std::move(vec_a));  // Move
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorTest, MoveVector_AddConst_WithSpill) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    Vector<const C1*, 2> vec_b(std::move(vec_a));  // Move
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllExternallyHeld(vec_b));  // Moved, not copied
+}
+
+TEST(TintVectorTest, MoveVector_UpcastAndAddConst_NoSpill) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 2> vec_a{&c2a, &c2b};
+    Vector<const C0*, 2> vec_b(std::move(vec_a));  // Move
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorTest, MoveVector_UpcastAndAddConst_WithSpill) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    Vector<const C0*, 2> vec_b(std::move(vec_a));  // Move
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllExternallyHeld(vec_b));  // Moved, not copied
+}
+
+TEST(TintVectorTest, CopyAssign_NoSpill_N2_to_N2) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 2> vec_b;
+    vec_b = vec_a;
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssign_WithSpill_N2_to_N2) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 2> vec_b;
+    vec_b = vec_a;
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssign_NoSpill_N2_to_N1) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 1> vec_b;
+    vec_b = vec_a;
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssign_WithSpill_N2_to_N1) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 1> vec_b;
+    vec_b = vec_a;
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssign_NoSpill_N2_to_N3) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 3> vec_b;
+    vec_b = vec_a;
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssign_WithSpill_N2_to_N3) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 3> vec_b;
+    vec_b = vec_a;
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssign_NoSpill_N2_to_N0) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 0> vec_b;
+    vec_b = vec_a;
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssign_WithSpill_N2_to_N0) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 0> vec_b;
+    vec_b = vec_a;
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssign_Self_NoSpill) {
+    Vector<std::string, 2> vec{"hello", "world"};
+    auto* vec_ptr = &vec;  // Used to avoid -Wself-assign-overloaded
+    vec = *vec_ptr;
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, CopyAssign_Self_WithSpill) {
+    Vector<std::string, 1> vec{"hello", "world"};
+    auto* vec_ptr = &vec;  // Used to avoid -Wself-assign-overloaded
+    vec = *vec_ptr;
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, MoveAssign_NoSpill_N2_to_N2) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 2> vec_b;
+    vec_b = std::move(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssign_WithSpill_N2_to_N2) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 2> vec_b;
+    vec_b = std::move(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssign_NoSpill_N2_to_N1) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 1> vec_b;
+    vec_b = std::move(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssign_SpillSpill_N2_to_N1) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 1> vec_b;
+    vec_b = std::move(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssign_NoSpill_N2_to_N3) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 3> vec_b;
+    vec_b = std::move(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssign_WithSpill_N2_to_N3) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 3> vec_b;
+    vec_b = std::move(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssign_NoSpill_N2_to_N0) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 0> vec_b;
+    vec_b = std::move(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssign_WithSpill_N2_to_N0) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 0> vec_b;
+    vec_b = std::move(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssign_Self_NoSpill) {
+    Vector<std::string, 2> vec{"hello", "world"};
+    auto* vec_ptr = &vec;  // Used to avoid -Wself-move
+    vec = std::move(*vec_ptr);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, MoveAssign_Self_WithSpill) {
+    Vector<std::string, 1> vec{"hello", "world"};
+    auto* vec_ptr = &vec;  // Used to avoid -Wself-move
+    vec = std::move(*vec_ptr);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, RepeatMoveAssign_NoSpill) {
+    Vector<std::string, 3> vec_a{"hello", "world"};
+    Vector<std::string, 3> vec_b{"Ciao", "mondo"};
+    Vector<std::string, 3> vec_c{"Bonjour", "le", "monde"};
+    Vector<std::string, 3> vec;
+    vec = std::move(vec_a);
+    vec = std::move(vec_b);
+    vec = std::move(vec_c);
+    EXPECT_EQ(vec.Length(), 3u);
+    EXPECT_EQ(vec.Capacity(), 3u);
+    EXPECT_EQ(vec[0], "Bonjour");
+    EXPECT_EQ(vec[1], "le");
+    EXPECT_EQ(vec[2], "monde");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, RepeatMoveAssign_WithSpill) {
+    Vector<std::string, 1> vec_a{"hello", "world"};
+    Vector<std::string, 1> vec_b{"Ciao", "mondo"};
+    Vector<std::string, 1> vec_c{"bonjour", "le", "monde"};
+    Vector<std::string, 1> vec;
+    vec = std::move(vec_a);
+    vec = std::move(vec_b);
+    vec = std::move(vec_c);
+    EXPECT_EQ(vec.Length(), 3u);
+    EXPECT_EQ(vec.Capacity(), 3u);
+    EXPECT_EQ(vec[0], "bonjour");
+    EXPECT_EQ(vec[1], "le");
+    EXPECT_EQ(vec[2], "monde");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, CopyAssignRef_NoSpill_N2_to_N2) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 2> vec_b;
+    vec_b = ref;
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssignRef_WithSpill_N2_to_N2) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 2> vec_b;
+    vec_b = ref;
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssignRef_NoSpill_N2_to_N1) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 1> vec_b;
+    vec_b = ref;
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssignRef_WithSpill_N2_to_N1) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 1> vec_b;
+    vec_b = ref;
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssignRef_NoSpill_N2_to_N3) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 3> vec_b;
+    vec_b = ref;
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssignRef_WithSpill_N2_to_N3) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 3> vec_b;
+    vec_b = ref;
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssignRef_NoSpill_N2_to_N0) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 0> vec_b;
+    vec_b = ref;
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssignRef_WithSpill_N2_to_N0) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 0> vec_b;
+    vec_b = ref;
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssignRef_Self_NoSpill) {
+    Vector<std::string, 2> vec{"hello", "world"};
+    VectorRef<std::string> ref{std::move(vec)};
+    vec = ref;
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, CopyAssignRef_Self_WithSpill) {
+    Vector<std::string, 1> vec{"hello", "world"};
+    VectorRef<std::string> ref{std::move(vec)};
+    vec = ref;
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, MoveAssignRef_NoSpill_N2_to_N2) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 2> vec_b;
+    vec_b = std::move(ref);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssignRef_WithSpill_N2_to_N2) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 2> vec_b;
+    vec_b = std::move(ref);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssignRef_NoSpill_N2_to_N1) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 1> vec_b;
+    vec_b = std::move(ref);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssignRef_SpillSpill_N2_to_N1) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 1> vec_b;
+    vec_b = std::move(ref);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssignRef_NoSpill_N2_to_N3) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 3> vec_b;
+    vec_b = std::move(ref);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssignRef_WithSpill_N2_to_N3) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 3> vec_b;
+    vec_b = std::move(ref);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssignRef_NoSpill_N2_to_N0) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 0> vec_b;
+    vec_b = std::move(ref);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssignRef_WithSpill_N2_to_N0) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    VectorRef<std::string> ref{std::move(vec_a)};
+    Vector<std::string, 0> vec_b;
+    vec_b = std::move(ref);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, MoveAssignRef_Self_NoSpill) {
+    Vector<std::string, 2> vec{"hello", "world"};
+    VectorRef<std::string> ref{std::move(vec)};
+    vec = std::move(ref);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, MoveAssignRef_Self_WithSpill) {
+    Vector<std::string, 1> vec{"hello", "world"};
+    VectorRef<std::string> ref{std::move(vec)};
+    vec = std::move(ref);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, RepeatMoveAssignRef_NoSpill) {
+    Vector<std::string, 3> vec_a{"hello", "world"};
+    Vector<std::string, 3> vec_b{"Ciao", "mondo"};
+    Vector<std::string, 3> vec_c{"Bonjour", "le", "monde"};
+    VectorRef<std::string> ref_a{std::move(vec_a)};
+    VectorRef<std::string> ref_b{std::move(vec_b)};
+    VectorRef<std::string> ref_c{std::move(vec_c)};
+    Vector<std::string, 3> vec;
+    vec = std::move(ref_a);
+    vec = std::move(ref_b);
+    vec = std::move(ref_c);
+    EXPECT_EQ(vec.Length(), 3u);
+    EXPECT_EQ(vec.Capacity(), 3u);
+    EXPECT_EQ(vec[0], "Bonjour");
+    EXPECT_EQ(vec[1], "le");
+    EXPECT_EQ(vec[2], "monde");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, RepeatMoveAssignRef_WithSpill) {
+    Vector<std::string, 1> vec_a{"hello", "world"};
+    Vector<std::string, 1> vec_b{"Ciao", "mondo"};
+    Vector<std::string, 1> vec_c{"bonjour", "le", "monde"};
+    VectorRef<std::string> ref_a{std::move(vec_a)};
+    VectorRef<std::string> ref_b{std::move(vec_b)};
+    VectorRef<std::string> ref_c{std::move(vec_c)};
+    Vector<std::string, 1> vec;
+    vec = std::move(ref_a);
+    vec = std::move(ref_b);
+    vec = std::move(ref_c);
+    EXPECT_EQ(vec.Length(), 3u);
+    EXPECT_EQ(vec.Capacity(), 3u);
+    EXPECT_EQ(vec[0], "bonjour");
+    EXPECT_EQ(vec[1], "le");
+    EXPECT_EQ(vec[2], "monde");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, CopyAssignSlice_N2_to_N2) {
+    std::string data[] = {"hello", "world"};
+    Slice<std::string> slice(data);
+    Vector<std::string, 2> vec_b;
+    vec_b = slice;
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssignSlice_N2_to_N1) {
+    std::string data[] = {"hello", "world"};
+    Slice<std::string> slice(data);
+    Vector<std::string, 1> vec_b;
+    vec_b = slice;
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssignSlice_N2_to_N3) {
+    std::string data[] = {"hello", "world"};
+    Slice<std::string> slice(data);
+    Vector<std::string, 3> vec_b;
+    vec_b = slice;
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, CopyAssignSlice_N2_to_N0) {
+    std::string data[] = {"hello", "world"};
+    Slice<std::string> slice(data);
+    Vector<std::string, 0> vec_b;
+    vec_b = slice;
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Index) {
+    Vector<std::string, 2> vec{"hello", "world"};
+    static_assert(!std::is_const_v<std::remove_reference_t<decltype(vec[0])>>);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "world");
+}
+
+TEST(TintVectorTest, ConstIndex) {
+    const Vector<std::string, 2> vec{"hello", "world"};
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(vec[0])>>);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "world");
+}
+
+TEST(TintVectorTest, Reserve_NoSpill) {
+    Vector<std::string, 2> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    vec.Reserve(1);
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    vec.Reserve(2);
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    vec.Push("hello");
+    vec.Push("world");
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+    vec.Reserve(1);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, Reserve_WithSpill) {
+    Vector<std::string, 1> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 1u);
+    vec.Reserve(1);
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 1u);
+    vec.Reserve(2);
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    vec.Push("hello");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+    vec.Push("world");
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_TRUE(AllExternallyHeld(vec));
+    vec.Reserve(1);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, ResizeZero_NoSpill) {
+    Vector<std::string, 2> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    vec.Resize(1);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+    vec[0] = "hello";
+    vec.Resize(2);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+    vec[1] = "world";
+    vec.Resize(1);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+    vec.Resize(2);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, ResizeZero_WithSpill) {
+    Vector<std::string, 1> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 1u);
+    vec.Resize(1);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec.Capacity(), 1u);
+    EXPECT_EQ(vec[0], "");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+    vec[0] = "hello";
+    vec.Resize(2);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+    vec[1] = "world";
+    vec.Resize(1);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+    vec.Resize(2);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, ResizeValue_NoSpill) {
+    Vector<std::string, 2> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    vec.Resize(1, "meow");
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "meow");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+    vec[0] = "hello";
+    vec.Resize(2, "woof");
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "woof");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+    vec[1] = "world";
+    vec.Resize(1, "quack");
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+    vec.Resize(2, "hiss");
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "hiss");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, ResizeValue_WithSpill) {
+    Vector<std::string, 1> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 1u);
+    vec.Resize(1, "meow");
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec.Capacity(), 1u);
+    EXPECT_EQ(vec[0], "meow");
+    EXPECT_TRUE(AllInternallyHeld(vec));
+    vec[0] = "hello";
+    vec.Resize(2, "woof");
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "woof");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+    vec[1] = "world";
+    vec.Resize(1, "quack");
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+    vec.Resize(2, "hiss");
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "hiss");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, Reserve_NoSmallArray) {
+    Vector<std::string, 0> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 0u);
+    vec.Reserve(1);
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 1u);
+    vec.Reserve(2);
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    vec.Push("hello");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+    vec.Push("world");
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_TRUE(AllExternallyHeld(vec));
+    vec.Reserve(1);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, Resize_NoSmallArray) {
+    Vector<std::string, 0> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 0u);
+    vec.Resize(1);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec.Capacity(), 1u);
+    EXPECT_EQ(vec[0], "");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+    vec[0] = "hello";
+    vec.Resize(2);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+    vec[1] = "world";
+    vec.Resize(1);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+    vec.Resize(2);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+    EXPECT_EQ(vec[0], "hello");
+    EXPECT_EQ(vec[1], "");
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, Copy_NoSpill_N2_to_N2_Empty) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 2> vec_b;
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_NoSpill_N2_to_N2_NonEmpty) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 2> vec_b{"hallo", "wereld"};
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_NoSpill_N2_to_N2_Spill) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 2> vec_b{"hallo", "wereld", "spill"};
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_WithSpill_N2_to_N2_Empty) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 2> vec_b;
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_WithSpill_N2_to_N2_NonEmpty) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 2> vec_b{"hallo", "wereld"};
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_WithSpill_N2_to_N2_Spill) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 2> vec_b{"hallo", "wereld", "morsen"};
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_NoSpill_N2_to_N1_Empty) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 1> vec_b;
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_NoSpill_N2_to_N1_NonEmpty) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 1> vec_b{"hallo"};
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_NoSpill_N2_to_N1_Spill) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 1> vec_b{"hallo", "morsen"};
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 2u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_WithSpill_N2_to_N1_Empty) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 1> vec_b;
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_WithSpill_N2_to_N1_NonEmpty) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 1> vec_b{"hallo"};
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_WithSpill_N2_to_N1_Spill) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 1> vec_b{"hallo", "wereld"};
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_NoSpill_N2_to_N3_Empty) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 3> vec_b;
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_NoSpill_N2_to_N3_NonEmpty) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 3> vec_b{"hallo", "fijne", "wereld"};
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_NoSpill_N2_to_N3_Spill) {
+    Vector<std::string, 2> vec_a{"hello", "world"};
+    Vector<std::string, 3> vec_b{"hallo", "fijne", "wereld", "morsen"};
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 2u);
+    EXPECT_EQ(vec_b.Capacity(), 4u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_WithSpill_N2_to_N3_Empty) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 3> vec_b;
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_WithSpill_N2_to_N3_NonEmpty) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 3> vec_b{"hallo", "fijne", "wereld"};
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 3u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Copy_WithSpill_N2_to_N3_Spill) {
+    Vector<std::string, 2> vec_a{"hello", "world", "spill"};
+    Vector<std::string, 3> vec_b{"hallo", "fijne", "wereld", "morsen"};
+    vec_b.Copy(vec_a);
+    EXPECT_EQ(vec_b.Length(), 3u);
+    EXPECT_EQ(vec_b.Capacity(), 4u);
+    EXPECT_EQ(vec_b[0], "hello");
+    EXPECT_EQ(vec_b[1], "world");
+    EXPECT_EQ(vec_b[2], "spill");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));
+}
+
+TEST(TintVectorTest, Clear_Empty) {
+    Vector<std::string, 2> vec;
+    vec.Clear();
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+}
+
+TEST(TintVectorTest, Clear_NoSpill) {
+    Vector<std::string, 2> vec{"hello", "world"};
+    vec.Clear();
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 2u);
+}
+
+TEST(TintVectorTest, Clear_WithSpill) {
+    Vector<std::string, 2> vec{"hello", "world", "spill"};
+    vec.Clear();
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_EQ(vec.Capacity(), 3u);
+}
+
+TEST(TintVectorTest, PushPop_StringNoSpill) {
+    const std::string hello = "hello";
+    const std::string world = "world";
+
+    Vector<std::string, 2> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    vec.Push(hello);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    vec.Push(world);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    EXPECT_EQ(vec.Pop(), world);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    EXPECT_EQ(vec.Pop(), hello);
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, PushPop_StringWithSpill) {
+    const std::string hello = "hello";
+    const std::string world = "world";
+
+    Vector<std::string, 1> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    vec.Push(hello);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    vec.Push(world);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_TRUE(AllExternallyHeld(vec));
+
+    EXPECT_EQ(vec.Pop(), world);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_TRUE(AllExternallyHeld(vec));
+
+    EXPECT_EQ(vec.Pop(), hello);
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, PushPop_StringMoveNoSpill) {
+    std::string hello = "hello";
+    std::string world = "world";
+
+    Vector<std::string, 2> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    vec.Push(std::move(hello));
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    vec.Push(std::move(world));
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    EXPECT_EQ(vec.Pop(), "world");
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    EXPECT_EQ(vec.Pop(), "hello");
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, PushPop_StringMoveWithSpill) {
+    std::string hello = "hello";
+    std::string world = "world";
+
+    Vector<std::string, 1> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    vec.Push(std::move(hello));
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    vec.Push(std::move(world));
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_TRUE(AllExternallyHeld(vec));
+
+    EXPECT_EQ(vec.Pop(), "world");
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_TRUE(AllExternallyHeld(vec));
+
+    EXPECT_EQ(vec.Pop(), "hello");
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, EmplacePop_TupleVarArgNoSpill) {
+    Vector<std::tuple<int, float, bool>, 2> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    vec.Emplace(1, 2.0, false);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    vec.Emplace(3, 4.0, true);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    EXPECT_EQ(vec.Pop(), std::make_tuple(3, 4.0, true));
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    EXPECT_EQ(vec.Pop(), std::make_tuple(1, 2.0, false));
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+}
+
+TEST(TintVectorTest, EmplacePop_TupleVarArgWithSpill) {
+    Vector<std::tuple<int, float, bool>, 1> vec;
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    vec.Emplace(1, 2.0, false);
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_TRUE(AllInternallyHeld(vec));
+
+    vec.Emplace(3, 4.0, true);
+    EXPECT_EQ(vec.Length(), 2u);
+    EXPECT_TRUE(AllExternallyHeld(vec));
+
+    EXPECT_EQ(vec.Pop(), std::make_tuple(3, 4.0, true));
+    EXPECT_EQ(vec.Length(), 1u);
+    EXPECT_TRUE(AllExternallyHeld(vec));
+
+    EXPECT_EQ(vec.Pop(), std::make_tuple(1, 2.0, false));
+    EXPECT_EQ(vec.Length(), 0u);
+    EXPECT_TRUE(AllExternallyHeld(vec));
+}
+
+TEST(TintVectorTest, IsEmpty) {
+    Vector<std::string, 1> vec;
+    EXPECT_TRUE(vec.IsEmpty());
+    vec.Push("one");
+    EXPECT_FALSE(vec.IsEmpty());
+    vec.Pop();
+    EXPECT_TRUE(vec.IsEmpty());
+}
+
+TEST(TintVectorTest, FrontBack_NoSpill) {
+    Vector<std::string, 3> vec{"front", "mid", "back"};
+    static_assert(!std::is_const_v<std::remove_reference_t<decltype(vec.Front())>>);
+    static_assert(!std::is_const_v<std::remove_reference_t<decltype(vec.Back())>>);
+    EXPECT_EQ(vec.Front(), "front");
+    EXPECT_EQ(vec.Back(), "back");
+}
+
+TEST(TintVectorTest, FrontBack_WithSpill) {
+    Vector<std::string, 2> vec{"front", "mid", "back"};
+    static_assert(!std::is_const_v<std::remove_reference_t<decltype(vec.Front())>>);
+    static_assert(!std::is_const_v<std::remove_reference_t<decltype(vec.Back())>>);
+    EXPECT_EQ(vec.Front(), "front");
+    EXPECT_EQ(vec.Back(), "back");
+}
+
+TEST(TintVectorTest, ConstFrontBack_NoSpill) {
+    const Vector<std::string, 3> vec{"front", "mid", "back"};
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(vec.Front())>>);
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(vec.Back())>>);
+    EXPECT_EQ(vec.Front(), "front");
+    EXPECT_EQ(vec.Back(), "back");
+}
+
+TEST(TintVectorTest, ConstFrontBack_WithSpill) {
+    const Vector<std::string, 2> vec{"front", "mid", "back"};
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(vec.Front())>>);
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(vec.Back())>>);
+    EXPECT_EQ(vec.Front(), "front");
+    EXPECT_EQ(vec.Back(), "back");
+}
+
+TEST(TintVectorTest, BeginEnd_NoSpill) {
+    Vector<std::string, 3> vec{"front", "mid", "back"};
+    static_assert(!std::is_const_v<std::remove_reference_t<decltype(*vec.begin())>>);
+    static_assert(!std::is_const_v<std::remove_reference_t<decltype(*vec.end())>>);
+    EXPECT_EQ(vec.begin(), &vec[0]);
+    EXPECT_EQ(vec.end(), &vec[0] + 3);
+}
+
+TEST(TintVectorTest, BeginEnd_WithSpill) {
+    Vector<std::string, 2> vec{"front", "mid", "back"};
+    static_assert(!std::is_const_v<std::remove_reference_t<decltype(*vec.begin())>>);
+    static_assert(!std::is_const_v<std::remove_reference_t<decltype(*vec.end())>>);
+    EXPECT_EQ(vec.begin(), &vec[0]);
+    EXPECT_EQ(vec.end(), &vec[0] + 3);
+}
+
+TEST(TintVectorTest, ConstBeginEnd_NoSpill) {
+    const Vector<std::string, 3> vec{"front", "mid", "back"};
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(*vec.begin())>>);
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(*vec.end())>>);
+    EXPECT_EQ(vec.begin(), &vec[0]);
+    EXPECT_EQ(vec.end(), &vec[0] + 3);
+}
+
+TEST(TintVectorTest, ConstBeginEnd_WithSpill) {
+    const Vector<std::string, 2> vec{"front", "mid", "back"};
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(*vec.begin())>>);
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(*vec.end())>>);
+    EXPECT_EQ(vec.begin(), &vec[0]);
+    EXPECT_EQ(vec.end(), &vec[0] + 3);
+}
+
+TEST(TintVectorTest, Equality) {
+    EXPECT_EQ((Vector<int, 2>{1, 2}), (Vector<int, 2>{1, 2}));
+    EXPECT_EQ((Vector<int, 1>{1, 2}), (Vector<int, 3>{1, 2}));
+    EXPECT_NE((Vector{1, 2}), (Vector{1}));
+    EXPECT_NE((Vector{1}), (Vector{1, 2}));
+    EXPECT_NE((Vector{1, 2}), (Vector{2, 1}));
+    EXPECT_NE((Vector{2, 1}), (Vector{1, 2}));
+}
+
+TEST(TintVectorTest, Sort) {
+    Vector vec{1, 5, 3, 4, 2};
+    vec.Sort();
+    EXPECT_THAT(vec, testing::ElementsAre(1, 2, 3, 4, 5));
+}
+
+TEST(TintVectorTest, ReverseOdd) {
+    Vector vec{1, 5, 3, 4, 2};
+    vec.Reverse();
+    EXPECT_THAT(vec, testing::ElementsAre(2, 4, 3, 5, 1));
+}
+
+TEST(TintVectorTest, ReverseEven) {
+    Vector vec{1, 5, 3, 4, 2, 9};
+    vec.Reverse();
+    EXPECT_THAT(vec, testing::ElementsAre(9, 2, 4, 3, 5, 1));
+}
+
+TEST(TintVectorTest, Any) {
+    Vector vec{1, 7, 5, 9};
+    EXPECT_TRUE(vec.Any(Eq(1)));
+    EXPECT_FALSE(vec.Any(Eq(2)));
+    EXPECT_FALSE(vec.Any(Eq(3)));
+    EXPECT_FALSE(vec.Any(Eq(4)));
+    EXPECT_TRUE(vec.Any(Eq(5)));
+    EXPECT_FALSE(vec.Any(Eq(6)));
+    EXPECT_TRUE(vec.Any(Eq(7)));
+    EXPECT_FALSE(vec.Any(Eq(8)));
+    EXPECT_TRUE(vec.Any(Eq(9)));
+}
+
+TEST(TintVectorTest, All) {
+    Vector vec{1, 7, 5, 9};
+    EXPECT_FALSE(vec.All(Ne(1)));
+    EXPECT_TRUE(vec.All(Ne(2)));
+    EXPECT_TRUE(vec.All(Ne(3)));
+    EXPECT_TRUE(vec.All(Ne(4)));
+    EXPECT_FALSE(vec.All(Ne(5)));
+    EXPECT_TRUE(vec.All(Ne(6)));
+    EXPECT_FALSE(vec.All(Ne(7)));
+    EXPECT_TRUE(vec.All(Ne(8)));
+    EXPECT_FALSE(vec.All(Ne(9)));
+}
+
+TEST(TintVectorTest, Slice) {
+    Vector<std::string, 3> vec{"hello", "world"};
+    auto slice = vec.Slice();
+    static_assert(std::is_same_v<decltype(slice), Slice<std::string>>);
+    EXPECT_EQ(slice.data, &vec[0]);
+    EXPECT_EQ(slice.len, 2u);
+    EXPECT_EQ(slice.cap, 3u);
+}
+
+TEST(TintVectorTest, SliceConst) {
+    const Vector<std::string, 3> vec{"hello", "world"};
+    auto slice = vec.Slice();
+    static_assert(std::is_same_v<decltype(slice), Slice<const std::string>>);
+    EXPECT_EQ(slice.data, &vec[0]);
+    EXPECT_EQ(slice.len, 2u);
+    EXPECT_EQ(slice.cap, 3u);
+}
+
+TEST(TintVectorTest, ostream) {
+    utils::StringStream ss;
+    ss << Vector{1, 2, 3};
+    EXPECT_EQ(ss.str(), "[1, 2, 3]");
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// TintVectorRefTest
+////////////////////////////////////////////////////////////////////////////////
+TEST(TintVectorRefTest, CopyVectorRef) {
+    Vector<std::string, 1> vec_a{"one", "two"};
+    VectorRef<std::string> vec_ref_a(std::move(vec_a));
+    VectorRef<std::string> vec_ref_b(vec_ref_a);  // No move
+    Vector<std::string, 2> vec_b(std::move(vec_ref_b));
+    EXPECT_EQ(vec_b[0], "one");
+    EXPECT_EQ(vec_b[1], "two");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorRefTest, CopyVectorRef_Upcast) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    VectorRef<C1*> vec_ref_a(std::move(vec_a));
+    VectorRef<C0*> vec_ref_b(vec_ref_a);  // No-move. Up-cast
+    Vector<C0*, 2> vec_b(std::move(vec_ref_b));
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorRefTest, CopyVectorRef_AddConst) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    VectorRef<C1*> vec_ref_a(std::move(vec_a));
+    VectorRef<const C1*> vec_ref_b(vec_ref_a);  // No-move. Up-cast
+    Vector<const C1*, 2> vec_b(std::move(vec_ref_b));
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorRefTest, CopyVectorRef_UpcastAndAddConst) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    VectorRef<C1*> vec_ref_a(std::move(vec_a));
+    VectorRef<const C0*> vec_ref_b(vec_ref_a);  // No-move. Up-cast
+    Vector<const C0*, 2> vec_b(std::move(vec_ref_b));
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorRefTest, MoveVectorRef) {
+    Vector<std::string, 1> vec_a{"one", "two"};
+    VectorRef<std::string> vec_ref_a(std::move(vec_a));  // Move
+    VectorRef<std::string> vec_ref_b(std::move(vec_ref_a));
+    Vector<std::string, 2> vec_b(std::move(vec_ref_b));
+    EXPECT_EQ(vec_b[0], "one");
+    EXPECT_EQ(vec_b[1], "two");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));  // Moved, not copied
+}
+
+TEST(TintVectorRefTest, MoveVectorRef_Upcast) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    VectorRef<C1*> vec_ref_a(std::move(vec_a));
+    VectorRef<C0*> vec_ref_b(std::move(vec_ref_a));  // Moved. Up-cast
+    Vector<C0*, 2> vec_b(std::move(vec_ref_b));
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllExternallyHeld(vec_b));  // Moved, not copied
+}
+
+TEST(TintVectorRefTest, MoveVectorRef_AddConst) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    VectorRef<C1*> vec_ref_a(std::move(vec_a));
+    VectorRef<const C1*> vec_ref_b(std::move(vec_ref_a));  // Moved. Up-cast
+    Vector<const C1*, 2> vec_b(std::move(vec_ref_b));
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllExternallyHeld(vec_b));  // Moved, not copied
+}
+
+TEST(TintVectorRefTest, MoveVectorRef_UpcastAndAddConst) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    VectorRef<C1*> vec_ref_a(std::move(vec_a));
+    VectorRef<const C0*> vec_ref_b(std::move(vec_ref_a));  // Moved. Up-cast
+    Vector<const C0*, 2> vec_b(std::move(vec_ref_b));
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllExternallyHeld(vec_b));  // Moved, not copied
+}
+
+TEST(TintVectorRefTest, CopyVector) {
+    Vector<std::string, 1> vec_a{"one", "two"};
+    VectorRef<std::string> vec_ref(vec_a);  // No move
+    Vector<std::string, 2> vec_b(std::move(vec_ref));
+    EXPECT_EQ(vec_b[0], "one");
+    EXPECT_EQ(vec_b[1], "two");
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorRefTest, CopyVector_Upcast) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    VectorRef<C0*> vec_ref(vec_a);  // No move
+    EXPECT_EQ(vec_ref[0], &c2a);
+    EXPECT_EQ(vec_ref[1], &c2b);
+    Vector<C0*, 2> vec_b(std::move(vec_ref));
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorRefTest, CopyVector_AddConst) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    VectorRef<const C1*> vec_ref(vec_a);  // No move
+    EXPECT_EQ(vec_ref[0], &c2a);
+    EXPECT_EQ(vec_ref[1], &c2b);
+    Vector<const C1*, 2> vec_b(std::move(vec_ref));
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorRefTest, CopyVector_UpcastAndAddConst) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    VectorRef<const C0*> vec_ref(vec_a);  // No move
+    EXPECT_EQ(vec_ref[0], &c2a);
+    EXPECT_EQ(vec_ref[1], &c2b);
+    Vector<const C0*, 2> vec_b(std::move(vec_ref));
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllInternallyHeld(vec_b));  // Copied, not moved
+}
+
+TEST(TintVectorRefTest, MoveVector) {
+    Vector<std::string, 1> vec_a{"one", "two"};
+    VectorRef<std::string> vec_ref(std::move(vec_a));  // Move
+    Vector<std::string, 2> vec_b(std::move(vec_ref));
+    EXPECT_EQ(vec_b[0], "one");
+    EXPECT_EQ(vec_b[1], "two");
+    EXPECT_TRUE(AllExternallyHeld(vec_b));  // Moved, not copied
+}
+
+TEST(TintVectorRefTest, MoveVector_Upcast) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    VectorRef<C0*> vec_ref(std::move(vec_a));  // Move
+    EXPECT_EQ(vec_ref[0], &c2a);
+    EXPECT_EQ(vec_ref[1], &c2b);
+    Vector<C0*, 2> vec_b(std::move(vec_ref));
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllExternallyHeld(vec_b));  // Moved, not copied
+}
+
+TEST(TintVectorRefTest, MoveVector_AddConst) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    VectorRef<const C1*> vec_ref(std::move(vec_a));  // Move
+    EXPECT_EQ(vec_ref[0], &c2a);
+    EXPECT_EQ(vec_ref[1], &c2b);
+    Vector<const C1*, 2> vec_b(std::move(vec_ref));
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllExternallyHeld(vec_b));  // Moved, not copied
+}
+
+TEST(TintVectorRefTest, MoveVector_UpcastAndAddConst) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C1*, 1> vec_a{&c2a, &c2b};
+    VectorRef<const C0*> vec_ref(std::move(vec_a));  // Move
+    EXPECT_EQ(vec_ref[0], &c2a);
+    EXPECT_EQ(vec_ref[1], &c2b);
+    Vector<const C0*, 2> vec_b(std::move(vec_ref));
+    EXPECT_EQ(vec_b[0], &c2a);
+    EXPECT_EQ(vec_b[1], &c2b);
+    EXPECT_TRUE(AllExternallyHeld(vec_b));  // Moved, not copied
+}
+
+TEST(TintVectorRefTest, MoveVector_ReinterpretCast) {
+    C2a c2a;
+    C2b c2b;
+    Vector<C0*, 1> vec_a{&c2a, &c2b};
+    VectorRef<const C0*> vec_ref(std::move(vec_a));  // Move
+    EXPECT_EQ(vec_ref[0], &c2a);
+    EXPECT_EQ(vec_ref[1], &c2b);
+    VectorRef<const C1*> reinterpret = vec_ref.ReinterpretCast<const C1*>();
+    EXPECT_EQ(reinterpret[0], &c2a);
+    EXPECT_EQ(reinterpret[1], &c2b);
+}
+
+TEST(TintVectorRefTest, Index) {
+    Vector<std::string, 2> vec{"one", "two"};
+    VectorRef<std::string> vec_ref(vec);
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(vec_ref[0])>>);
+    EXPECT_EQ(vec_ref[0], "one");
+    EXPECT_EQ(vec_ref[1], "two");
+}
+
+TEST(TintVectorRefTest, SortPredicate) {
+    Vector vec{1, 5, 3, 4, 2};
+    vec.Sort([](int a, int b) { return b < a; });
+    EXPECT_THAT(vec, testing::ElementsAre(5, 4, 3, 2, 1));
+}
+
+TEST(TintVectorRefTest, ConstIndex) {
+    Vector<std::string, 2> vec{"one", "two"};
+    const VectorRef<std::string> vec_ref(vec);
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(vec_ref[0])>>);
+    EXPECT_EQ(vec_ref[0], "one");
+    EXPECT_EQ(vec_ref[1], "two");
+}
+
+TEST(TintVectorRefTest, Length) {
+    Vector<std::string, 2> vec{"one", "two", "three"};
+    VectorRef<std::string> vec_ref(vec);
+    EXPECT_EQ(vec_ref.Length(), 3u);
+}
+
+TEST(TintVectorRefTest, Capacity) {
+    Vector<std::string, 5> vec{"one", "two", "three"};
+    VectorRef<std::string> vec_ref(vec);
+    EXPECT_EQ(vec_ref.Capacity(), 5u);
+}
+
+TEST(TintVectorRefTest, IsEmpty) {
+    Vector<std::string, 1> vec;
+    VectorRef<std::string> vec_ref(vec);
+    EXPECT_TRUE(vec_ref.IsEmpty());
+    vec.Push("one");
+    EXPECT_FALSE(vec_ref.IsEmpty());
+    vec.Pop();
+    EXPECT_TRUE(vec_ref.IsEmpty());
+}
+
+TEST(TintVectorRefTest, FrontBack) {
+    Vector<std::string, 3> vec{"front", "mid", "back"};
+    const VectorRef<std::string> vec_ref(vec);
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(vec_ref.Front())>>);
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(vec_ref.Back())>>);
+    EXPECT_EQ(vec_ref.Front(), "front");
+    EXPECT_EQ(vec_ref.Back(), "back");
+}
+
+TEST(TintVectorRefTest, BeginEnd) {
+    Vector<std::string, 3> vec{"front", "mid", "back"};
+    const VectorRef<std::string> vec_ref(vec);
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(*vec_ref.begin())>>);
+    static_assert(std::is_const_v<std::remove_reference_t<decltype(*vec_ref.end())>>);
+    EXPECT_EQ(vec_ref.begin(), &vec[0]);
+    EXPECT_EQ(vec_ref.end(), &vec[0] + 3);
+}
+
+TEST(TintVectorRefTest, ostream) {
+    utils::StringStream ss;
+    Vector vec{1, 2, 3};
+    const VectorRef<int> vec_ref(vec);
+    ss << vec_ref;
+    EXPECT_EQ(ss.str(), "[1, 2, 3]");
+}
+
+}  // namespace
+}  // namespace tint::utils
+
+TINT_INSTANTIATE_TYPEINFO(tint::utils::C0);
+TINT_INSTANTIATE_TYPEINFO(tint::utils::C1);
+TINT_INSTANTIATE_TYPEINFO(tint::utils::C2a);
+TINT_INSTANTIATE_TYPEINFO(tint::utils::C2b);
diff --git a/src/tint/utils/debug/debug.cc b/src/tint/utils/debug/debug.cc
new file mode 100644
index 0000000..c5c07f1
--- /dev/null
+++ b/src/tint/utils/debug/debug.cc
@@ -0,0 +1,50 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/debug/debug.h"
+
+#include <memory>
+
+#include "src/tint/utils/debug/debugger.h"
+
+namespace tint {
+namespace {
+
+InternalCompilerErrorReporter* ice_reporter = nullptr;
+
+}  // namespace
+
+void SetInternalCompilerErrorReporter(InternalCompilerErrorReporter* reporter) {
+    ice_reporter = reporter;
+}
+
+InternalCompilerError::InternalCompilerError(const char* file,
+                                             size_t line,
+                                             diag::System system,
+                                             diag::List& diagnostics)
+    : file_(file), line_(line), system_(system), diagnostics_(diagnostics) {}
+
+InternalCompilerError::~InternalCompilerError() {
+    auto file = std::make_shared<Source::File>(file_, "");
+    Source source{Source::Range{{line_}}, file.get()};
+    diagnostics_.add_ice(system_, msg_.str(), source, std::move(file));
+
+    if (ice_reporter) {
+        ice_reporter(diagnostics_);
+    }
+
+    debugger::Break();
+}
+
+}  // namespace tint
diff --git a/src/tint/utils/debug/debug.h b/src/tint/utils/debug/debug.h
new file mode 100644
index 0000000..4cf4257
--- /dev/null
+++ b/src/tint/utils/debug/debug.h
@@ -0,0 +1,156 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_DEBUG_DEBUG_H_
+#define SRC_TINT_UTILS_DEBUG_DEBUG_H_
+
+#include <utility>
+
+#include "src/tint/utils/diagnostic/diagnostic.h"
+#include "src/tint/utils/diagnostic/formatter.h"
+#include "src/tint/utils/diagnostic/printer.h"
+#include "src/tint/utils/macros/compiler.h"
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint {
+
+/// Function type used for registering an internal compiler error reporter
+using InternalCompilerErrorReporter = void(const diag::List&);
+
+/// Sets the global error reporter to be called in case of internal compiler
+/// errors.
+/// @param reporter the error reporter
+void SetInternalCompilerErrorReporter(InternalCompilerErrorReporter* reporter);
+
+/// InternalCompilerError is a helper for reporting internal compiler errors.
+/// Construct the InternalCompilerError with the source location of the ICE
+/// fault and append any error details with the `<<` operator.
+/// When the InternalCompilerError is destructed, the concatenated error message
+/// is appended to the diagnostics list with the severity of
+/// tint::diag::Severity::InternalCompilerError, and if a
+/// InternalCompilerErrorReporter is set, then it is called with the diagnostic
+/// list.
+class InternalCompilerError {
+  public:
+    /// Constructor
+    /// @param file the file containing the ICE
+    /// @param line the line containing the ICE
+    /// @param system the Tint system that has raised the ICE
+    /// @param diagnostics the list of diagnostics to append the ICE message to
+    InternalCompilerError(const char* file,
+                          size_t line,
+                          diag::System system,
+                          diag::List& diagnostics);
+
+    /// Destructor.
+    /// Adds the internal compiler error message to the diagnostics list, and then
+    /// calls the InternalCompilerErrorReporter if one is set.
+    ~InternalCompilerError();
+
+    /// Appends `arg` to the ICE message.
+    /// @param arg the argument to append to the ICE message
+    /// @returns this object so calls can be chained
+    template <typename T>
+    InternalCompilerError& operator<<(T&& arg) {
+        msg_ << std::forward<T>(arg);
+        return *this;
+    }
+
+  private:
+    char const* const file_;
+    const size_t line_;
+    diag::System system_;
+    diag::List& diagnostics_;
+    utils::StringStream msg_;
+};
+
+}  // namespace tint
+
+/// TINT_ICE() is a macro for appending an internal compiler error message
+/// to the diagnostics list `diagnostics`, and calling the
+/// InternalCompilerErrorReporter with the full diagnostic list if a reporter is
+/// set.
+/// The ICE message contains the callsite's file and line.
+/// Use the `<<` operator to append an error message to the ICE.
+#define TINT_ICE(system, diagnostics) \
+    tint::InternalCompilerError(__FILE__, __LINE__, ::tint::diag::System::system, diagnostics)
+
+/// TINT_UNREACHABLE() is a macro for appending a "TINT_UNREACHABLE"
+/// internal compiler error message to the diagnostics list `diagnostics`, and
+/// calling the InternalCompilerErrorReporter with the full diagnostic list if a
+/// reporter is set.
+/// The ICE message contains the callsite's file and line.
+/// Use the `<<` operator to append an error message to the ICE.
+#define TINT_UNREACHABLE(system, diagnostics) TINT_ICE(system, diagnostics) << "TINT_UNREACHABLE "
+
+/// TINT_UNIMPLEMENTED() is a macro for appending a "TINT_UNIMPLEMENTED"
+/// internal compiler error message to the diagnostics list `diagnostics`, and
+/// calling the InternalCompilerErrorReporter with the full diagnostic list if a
+/// reporter is set.
+/// The ICE message contains the callsite's file and line.
+/// Use the `<<` operator to append an error message to the ICE.
+#define TINT_UNIMPLEMENTED(system, diagnostics) \
+    TINT_ICE(system, diagnostics) << "TINT_UNIMPLEMENTED "
+
+/// TINT_ASSERT() is a macro for checking the expression is true, triggering a
+/// TINT_ICE if it is not.
+/// The ICE message contains the callsite's file and line.
+/// @warning: Unlike TINT_ICE() and TINT_UNREACHABLE(), TINT_ASSERT() does not
+/// append a message to an existing tint::diag::List. As such, TINT_ASSERT()
+/// may silently fail in builds where SetInternalCompilerErrorReporter() is not
+/// called. Only use in places where there's no sensible place to put proper
+/// error handling.
+#define TINT_ASSERT(system, condition)                                                   \
+    do {                                                                                 \
+        if (TINT_UNLIKELY(!(condition))) {                                               \
+            tint::diag::List diagnostics;                                                \
+            TINT_ICE(system, diagnostics) << "TINT_ASSERT(" #system ", " #condition ")"; \
+        }                                                                                \
+    } while (false)
+
+/// TINT_ASSERT_OR_RETURN() is a macro for checking the expression is true, triggering a
+/// TINT_ICE if it is not and returning from the calling function.
+/// The ICE message contains the callsite's file and line.
+/// @warning: Unlike TINT_ICE() and TINT_UNREACHABLE(), TINT_ASSERT_OR_RETURN() does not
+/// append a message to an existing tint::diag::List. As such, TINT_ASSERT_OR_RETURN()
+/// may silently fail in builds where SetInternalCompilerErrorReporter() is not
+/// called. Only use in places where there's no sensible place to put proper
+/// error handling.
+#define TINT_ASSERT_OR_RETURN(system, condition)                                         \
+    do {                                                                                 \
+        if (TINT_UNLIKELY(!(condition))) {                                               \
+            tint::diag::List diagnostics;                                                \
+            TINT_ICE(system, diagnostics) << "TINT_ASSERT(" #system ", " #condition ")"; \
+            return;                                                                      \
+        }                                                                                \
+    } while (false)
+
+/// TINT_ASSERT_OR_RETURN_VALUE() is a macro for checking the expression is true, triggering a
+/// TINT_ICE if it is not and returning a value from the calling function.
+/// The ICE message contains the callsite's file and line.
+/// @warning: Unlike TINT_ICE() and TINT_UNREACHABLE(), TINT_ASSERT_OR_RETURN_VALUE() does not
+/// append a message to an existing tint::diag::List. As such, TINT_ASSERT_OR_RETURN_VALUE()
+/// may silently fail in builds where SetInternalCompilerErrorReporter() is not
+/// called. Only use in places where there's no sensible place to put proper
+/// error handling.
+#define TINT_ASSERT_OR_RETURN_VALUE(system, condition, value)                            \
+    do {                                                                                 \
+        if (TINT_UNLIKELY(!(condition))) {                                               \
+            tint::diag::List diagnostics;                                                \
+            TINT_ICE(system, diagnostics) << "TINT_ASSERT(" #system ", " #condition ")"; \
+            return value;                                                                \
+        }                                                                                \
+    } while (false)
+
+#endif  // SRC_TINT_UTILS_DEBUG_DEBUG_H_
diff --git a/src/tint/utils/debug/debug_test.cc b/src/tint/utils/debug/debug_test.cc
new file mode 100644
index 0000000..3566427
--- /dev/null
+++ b/src/tint/utils/debug/debug_test.cc
@@ -0,0 +1,40 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/debug/debug.h"
+
+#include "gtest/gtest-spi.h"
+
+namespace tint {
+namespace {
+
+TEST(DebugTest, Unreachable) {
+    EXPECT_FATAL_FAILURE(
+        {
+            diag::List diagnostics;
+            TINT_UNREACHABLE(Test, diagnostics);
+        },
+        "internal compiler error");
+}
+
+TEST(DebugTest, AssertTrue) {
+    TINT_ASSERT(Test, true);
+}
+
+TEST(DebugTest, AssertFalse) {
+    EXPECT_FATAL_FAILURE({ TINT_ASSERT(Test, false); }, "internal compiler error");
+}
+
+}  // namespace
+}  // namespace tint
diff --git a/src/tint/utils/debug/debugger.cc b/src/tint/utils/debug/debugger.cc
new file mode 100644
index 0000000..0cb6b59
--- /dev/null
+++ b/src/tint/utils/debug/debugger.cc
@@ -0,0 +1,62 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/debug/debugger.h"
+
+#ifdef TINT_ENABLE_BREAK_IN_DEBUGGER
+
+#ifdef _MSC_VER
+#include <Windows.h>
+#elif defined(__linux__)
+#include <signal.h>
+#include <fstream>
+#include <string>
+#endif
+
+#ifdef _MSC_VER
+#define TINT_DEBUGGER_BREAK_DEFINED
+void tint::debugger::Break() {
+    if (::IsDebuggerPresent()) {
+        ::DebugBreak();
+    }
+}
+
+#elif defined(__linux__)
+
+#define TINT_DEBUGGER_BREAK_DEFINED
+void tint::debugger::Break() {
+    // A process is being traced (debugged) if "/proc/self/status" contains a
+    // line with "TracerPid: <non-zero-digit>...".
+    bool is_traced = false;
+    std::ifstream fin("/proc/self/status");
+    std::string line;
+    while (!is_traced && std::getline(fin, line)) {
+        const char kPrefix[] = "TracerPid:\t";
+        static constexpr int kPrefixLen = sizeof(kPrefix) - 1;
+        if (line.length() > kPrefixLen && line.compare(0, kPrefixLen, kPrefix) == 0) {
+            is_traced = line[kPrefixLen] != '0';
+        }
+    }
+
+    if (is_traced) {
+        raise(SIGTRAP);
+    }
+}
+#endif  // platform
+
+#endif  // TINT_ENABLE_BREAK_IN_DEBUGGER
+
+#ifndef TINT_DEBUGGER_BREAK_DEFINED
+void tint::debugger::Break() {}
+#endif
diff --git a/src/tint/utils/debug/debugger.h b/src/tint/utils/debug/debugger.h
new file mode 100644
index 0000000..2558678
--- /dev/null
+++ b/src/tint/utils/debug/debugger.h
@@ -0,0 +1,27 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_DEBUG_DEBUGGER_H_
+#define SRC_TINT_UTILS_DEBUG_DEBUGGER_H_
+
+namespace tint::debugger {
+
+/// If debugger is attached and the `TINT_ENABLE_BREAK_IN_DEBUGGER` preprocessor
+/// macro is defined for `debugger.cc`, calling `Break()` will cause the
+/// debugger to break at the call site.
+void Break();
+
+}  // namespace tint::debugger
+
+#endif  // SRC_TINT_UTILS_DEBUG_DEBUGGER_H_
diff --git a/src/tint/utils/diagnostic/diagnostic.cc b/src/tint/utils/diagnostic/diagnostic.cc
new file mode 100644
index 0000000..f108c44
--- /dev/null
+++ b/src/tint/utils/diagnostic/diagnostic.cc
@@ -0,0 +1,51 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/diagnostic/diagnostic.h"
+
+#include <unordered_map>
+
+#include "src/tint/utils/diagnostic/formatter.h"
+
+namespace tint::diag {
+
+Diagnostic::Diagnostic() = default;
+Diagnostic::Diagnostic(const Diagnostic&) = default;
+Diagnostic::~Diagnostic() = default;
+Diagnostic& Diagnostic::operator=(const Diagnostic&) = default;
+
+List::List() = default;
+List::List(std::initializer_list<Diagnostic> list) : entries_(list) {}
+List::List(const List& rhs) = default;
+
+List::List(List&& rhs) = default;
+
+List::~List() = default;
+
+List& List::operator=(const List& rhs) = default;
+
+List& List::operator=(List&& rhs) = default;
+
+std::string List::str() const {
+    diag::Formatter::Style style;
+    style.print_newline_at_end = false;
+    return Formatter{style}.format(*this);
+}
+
+std::ostream& operator<<(std::ostream& out, const List& list) {
+    out << list.str();
+    return out;
+}
+
+}  // namespace tint::diag
diff --git a/src/tint/utils/diagnostic/diagnostic.h b/src/tint/utils/diagnostic/diagnostic.h
new file mode 100644
index 0000000..0eb7727
--- /dev/null
+++ b/src/tint/utils/diagnostic/diagnostic.h
@@ -0,0 +1,261 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_DIAGNOSTIC_DIAGNOSTIC_H_
+#define SRC_TINT_UTILS_DIAGNOSTIC_DIAGNOSTIC_H_
+
+#include <memory>
+#include <ostream>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "src/tint/utils/diagnostic/source.h"
+
+namespace tint::diag {
+
+/// Severity is an enumerator of diagnostic severities.
+enum class Severity { Note, Warning, Error, InternalCompilerError, Fatal };
+
+/// @return true iff `a` is more than, or of equal severity to `b`
+inline bool operator>=(Severity a, Severity b) {
+    return static_cast<int>(a) >= static_cast<int>(b);
+}
+
+/// System is an enumerator of Tint systems that can be the originator of a
+/// diagnostic message.
+enum class System {
+    AST,
+    Builtin,
+    Clone,
+    Constant,
+    Inspector,
+    IR,
+    Program,
+    ProgramBuilder,
+    Reader,
+    Resolver,
+    Semantic,
+    Symbol,
+    Test,
+    Transform,
+    Type,
+    Utils,
+    Writer,
+};
+
+/// Diagnostic holds all the information for a single compiler diagnostic
+/// message.
+class Diagnostic {
+  public:
+    /// Constructor
+    Diagnostic();
+    /// Copy constructor
+    Diagnostic(const Diagnostic&);
+    /// Destructor
+    ~Diagnostic();
+
+    /// Copy assignment operator
+    /// @return this diagnostic
+    Diagnostic& operator=(const Diagnostic&);
+
+    /// severity is the severity of the diagnostic message.
+    Severity severity = Severity::Error;
+    /// source is the location of the diagnostic.
+    Source source;
+    /// message is the text associated with the diagnostic.
+    std::string message;
+    /// system is the Tint system that raised the diagnostic.
+    System system;
+    /// code is the error code, for example a validation error might have the code
+    /// `"v-0001"`.
+    const char* code = nullptr;
+    /// A shared pointer to a Source::File. Only used if the diagnostic Source
+    /// points to a file that was created specifically for this diagnostic
+    /// (usually an ICE).
+    std::shared_ptr<Source::File> owned_file = nullptr;
+};
+
+/// List is a container of Diagnostic messages.
+class List {
+  public:
+    /// iterator is the type used for range based iteration.
+    using iterator = std::vector<Diagnostic>::const_iterator;
+
+    /// Constructs the list with no elements.
+    List();
+
+    /// Copy constructor. Copies the diagnostics from `list` into this list.
+    /// @param list the list of diagnostics to copy into this list.
+    List(std::initializer_list<Diagnostic> list);
+
+    /// Copy constructor. Copies the diagnostics from `list` into this list.
+    /// @param list the list of diagnostics to copy into this list.
+    List(const List& list);
+
+    /// Move constructor. Moves the diagnostics from `list` into this list.
+    /// @param list the list of diagnostics to move into this list.
+    List(List&& list);
+
+    /// Destructor
+    ~List();
+
+    /// Assignment operator. Copies the diagnostics from `list` into this list.
+    /// @param list the list to copy into this list.
+    /// @return this list.
+    List& operator=(const List& list);
+
+    /// Assignment move operator. Moves the diagnostics from `list` into this
+    /// list.
+    /// @param list the list to move into this list.
+    /// @return this list.
+    List& operator=(List&& list);
+
+    /// adds a diagnostic to the end of this list.
+    /// @param diag the diagnostic to append to this list.
+    void add(Diagnostic&& diag) {
+        if (diag.severity >= Severity::Error) {
+            error_count_++;
+        }
+        entries_.emplace_back(std::move(diag));
+    }
+
+    /// adds a list of diagnostics to the end of this list.
+    /// @param list the diagnostic to append to this list.
+    void add(const List& list) {
+        for (auto diag : list) {
+            add(std::move(diag));
+        }
+    }
+
+    /// adds the note message with the given Source to the end of this list.
+    /// @param system the system raising the note message
+    /// @param note_msg the note message
+    /// @param source the source of the note diagnostic
+    void add_note(System system, std::string_view note_msg, const Source& source) {
+        diag::Diagnostic note{};
+        note.severity = diag::Severity::Note;
+        note.system = system;
+        note.source = source;
+        note.message = note_msg;
+        add(std::move(note));
+    }
+
+    /// adds the warning message with the given Source to the end of this list.
+    /// @param system the system raising the warning message
+    /// @param warning_msg the warning message
+    /// @param source the source of the warning diagnostic
+    void add_warning(System system, std::string_view warning_msg, const Source& source) {
+        diag::Diagnostic warning{};
+        warning.severity = diag::Severity::Warning;
+        warning.system = system;
+        warning.source = source;
+        warning.message = warning_msg;
+        add(std::move(warning));
+    }
+
+    /// adds the error message without a source to the end of this list.
+    /// @param system the system raising the error message
+    /// @param err_msg the error message
+    void add_error(System system, std::string_view err_msg) {
+        diag::Diagnostic error{};
+        error.severity = diag::Severity::Error;
+        error.system = system;
+        error.message = err_msg;
+        add(std::move(error));
+    }
+
+    /// adds the error message with the given Source to the end of this list.
+    /// @param system the system raising the error message
+    /// @param err_msg the error message
+    /// @param source the source of the error diagnostic
+    void add_error(System system, std::string_view err_msg, const Source& source) {
+        diag::Diagnostic error{};
+        error.severity = diag::Severity::Error;
+        error.system = system;
+        error.source = source;
+        error.message = err_msg;
+        add(std::move(error));
+    }
+
+    /// adds the error message with the given code and Source to the end of this
+    /// list.
+    /// @param system the system raising the error message
+    /// @param code the error code
+    /// @param err_msg the error message
+    /// @param source the source of the error diagnostic
+    void add_error(System system,
+                   const char* code,
+                   std::string_view err_msg,
+                   const Source& source) {
+        diag::Diagnostic error{};
+        error.code = code;
+        error.severity = diag::Severity::Error;
+        error.system = system;
+        error.source = source;
+        error.message = err_msg;
+        add(std::move(error));
+    }
+
+    /// adds an internal compiler error message to the end of this list.
+    /// @param system the system raising the error message
+    /// @param err_msg the error message
+    /// @param source the source of the internal compiler error
+    /// @param file the Source::File owned by this diagnostic
+    void add_ice(System system,
+                 std::string_view err_msg,
+                 const Source& source,
+                 std::shared_ptr<Source::File> file) {
+        diag::Diagnostic ice{};
+        ice.severity = diag::Severity::InternalCompilerError;
+        ice.system = system;
+        ice.source = source;
+        ice.message = err_msg;
+        ice.owned_file = std::move(file);
+        add(std::move(ice));
+    }
+
+    /// @returns true iff the diagnostic list contains errors diagnostics (or of
+    /// higher severity).
+    bool contains_errors() const { return error_count_ > 0; }
+    /// @returns the number of error diagnostics (or of higher severity).
+    size_t error_count() const { return error_count_; }
+    /// @returns the number of entries in the list.
+    size_t count() const { return entries_.size(); }
+    /// @returns true if the diagnostics list is empty
+    bool empty() const { return entries_.empty(); }
+    /// @returns the number of entrise in the diagnostics list
+    size_t size() const { return entries_.size(); }
+    /// @returns the first diagnostic in the list.
+    iterator begin() const { return entries_.begin(); }
+    /// @returns the last diagnostic in the list.
+    iterator end() const { return entries_.end(); }
+
+    /// @returns a formatted string of all the diagnostics in this list.
+    std::string str() const;
+
+  private:
+    std::vector<Diagnostic> entries_;
+    size_t error_count_ = 0;
+};
+
+/// Write the diagnostic list to the given stream
+/// @param out the output stream
+/// @param list the list to emit
+/// @returns the output stream
+std::ostream& operator<<(std::ostream& out, const List& list);
+
+}  // namespace tint::diag
+
+#endif  // SRC_TINT_UTILS_DIAGNOSTIC_DIAGNOSTIC_H_
diff --git a/src/tint/utils/diagnostic/diagnostic_test.cc b/src/tint/utils/diagnostic/diagnostic_test.cc
new file mode 100644
index 0000000..0077649
--- /dev/null
+++ b/src/tint/utils/diagnostic/diagnostic_test.cc
@@ -0,0 +1,40 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/diagnostic/formatter.h"
+
+#include "gtest/gtest.h"
+#include "src/tint/utils/diagnostic/diagnostic.h"
+
+namespace tint::diag {
+namespace {
+
+TEST(DiagListTest, OwnedFilesShared) {
+    auto file = std::make_shared<Source::File>("path", "content");
+
+    diag::List list_a, list_b;
+    {
+        diag::Diagnostic diag{};
+        diag.source = Source{Source::Range{{0, 0}}, file.get()};
+        list_a.add(std::move(diag));
+    }
+
+    list_b = list_a;
+
+    ASSERT_EQ(list_b.count(), list_a.count());
+    EXPECT_EQ(list_b.begin()->source.file, file.get());
+}
+
+}  // namespace
+}  // namespace tint::diag
diff --git a/src/tint/utils/diagnostic/formatter.cc b/src/tint/utils/diagnostic/formatter.cc
new file mode 100644
index 0000000..416551f
--- /dev/null
+++ b/src/tint/utils/diagnostic/formatter.cc
@@ -0,0 +1,267 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/diagnostic/formatter.h"
+
+#include <algorithm>
+#include <iterator>
+#include <utility>
+#include <vector>
+
+#include "src/tint/utils/diagnostic/diagnostic.h"
+#include "src/tint/utils/diagnostic/printer.h"
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint::diag {
+namespace {
+
+const char* to_str(Severity severity) {
+    switch (severity) {
+        case Severity::Note:
+            return "note";
+        case Severity::Warning:
+            return "warning";
+        case Severity::Error:
+            return "error";
+        case Severity::InternalCompilerError:
+            return "internal compiler error";
+        case Severity::Fatal:
+            return "fatal";
+    }
+    return "";
+}
+
+std::string to_str(const Source::Location& location) {
+    utils::StringStream ss;
+    if (location.line > 0) {
+        ss << location.line;
+        if (location.column > 0) {
+            ss << ":" << location.column;
+        }
+    }
+    return ss.str();
+}
+
+}  // namespace
+
+/// State holds the internal formatter state for a format() call.
+struct Formatter::State {
+    /// Constructs a State associated with the given printer.
+    /// @param p the printer to write formatted messages to.
+    explicit State(Printer* p) : printer(p) {}
+    ~State() { flush(); }
+
+    /// set_style() sets the current style to new_style, flushing any pending
+    /// messages to the printer if the style changed.
+    /// @param new_style the new style to apply for future written messages.
+    void set_style(const diag::Style& new_style) {
+        if (style.color != new_style.color || style.bold != new_style.bold) {
+            flush();
+            style = new_style;
+        }
+    }
+
+    /// flush writes any pending messages to the printer, clearing the buffer.
+    void flush() {
+        auto str = stream.str();
+        if (str.length() > 0) {
+            printer->write(str, style);
+            utils::StringStream reset;
+            stream.swap(reset);
+        }
+    }
+
+    /// operator<< queues msg to be written to the printer.
+    /// @param msg the value or string to write to the printer
+    /// @returns this State so that calls can be chained
+    template <typename T>
+    State& operator<<(T&& msg) {
+        stream << std::forward<T>(msg);
+        return *this;
+    }
+
+    /// newline queues a newline to be written to the printer.
+    void newline() { stream << std::endl; }
+
+    /// repeat queues the character c to be written to the printer n times.
+    /// @param c the character to print `n` times
+    /// @param n the number of times to print character `c`
+    void repeat(char c, size_t n) { stream.repeat(c, n); }
+
+  private:
+    Printer* printer;
+    diag::Style style;
+    utils::StringStream stream;
+};
+
+Formatter::Formatter() {}
+Formatter::Formatter(const Style& style) : style_(style) {}
+
+void Formatter::format(const List& list, Printer* printer) const {
+    State state{printer};
+
+    bool first = true;
+    for (auto diag : list) {
+        state.set_style({});
+        if (!first) {
+            state.newline();
+        }
+        format(diag, state);
+        first = false;
+    }
+
+    if (style_.print_newline_at_end) {
+        state.newline();
+    }
+}
+
+void Formatter::format(const Diagnostic& diag, State& state) const {
+    auto const& src = diag.source;
+    auto const& rng = src.range;
+    bool has_code = diag.code != nullptr && diag.code[0] != '\0';
+
+    state.set_style({Color::kDefault, true});
+
+    struct TextAndColor {
+        std::string text;
+        Color color;
+        bool bold = false;
+    };
+    std::vector<TextAndColor> prefix;
+    prefix.reserve(6);
+
+    if (style_.print_file && src.file != nullptr) {
+        if (rng.begin.line > 0) {
+            prefix.emplace_back(
+                TextAndColor{src.file->path + ":" + to_str(rng.begin), Color::kDefault});
+        } else {
+            prefix.emplace_back(TextAndColor{src.file->path, Color::kDefault});
+        }
+    } else if (rng.begin.line > 0) {
+        prefix.emplace_back(TextAndColor{to_str(rng.begin), Color::kDefault});
+    }
+
+    Color severity_color = Color::kDefault;
+    switch (diag.severity) {
+        case Severity::Note:
+            break;
+        case Severity::Warning:
+            severity_color = Color::kYellow;
+            break;
+        case Severity::Error:
+            severity_color = Color::kRed;
+            break;
+        case Severity::Fatal:
+        case Severity::InternalCompilerError:
+            severity_color = Color::kMagenta;
+            break;
+    }
+    if (style_.print_severity) {
+        prefix.emplace_back(TextAndColor{to_str(diag.severity), severity_color, true});
+    }
+    if (has_code) {
+        prefix.emplace_back(TextAndColor{diag.code, severity_color});
+    }
+
+    for (size_t i = 0; i < prefix.size(); i++) {
+        if (i > 0) {
+            state << " ";
+        }
+        state.set_style({prefix[i].color, prefix[i].bold});
+        state << prefix[i].text;
+    }
+
+    state.set_style({Color::kDefault, true});
+    if (!prefix.empty()) {
+        state << ": ";
+    }
+    state << diag.message;
+
+    if (style_.print_line && src.file && rng.begin.line > 0) {
+        state.newline();
+        state.set_style({Color::kDefault, false});
+
+        for (size_t line_num = rng.begin.line;
+             (line_num <= rng.end.line) && (line_num <= src.file->content.lines.size());
+             line_num++) {
+            auto& line = src.file->content.lines[line_num - 1];
+            auto line_len = line.size();
+
+            bool is_ascii = true;
+            for (auto c : line) {
+                if (c == '\t') {
+                    state.repeat(' ', style_.tab_width);
+                } else {
+                    state << c;
+                }
+                if (c & 0x80) {
+                    is_ascii = false;
+                }
+            }
+
+            state.newline();
+
+            // If the line contains non-ascii characters, then we cannot assume that
+            // a single utf8 code unit represents a single glyph, so don't attempt to
+            // draw squiggles.
+            if (!is_ascii) {
+                continue;
+            }
+
+            state.set_style({Color::kCyan, false});
+
+            // Count the number of glyphs in the line span.
+            // start and end use 1-based indexing.
+            auto num_glyphs = [&](size_t start, size_t end) {
+                size_t count = 0;
+                start = (start > 0) ? (start - 1) : 0;
+                end = (end > 0) ? (end - 1) : 0;
+                for (size_t i = start; (i < end) && (i < line_len); i++) {
+                    count += (line[i] == '\t') ? style_.tab_width : 1;
+                }
+                return count;
+            };
+
+            if (line_num == rng.begin.line && line_num == rng.end.line) {
+                // Single line
+                state.repeat(' ', num_glyphs(1, rng.begin.column));
+                state.repeat('^',
+                             std::max<size_t>(num_glyphs(rng.begin.column, rng.end.column), 1));
+            } else if (line_num == rng.begin.line) {
+                // Start of multi-line
+                state.repeat(' ', num_glyphs(1, rng.begin.column));
+                state.repeat('^', num_glyphs(rng.begin.column, line_len + 1));
+            } else if (line_num == rng.end.line) {
+                // End of multi-line
+                state.repeat('^', num_glyphs(1, rng.end.column));
+            } else {
+                // Middle of multi-line
+                state.repeat('^', num_glyphs(1, line_len + 1));
+            }
+            state.newline();
+        }
+
+        state.set_style({});
+    }
+}
+
+std::string Formatter::format(const List& list) const {
+    StringPrinter printer;
+    format(list, &printer);
+    return printer.str();
+}
+
+Formatter::~Formatter() = default;
+
+}  // namespace tint::diag
diff --git a/src/tint/utils/diagnostic/formatter.h b/src/tint/utils/diagnostic/formatter.h
new file mode 100644
index 0000000..1b9a9b3
--- /dev/null
+++ b/src/tint/utils/diagnostic/formatter.h
@@ -0,0 +1,70 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_DIAGNOSTIC_FORMATTER_H_
+#define SRC_TINT_UTILS_DIAGNOSTIC_FORMATTER_H_
+
+#include <string>
+
+namespace tint::diag {
+
+class Diagnostic;
+class List;
+class Printer;
+
+/// Formatter are used to print a list of diagnostics messages.
+class Formatter {
+  public:
+    /// Style controls the formatter's output style.
+    struct Style {
+        /// include the file path for each diagnostic
+        bool print_file = true;
+        /// include the severity for each diagnostic
+        bool print_severity = true;
+        /// include the source line(s) for the diagnostic
+        bool print_line = true;
+        /// print a newline at the end of a diagnostic list
+        bool print_newline_at_end = true;
+        /// width of a tab character
+        size_t tab_width = 2u;
+    };
+
+    /// Constructor for the formatter using a default style.
+    Formatter();
+
+    /// Constructor for the formatter using the custom style.
+    /// @param style the style used for the formatter.
+    explicit Formatter(const Style& style);
+
+    ~Formatter();
+
+    /// @param list the list of diagnostic messages to format
+    /// @param printer the printer used to display the formatted diagnostics
+    void format(const List& list, Printer* printer) const;
+
+    /// @return the list of diagnostics `list` formatted to a string.
+    /// @param list the list of diagnostic messages to format
+    std::string format(const List& list) const;
+
+  private:
+    struct State;
+
+    void format(const Diagnostic& diag, State& state) const;
+
+    const Style style_;
+};
+
+}  // namespace tint::diag
+
+#endif  // SRC_TINT_UTILS_DIAGNOSTIC_FORMATTER_H_
diff --git a/src/tint/utils/diagnostic/formatter_test.cc b/src/tint/utils/diagnostic/formatter_test.cc
new file mode 100644
index 0000000..242be15
--- /dev/null
+++ b/src/tint/utils/diagnostic/formatter_test.cc
@@ -0,0 +1,294 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/diagnostic/formatter.h"
+
+#include <utility>
+
+#include "gtest/gtest.h"
+#include "src/tint/utils/diagnostic/diagnostic.h"
+
+namespace tint::diag {
+namespace {
+
+Diagnostic Diag(Severity severity,
+                Source source,
+                std::string message,
+                System system,
+                const char* code = nullptr) {
+    Diagnostic d;
+    d.severity = severity;
+    d.source = source;
+    d.message = std::move(message);
+    d.system = system;
+    d.code = code;
+    return d;
+}
+
+constexpr const char* ascii_content =  // Note: words are tab-delimited
+    R"(the	cat	says	meow
+the	dog	says	woof
+the	snake	says	quack
+the	snail	says	???
+)";
+
+constexpr const char* utf8_content =                      // Note: words are tab-delimited
+    "the	\xf0\x9f\x90\xb1	says	meow\n"   // NOLINT: tabs
+    "the	\xf0\x9f\x90\x95	says	woof\n"   // NOLINT: tabs
+    "the	\xf0\x9f\x90\x8d	says	quack\n"  // NOLINT: tabs
+    "the	\xf0\x9f\x90\x8c	says	???\n";   // NOLINT: tabs
+
+class DiagFormatterTest : public testing::Test {
+  public:
+    Source::File ascii_file{"file.name", ascii_content};
+    Source::File utf8_file{"file.name", utf8_content};
+    Diagnostic ascii_diag_note = Diag(Severity::Note,
+                                      Source{Source::Range{Source::Location{1, 14}}, &ascii_file},
+                                      "purr",
+                                      System::Test);
+    Diagnostic ascii_diag_warn = Diag(Severity::Warning,
+                                      Source{Source::Range{{2, 14}, {2, 18}}, &ascii_file},
+                                      "grrr",
+                                      System::Test);
+    Diagnostic ascii_diag_err = Diag(Severity::Error,
+                                     Source{Source::Range{{3, 16}, {3, 21}}, &ascii_file},
+                                     "hiss",
+                                     System::Test,
+                                     "abc123");
+    Diagnostic ascii_diag_ice = Diag(Severity::InternalCompilerError,
+                                     Source{Source::Range{{4, 16}, {4, 19}}, &ascii_file},
+                                     "unreachable",
+                                     System::Test);
+    Diagnostic ascii_diag_fatal = Diag(Severity::Fatal,
+                                       Source{Source::Range{{4, 16}, {4, 19}}, &ascii_file},
+                                       "nothing",
+                                       System::Test);
+
+    Diagnostic utf8_diag_note = Diag(Severity::Note,
+                                     Source{Source::Range{Source::Location{1, 15}}, &utf8_file},
+                                     "purr",
+                                     System::Test);
+    Diagnostic utf8_diag_warn = Diag(Severity::Warning,
+                                     Source{Source::Range{{2, 15}, {2, 19}}, &utf8_file},
+                                     "grrr",
+                                     System::Test);
+    Diagnostic utf8_diag_err = Diag(Severity::Error,
+                                    Source{Source::Range{{3, 15}, {3, 20}}, &utf8_file},
+                                    "hiss",
+                                    System::Test,
+                                    "abc123");
+    Diagnostic utf8_diag_ice = Diag(Severity::InternalCompilerError,
+                                    Source{Source::Range{{4, 15}, {4, 18}}, &utf8_file},
+                                    "unreachable",
+                                    System::Test);
+    Diagnostic utf8_diag_fatal = Diag(Severity::Fatal,
+                                      Source{Source::Range{{4, 15}, {4, 18}}, &utf8_file},
+                                      "nothing",
+                                      System::Test);
+};
+
+TEST_F(DiagFormatterTest, Simple) {
+    Formatter fmt{{false, false, false, false}};
+    auto got = fmt.format(List{ascii_diag_note, ascii_diag_warn, ascii_diag_err});
+    auto* expect = R"(1:14: purr
+2:14: grrr
+3:16 abc123: hiss)";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, SimpleNewlineAtEnd) {
+    Formatter fmt{{false, false, false, true}};
+    auto got = fmt.format(List{ascii_diag_note, ascii_diag_warn, ascii_diag_err});
+    auto* expect = R"(1:14: purr
+2:14: grrr
+3:16 abc123: hiss
+)";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, SimpleNoSource) {
+    Formatter fmt{{false, false, false, false}};
+    auto diag = Diag(Severity::Note, Source{}, "no source!", System::Test);
+    auto got = fmt.format(List{diag});
+    auto* expect = "no source!";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, WithFile) {
+    Formatter fmt{{true, false, false, false}};
+    auto got = fmt.format(List{ascii_diag_note, ascii_diag_warn, ascii_diag_err});
+    auto* expect = R"(file.name:1:14: purr
+file.name:2:14: grrr
+file.name:3:16 abc123: hiss)";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, WithSeverity) {
+    Formatter fmt{{false, true, false, false}};
+    auto got = fmt.format(List{ascii_diag_note, ascii_diag_warn, ascii_diag_err});
+    auto* expect = R"(1:14 note: purr
+2:14 warning: grrr
+3:16 error abc123: hiss)";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, WithLine) {
+    Formatter fmt{{false, false, true, false}};
+    auto got = fmt.format(List{ascii_diag_note, ascii_diag_warn, ascii_diag_err});
+    auto* expect = R"(1:14: purr
+the  cat  says  meow
+                ^
+
+2:14: grrr
+the  dog  says  woof
+                ^^^^
+
+3:16 abc123: hiss
+the  snake  says  quack
+                  ^^^^^
+)";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, UnicodeWithLine) {
+    Formatter fmt{{false, false, true, false}};
+    auto got = fmt.format(List{utf8_diag_note, utf8_diag_warn, utf8_diag_err});
+    auto* expect =
+        "1:15: purr\n"
+        "the  \xf0\x9f\x90\xb1  says  meow\n"
+        "\n"
+        "2:15: grrr\n"
+        "the  \xf0\x9f\x90\x95  says  woof\n"
+        "\n"
+        "3:15 abc123: hiss\n"
+        "the  \xf0\x9f\x90\x8d  says  quack\n";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, BasicWithFileSeverityLine) {
+    Formatter fmt{{true, true, true, false}};
+    auto got = fmt.format(List{ascii_diag_note, ascii_diag_warn, ascii_diag_err});
+    auto* expect = R"(file.name:1:14 note: purr
+the  cat  says  meow
+                ^
+
+file.name:2:14 warning: grrr
+the  dog  says  woof
+                ^^^^
+
+file.name:3:16 error abc123: hiss
+the  snake  says  quack
+                  ^^^^^
+)";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, BasicWithMultiLine) {
+    auto multiline = Diag(Severity::Warning, Source{Source::Range{{2, 9}, {4, 15}}, &ascii_file},
+                          "multiline", System::Test);
+    Formatter fmt{{false, false, true, false}};
+    auto got = fmt.format(List{multiline});
+    auto* expect = R"(2:9: multiline
+the  dog  says  woof
+          ^^^^^^^^^^
+the  snake  says  quack
+^^^^^^^^^^^^^^^^^^^^^^^
+the  snail  says  ???
+^^^^^^^^^^^^^^^^
+)";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, UnicodeWithMultiLine) {
+    auto multiline = Diag(Severity::Warning, Source{Source::Range{{2, 9}, {4, 15}}, &utf8_file},
+                          "multiline", System::Test);
+    Formatter fmt{{false, false, true, false}};
+    auto got = fmt.format(List{multiline});
+    auto* expect =
+        "2:9: multiline\n"
+        "the  \xf0\x9f\x90\x95  says  woof\n"
+        "the  \xf0\x9f\x90\x8d  says  quack\n"
+        "the  \xf0\x9f\x90\x8c  says  ???\n";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, BasicWithFileSeverityLineTab4) {
+    Formatter fmt{{true, true, true, false, 4u}};
+    auto got = fmt.format(List{ascii_diag_note, ascii_diag_warn, ascii_diag_err});
+    auto* expect = R"(file.name:1:14 note: purr
+the    cat    says    meow
+                      ^
+
+file.name:2:14 warning: grrr
+the    dog    says    woof
+                      ^^^^
+
+file.name:3:16 error abc123: hiss
+the    snake    says    quack
+                        ^^^^^
+)";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, BasicWithMultiLineTab4) {
+    auto multiline = Diag(Severity::Warning, Source{Source::Range{{2, 9}, {4, 15}}, &ascii_file},
+                          "multiline", System::Test);
+    Formatter fmt{{false, false, true, false, 4u}};
+    auto got = fmt.format(List{multiline});
+    auto* expect = R"(2:9: multiline
+the    dog    says    woof
+              ^^^^^^^^^^^^
+the    snake    says    quack
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+the    snail    says    ???
+^^^^^^^^^^^^^^^^^^^^
+)";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, ICE) {
+    Formatter fmt{{}};
+    auto got = fmt.format(List{ascii_diag_ice});
+    auto* expect = R"(file.name:4:16 internal compiler error: unreachable
+the  snail  says  ???
+                  ^^^
+
+)";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, Fatal) {
+    Formatter fmt{{}};
+    auto got = fmt.format(List{ascii_diag_fatal});
+    auto* expect = R"(file.name:4:16 fatal: nothing
+the  snail  says  ???
+                  ^^^
+
+)";
+    ASSERT_EQ(expect, got);
+}
+
+TEST_F(DiagFormatterTest, RangeOOB) {
+    Formatter fmt{{true, true, true, true}};
+    diag::List list;
+    list.add_error(System::Test, "oob", Source{{{10, 20}, {30, 20}}, &ascii_file});
+    auto got = fmt.format(list);
+    auto* expect = R"(file.name:10:20 error: oob
+
+)";
+    ASSERT_EQ(expect, got);
+}
+
+}  // namespace
+}  // namespace tint::diag
diff --git a/src/tint/utils/diagnostic/printer.cc b/src/tint/utils/diagnostic/printer.cc
new file mode 100644
index 0000000..f2c3345
--- /dev/null
+++ b/src/tint/utils/diagnostic/printer.cc
@@ -0,0 +1,32 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/diagnostic/printer.h"
+
+namespace tint::diag {
+
+Printer::~Printer() = default;
+
+StringPrinter::StringPrinter() = default;
+StringPrinter::~StringPrinter() = default;
+
+std::string StringPrinter::str() const {
+    return stream.str();
+}
+
+void StringPrinter::write(const std::string& str, const Style&) {
+    stream << str;
+}
+
+}  // namespace tint::diag
diff --git a/src/tint/utils/diagnostic/printer.h b/src/tint/utils/diagnostic/printer.h
new file mode 100644
index 0000000..07b2c2c
--- /dev/null
+++ b/src/tint/utils/diagnostic/printer.h
@@ -0,0 +1,82 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_DIAGNOSTIC_PRINTER_H_
+#define SRC_TINT_UTILS_DIAGNOSTIC_PRINTER_H_
+
+#include <memory>
+#include <string>
+
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint::diag {
+
+class List;
+
+/// Color is an enumerator of colors used by Style.
+enum class Color {
+    kDefault,
+    kBlack,
+    kRed,
+    kGreen,
+    kYellow,
+    kBlue,
+    kMagenta,
+    kCyan,
+    kWhite,
+};
+
+/// Style describes how a diagnostic message should be printed.
+struct Style {
+    /// The foreground text color
+    Color color = Color::kDefault;
+    /// If true the text will be displayed with a strong weight
+    bool bold = false;
+};
+
+/// Printers are used to print formatted diagnostic messages to a terminal.
+class Printer {
+  public:
+    /// @returns a diagnostic Printer
+    /// @param out the file to print to.
+    /// @param use_colors if true, the printer will use colors if `out` is a
+    /// terminal and supports them.
+    static std::unique_ptr<Printer> create(FILE* out, bool use_colors);
+
+    virtual ~Printer();
+
+    /// writes the string str to the printer with the given style.
+    /// @param str the string to write to the printer
+    /// @param style the style used to print `str`
+    virtual void write(const std::string& str, const Style& style) = 0;
+};
+
+/// StringPrinter is an implementation of Printer that writes to a std::string.
+class StringPrinter : public Printer {
+  public:
+    StringPrinter();
+    ~StringPrinter() override;
+
+    /// @returns the printed string.
+    std::string str() const;
+
+    void write(const std::string& str, const Style&) override;
+
+  private:
+    utils::StringStream stream;
+};
+
+}  // namespace tint::diag
+
+#endif  // SRC_TINT_UTILS_DIAGNOSTIC_PRINTER_H_
diff --git a/src/tint/utils/diagnostic/printer_other.cc b/src/tint/utils/diagnostic/printer_other.cc
new file mode 100644
index 0000000..2fe7c2d
--- /dev/null
+++ b/src/tint/utils/diagnostic/printer_other.cc
@@ -0,0 +1,40 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <cstring>
+
+#include "src/tint/utils/diagnostic/printer.h"
+
+namespace tint::diag {
+namespace {
+
+class PrinterOther : public Printer {
+  public:
+    explicit PrinterOther(FILE* f) : file(f) {}
+
+    void write(const std::string& str, const Style&) override {
+        fwrite(str.data(), 1, str.size(), file);
+    }
+
+  private:
+    FILE* file;
+};
+
+}  // namespace
+
+std::unique_ptr<Printer> Printer::create(FILE* out, bool) {
+    return std::make_unique<PrinterOther>(out);
+}
+
+}  // namespace tint::diag
diff --git a/src/tint/utils/diagnostic/printer_posix.cc b/src/tint/utils/diagnostic/printer_posix.cc
new file mode 100644
index 0000000..0d5e092
--- /dev/null
+++ b/src/tint/utils/diagnostic/printer_posix.cc
@@ -0,0 +1,97 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <unistd.h>
+
+#include <cstring>
+
+#include "src/tint/utils/diagnostic/printer.h"
+
+namespace tint::diag {
+namespace {
+
+bool supports_colors(FILE* f) {
+    if (!isatty(fileno(f))) {
+        return false;
+    }
+
+    const char* cterm = getenv("TERM");
+    if (cterm == nullptr) {
+        return false;
+    }
+
+    std::string term = getenv("TERM");
+    if (term != "cygwin" && term != "linux" && term != "rxvt-unicode-256color" &&
+        term != "rxvt-unicode" && term != "screen-256color" && term != "screen" &&
+        term != "tmux-256color" && term != "tmux" && term != "xterm-256color" &&
+        term != "xterm-color" && term != "xterm") {
+        return false;
+    }
+
+    return true;
+}
+
+class PrinterPosix : public Printer {
+  public:
+    PrinterPosix(FILE* f, bool colors) : file(f), use_colors(colors && supports_colors(f)) {}
+
+    void write(const std::string& str, const Style& style) override {
+        write_color(style.color, style.bold);
+        fwrite(str.data(), 1, str.size(), file);
+        write_color(Color::kDefault, false);
+    }
+
+  private:
+    constexpr const char* color_code(Color color, bool bold) {
+        switch (color) {
+            case Color::kDefault:
+                return bold ? "\u001b[1m" : "\u001b[0m";
+            case Color::kBlack:
+                return bold ? "\u001b[30;1m" : "\u001b[30m";
+            case Color::kRed:
+                return bold ? "\u001b[31;1m" : "\u001b[31m";
+            case Color::kGreen:
+                return bold ? "\u001b[32;1m" : "\u001b[32m";
+            case Color::kYellow:
+                return bold ? "\u001b[33;1m" : "\u001b[33m";
+            case Color::kBlue:
+                return bold ? "\u001b[34;1m" : "\u001b[34m";
+            case Color::kMagenta:
+                return bold ? "\u001b[35;1m" : "\u001b[35m";
+            case Color::kCyan:
+                return bold ? "\u001b[36;1m" : "\u001b[36m";
+            case Color::kWhite:
+                return bold ? "\u001b[37;1m" : "\u001b[37m";
+        }
+        return "";  // unreachable
+    }
+
+    void write_color(Color color, bool bold) {
+        if (use_colors) {
+            auto* code = color_code(color, bold);
+            fwrite(code, 1, strlen(code), file);
+        }
+    }
+
+    FILE* const file;
+    const bool use_colors;
+};
+
+}  // namespace
+
+std::unique_ptr<Printer> Printer::create(FILE* out, bool use_colors) {
+    return std::make_unique<PrinterPosix>(out, use_colors);
+}
+
+}  // namespace tint::diag
diff --git a/src/tint/utils/diagnostic/printer_test.cc b/src/tint/utils/diagnostic/printer_test.cc
new file mode 100644
index 0000000..6af9470
--- /dev/null
+++ b/src/tint/utils/diagnostic/printer_test.cc
@@ -0,0 +1,96 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/diagnostic/printer.h"
+
+#include "gtest/gtest.h"
+
+namespace tint::diag {
+namespace {
+
+// Actually verifying that the expected colors are printed is exceptionally
+// difficult as:
+// a) The color emission varies by OS.
+// b) The logic checks to see if the printer is writing to a terminal, making
+//    mocking hard.
+// c) Actually probing what gets written to a FILE* is notoriously tricky.
+//
+// The least we can do is to exersice the code - which is what we do here.
+// The test will print each of the colors, and can be examined with human
+// eyeballs.
+// This can be enabled or disabled with ENABLE_PRINTER_TESTS
+#define ENABLE_PRINTER_TESTS 0
+#if ENABLE_PRINTER_TESTS
+
+using PrinterTest = testing::Test;
+
+TEST_F(PrinterTest, WithColors) {
+    auto printer = Printer::create(stdout, true);
+    printer->write("Default", Style{Color::kDefault, false});
+    printer->write("Black", Style{Color::kBlack, false});
+    printer->write("Red", Style{Color::kRed, false});
+    printer->write("Green", Style{Color::kGreen, false});
+    printer->write("Yellow", Style{Color::kYellow, false});
+    printer->write("Blue", Style{Color::kBlue, false});
+    printer->write("Magenta", Style{Color::kMagenta, false});
+    printer->write("Cyan", Style{Color::kCyan, false});
+    printer->write("White", Style{Color::kWhite, false});
+    printf("\n");
+}
+
+TEST_F(PrinterTest, BoldWithColors) {
+    auto printer = Printer::create(stdout, true);
+    printer->write("Default", Style{Color::kDefault, true});
+    printer->write("Black", Style{Color::kBlack, true});
+    printer->write("Red", Style{Color::kRed, true});
+    printer->write("Green", Style{Color::kGreen, true});
+    printer->write("Yellow", Style{Color::kYellow, true});
+    printer->write("Blue", Style{Color::kBlue, true});
+    printer->write("Magenta", Style{Color::kMagenta, true});
+    printer->write("Cyan", Style{Color::kCyan, true});
+    printer->write("White", Style{Color::kWhite, true});
+    printf("\n");
+}
+
+TEST_F(PrinterTest, WithoutColors) {
+    auto printer = Printer::create(stdout, false);
+    printer->write("Default", Style{Color::kDefault, false});
+    printer->write("Black", Style{Color::kBlack, false});
+    printer->write("Red", Style{Color::kRed, false});
+    printer->write("Green", Style{Color::kGreen, false});
+    printer->write("Yellow", Style{Color::kYellow, false});
+    printer->write("Blue", Style{Color::kBlue, false});
+    printer->write("Magenta", Style{Color::kMagenta, false});
+    printer->write("Cyan", Style{Color::kCyan, false});
+    printer->write("White", Style{Color::kWhite, false});
+    printf("\n");
+}
+
+TEST_F(PrinterTest, BoldWithoutColors) {
+    auto printer = Printer::create(stdout, false);
+    printer->write("Default", Style{Color::kDefault, true});
+    printer->write("Black", Style{Color::kBlack, true});
+    printer->write("Red", Style{Color::kRed, true});
+    printer->write("Green", Style{Color::kGreen, true});
+    printer->write("Yellow", Style{Color::kYellow, true});
+    printer->write("Blue", Style{Color::kBlue, true});
+    printer->write("Magenta", Style{Color::kMagenta, true});
+    printer->write("Cyan", Style{Color::kCyan, true});
+    printer->write("White", Style{Color::kWhite, true});
+    printf("\n");
+}
+
+#endif  // ENABLE_PRINTER_TESTS
+}  // namespace
+}  // namespace tint::diag
diff --git a/src/tint/utils/diagnostic/printer_windows.cc b/src/tint/utils/diagnostic/printer_windows.cc
new file mode 100644
index 0000000..a9582d4
--- /dev/null
+++ b/src/tint/utils/diagnostic/printer_windows.cc
@@ -0,0 +1,108 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <cstring>
+
+#include "src/tint/utils/diagnostic/printer.h"
+
+#define WIN32_LEAN_AND_MEAN 1
+#include <Windows.h>
+
+namespace tint::diag {
+namespace {
+
+struct ConsoleInfo {
+    HANDLE handle = INVALID_HANDLE_VALUE;
+    WORD default_attributes = 0;
+    operator bool() const { return handle != INVALID_HANDLE_VALUE; }
+};
+
+ConsoleInfo console_info(FILE* file) {
+    if (file == nullptr) {
+        return {};
+    }
+
+    ConsoleInfo console{};
+    if (file == stdout) {
+        console.handle = GetStdHandle(STD_OUTPUT_HANDLE);
+    } else if (file == stderr) {
+        console.handle = GetStdHandle(STD_ERROR_HANDLE);
+    } else {
+        return {};
+    }
+
+    CONSOLE_SCREEN_BUFFER_INFO info{};
+    if (GetConsoleScreenBufferInfo(console.handle, &info) == 0) {
+        return {};
+    }
+
+    console.default_attributes = info.wAttributes;
+    return console;
+}
+
+class PrinterWindows : public Printer {
+  public:
+    PrinterWindows(FILE* f, bool use_colors)
+        : file(f), console(console_info(use_colors ? f : nullptr)) {}
+
+    void write(const std::string& str, const Style& style) override {
+        write_color(style.color, style.bold);
+        fwrite(str.data(), 1, str.size(), file);
+        write_color(Color::kDefault, false);
+    }
+
+  private:
+    WORD attributes(Color color, bool bold) {
+        switch (color) {
+            case Color::kDefault:
+                return console.default_attributes;
+            case Color::kBlack:
+                return 0;
+            case Color::kRed:
+                return FOREGROUND_RED | (bold ? FOREGROUND_INTENSITY : 0);
+            case Color::kGreen:
+                return FOREGROUND_GREEN | (bold ? FOREGROUND_INTENSITY : 0);
+            case Color::kYellow:
+                return FOREGROUND_RED | FOREGROUND_GREEN | (bold ? FOREGROUND_INTENSITY : 0);
+            case Color::kBlue:
+                return FOREGROUND_BLUE | (bold ? FOREGROUND_INTENSITY : 0);
+            case Color::kMagenta:
+                return FOREGROUND_RED | FOREGROUND_BLUE | (bold ? FOREGROUND_INTENSITY : 0);
+            case Color::kCyan:
+                return FOREGROUND_GREEN | FOREGROUND_BLUE | (bold ? FOREGROUND_INTENSITY : 0);
+            case Color::kWhite:
+                return FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE |
+                       (bold ? FOREGROUND_INTENSITY : 0);
+        }
+        return 0;  // unreachable
+    }
+
+    void write_color(Color color, bool bold) {
+        if (console) {
+            SetConsoleTextAttribute(console.handle, attributes(color, bold));
+            fflush(file);
+        }
+    }
+
+    FILE* const file;
+    const ConsoleInfo console;
+};
+
+}  // namespace
+
+std::unique_ptr<Printer> Printer::create(FILE* out, bool use_colors) {
+    return std::make_unique<PrinterWindows>(out, use_colors);
+}
+
+}  // namespace tint::diag
diff --git a/src/tint/utils/diagnostic/source.cc b/src/tint/utils/diagnostic/source.cc
new file mode 100644
index 0000000..d2b079c
--- /dev/null
+++ b/src/tint/utils/diagnostic/source.cc
@@ -0,0 +1,177 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/diagnostic/source.h"
+
+#include <algorithm>
+#include <string_view>
+#include <utility>
+
+#include "src/tint/utils/text/unicode.h"
+
+namespace tint {
+namespace {
+
+bool ParseLineBreak(std::string_view str, size_t i, bool* is_line_break, size_t* line_break_size) {
+    // See https://www.w3.org/TR/WGSL/#blankspace
+
+    auto* utf8 = reinterpret_cast<const uint8_t*>(&str[i]);
+    auto [cp, n] = utils::utf8::Decode(utf8, str.size() - i);
+
+    if (n == 0) {
+        return false;
+    }
+
+    static const auto kLF = utils::CodePoint(0x000A);    // line feed
+    static const auto kVTab = utils::CodePoint(0x000B);  // vertical tab
+    static const auto kFF = utils::CodePoint(0x000C);    // form feed
+    static const auto kNL = utils::CodePoint(0x0085);    // next line
+    static const auto kCR = utils::CodePoint(0x000D);    // carriage return
+    static const auto kLS = utils::CodePoint(0x2028);    // line separator
+    static const auto kPS = utils::CodePoint(0x2029);    // parargraph separator
+
+    if (cp == kLF || cp == kVTab || cp == kFF || cp == kNL || cp == kPS || cp == kLS) {
+        *is_line_break = true;
+        *line_break_size = n;
+        return true;
+    }
+
+    // Handle CRLF as one line break, and CR alone as one line break
+    if (cp == kCR) {
+        *is_line_break = true;
+        *line_break_size = n;
+
+        if (auto next_i = i + n; next_i < str.size()) {
+            auto* next_utf8 = reinterpret_cast<const uint8_t*>(&str[next_i]);
+            auto [next_cp, next_n] = utils::utf8::Decode(next_utf8, str.size() - next_i);
+
+            if (next_n == 0) {
+                return false;
+            }
+
+            if (next_cp == kLF) {
+                // CRLF as one break
+                *line_break_size = n + next_n;
+            }
+        }
+
+        return true;
+    }
+
+    *is_line_break = false;
+    return true;
+}
+
+std::vector<std::string_view> SplitLines(std::string_view str) {
+    std::vector<std::string_view> lines;
+
+    size_t lineStart = 0;
+    for (size_t i = 0; i < str.size();) {
+        bool is_line_break{};
+        size_t line_break_size{};
+        // We don't handle decode errors from ParseLineBreak. Instead, we rely on
+        // the Lexer to do so.
+        ParseLineBreak(str, i, &is_line_break, &line_break_size);
+        if (is_line_break) {
+            lines.push_back(str.substr(lineStart, i - lineStart));
+            i += line_break_size;
+            lineStart = i;
+        } else {
+            ++i;
+        }
+    }
+    if (lineStart < str.size()) {
+        lines.push_back(str.substr(lineStart));
+    }
+
+    return lines;
+}
+
+std::vector<std::string_view> CopyRelativeStringViews(const std::vector<std::string_view>& src_list,
+                                                      const std::string_view& src_view,
+                                                      const std::string_view& dst_view) {
+    std::vector<std::string_view> out(src_list.size());
+    for (size_t i = 0; i < src_list.size(); i++) {
+        auto offset = static_cast<size_t>(&src_list[i].front() - &src_view.front());
+        auto count = src_list[i].length();
+        out[i] = dst_view.substr(offset, count);
+    }
+    return out;
+}
+
+}  // namespace
+
+Source::FileContent::FileContent(const std::string& body) : data(body), lines(SplitLines(data)) {}
+
+Source::FileContent::FileContent(const FileContent& rhs)
+    : data(rhs.data), lines(CopyRelativeStringViews(rhs.lines, rhs.data, data)) {}
+
+Source::FileContent::~FileContent() = default;
+
+Source::File::~File() = default;
+
+utils::StringStream& operator<<(utils::StringStream& out, const Source& source) {
+    auto rng = source.range;
+
+    if (source.file) {
+        out << source.file->path << ":";
+    }
+    if (rng.begin.line) {
+        out << rng.begin.line << ":";
+        if (rng.begin.column) {
+            out << rng.begin.column;
+        }
+
+        if (source.file) {
+            out << std::endl << std::endl;
+
+            auto repeat = [&](char c, size_t n) {
+                while (n--) {
+                    out << c;
+                }
+            };
+
+            for (size_t line = rng.begin.line; line <= rng.end.line; line++) {
+                if (line < source.file->content.lines.size() + 1) {
+                    auto len = source.file->content.lines[line - 1].size();
+
+                    out << source.file->content.lines[line - 1];
+
+                    out << std::endl;
+
+                    if (line == rng.begin.line && line == rng.end.line) {
+                        // Single line
+                        repeat(' ', rng.begin.column - 1);
+                        repeat('^', std::max<size_t>(rng.end.column - rng.begin.column, 1));
+                    } else if (line == rng.begin.line) {
+                        // Start of multi-line
+                        repeat(' ', rng.begin.column - 1);
+                        repeat('^', len - (rng.begin.column - 1));
+                    } else if (line == rng.end.line) {
+                        // End of multi-line
+                        repeat('^', rng.end.column - 1);
+                    } else {
+                        // Middle of multi-line
+                        repeat('^', len);
+                    }
+
+                    out << std::endl;
+                }
+            }
+        }
+    }
+    return out;
+}
+
+}  // namespace tint
diff --git a/src/tint/utils/diagnostic/source.h b/src/tint/utils/diagnostic/source.h
new file mode 100644
index 0000000..7f5f765
--- /dev/null
+++ b/src/tint/utils/diagnostic/source.h
@@ -0,0 +1,231 @@
+
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_DIAGNOSTIC_SOURCE_H_
+#define SRC_TINT_UTILS_DIAGNOSTIC_SOURCE_H_
+
+#include <string>
+#include <string_view>
+#include <tuple>
+#include <vector>
+
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint {
+
+/// Source describes a range of characters within a source file.
+class Source {
+  public:
+    /// FileContent describes the content of a source file encoded using utf-8.
+    class FileContent {
+      public:
+        /// Constructs the FileContent with the given file content.
+        /// @param data the file contents
+        explicit FileContent(const std::string& data);
+
+        /// Copy constructor
+        /// @param rhs the FileContent to copy
+        FileContent(const FileContent& rhs);
+
+        /// Destructor
+        ~FileContent();
+
+        /// The original un-split file content
+        const std::string data;
+        /// #data split by lines
+        const std::vector<std::string_view> lines;
+    };
+
+    /// File describes a source file, including path and content.
+    class File {
+      public:
+        /// Constructs the File with the given file path and content.
+        /// @param p the path for this file
+        /// @param c the file contents
+        inline File(const std::string& p, const std::string& c) : path(p), content(c) {}
+
+        /// Copy constructor
+        File(const File&) = default;
+
+        /// Move constructor
+        File(File&&) = default;
+
+        /// Destructor
+        ~File();
+
+        /// file path
+        const std::string path;
+        /// file content
+        const FileContent content;
+    };
+
+    /// Location holds a 1-based line and column index.
+    class Location {
+      public:
+        /// the 1-based line number. 0 represents no line information.
+        size_t line = 0;
+        /// the 1-based column number in utf8-code units (bytes).
+        /// 0 represents no column information.
+        size_t column = 0;
+
+        /// Returns true if `this` location is lexicographically less than `rhs`
+        /// @param rhs location to compare against
+        /// @returns true if `this` < `rhs`
+        inline bool operator<(const Source::Location& rhs) {
+            return std::tie(line, column) < std::tie(rhs.line, rhs.column);
+        }
+
+        /// Returns true of `this` location is equal to `rhs`
+        /// @param rhs location to compare against
+        /// @returns true if `this` == `rhs`
+        inline bool operator==(const Location& rhs) const {
+            return line == rhs.line && column == rhs.column;
+        }
+
+        /// Returns true of `this` location is not equal to `rhs`
+        /// @param rhs location to compare against
+        /// @returns true if `this` != `rhs`
+        inline bool operator!=(const Location& rhs) const { return !(*this == rhs); }
+    };
+
+    /// Range holds a Location interval described by [begin, end).
+    class Range {
+      public:
+        /// Constructs a zero initialized Range.
+        inline Range() = default;
+
+        /// Constructs a zero-length Range starting at `loc`
+        /// @param loc the start and end location for the range
+        inline constexpr explicit Range(const Location& loc) : begin(loc), end(loc) {}
+
+        /// Constructs the Range beginning at `b` and ending at `e`
+        /// @param b the range start location
+        /// @param e the range end location
+        inline constexpr Range(const Location& b, const Location& e) : begin(b), end(e) {}
+
+        /// Return a column-shifted Range
+        /// @param n the number of characters to shift by
+        /// @returns a Range with a #begin and #end column shifted by `n`
+        inline Range operator+(size_t n) const {
+            return Range{{begin.line, begin.column + n}, {end.line, end.column + n}};
+        }
+
+        /// Returns true of `this` range is not equal to `rhs`
+        /// @param rhs range to compare against
+        /// @returns true if `this` != `rhs`
+        inline bool operator==(const Range& rhs) const {
+            return begin == rhs.begin && end == rhs.end;
+        }
+
+        /// Returns true of `this` range is equal to `rhs`
+        /// @param rhs range to compare against
+        /// @returns true if `this` == `rhs`
+        inline bool operator!=(const Range& rhs) const { return !(*this == rhs); }
+
+        /// The location of the first character in the range.
+        Location begin;
+        /// The location of one-past the last character in the range.
+        Location end;
+    };
+
+    /// Constructs the Source with an zero initialized Range and null File.
+    inline Source() : range() {}
+
+    /// Constructs the Source with the Range `rng` and a null File
+    /// @param rng the source range
+    inline explicit Source(const Range& rng) : range(rng) {}
+
+    /// Constructs the Source with the Range `loc` and a null File
+    /// @param loc the start and end location for the source range
+    inline explicit Source(const Location& loc) : range(Range(loc)) {}
+
+    /// Constructs the Source with the Range `rng` and File `file`
+    /// @param rng the source range
+    /// @param f the source file
+    inline Source(const Range& rng, File const* f) : range(rng), file(f) {}
+
+    /// @returns a Source that points to the begin range of this Source.
+    inline Source Begin() const { return Source(Range{range.begin}, file); }
+
+    /// @returns a Source that points to the end range of this Source.
+    inline Source End() const { return Source(Range{range.end}, file); }
+
+    /// Return a column-shifted Source
+    /// @param n the number of characters to shift by
+    /// @returns a Source with the range's columns shifted by `n`
+    inline Source operator+(size_t n) const { return Source(range + n, file); }
+
+    /// Returns true of `this` Source is lexicographically less than `rhs`
+    /// @param rhs source to compare against
+    /// @returns true if `this` < `rhs`
+    inline bool operator<(const Source& rhs) {
+        if (file != rhs.file) {
+            return false;
+        }
+        return range.begin < rhs.range.begin;
+    }
+
+    /// Helper function that returns the range union of two source locations. The
+    /// `start` and `end` locations are assumed to refer to the same source file.
+    /// @param start the start source of the range
+    /// @param end the end source of the range
+    /// @returns the combined source
+    inline static Source Combine(const Source& start, const Source& end) {
+        return Source(Source::Range(start.range.begin, end.range.end), start.file);
+    }
+
+    /// range is the span of text this source refers to in #file
+    Range range;
+    /// file is the optional source content this source refers to
+    const File* file = nullptr;
+};
+
+/// Writes the Source::Location to the stream.
+/// @param out the stream to write to
+/// @param loc the location to write
+/// @returns out so calls can be chained
+inline utils::StringStream& operator<<(utils::StringStream& out, const Source::Location& loc) {
+    out << loc.line << ":" << loc.column;
+    return out;
+}
+
+/// Writes the Source::Range to the stream.
+/// @param out the stream to write to
+/// @param range the range to write
+/// @returns out so calls can be chained
+inline utils::StringStream& operator<<(utils::StringStream& out, const Source::Range& range) {
+    out << "[" << range.begin << ", " << range.end << "]";
+    return out;
+}
+
+/// Writes the Source to the stream.
+/// @param out the stream to write to
+/// @param source the source to write
+/// @returns out so calls can be chained
+utils::StringStream& operator<<(utils::StringStream& out, const Source& source);
+
+/// Writes the Source::FileContent to the stream.
+/// @param out the stream to write to
+/// @param content the file content to write
+/// @returns out so calls can be chained
+inline utils::StringStream& operator<<(utils::StringStream& out,
+                                       const Source::FileContent& content) {
+    out << content.data;
+    return out;
+}
+
+}  // namespace tint
+
+#endif  // SRC_TINT_UTILS_DIAGNOSTIC_SOURCE_H_
diff --git a/src/tint/utils/diagnostic/source_test.cc b/src/tint/utils/diagnostic/source_test.cc
new file mode 100644
index 0000000..8d9ceed
--- /dev/null
+++ b/src/tint/utils/diagnostic/source_test.cc
@@ -0,0 +1,99 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/diagnostic/source.h"
+
+#include <memory>
+#include <utility>
+
+#include "gtest/gtest.h"
+
+namespace tint {
+namespace {
+
+static constexpr const char* kSource = R"(line one
+line two
+line three)";
+
+using SourceFileContentTest = testing::Test;
+
+TEST_F(SourceFileContentTest, Init) {
+    Source::FileContent fc(kSource);
+    EXPECT_EQ(fc.data, kSource);
+    ASSERT_EQ(fc.lines.size(), 3u);
+    EXPECT_EQ(fc.lines[0], "line one");
+    EXPECT_EQ(fc.lines[1], "line two");
+    EXPECT_EQ(fc.lines[2], "line three");
+}
+
+TEST_F(SourceFileContentTest, CopyInit) {
+    auto src = std::make_unique<Source::FileContent>(kSource);
+    Source::FileContent fc{*src};
+    src.reset();
+    EXPECT_EQ(fc.data, kSource);
+    ASSERT_EQ(fc.lines.size(), 3u);
+    EXPECT_EQ(fc.lines[0], "line one");
+    EXPECT_EQ(fc.lines[1], "line two");
+    EXPECT_EQ(fc.lines[2], "line three");
+}
+
+TEST_F(SourceFileContentTest, MoveInit) {
+    auto src = std::make_unique<Source::FileContent>(kSource);
+    Source::FileContent fc{std::move(*src)};
+    src.reset();
+    EXPECT_EQ(fc.data, kSource);
+    ASSERT_EQ(fc.lines.size(), 3u);
+    EXPECT_EQ(fc.lines[0], "line one");
+    EXPECT_EQ(fc.lines[1], "line two");
+    EXPECT_EQ(fc.lines[2], "line three");
+}
+
+// Line break code points
+#define kCR "\r"
+#define kLF "\n"
+#define kVTab "\x0B"
+#define kFF "\x0C"
+#define kNL "\xC2\x85"
+#define kLS "\xE2\x80\xA8"
+#define kPS "\xE2\x80\xA9"
+
+using LineBreakTest = testing::TestWithParam<const char*>;
+TEST_P(LineBreakTest, Single) {
+    std::string src = "line one";
+    src += GetParam();
+    src += "line two";
+
+    Source::FileContent fc(src);
+    EXPECT_EQ(fc.lines.size(), 2u);
+    EXPECT_EQ(fc.lines[0], "line one");
+    EXPECT_EQ(fc.lines[1], "line two");
+}
+TEST_P(LineBreakTest, Double) {
+    std::string src = "line one";
+    src += GetParam();
+    src += GetParam();
+    src += "line two";
+
+    Source::FileContent fc(src);
+    EXPECT_EQ(fc.lines.size(), 3u);
+    EXPECT_EQ(fc.lines[0], "line one");
+    EXPECT_EQ(fc.lines[1], "");
+    EXPECT_EQ(fc.lines[2], "line two");
+}
+INSTANTIATE_TEST_SUITE_P(SourceFileContentTest,
+                         LineBreakTest,
+                         testing::Values(kVTab, kFF, kNL, kLS, kPS, kLF, kCR, kCR kLF));
+
+}  // namespace
+}  // namespace tint
diff --git a/src/tint/utils/file/tmpfile.h b/src/tint/utils/file/tmpfile.h
new file mode 100644
index 0000000..ed83918
--- /dev/null
+++ b/src/tint/utils/file/tmpfile.h
@@ -0,0 +1,75 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_FILE_TMPFILE_H_
+#define SRC_TINT_UTILS_FILE_TMPFILE_H_
+
+#include <string>
+
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint::utils {
+
+/// TmpFile constructs a temporary file that can be written to, and is
+/// automatically deleted on destruction.
+class TmpFile {
+  public:
+    /// Constructor.
+    /// Creates a new temporary file which can be written to.
+    /// The temporary file will be automatically deleted on destruction.
+    /// @param extension optional file extension to use with the file. The file
+    /// have no extension by default.
+    explicit TmpFile(std::string extension = "");
+
+    /// Destructor.
+    /// Deletes the temporary file.
+    ~TmpFile();
+
+    /// @return true if the temporary file was successfully created.
+    operator bool() { return !path_.empty(); }
+
+    /// @return the path to the temporary file
+    std::string Path() const { return path_; }
+
+    /// Opens the temporary file and appends |size| bytes from |data| to the end
+    /// of the temporary file. The temporary file is closed again before
+    /// returning, allowing other processes to open the file on operating systems
+    /// that require exclusive ownership of opened files.
+    /// @param data the data to write to the end of the file
+    /// @param size the number of bytes to write from data
+    /// @returns true on success, otherwise false
+    bool Append(const void* data, size_t size) const;
+
+    /// Appends the argument to the end of the file.
+    /// @param data the data to write to the end of the file
+    /// @return a reference to this TmpFile
+    template <typename T>
+    inline TmpFile& operator<<(T&& data) {
+        utils::StringStream ss;
+        ss << data;
+        std::string str = ss.str();
+        Append(str.data(), str.size());
+        return *this;
+    }
+
+  private:
+    TmpFile(const TmpFile&) = delete;
+    TmpFile& operator=(const TmpFile&) = delete;
+
+    std::string path_;
+};
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_FILE_TMPFILE_H_
diff --git a/src/tint/utils/file/tmpfile_other.cc b/src/tint/utils/file/tmpfile_other.cc
new file mode 100644
index 0000000..1a7da4c
--- /dev/null
+++ b/src/tint/utils/file/tmpfile_other.cc
@@ -0,0 +1,27 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/file/tmpfile.h"
+
+namespace tint::utils {
+
+TmpFile::TmpFile(std::string) {}
+
+TmpFile::~TmpFile() = default;
+
+bool TmpFile::Append(const void*, size_t) const {
+    return false;
+}
+
+}  // namespace tint::utils
diff --git a/src/tint/utils/file/tmpfile_posix.cc b/src/tint/utils/file/tmpfile_posix.cc
new file mode 100644
index 0000000..f26747d
--- /dev/null
+++ b/src/tint/utils/file/tmpfile_posix.cc
@@ -0,0 +1,66 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/file/tmpfile.h"
+
+#include <unistd.h>
+#include <limits>
+
+#include "src/tint/utils/debug/debug.h"
+
+namespace tint::utils {
+
+namespace {
+
+std::string TmpFilePath(std::string ext) {
+    char const* dir = getenv("TMPDIR");
+    if (dir == nullptr) {
+        dir = "/tmp";
+    }
+
+    // mkstemps requires an `int` for the file extension name but STL represents
+    // size_t. Pre-C++20 there the behavior for unsigned-to-signed conversion
+    // (when the source value exceeds the representable range) is implementation
+    // defined. While such a large file extension is unlikely in practice, we
+    // enforce this here at runtime.
+    TINT_ASSERT(Utils, ext.length() <= static_cast<size_t>(std::numeric_limits<int>::max()));
+    std::string name = std::string(dir) + "/tint_XXXXXX" + ext;
+    int file = mkstemps(&name[0], static_cast<int>(ext.length()));
+    if (file != -1) {
+        close(file);
+        return name;
+    }
+    return "";
+}
+
+}  // namespace
+
+TmpFile::TmpFile(std::string extension) : path_(TmpFilePath(std::move(extension))) {}
+
+TmpFile::~TmpFile() {
+    if (!path_.empty()) {
+        remove(path_.c_str());
+    }
+}
+
+bool TmpFile::Append(const void* data, size_t size) const {
+    if (auto* file = fopen(path_.c_str(), "ab")) {
+        fwrite(data, size, 1, file);
+        fclose(file);
+        return true;
+    }
+    return false;
+}
+
+}  // namespace tint::utils
diff --git a/src/tint/utils/file/tmpfile_test.cc b/src/tint/utils/file/tmpfile_test.cc
new file mode 100644
index 0000000..e2fff23
--- /dev/null
+++ b/src/tint/utils/file/tmpfile_test.cc
@@ -0,0 +1,88 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/file/tmpfile.h"
+
+#include <fstream>
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(TmpFileTest, WriteReadAppendDelete) {
+    std::string path;
+    {
+        TmpFile tmp;
+        if (!tmp) {
+            GTEST_SKIP() << "Unable to create a temporary file";
+        }
+
+        path = tmp.Path();
+
+        // Write a string to the temporary file
+        tmp << "hello world\n";
+
+        // Check the content of the file
+        {
+            std::ifstream file(path);
+            ASSERT_TRUE(file);
+            std::string line;
+            EXPECT_TRUE(std::getline(file, line));
+            EXPECT_EQ(line, "hello world");
+            EXPECT_FALSE(std::getline(file, line));
+        }
+
+        // Write some more content to the file
+        tmp << 42;
+
+        // Check the content of the file again
+        {
+            std::ifstream file(path);
+            ASSERT_TRUE(file);
+            std::string line;
+            EXPECT_TRUE(std::getline(file, line));
+            EXPECT_EQ(line, "hello world");
+            EXPECT_TRUE(std::getline(file, line));
+            EXPECT_EQ(line, "42");
+            EXPECT_FALSE(std::getline(file, line));
+        }
+    }
+
+    // Check the file has been deleted when it fell out of scope
+    std::ifstream file(path);
+    ASSERT_FALSE(file);
+}
+
+TEST(TmpFileTest, FileExtension) {
+    const std::string kExt = ".foo";
+    std::string path;
+    {
+        TmpFile tmp(kExt);
+        if (!tmp) {
+            GTEST_SKIP() << "Unable create a temporary file";
+        }
+        path = tmp.Path();
+    }
+
+    ASSERT_GT(path.length(), kExt.length());
+    EXPECT_EQ(kExt, path.substr(path.length() - kExt.length()));
+
+    // Check the file has been deleted when it fell out of scope
+    std::ifstream file(path);
+    ASSERT_FALSE(file);
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/file/tmpfile_windows.cc b/src/tint/utils/file/tmpfile_windows.cc
new file mode 100644
index 0000000..0a4598e
--- /dev/null
+++ b/src/tint/utils/file/tmpfile_windows.cc
@@ -0,0 +1,61 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/file/tmpfile.h"
+
+#include <stdio.h>
+#include <cstdio>
+
+namespace tint::utils {
+
+namespace {
+
+std::string TmpFilePath(const std::string& ext) {
+    char name[L_tmpnam];
+    // As we're adding an extension, to ensure the file is really unique, try
+    // creating it, failing if it already exists.
+    while (tmpnam_s(name, L_tmpnam - 1) == 0) {
+        std::string name_with_ext = std::string(name) + ext;
+        FILE* f = nullptr;
+        // The "x" arg forces the function to fail if the file already exists.
+        fopen_s(&f, name_with_ext.c_str(), "wbx");
+        if (f) {
+            fclose(f);
+            return name_with_ext;
+        }
+    }
+    return {};
+}
+
+}  // namespace
+
+TmpFile::TmpFile(std::string ext) : path_(TmpFilePath(ext)) {}
+
+TmpFile::~TmpFile() {
+    if (!path_.empty()) {
+        remove(path_.c_str());
+    }
+}
+
+bool TmpFile::Append(const void* data, size_t size) const {
+    FILE* file = nullptr;
+    if (fopen_s(&file, path_.c_str(), "ab") != 0) {
+        return false;
+    }
+    fwrite(data, size, 1, file);
+    fclose(file);
+    return true;
+}
+
+}  // namespace tint::utils
diff --git a/src/tint/utils/macros/compiler.h b/src/tint/utils/macros/compiler.h
new file mode 100644
index 0000000..f0af760
--- /dev/null
+++ b/src/tint/utils/macros/compiler.h
@@ -0,0 +1,110 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/macros/concat.h"
+
+#ifndef SRC_TINT_UTILS_MACROS_COMPILER_H_
+#define SRC_TINT_UTILS_MACROS_COMPILER_H_
+
+#define TINT_REQUIRE_SEMICOLON static_assert(true)
+
+#if defined(_MSC_VER) && !defined(__clang__)
+////////////////////////////////////////////////////////////////////////////////
+// MSVC
+////////////////////////////////////////////////////////////////////////////////
+#define TINT_DISABLE_WARNING_CONSTANT_OVERFLOW __pragma(warning(disable : 4756))
+#define TINT_DISABLE_WARNING_MAYBE_UNINITIALIZED /* currently no-op */
+#define TINT_DISABLE_WARNING_NEWLINE_EOF         /* currently no-op */
+#define TINT_DISABLE_WARNING_OLD_STYLE_CAST      /* currently no-op */
+#define TINT_DISABLE_WARNING_SIGN_CONVERSION     /* currently no-op */
+#define TINT_DISABLE_WARNING_UNREACHABLE_CODE __pragma(warning(disable : 4702))
+#define TINT_DISABLE_WARNING_WEAK_VTABLES /* currently no-op */
+#define TINT_DISABLE_WARNING_FLOAT_EQUAL  /* currently no-op */
+
+// clang-format off
+#define TINT_BEGIN_DISABLE_WARNING(name)     \
+    __pragma(warning(push))                  \
+    TINT_CONCAT(TINT_DISABLE_WARNING_, name) \
+    TINT_REQUIRE_SEMICOLON
+#define TINT_END_DISABLE_WARNING(name)       \
+    __pragma(warning(pop))                   \
+    TINT_REQUIRE_SEMICOLON
+// clang-format on
+
+#define TINT_UNLIKELY(x) x /* currently no-op */
+#define TINT_LIKELY(x) x   /* currently no-op */
+#elif defined(__clang__)
+////////////////////////////////////////////////////////////////////////////////
+// Clang
+////////////////////////////////////////////////////////////////////////////////
+#define TINT_DISABLE_WARNING_CONSTANT_OVERFLOW   /* currently no-op */
+#define TINT_DISABLE_WARNING_MAYBE_UNINITIALIZED /* currently no-op */
+#define TINT_DISABLE_WARNING_NEWLINE_EOF _Pragma("clang diagnostic ignored \"-Wnewline-eof\"")
+#define TINT_DISABLE_WARNING_OLD_STYLE_CAST _Pragma("clang diagnostic ignored \"-Wold-style-cast\"")
+#define TINT_DISABLE_WARNING_SIGN_CONVERSION \
+    _Pragma("clang diagnostic ignored \"-Wsign-conversion\"")
+#define TINT_DISABLE_WARNING_UNREACHABLE_CODE /* currently no-op */
+#define TINT_DISABLE_WARNING_WEAK_VTABLES _Pragma("clang diagnostic ignored \"-Wweak-vtables\"")
+#define TINT_DISABLE_WARNING_FLOAT_EQUAL _Pragma("clang diagnostic ignored \"-Wfloat-equal\"")
+
+// clang-format off
+#define TINT_BEGIN_DISABLE_WARNING(name)     \
+    _Pragma("clang diagnostic push")         \
+    TINT_CONCAT(TINT_DISABLE_WARNING_, name) \
+    TINT_REQUIRE_SEMICOLON
+#define TINT_END_DISABLE_WARNING(name)       \
+    _Pragma("clang diagnostic pop")          \
+    TINT_REQUIRE_SEMICOLON
+// clang-format on
+
+#define TINT_UNLIKELY(x) __builtin_expect(!!(x), false)
+#define TINT_LIKELY(x) __builtin_expect(!!(x), true)
+#elif defined(__GNUC__)
+////////////////////////////////////////////////////////////////////////////////
+// GCC
+////////////////////////////////////////////////////////////////////////////////
+#define TINT_DISABLE_WARNING_CONSTANT_OVERFLOW /* currently no-op */
+#define TINT_DISABLE_WARNING_MAYBE_UNINITIALIZED \
+    _Pragma("GCC diagnostic ignored \"-Wmaybe-uninitialized\"")
+#define TINT_DISABLE_WARNING_NEWLINE_EOF      /* currently no-op */
+#define TINT_DISABLE_WARNING_OLD_STYLE_CAST   /* currently no-op */
+#define TINT_DISABLE_WARNING_SIGN_CONVERSION  /* currently no-op */
+#define TINT_DISABLE_WARNING_UNREACHABLE_CODE /* currently no-op */
+#define TINT_DISABLE_WARNING_WEAK_VTABLES     /* currently no-op */
+#define TINT_DISABLE_WARNING_FLOAT_EQUAL      /* currently no-op */
+
+// clang-format off
+#define TINT_BEGIN_DISABLE_WARNING(name)     \
+    _Pragma("GCC diagnostic push")           \
+    TINT_CONCAT(TINT_DISABLE_WARNING_, name) \
+    TINT_REQUIRE_SEMICOLON
+#define TINT_END_DISABLE_WARNING(name)       \
+    _Pragma("GCC diagnostic pop")            \
+    TINT_REQUIRE_SEMICOLON
+// clang-format on
+
+#define TINT_UNLIKELY(x) __builtin_expect(!!(x), false)
+#define TINT_LIKELY(x) __builtin_expect(!!(x), true)
+#else
+////////////////////////////////////////////////////////////////////////////////
+// Other
+////////////////////////////////////////////////////////////////////////////////
+#define TINT_BEGIN_DISABLE_WARNING(name) TINT_REQUIRE_SEMICOLON
+#define TINT_END_DISABLE_WARNING(name) TINT_REQUIRE_SEMICOLON
+#define TINT_UNLIKELY(x) x
+#define TINT_LIKELY(x) x
+
+#endif
+
+#endif  // SRC_TINT_UTILS_MACROS_COMPILER_H_
diff --git a/src/tint/utils/macros/concat.h b/src/tint/utils/macros/concat.h
new file mode 100644
index 0000000..56b47ce
--- /dev/null
+++ b/src/tint/utils/macros/concat.h
@@ -0,0 +1,22 @@
+
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_MACROS_CONCAT_H_
+#define SRC_TINT_UTILS_MACROS_CONCAT_H_
+
+#define TINT_CONCAT_2(a, b) a##b
+#define TINT_CONCAT(a, b) TINT_CONCAT_2(a, b)
+
+#endif  // SRC_TINT_UTILS_MACROS_CONCAT_H_
diff --git a/src/tint/utils/macros/defer.h b/src/tint/utils/macros/defer.h
new file mode 100644
index 0000000..7320f87
--- /dev/null
+++ b/src/tint/utils/macros/defer.h
@@ -0,0 +1,60 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_MACROS_DEFER_H_
+#define SRC_TINT_UTILS_MACROS_DEFER_H_
+
+#include <utility>
+
+#include "src/tint/utils/macros/concat.h"
+
+namespace tint::utils {
+
+/// Defer executes a function or function like object when it is destructed.
+template <typename F>
+class Defer {
+  public:
+    /// Constructor
+    /// @param f the function to call when the Defer is destructed
+    explicit Defer(F&& f) : f_(std::move(f)) {}
+
+    /// Move constructor
+    Defer(Defer&&) = default;
+
+    /// Destructor
+    /// Calls the deferred function
+    ~Defer() { f_(); }
+
+  private:
+    Defer(const Defer&) = delete;
+    Defer& operator=(const Defer&) = delete;
+
+    F f_;
+};
+
+/// Constructor
+/// @param f the function to call when the Defer is destructed
+template <typename F>
+inline Defer<F> MakeDefer(F&& f) {
+    return Defer<F>(std::forward<F>(f));
+}
+
+}  // namespace tint::utils
+
+/// TINT_DEFER(S) executes the statement(s) `S` when exiting the current lexical
+/// scope.
+#define TINT_DEFER(S) \
+    auto TINT_CONCAT(tint_defer_, __COUNTER__) = ::tint::utils::MakeDefer([&] { S; })
+
+#endif  // SRC_TINT_UTILS_MACROS_DEFER_H_
diff --git a/src/tint/utils/macros/defer_test.cc b/src/tint/utils/macros/defer_test.cc
new file mode 100644
index 0000000..f131cc8
--- /dev/null
+++ b/src/tint/utils/macros/defer_test.cc
@@ -0,0 +1,42 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/macros/defer.h"
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(DeferTest, Basic) {
+    bool deferCalled = false;
+    { TINT_DEFER(deferCalled = true); }
+    ASSERT_TRUE(deferCalled);
+}
+
+TEST(DeferTest, DeferOrder) {
+    int counter = 0;
+    int a = 0, b = 0, c = 0;
+    {
+        TINT_DEFER(a = ++counter);
+        TINT_DEFER(b = ++counter);
+        TINT_DEFER(c = ++counter);
+    }
+    ASSERT_EQ(a, 3);
+    ASSERT_EQ(b, 2);
+    ASSERT_EQ(c, 1);
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/macros/foreach.h b/src/tint/utils/macros/foreach.h
new file mode 100644
index 0000000..adfc974
--- /dev/null
+++ b/src/tint/utils/macros/foreach.h
@@ -0,0 +1,91 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_MACROS_FOREACH_H_
+#define SRC_TINT_UTILS_MACROS_FOREACH_H_
+
+// Macro magic to perform macro variadic dispatch.
+// See:
+// https://renenyffenegger.ch/notes/development/languages/C-C-plus-plus/preprocessor/macros/__VA_ARGS__/count-arguments
+// Note, this doesn't attempt to use the ##__VA_ARGS__ trick to handle 0
+
+// Helper macro to force expanding __VA_ARGS__ to satisfy MSVC compiler.
+#define TINT_MSVC_EXPAND_BUG(X) X
+
+/// TINT_COUNT_ARGUMENTS_NTH_ARG is used by TINT_COUNT_ARGUMENTS to get the number of arguments in a
+/// variadic macro call.
+#define TINT_COUNT_ARGUMENTS_NTH_ARG(_1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, \
+                                     _15, _16, N, ...)                                            \
+    N
+
+/// TINT_COUNT_ARGUMENTS evaluates to the number of arguments passed to the macro
+#define TINT_COUNT_ARGUMENTS(...)                                                                 \
+    TINT_MSVC_EXPAND_BUG(TINT_COUNT_ARGUMENTS_NTH_ARG(__VA_ARGS__, 16, 15, 14, 13, 12, 11, 10, 9, \
+                                                      8, 7, 6, 5, 4, 3, 2, 1, 0))
+
+// Correctness checks.
+static_assert(1 == TINT_COUNT_ARGUMENTS(a), "TINT_COUNT_ARGUMENTS broken");
+static_assert(2 == TINT_COUNT_ARGUMENTS(a, b), "TINT_COUNT_ARGUMENTS broken");
+static_assert(3 == TINT_COUNT_ARGUMENTS(a, b, c), "TINT_COUNT_ARGUMENTS broken");
+
+/// TINT_FOREACH calls CB with each of the variadic arguments.
+#define TINT_FOREACH(CB, ...) \
+    TINT_MSVC_EXPAND_BUG(     \
+        TINT_CONCAT(TINT_FOREACH_, TINT_COUNT_ARGUMENTS(__VA_ARGS__))(CB, __VA_ARGS__))
+
+#define TINT_FOREACH_1(CB, _1) CB(_1)
+#define TINT_FOREACH_2(CB, _1, _2) \
+    TINT_FOREACH_1(CB, _1)         \
+    CB(_2)
+#define TINT_FOREACH_3(CB, _1, _2, _3) \
+    TINT_FOREACH_2(CB, _1, _2)         \
+    CB(_3)
+#define TINT_FOREACH_4(CB, _1, _2, _3, _4) \
+    TINT_FOREACH_3(CB, _1, _2, _3)         \
+    CB(_4)
+#define TINT_FOREACH_5(CB, _1, _2, _3, _4, _5) \
+    TINT_FOREACH_4(CB, _1, _2, _3, _4)         \
+    CB(_5)
+#define TINT_FOREACH_6(CB, _1, _2, _3, _4, _5, _6) \
+    TINT_FOREACH_5(CB, _1, _2, _3, _4, _5)         \
+    CB(_6)
+#define TINT_FOREACH_7(CB, _1, _2, _3, _4, _5, _6, _7) \
+    TINT_FOREACH_6(CB, _1, _2, _3, _4, _5, _6)         \
+    CB(_7)
+#define TINT_FOREACH_8(CB, _1, _2, _3, _4, _5, _6, _7, _8) \
+    TINT_FOREACH_7(CB, _1, _2, _3, _4, _5, _6, _7)         \
+    CB(_8)
+#define TINT_FOREACH_9(CB, _1, _2, _3, _4, _5, _6, _7, _8, _9) \
+    TINT_FOREACH_8(CB, _1, _2, _3, _4, _5, _6, _7, _8)         \
+    CB(_9)
+#define TINT_FOREACH_10(CB, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10) \
+    TINT_FOREACH_9(CB, _1, _2, _3, _4, _5, _6, _7, _8, _9)           \
+    CB(_10)
+#define TINT_FOREACH_11(CB, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11) \
+    TINT_FOREACH_10(CB, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10)          \
+    CB(_11)
+#define TINT_FOREACH_12(CB, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12) \
+    TINT_FOREACH_11(CB, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11)          \
+    CB(_12)
+#define TINT_FOREACH_13(CB, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13) \
+    TINT_FOREACH_11(CB, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12)          \
+    CB(_13)
+#define TINT_FOREACH_14(CB, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14) \
+    TINT_FOREACH_11(CB, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13)          \
+    CB(_14)
+#define TINT_FOREACH_15(CB, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15) \
+    TINT_FOREACH_11(CB, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14)          \
+    CB(_15)
+
+#endif  // SRC_TINT_UTILS_MACROS_FOREACH_H_
diff --git a/src/tint/utils/macros/scoped_assignment.h b/src/tint/utils/macros/scoped_assignment.h
new file mode 100644
index 0000000..d5279f8
--- /dev/null
+++ b/src/tint/utils/macros/scoped_assignment.h
@@ -0,0 +1,62 @@
+
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_MACROS_SCOPED_ASSIGNMENT_H_
+#define SRC_TINT_UTILS_MACROS_SCOPED_ASSIGNMENT_H_
+
+#include <type_traits>
+
+#include "src/tint/utils/macros/concat.h"
+
+namespace tint::utils {
+
+/// Helper class that temporarily assigns a value to a variable for the lifetime
+/// of the ScopedAssignment object. Once the ScopedAssignment is destructed, the
+/// original value is restored.
+template <typename T>
+class ScopedAssignment {
+  public:
+    /// Constructor
+    /// @param var the variable to temporarily assign a new value to
+    /// @param val the value to assign to `ref` for the lifetime of this
+    /// ScopedAssignment.
+    ScopedAssignment(T& var, T val) : ref_(var) {
+        old_value_ = var;
+        var = val;
+    }
+
+    /// Destructor
+    /// Restores the original value of the variable.
+    ~ScopedAssignment() { ref_ = old_value_; }
+
+  private:
+    ScopedAssignment(const ScopedAssignment&) = delete;
+    ScopedAssignment& operator=(const ScopedAssignment&) = delete;
+
+    T& ref_;
+    T old_value_;
+};
+
+}  // namespace tint::utils
+
+/// TINT_SCOPED_ASSIGNMENT(var, val) assigns `val` to `var`, and automatically
+/// restores the original value of `var` when exiting the current lexical scope.
+#define TINT_SCOPED_ASSIGNMENT(var, val)                                                 \
+    ::tint::utils::ScopedAssignment<std::remove_reference_t<decltype(var)>> TINT_CONCAT( \
+        tint_scoped_assignment_, __COUNTER__) {                                          \
+        var, val                                                                         \
+    }
+
+#endif  // SRC_TINT_UTILS_MACROS_SCOPED_ASSIGNMENT_H_
diff --git a/src/tint/utils/macros/scoped_assignment_test.cc b/src/tint/utils/macros/scoped_assignment_test.cc
new file mode 100644
index 0000000..fa3b4ad
--- /dev/null
+++ b/src/tint/utils/macros/scoped_assignment_test.cc
@@ -0,0 +1,45 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/macros/scoped_assignment.h"
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(ScopedAssignmentTest, Scopes) {
+    int i = 0;
+    EXPECT_EQ(i, 0);
+    {
+        EXPECT_EQ(i, 0);
+        TINT_SCOPED_ASSIGNMENT(i, 1);
+        EXPECT_EQ(i, 1);
+        {
+            EXPECT_EQ(i, 1);
+            TINT_SCOPED_ASSIGNMENT(i, 2);
+            EXPECT_EQ(i, 2);
+        }
+        {
+            EXPECT_EQ(i, 1);
+            TINT_SCOPED_ASSIGNMENT(i, 3);
+            EXPECT_EQ(i, 3);
+        }
+        EXPECT_EQ(i, 1);
+    }
+    EXPECT_EQ(i, 0);
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/math/crc32.h b/src/tint/utils/math/crc32.h
new file mode 100644
index 0000000..022eefd
--- /dev/null
+++ b/src/tint/utils/math/crc32.h
@@ -0,0 +1,81 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_MATH_CRC32_H_
+#define SRC_TINT_UTILS_MATH_CRC32_H_
+
+#include <stdint.h>
+#include <cstddef>
+
+namespace tint::utils {
+
+constexpr uint32_t kCRC32LUT[] = {
+    0,          0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3,
+    0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91,
+    0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7,
+    0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5,
+    0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b,
+    0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59,
+    0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f,
+    0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d,
+    0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
+    0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01,
+    0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457,
+    0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65,
+    0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb,
+    0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9,
+    0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f,
+    0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad,
+    0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683,
+    0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
+    0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7,
+    0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5,
+    0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
+    0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79,
+    0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f,
+    0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d,
+    0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713,
+    0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21,
+    0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
+    0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45,
+    0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db,
+    0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9,
+    0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf,
+    0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d};
+
+/// @returns the CRC32 of the string @p s.
+/// @note this function can be used to calculate the CRC32 of a string literal
+/// at compile time.
+/// @see https://en.wikipedia.org/wiki/Cyclic_redundancy_check#CRC-32_algorithm
+constexpr uint32_t CRC32(const char* s) {
+    uint32_t crc = 0xffffffff;
+    for (auto* p = s; *p != '\0'; ++p) {
+        crc = (crc >> 8) ^ kCRC32LUT[static_cast<uint8_t>(crc) ^ static_cast<uint8_t>(*p)];
+    }
+    return crc ^ 0xffffffff;
+}
+
+/// @returns the CRC32 of the data at @p ptr of size @p size.
+inline uint32_t CRC32(const void* ptr, size_t size) {
+    auto* p = static_cast<const uint8_t*>(ptr);
+    uint32_t crc = 0xffffffff;
+    while (size--) {
+        crc = (crc >> 8) ^ kCRC32LUT[static_cast<uint8_t>(crc) ^ *p++];
+    }
+    return crc ^ 0xffffffff;
+}
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_MATH_CRC32_H_
diff --git a/src/tint/utils/math/crc32_test.cc b/src/tint/utils/math/crc32_test.cc
new file mode 100644
index 0000000..f029108
--- /dev/null
+++ b/src/tint/utils/math/crc32_test.cc
@@ -0,0 +1,35 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/math/crc32.h"
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(CRC32Test, Compiletime) {
+    static_assert(CRC32("") == 0x00000000u);
+    static_assert(CRC32("hello world") == 0x0d4a1185u);
+    static_assert(CRC32("123456789") == 0xcbf43926u);
+}
+
+TEST(CRC32Test, Runtime) {
+    EXPECT_EQ(CRC32(""), 0x00000000u);
+    EXPECT_EQ(CRC32("hello world"), 0x0d4a1185u);
+    EXPECT_EQ(CRC32("123456789"), 0xcbf43926u);
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/math/hash.h b/src/tint/utils/math/hash.h
new file mode 100644
index 0000000..74f053b
--- /dev/null
+++ b/src/tint/utils/math/hash.h
@@ -0,0 +1,301 @@
+// Copyright 2021 The Tint Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_MATH_HASH_H_
+#define SRC_TINT_UTILS_MATH_HASH_H_
+
+#include <stdint.h>
+#include <cstdio>
+#include <functional>
+#include <string>
+#include <tuple>
+#include <utility>
+#include <variant>
+#include <vector>
+
+#include "src/tint/utils/containers/vector.h"
+#include "src/tint/utils/math/crc32.h"
+
+namespace tint::utils {
+namespace detail {
+
+/// Helper for obtaining a seed bias value for HashCombine with a bit-width
+/// dependent on the size of size_t.
+template <int SIZE_OF_SIZE_T>
+struct HashCombineOffset {};
+
+/// Specialization of HashCombineOffset for size_t == 4.
+template <>
+struct HashCombineOffset<4> {
+    /// @returns the seed bias value for HashCombine()
+    static constexpr inline uint32_t value() {
+        constexpr uint32_t base = 0x7f4a7c16;
+#ifdef TINT_HASH_SEED
+        return base ^ static_cast<uint32_t>(TINT_HASH_SEED);
+#endif
+        return base;
+    }
+};
+
+/// Specialization of HashCombineOffset for size_t == 8.
+template <>
+struct HashCombineOffset<8> {
+    /// @returns the seed bias value for HashCombine()
+    static constexpr inline uint64_t value() {
+        constexpr uint64_t base = 0x9e3779b97f4a7c16;
+#ifdef TINT_HASH_SEED
+        return base ^ static_cast<uint64_t>(TINT_HASH_SEED);
+#endif
+        return base;
+    }
+};
+
+}  // namespace detail
+
+/// Forward declarations (see below)
+template <typename... ARGS>
+size_t Hash(const ARGS&... values);
+
+template <typename... ARGS>
+size_t HashCombine(size_t hash, const ARGS&... values);
+
+/// A STL-compatible hasher that does a more thorough job than most implementations of std::hash.
+/// Hasher has been optimized for a better quality hash at the expense of increased computation
+/// costs.
+template <typename T>
+struct Hasher {
+    /// @param value the value to hash
+    /// @returns a hash of the value
+    size_t operator()(const T& value) const { return std::hash<T>()(value); }
+};
+
+/// Hasher specialization for pointers
+/// std::hash<T*> typically uses a reinterpret of the pointer to a size_t.
+/// As most pointers a 4 or 16 byte aligned, this usually results in the LSBs of the hash being 0,
+/// resulting in bad hashes for hashtables. This implementation mixes up those LSBs.
+template <typename T>
+struct Hasher<T*> {
+    /// @param ptr the pointer to hash
+    /// @returns a hash of the pointer
+    size_t operator()(T* ptr) const {
+        auto hash = std::hash<T*>()(ptr);
+#ifdef TINT_HASH_SEED
+        hash ^= static_cast<uint32_t>(TINT_HASH_SEED);
+#endif
+        return hash ^ (hash >> 4);
+    }
+};
+
+/// Hasher specialization for std::vector
+template <typename T>
+struct Hasher<std::vector<T>> {
+    /// @param vector the vector to hash
+    /// @returns a hash of the vector
+    size_t operator()(const std::vector<T>& vector) const {
+        auto hash = Hash(vector.size());
+        for (auto& el : vector) {
+            hash = HashCombine(hash, el);
+        }
+        return hash;
+    }
+};
+
+/// Hasher specialization for utils::Vector
+template <typename T, size_t N>
+struct Hasher<utils::Vector<T, N>> {
+    /// @param vector the Vector to hash
+    /// @returns a hash of the Vector
+    size_t operator()(const utils::Vector<T, N>& vector) const {
+        auto hash = Hash(vector.Length());
+        for (auto& el : vector) {
+            hash = HashCombine(hash, el);
+        }
+        return hash;
+    }
+};
+
+/// Hasher specialization for utils::VectorRef
+template <typename T>
+struct Hasher<utils::VectorRef<T>> {
+    /// @param vector the VectorRef reference to hash
+    /// @returns a hash of the Vector
+    size_t operator()(const utils::VectorRef<T>& vector) const {
+        auto hash = Hash(vector.Length());
+        for (auto& el : vector) {
+            hash = HashCombine(hash, el);
+        }
+        return hash;
+    }
+};
+
+/// Hasher specialization for std::tuple
+template <typename... TYPES>
+struct Hasher<std::tuple<TYPES...>> {
+    /// @param tuple the tuple to hash
+    /// @returns a hash of the tuple
+    size_t operator()(const std::tuple<TYPES...>& tuple) const {
+        return std::apply(Hash<TYPES...>, tuple);
+    }
+};
+
+/// Hasher specialization for std::pair
+template <typename A, typename B>
+struct Hasher<std::pair<A, B>> {
+    /// @param tuple the tuple to hash
+    /// @returns a hash of the tuple
+    size_t operator()(const std::pair<A, B>& tuple) const { return std::apply(Hash<A, B>, tuple); }
+};
+
+/// Hasher specialization for std::variant
+template <typename... TYPES>
+struct Hasher<std::variant<TYPES...>> {
+    /// @param variant the variant to hash
+    /// @returns a hash of the tuple
+    size_t operator()(const std::variant<TYPES...>& variant) const {
+        return std::visit([](auto&& val) { return Hash(val); }, variant);
+    }
+};
+
+/// Hasher specialization for std::string, which also supports hashing of const char* and
+/// std::string_view without first constructing a std::string.
+template <>
+struct Hasher<std::string> {
+    /// @param str the string to hash
+    /// @returns a hash of the string
+    size_t operator()(const std::string& str) const {
+        return std::hash<std::string_view>()(std::string_view(str));
+    }
+
+    /// @param str the string to hash
+    /// @returns a hash of the string
+    size_t operator()(const char* str) const {
+        return std::hash<std::string_view>()(std::string_view(str));
+    }
+
+    /// @param str the string to hash
+    /// @returns a hash of the string
+    size_t operator()(const std::string_view& str) const {
+        return std::hash<std::string_view>()(str);
+    }
+};
+
+/// @returns a hash of the variadic list of arguments.
+///          The returned hash is dependent on the order of the arguments.
+template <typename... ARGS>
+size_t Hash(const ARGS&... args) {
+    if constexpr (sizeof...(ARGS) == 0) {
+        return 0;
+    } else if constexpr (sizeof...(ARGS) == 1) {
+        using T = std::tuple_element_t<0, std::tuple<ARGS...>>;
+        return Hasher<T>()(args...);
+    } else {
+        size_t hash = 102931;  // seed with an arbitrary prime
+        return HashCombine(hash, args...);
+    }
+}
+
+/// @returns a hash of the variadic list of arguments.
+///          The returned hash is dependent on the order of the arguments.
+template <typename... ARGS>
+size_t HashCombine(size_t hash, const ARGS&... values) {
+    constexpr size_t offset = utils::detail::HashCombineOffset<sizeof(size_t)>::value();
+    ((hash ^= Hash(values) + (offset ^ (hash >> 2))), ...);
+    return hash;
+}
+
+/// A STL-compatible equal_to implementation that specializes for types.
+template <typename T>
+struct EqualTo {
+    /// @param lhs the left hand side value
+    /// @param rhs the right hand side value
+    /// @returns true if the two values are equal
+    constexpr bool operator()(const T& lhs, const T& rhs) const {
+        return std::equal_to<T>()(lhs, rhs);
+    }
+};
+
+/// A specialization for EqualTo for std::string, which supports additional comparision with
+/// std::string_view and const char*.
+template <>
+struct EqualTo<std::string> {
+    /// @param lhs the left hand side value
+    /// @param rhs the right hand side value
+    /// @returns true if the two values are equal
+    bool operator()(const std::string& lhs, const std::string& rhs) const { return lhs == rhs; }
+
+    /// @param lhs the left hand side value
+    /// @param rhs the right hand side value
+    /// @returns true if the two values are equal
+    bool operator()(const std::string& lhs, const char* rhs) const { return lhs == rhs; }
+
+    /// @param lhs the left hand side value
+    /// @param rhs the right hand side value
+    /// @returns true if the two values are equal
+    bool operator()(const std::string& lhs, std::string_view rhs) const { return lhs == rhs; }
+
+    /// @param lhs the left hand side value
+    /// @param rhs the right hand side value
+    /// @returns true if the two values are equal
+    bool operator()(const char* lhs, const std::string& rhs) const { return lhs == rhs; }
+
+    /// @param lhs the left hand side value
+    /// @param rhs the right hand side value
+    /// @returns true if the two values are equal
+    bool operator()(std::string_view lhs, const std::string& rhs) const { return lhs == rhs; }
+};
+
+/// Wrapper for a hashable type enabling the wrapped value to be used as a key
+/// for an unordered_map or unordered_set.
+template <typename T>
+struct UnorderedKeyWrapper {
+    /// The wrapped value
+    T value;
+    /// The hash of value
+    size_t hash;
+
+    /// Constructor
+    /// @param v the value to wrap
+    explicit UnorderedKeyWrapper(const T& v) : value(v), hash(Hash(v)) {}
+
+    /// Move constructor
+    /// @param v the value to wrap
+    explicit UnorderedKeyWrapper(T&& v) : value(std::move(v)), hash(Hash(value)) {}
+
+    /// @returns true if this wrapper comes before other
+    /// @param other the RHS of the operator
+    bool operator<(const UnorderedKeyWrapper& other) const { return hash < other.hash; }
+
+    /// @returns true if this wrapped value is equal to the other wrapped value
+    /// @param other the RHS of the operator
+    bool operator==(const UnorderedKeyWrapper& other) const { return value == other.value; }
+};
+
+}  // namespace tint::utils
+
+namespace std {
+
+/// Custom std::hash specialization for tint::utils::UnorderedKeyWrapper
+template <typename T>
+class hash<tint::utils::UnorderedKeyWrapper<T>> {
+  public:
+    /// @param w the UnorderedKeyWrapper
+    /// @return the hash value
+    inline std::size_t operator()(const tint::utils::UnorderedKeyWrapper<T>& w) const {
+        return w.hash;
+    }
+};
+
+}  // namespace std
+
+#endif  // SRC_TINT_UTILS_MATH_HASH_H_
diff --git a/src/tint/utils/math/hash_test.cc b/src/tint/utils/math/hash_test.cc
new file mode 100644
index 0000000..7083170
--- /dev/null
+++ b/src/tint/utils/math/hash_test.cc
@@ -0,0 +1,128 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/math/hash.h"
+
+#include <string>
+#include <tuple>
+#include <unordered_map>
+
+#include "gtest/gtest.h"
+#include "src/tint/utils/containers/vector.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(HashTests, Basic) {
+    EXPECT_EQ(Hash(123), Hash(123));
+    EXPECT_EQ(Hash(123, 456), Hash(123, 456));
+    EXPECT_EQ(Hash(123, 456, false), Hash(123, 456, false));
+    EXPECT_EQ(Hash(std::string("hello")), Hash(std::string("hello")));
+}
+
+TEST(HashTests, StdVector) {
+    EXPECT_EQ(Hash(std::vector<int>({})), Hash(std::vector<int>({})));
+    EXPECT_EQ(Hash(std::vector<int>({1, 2, 3})), Hash(std::vector<int>({1, 2, 3})));
+}
+
+TEST(HashTests, TintVector) {
+    EXPECT_EQ(Hash(Vector<int, 0>({})), Hash(Vector<int, 0>({})));
+    EXPECT_EQ(Hash(Vector<int, 0>({1, 2, 3})), Hash(Vector<int, 0>({1, 2, 3})));
+    EXPECT_EQ(Hash(Vector<int, 3>({1, 2, 3})), Hash(Vector<int, 4>({1, 2, 3})));
+    EXPECT_EQ(Hash(Vector<int, 3>({1, 2, 3})), Hash(Vector<int, 2>({1, 2, 3})));
+}
+
+TEST(HashTests, TintVectorRef) {
+    EXPECT_EQ(Hash(VectorRef<int>(Vector<int, 0>({}))), Hash(VectorRef<int>(Vector<int, 0>({}))));
+    EXPECT_EQ(Hash(VectorRef<int>(Vector<int, 0>({1, 2, 3}))),
+              Hash(VectorRef<int>(Vector<int, 0>({1, 2, 3}))));
+    EXPECT_EQ(Hash(VectorRef<int>(Vector<int, 3>({1, 2, 3}))),
+              Hash(VectorRef<int>(Vector<int, 4>({1, 2, 3}))));
+    EXPECT_EQ(Hash(VectorRef<int>(Vector<int, 3>({1, 2, 3}))),
+              Hash(VectorRef<int>(Vector<int, 2>({1, 2, 3}))));
+
+    EXPECT_EQ(Hash(VectorRef<int>(Vector<int, 0>({}))), Hash(Vector<int, 0>({})));
+    EXPECT_EQ(Hash(VectorRef<int>(Vector<int, 0>({1, 2, 3}))), Hash(Vector<int, 0>({1, 2, 3})));
+    EXPECT_EQ(Hash(VectorRef<int>(Vector<int, 3>({1, 2, 3}))), Hash(Vector<int, 4>({1, 2, 3})));
+    EXPECT_EQ(Hash(VectorRef<int>(Vector<int, 3>({1, 2, 3}))), Hash(Vector<int, 2>({1, 2, 3})));
+}
+
+TEST(HashTests, Tuple) {
+    EXPECT_EQ(Hash(std::make_tuple(1)), Hash(std::make_tuple(1)));
+    EXPECT_EQ(Hash(std::make_tuple(1, 2, 3)), Hash(std::make_tuple(1, 2, 3)));
+}
+
+TEST(HashTests, UnorderedKeyWrapper) {
+    using W = UnorderedKeyWrapper<std::vector<int>>;
+
+    std::unordered_map<W, int> m;
+
+    m.emplace(W{{1, 2}}, -1);
+    EXPECT_EQ(m.size(), 1u);
+    EXPECT_EQ(m[W({1, 2})], -1);
+
+    m.emplace(W{{3, 2}}, 1);
+    EXPECT_EQ(m.size(), 2u);
+    EXPECT_EQ(m[W({3, 2})], 1);
+    EXPECT_EQ(m[W({1, 2})], -1);
+
+    m.emplace(W{{100}}, 100);
+    EXPECT_EQ(m.size(), 3u);
+    EXPECT_EQ(m[W({100})], 100);
+    EXPECT_EQ(m[W({3, 2})], 1);
+    EXPECT_EQ(m[W({1, 2})], -1);
+
+    // Reversed vector element order
+    EXPECT_EQ(m[W({2, 3})], 0);
+    EXPECT_EQ(m[W({2, 1})], 0);
+}
+
+TEST(EqualTo, String) {
+    std::string str_a = "hello";
+    std::string str_b = "world";
+    const char* cstr_a = "hello";
+    const char* cstr_b = "world";
+    std::string_view sv_a = "hello";
+    std::string_view sv_b = "world";
+    EXPECT_TRUE(EqualTo<std::string>()(str_a, str_a));
+    EXPECT_TRUE(EqualTo<std::string>()(str_a, cstr_a));
+    EXPECT_TRUE(EqualTo<std::string>()(str_a, sv_a));
+    EXPECT_TRUE(EqualTo<std::string>()(str_a, str_a));
+    EXPECT_TRUE(EqualTo<std::string>()(cstr_a, str_a));
+    EXPECT_TRUE(EqualTo<std::string>()(sv_a, str_a));
+
+    EXPECT_FALSE(EqualTo<std::string>()(str_a, str_b));
+    EXPECT_FALSE(EqualTo<std::string>()(str_a, cstr_b));
+    EXPECT_FALSE(EqualTo<std::string>()(str_a, sv_b));
+    EXPECT_FALSE(EqualTo<std::string>()(str_a, str_b));
+    EXPECT_FALSE(EqualTo<std::string>()(cstr_a, str_b));
+    EXPECT_FALSE(EqualTo<std::string>()(sv_a, str_b));
+
+    EXPECT_FALSE(EqualTo<std::string>()(str_b, str_a));
+    EXPECT_FALSE(EqualTo<std::string>()(str_b, cstr_a));
+    EXPECT_FALSE(EqualTo<std::string>()(str_b, sv_a));
+    EXPECT_FALSE(EqualTo<std::string>()(str_b, str_a));
+    EXPECT_FALSE(EqualTo<std::string>()(cstr_b, str_a));
+    EXPECT_FALSE(EqualTo<std::string>()(sv_b, str_a));
+
+    EXPECT_TRUE(EqualTo<std::string>()(str_b, str_b));
+    EXPECT_TRUE(EqualTo<std::string>()(str_b, cstr_b));
+    EXPECT_TRUE(EqualTo<std::string>()(str_b, sv_b));
+    EXPECT_TRUE(EqualTo<std::string>()(str_b, str_b));
+    EXPECT_TRUE(EqualTo<std::string>()(cstr_b, str_b));
+    EXPECT_TRUE(EqualTo<std::string>()(sv_b, str_b));
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/math/math.h b/src/tint/utils/math/math.h
new file mode 100644
index 0000000..59bd36c
--- /dev/null
+++ b/src/tint/utils/math/math.h
@@ -0,0 +1,103 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_MATH_MATH_H_
+#define SRC_TINT_UTILS_MATH_MATH_H_
+
+#include <stdint.h>
+
+#include <string>
+#include <type_traits>
+
+namespace tint::utils {
+
+/// @param alignment the next multiple to round `value` to
+/// @param value the value to round to the next multiple of `alignment`
+/// @return `value` rounded to the next multiple of `alignment`
+/// @note `alignment` must be positive. An alignment of zero will cause a DBZ.
+template <typename T>
+inline constexpr T RoundUp(T alignment, T value) {
+    return ((value + alignment - 1) / alignment) * alignment;
+}
+
+/// @param value the value to check whether it is a power-of-two
+/// @returns true if `value` is a power-of-two
+/// @note `value` must be positive if `T` is signed
+template <typename T>
+inline constexpr bool IsPowerOfTwo(T value) {
+    return (value & (value - 1)) == 0;
+}
+
+/// @param value the input value
+/// @returns the base-2 logarithm of @p value
+inline constexpr uint32_t Log2(uint64_t value) {
+#if defined(__clang__) || defined(__GNUC__)
+    return 63 - static_cast<uint32_t>(__builtin_clzll(value));
+#elif defined(_MSC_VER) && !defined(__clang__) && __cplusplus >= 202002L  // MSVC and C++20+
+    // note: std::is_constant_evaluated() added in C++20
+    //       required here as _BitScanReverse64 is not constexpr
+    if (!std::is_constant_evaluated()) {
+        // NOLINTNEXTLINE(runtime/int)
+        if constexpr (sizeof(unsigned long) == 8) {  // 64-bit
+            // NOLINTNEXTLINE(runtime/int)
+            unsigned long first_bit_index = 0;
+            _BitScanReverse64(&first_bit_index, value);
+            return first_bit_index;
+        } else {  // 32-bit
+            // NOLINTNEXTLINE(runtime/int)
+            unsigned long first_bit_index = 0;
+            if (_BitScanReverse(&first_bit_index, value >> 32)) {
+                return first_bit_index + 32;
+            }
+            _BitScanReverse(&first_bit_index, value & 0xffffffff);
+            return first_bit_index;
+        }
+    }
+#endif
+
+    // Non intrinsic (slow) path. Supports constexpr evaluation.
+    for (uint64_t clz = 0; clz < 64; clz++) {
+        uint64_t bit = 63 - clz;
+        if (value & (static_cast<uint64_t>(1u) << bit)) {
+            return static_cast<uint32_t>(bit);
+        }
+    }
+    return 64;
+}
+
+/// @param value the input value
+/// @returns the next power of two number greater or equal to @p value
+inline constexpr uint64_t NextPowerOfTwo(uint64_t value) {
+    if (value <= 1) {
+        return 1;
+    } else {
+        return static_cast<uint64_t>(1) << (Log2(value - 1) + 1);
+    }
+}
+
+/// @param value the input value
+/// @returns the largest power of two that `value` is a multiple of
+template <typename T>
+inline std::enable_if_t<std::is_unsigned<T>::value, T> MaxAlignOf(T value) {
+    T pot = 1;
+    while (value && ((value & 1u) == 0)) {
+        pot <<= 1;
+        value >>= 1;
+    }
+    return pot;
+}
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_MATH_MATH_H_
diff --git a/src/tint/utils/math/math_test.cc b/src/tint/utils/math/math_test.cc
new file mode 100644
index 0000000..8da26ba
--- /dev/null
+++ b/src/tint/utils/math/math_test.cc
@@ -0,0 +1,145 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/math/math.h"
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(MathTests, RoundUp) {
+    EXPECT_EQ(RoundUp(1, 0), 0);
+    EXPECT_EQ(RoundUp(1, 1), 1);
+    EXPECT_EQ(RoundUp(1, 2), 2);
+
+    EXPECT_EQ(RoundUp(1, 1), 1);
+    EXPECT_EQ(RoundUp(2, 1), 2);
+    EXPECT_EQ(RoundUp(3, 1), 3);
+    EXPECT_EQ(RoundUp(4, 1), 4);
+
+    EXPECT_EQ(RoundUp(1, 2), 2);
+    EXPECT_EQ(RoundUp(2, 2), 2);
+    EXPECT_EQ(RoundUp(3, 2), 3);
+    EXPECT_EQ(RoundUp(4, 2), 4);
+
+    EXPECT_EQ(RoundUp(1, 3), 3);
+    EXPECT_EQ(RoundUp(2, 3), 4);
+    EXPECT_EQ(RoundUp(3, 3), 3);
+    EXPECT_EQ(RoundUp(4, 3), 4);
+
+    EXPECT_EQ(RoundUp(1, 4), 4);
+    EXPECT_EQ(RoundUp(2, 4), 4);
+    EXPECT_EQ(RoundUp(3, 4), 6);
+    EXPECT_EQ(RoundUp(4, 4), 4);
+}
+
+TEST(MathTests, IsPowerOfTwo) {
+    EXPECT_EQ(IsPowerOfTwo(1), true);
+    EXPECT_EQ(IsPowerOfTwo(2), true);
+    EXPECT_EQ(IsPowerOfTwo(3), false);
+    EXPECT_EQ(IsPowerOfTwo(4), true);
+    EXPECT_EQ(IsPowerOfTwo(5), false);
+    EXPECT_EQ(IsPowerOfTwo(6), false);
+    EXPECT_EQ(IsPowerOfTwo(7), false);
+    EXPECT_EQ(IsPowerOfTwo(8), true);
+    EXPECT_EQ(IsPowerOfTwo(9), false);
+}
+
+TEST(MathTests, Log2) {
+    EXPECT_EQ(Log2(1), 0u);
+    EXPECT_EQ(Log2(2), 1u);
+    EXPECT_EQ(Log2(3), 1u);
+    EXPECT_EQ(Log2(4), 2u);
+    EXPECT_EQ(Log2(5), 2u);
+    EXPECT_EQ(Log2(6), 2u);
+    EXPECT_EQ(Log2(7), 2u);
+    EXPECT_EQ(Log2(8), 3u);
+    EXPECT_EQ(Log2(9), 3u);
+    EXPECT_EQ(Log2(0x7fffffffu), 30u);
+    EXPECT_EQ(Log2(0x80000000u), 31u);
+    EXPECT_EQ(Log2(0x80000001u), 31u);
+    EXPECT_EQ(Log2(0x7fffffffffffffffu), 62u);
+    EXPECT_EQ(Log2(0x8000000000000000u), 63u);
+
+    static_assert(Log2(1) == 0u);
+    static_assert(Log2(2) == 1u);
+    static_assert(Log2(3) == 1u);
+    static_assert(Log2(4) == 2u);
+    static_assert(Log2(5) == 2u);
+    static_assert(Log2(6) == 2u);
+    static_assert(Log2(7) == 2u);
+    static_assert(Log2(8) == 3u);
+    static_assert(Log2(9) == 3u);
+    static_assert(Log2(0x7fffffffu) == 30u);
+    static_assert(Log2(0x80000000u) == 31u);
+    static_assert(Log2(0x80000001u) == 31u);
+    static_assert(Log2(0x7fffffffffffffffu) == 62u);
+    static_assert(Log2(0x8000000000000000u) == 63u);
+}
+
+TEST(MathTests, NextPowerOfTwo) {
+    EXPECT_EQ(NextPowerOfTwo(0), 1u);
+    EXPECT_EQ(NextPowerOfTwo(1), 1u);
+    EXPECT_EQ(NextPowerOfTwo(2), 2u);
+    EXPECT_EQ(NextPowerOfTwo(3), 4u);
+    EXPECT_EQ(NextPowerOfTwo(4), 4u);
+    EXPECT_EQ(NextPowerOfTwo(5), 8u);
+    EXPECT_EQ(NextPowerOfTwo(6), 8u);
+    EXPECT_EQ(NextPowerOfTwo(7), 8u);
+    EXPECT_EQ(NextPowerOfTwo(8), 8u);
+    EXPECT_EQ(NextPowerOfTwo(9), 16u);
+    EXPECT_EQ(NextPowerOfTwo(0x7fffffffu), 0x80000000u);
+    EXPECT_EQ(NextPowerOfTwo(0x80000000u), 0x80000000u);
+    EXPECT_EQ(NextPowerOfTwo(0x80000001u), 0x100000000u);
+    EXPECT_EQ(NextPowerOfTwo(0x7fffffffffffffffu), 0x8000000000000000u);
+
+    static_assert(NextPowerOfTwo(0) == 1u);
+    static_assert(NextPowerOfTwo(1) == 1u);
+    static_assert(NextPowerOfTwo(2) == 2u);
+    static_assert(NextPowerOfTwo(3) == 4u);
+    static_assert(NextPowerOfTwo(4) == 4u);
+    static_assert(NextPowerOfTwo(5) == 8u);
+    static_assert(NextPowerOfTwo(6) == 8u);
+    static_assert(NextPowerOfTwo(7) == 8u);
+    static_assert(NextPowerOfTwo(8) == 8u);
+    static_assert(NextPowerOfTwo(9) == 16u);
+    static_assert(NextPowerOfTwo(0x7fffffffu) == 0x80000000u);
+    static_assert(NextPowerOfTwo(0x80000000u) == 0x80000000u);
+    static_assert(NextPowerOfTwo(0x80000001u) == 0x100000000u);
+    static_assert(NextPowerOfTwo(0x7fffffffffffffffu) == 0x8000000000000000u);
+}
+
+TEST(MathTests, MaxAlignOf) {
+    EXPECT_EQ(MaxAlignOf(0u), 1u);
+    EXPECT_EQ(MaxAlignOf(1u), 1u);
+    EXPECT_EQ(MaxAlignOf(2u), 2u);
+    EXPECT_EQ(MaxAlignOf(3u), 1u);
+    EXPECT_EQ(MaxAlignOf(4u), 4u);
+    EXPECT_EQ(MaxAlignOf(5u), 1u);
+    EXPECT_EQ(MaxAlignOf(6u), 2u);
+    EXPECT_EQ(MaxAlignOf(7u), 1u);
+    EXPECT_EQ(MaxAlignOf(8u), 8u);
+    EXPECT_EQ(MaxAlignOf(9u), 1u);
+    EXPECT_EQ(MaxAlignOf(10u), 2u);
+    EXPECT_EQ(MaxAlignOf(11u), 1u);
+    EXPECT_EQ(MaxAlignOf(12u), 4u);
+    EXPECT_EQ(MaxAlignOf(13u), 1u);
+    EXPECT_EQ(MaxAlignOf(14u), 2u);
+    EXPECT_EQ(MaxAlignOf(15u), 1u);
+    EXPECT_EQ(MaxAlignOf(16u), 16u);
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/memory/bitcast.h b/src/tint/utils/memory/bitcast.h
new file mode 100644
index 0000000..30f772b
--- /dev/null
+++ b/src/tint/utils/memory/bitcast.h
@@ -0,0 +1,53 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_MEMORY_BITCAST_H_
+#define SRC_TINT_UTILS_MEMORY_BITCAST_H_
+
+#include <cstddef>
+#include <cstring>
+#include <type_traits>
+
+namespace tint::utils {
+
+/// Bitcast performs a cast of `from` to the `TO` type using a memcpy.
+/// This unsafe cast avoids triggering Clang's Control Flow Integrity checks.
+/// See: crbug.com/dawn/1406
+/// See: https://clang.llvm.org/docs/ControlFlowIntegrity.html#bad-cast-checking
+/// @param from the value to cast
+/// @tparam TO the value to cast to
+/// @returns the cast value
+template <typename TO, typename FROM>
+inline TO Bitcast(FROM&& from) {
+    static_assert(sizeof(FROM) == sizeof(TO));
+    // gcc warns in cases where either TO or FROM are classes, even if they are trivially
+    // copyable, with for example:
+    //
+    // error: ‘void* memcpy(void*, const void*, size_t)’ copying an object of
+    // non-trivial type ‘struct tint::Number<unsigned int>’ from an array of ‘float’
+    // [-Werror=class-memaccess]
+    //
+    // We avoid this by asserting that both types are indeed trivially copyable, and casting both
+    // args to std::byte*.
+    static_assert(std::is_trivially_copyable_v<std::decay_t<FROM>>);
+    static_assert(std::is_trivially_copyable_v<std::decay_t<TO>>);
+    TO to;
+    memcpy(reinterpret_cast<std::byte*>(&to), reinterpret_cast<const std::byte*>(&from),
+           sizeof(TO));
+    return to;
+}
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_MEMORY_BITCAST_H_
diff --git a/src/tint/utils/memory/bitcast_test.cc b/src/tint/utils/memory/bitcast_test.cc
new file mode 100644
index 0000000..558c52f
--- /dev/null
+++ b/src/tint/utils/memory/bitcast_test.cc
@@ -0,0 +1,37 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/memory/bitcast.h"
+
+#include <stdint.h>
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(Bitcast, Integer) {
+    uint32_t a = 123;
+    int32_t b = Bitcast<int32_t>(a);
+    EXPECT_EQ(a, static_cast<uint32_t>(b));
+}
+
+TEST(Bitcast, Pointer) {
+    uint32_t a = 123;
+    void* b = Bitcast<void*>(&a);
+    EXPECT_EQ(&a, static_cast<uint32_t*>(b));
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/memory/block_allocator.h b/src/tint/utils/memory/block_allocator.h
new file mode 100644
index 0000000..1cc382c
--- /dev/null
+++ b/src/tint/utils/memory/block_allocator.h
@@ -0,0 +1,341 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_MEMORY_BLOCK_ALLOCATOR_H_
+#define SRC_TINT_UTILS_MEMORY_BLOCK_ALLOCATOR_H_
+
+#include <array>
+#include <cstring>
+#include <utility>
+
+#include "src/tint/utils/math/math.h"
+#include "src/tint/utils/memory/bitcast.h"
+
+namespace tint::utils {
+
+/// A container and allocator of objects of (or deriving from) the template type `T`.
+/// Objects are allocated by calling Create(), and are owned by the BlockAllocator.
+/// When the BlockAllocator is destructed, all constructed objects are automatically destructed and
+/// freed.
+///
+/// Objects held by the BlockAllocator can be iterated over using a View.
+template <typename T, size_t BLOCK_SIZE = 64 * 1024, size_t BLOCK_ALIGNMENT = 16>
+class BlockAllocator {
+    /// Pointers is a chunk of T* pointers, forming a linked list.
+    /// The list of Pointers are used to maintain the list of allocated objects.
+    /// Pointers are allocated out of the block memory.
+    struct Pointers {
+        static constexpr size_t kMax = 32;
+        std::array<T*, kMax> ptrs;
+        Pointers* next;
+        Pointers* prev;
+    };
+
+    /// Block is linked list of memory blocks.
+    /// Blocks are allocated out of heap memory.
+    ///
+    /// Note: We're not using std::aligned_storage here as this warns / errors on MSVC.
+    struct alignas(BLOCK_ALIGNMENT) Block {
+        uint8_t data[BLOCK_SIZE];
+        Block* next;
+    };
+
+    // Forward declaration
+    template <bool IS_CONST>
+    class TView;
+
+    /// An iterator for the objects owned by the BlockAllocator.
+    template <bool IS_CONST, bool FORWARD>
+    class TIterator {
+        using PointerTy = std::conditional_t<IS_CONST, const T*, T*>;
+
+      public:
+        /// Equality operator
+        /// @param other the iterator to compare this iterator to
+        /// @returns true if this iterator is equal to other
+        bool operator==(const TIterator& other) const {
+            return ptrs == other.ptrs && idx == other.idx;
+        }
+
+        /// Inequality operator
+        /// @param other the iterator to compare this iterator to
+        /// @returns true if this iterator is not equal to other
+        bool operator!=(const TIterator& other) const { return !(*this == other); }
+
+        /// Progress the iterator forward one element
+        /// @returns this iterator
+        TIterator& operator++() {
+            if (FORWARD) {
+                ProgressForward();
+            } else {
+                ProgressBackwards();
+            }
+            return *this;
+        }
+
+        /// Progress the iterator backwards one element
+        /// @returns this iterator
+        TIterator& operator--() {
+            if (FORWARD) {
+                ProgressBackwards();
+            } else {
+                ProgressForward();
+            }
+            return *this;
+        }
+
+        /// @returns the pointer to the object at the current iterator position
+        PointerTy operator*() const { return ptrs ? ptrs->ptrs[idx] : nullptr; }
+
+      private:
+        friend TView<IS_CONST>;  // Keep internal iterator impl private.
+        explicit TIterator(const Pointers* p, size_t i) : ptrs(p), idx(i) {}
+
+        /// Progresses the iterator forwards
+        void ProgressForward() {
+            if (ptrs != nullptr) {
+                ++idx;
+                if (idx == Pointers::kMax) {
+                    idx = 0;
+                    ptrs = ptrs->next;
+                }
+            }
+        }
+        /// Progresses the iterator backwards
+        void ProgressBackwards() {
+            if (ptrs != nullptr) {
+                if (idx == 0) {
+                    idx = Pointers::kMax - 1;
+                    ptrs = ptrs->prev;
+                }
+                --idx;
+            }
+        }
+
+        const Pointers* ptrs;
+        size_t idx;
+    };
+
+    /// View provides begin() and end() methods for looping over the objects owned by the
+    /// BlockAllocator.
+    template <bool IS_CONST>
+    class TView {
+      public:
+        /// @returns an iterator to the beginning of the view
+        TIterator<IS_CONST, true> begin() const {
+            return TIterator<IS_CONST, true>{allocator_->data.pointers.root, 0};
+        }
+
+        /// @returns an iterator to the end of the view
+        TIterator<IS_CONST, true> end() const {
+            return allocator_->data.pointers.current_index >= Pointers::kMax
+                       ? TIterator<IS_CONST, true>{nullptr, 0}
+                       : TIterator<IS_CONST, true>{allocator_->data.pointers.current,
+                                                   allocator_->data.pointers.current_index};
+        }
+
+        /// @returns an iterator to the beginning of the view
+        TIterator<IS_CONST, false> rbegin() const { return TIterator<IS_CONST, false>{nullptr, 0}; }
+
+        /// @returns an iterator to the end of the view
+        TIterator<IS_CONST, false> rend() const {
+            return TIterator<IS_CONST, false>{allocator_->data.pointers.current,
+                                              allocator_->data.pointers.current_index};
+        }
+
+      private:
+        friend BlockAllocator;  // For BlockAllocator::operator View()
+        explicit TView(BlockAllocator const* allocator) : allocator_(allocator) {}
+        BlockAllocator const* const allocator_;
+    };
+
+  public:
+    /// A forward-iterator type over the objects of the BlockAllocator
+    using Iterator = TIterator</* const */ false, /* forward */ true>;
+
+    /// An immutable forward-iterator type over the objects of the BlockAllocator
+    using ConstIterator = TIterator</* const */ true, /* forward */ true>;
+
+    /// A reverse-iterator type over the objects of the BlockAllocator
+    using ReverseIterator = TIterator</* const */ false, /* forward */ false>;
+
+    /// An immutable reverse-iterator type over the objects of the BlockAllocator
+    using ReverseConstIterator = TIterator</* const */ true, /* forward */ false>;
+
+    /// View provides begin() and end() methods for looping over the objects owned by the
+    /// BlockAllocator.
+    using View = TView<false>;
+
+    /// ConstView provides begin() and end() methods for looping over the objects owned by the
+    /// BlockAllocator.
+    using ConstView = TView<true>;
+
+    /// Constructor
+    BlockAllocator() = default;
+
+    /// Move constructor
+    /// @param rhs the BlockAllocator to move
+    BlockAllocator(BlockAllocator&& rhs) { std::swap(data, rhs.data); }
+
+    /// Move assignment operator
+    /// @param rhs the BlockAllocator to move
+    /// @return this BlockAllocator
+    BlockAllocator& operator=(BlockAllocator&& rhs) {
+        if (this != &rhs) {
+            Reset();
+            std::swap(data, rhs.data);
+        }
+        return *this;
+    }
+
+    /// Destructor
+    ~BlockAllocator() { Reset(); }
+
+    /// @return a View of all objects owned by this BlockAllocator
+    View Objects() { return View(this); }
+
+    /// @return a ConstView of all objects owned by this BlockAllocator
+    ConstView Objects() const { return ConstView(this); }
+
+    /// Creates a new `TYPE` owned by the BlockAllocator.
+    /// When the BlockAllocator is destructed the object will be destructed and freed.
+    /// @param args the arguments to pass to the constructor
+    /// @returns the pointer to the constructed object
+    template <typename TYPE = T, typename... ARGS>
+    TYPE* Create(ARGS&&... args) {
+        static_assert(std::is_same<T, TYPE>::value || std::is_base_of<T, TYPE>::value,
+                      "TYPE does not derive from T");
+        static_assert(std::is_same<T, TYPE>::value || std::has_virtual_destructor<T>::value,
+                      "TYPE requires a virtual destructor when calling Create() for a type "
+                      "that is not T");
+
+        auto* ptr = Allocate<TYPE>();
+        new (ptr) TYPE(std::forward<ARGS>(args)...);
+        AddObjectPointer(ptr);
+        data.count++;
+
+        return ptr;
+    }
+
+    /// Frees all allocations from the allocator.
+    void Reset() {
+        for (auto ptr : Objects()) {
+            ptr->~T();
+        }
+        auto* block = data.block.root;
+        while (block != nullptr) {
+            auto* next = block->next;
+            delete block;
+            block = next;
+        }
+        data = {};
+    }
+
+    /// @returns the total number of allocated objects.
+    size_t Count() const { return data.count; }
+
+  private:
+    BlockAllocator(const BlockAllocator&) = delete;
+    BlockAllocator& operator=(const BlockAllocator&) = delete;
+
+    /// Allocates an instance of TYPE from the current block, or from a newly allocated block if the
+    /// current block is full.
+    template <typename TYPE>
+    TYPE* Allocate() {
+        static_assert(sizeof(TYPE) <= BLOCK_SIZE,
+                      "Cannot construct TYPE with size greater than BLOCK_SIZE");
+        static_assert(alignof(TYPE) <= BLOCK_ALIGNMENT, "alignof(TYPE) is greater than ALIGNMENT");
+
+        auto& block = data.block;
+
+        block.current_offset = utils::RoundUp(alignof(TYPE), block.current_offset);
+        if (block.current_offset + sizeof(TYPE) > BLOCK_SIZE) {
+            // Allocate a new block from the heap
+            auto* prev_block = block.current;
+            block.current = new Block;
+            if (!block.current) {
+                return nullptr;  // out of memory
+            }
+            block.current->next = nullptr;
+            block.current_offset = 0;
+            if (prev_block) {
+                prev_block->next = block.current;
+            } else {
+                block.root = block.current;
+            }
+        }
+
+        auto* base = &block.current->data[0];
+        auto* ptr = utils::Bitcast<TYPE*>(base + block.current_offset);
+        block.current_offset += sizeof(TYPE);
+        return ptr;
+    }
+
+    /// Adds `ptr` to the linked list of objects owned by this BlockAllocator.
+    /// Once added, `ptr` will be tracked for destruction when the BlockAllocator is destructed.
+    void AddObjectPointer(T* ptr) {
+        auto& pointers = data.pointers;
+
+        if (pointers.current_index >= Pointers::kMax) {
+            auto* prev_pointers = pointers.current;
+            pointers.current = Allocate<Pointers>();
+            if (!pointers.current) {
+                return;  // out of memory
+            }
+            pointers.current->next = nullptr;
+            pointers.current->prev = prev_pointers;
+            pointers.current_index = 0;
+
+            if (prev_pointers) {
+                prev_pointers->next = pointers.current;
+            } else {
+                pointers.root = pointers.current;
+            }
+        }
+
+        pointers.current->ptrs[pointers.current_index++] = ptr;
+    }
+
+    struct {
+        struct {
+            /// The root block of the block linked list
+            Block* root = nullptr;
+            /// The current (end) block of the blocked linked list.
+            /// New allocations come from this block
+            Block* current = nullptr;
+            /// The byte offset in #current for the next allocation.
+            /// Initialized with BLOCK_SIZE so that the first allocation triggers a block
+            /// allocation.
+            size_t current_offset = BLOCK_SIZE;
+        } block;
+
+        struct {
+            /// The root Pointers structure of the pointers linked list
+            Pointers* root = nullptr;
+            /// The current (end) Pointers structure of the pointers linked list.
+            /// AddObjectPointer() adds to this structure.
+            Pointers* current = nullptr;
+            /// The array index in #current for the next append.
+            /// Initialized with Pointers::kMax so that the first append triggers a allocation of
+            /// the Pointers structure.
+            size_t current_index = Pointers::kMax;
+        } pointers;
+
+        size_t count = 0;
+    } data;
+};
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_MEMORY_BLOCK_ALLOCATOR_H_
diff --git a/src/tint/utils/memory/block_allocator_test.cc b/src/tint/utils/memory/block_allocator_test.cc
new file mode 100644
index 0000000..194769a
--- /dev/null
+++ b/src/tint/utils/memory/block_allocator_test.cc
@@ -0,0 +1,164 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/memory/block_allocator.h"
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+struct LifetimeCounter {
+    explicit LifetimeCounter(size_t* count) : count_(count) { (*count)++; }
+    ~LifetimeCounter() { (*count_)--; }
+
+    size_t* const count_;
+};
+
+using BlockAllocatorTest = testing::Test;
+
+TEST_F(BlockAllocatorTest, Empty) {
+    using Allocator = BlockAllocator<int>;
+
+    Allocator allocator;
+
+    EXPECT_EQ(allocator.Count(), 0u);
+    for (int* i : allocator.Objects()) {
+        (void)i;
+        if ((true)) {  // Workaround for "error: loop will run at most once"
+            FAIL() << "BlockAllocator should be empty";
+        }
+    }
+    for (const int* i : static_cast<const Allocator&>(allocator).Objects()) {
+        (void)i;
+        if ((true)) {  // Workaround for "error: loop will run at most once"
+            FAIL() << "BlockAllocator should be empty";
+        }
+    }
+}
+
+TEST_F(BlockAllocatorTest, Count) {
+    using Allocator = BlockAllocator<int>;
+
+    for (size_t n : {0u, 1u, 10u, 16u, 20u, 32u, 50u, 64u, 100u, 256u, 300u, 512u, 500u, 512u}) {
+        Allocator allocator;
+        EXPECT_EQ(allocator.Count(), 0u);
+        for (size_t i = 0; i < n; i++) {
+            allocator.Create(123);
+        }
+        EXPECT_EQ(allocator.Count(), n);
+    }
+}
+
+TEST_F(BlockAllocatorTest, ObjectLifetime) {
+    using Allocator = BlockAllocator<LifetimeCounter>;
+
+    size_t count = 0;
+    {
+        Allocator allocator;
+        EXPECT_EQ(count, 0u);
+        allocator.Create(&count);
+        EXPECT_EQ(count, 1u);
+        allocator.Create(&count);
+        EXPECT_EQ(count, 2u);
+        allocator.Create(&count);
+        EXPECT_EQ(count, 3u);
+    }
+    EXPECT_EQ(count, 0u);
+}
+
+TEST_F(BlockAllocatorTest, MoveConstruct) {
+    using Allocator = BlockAllocator<LifetimeCounter>;
+
+    for (size_t n : {0u, 1u, 10u, 16u, 20u, 32u, 50u, 64u, 100u, 256u, 300u, 512u, 500u, 512u}) {
+        size_t count = 0;
+        {
+            Allocator allocator_a;
+            for (size_t i = 0; i < n; i++) {
+                allocator_a.Create(&count);
+            }
+            EXPECT_EQ(count, n);
+            EXPECT_EQ(allocator_a.Count(), n);
+
+            Allocator allocator_b{std::move(allocator_a)};
+            EXPECT_EQ(count, n);
+            EXPECT_EQ(allocator_b.Count(), n);
+        }
+
+        EXPECT_EQ(count, 0u);
+    }
+}
+
+TEST_F(BlockAllocatorTest, MoveAssign) {
+    using Allocator = BlockAllocator<LifetimeCounter>;
+
+    for (size_t n : {0u, 1u, 10u, 16u, 20u, 32u, 50u, 64u, 100u, 256u, 300u, 512u, 500u, 512u}) {
+        size_t count_a = 0;
+        size_t count_b = 0;
+
+        {
+            Allocator allocator_a;
+            for (size_t i = 0; i < n; i++) {
+                allocator_a.Create(&count_a);
+            }
+            EXPECT_EQ(count_a, n);
+            EXPECT_EQ(allocator_a.Count(), n);
+
+            Allocator allocator_b;
+            for (size_t i = 0; i < n; i++) {
+                allocator_b.Create(&count_b);
+            }
+            EXPECT_EQ(count_b, n);
+            EXPECT_EQ(allocator_b.Count(), n);
+
+            allocator_b = std::move(allocator_a);
+            EXPECT_EQ(count_a, n);
+            EXPECT_EQ(count_b, 0u);
+            EXPECT_EQ(allocator_b.Count(), n);
+        }
+
+        EXPECT_EQ(count_a, 0u);
+        EXPECT_EQ(count_b, 0u);
+    }
+}
+
+TEST_F(BlockAllocatorTest, ObjectOrder) {
+    using Allocator = BlockAllocator<int>;
+
+    Allocator allocator;
+    constexpr int N = 10000;
+    for (int i = 0; i < N; i++) {
+        allocator.Create(i);
+    }
+
+    {
+        int i = 0;
+        for (int* p : allocator.Objects()) {
+            EXPECT_EQ(*p, i);
+            i++;
+        }
+        EXPECT_EQ(i, N);
+    }
+    {
+        int i = 0;
+        for (const int* p : static_cast<const Allocator&>(allocator).Objects()) {
+            EXPECT_EQ(*p, i);
+            i++;
+        }
+        EXPECT_EQ(i, N);
+    }
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/memory/bump_allocator.h b/src/tint/utils/memory/bump_allocator.h
new file mode 100644
index 0000000..c4c07e8
--- /dev/null
+++ b/src/tint/utils/memory/bump_allocator.h
@@ -0,0 +1,127 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_MEMORY_BUMP_ALLOCATOR_H_
+#define SRC_TINT_UTILS_MEMORY_BUMP_ALLOCATOR_H_
+
+#include <array>
+#include <cstring>
+#include <utility>
+
+#include "src/tint/utils/math/math.h"
+#include "src/tint/utils/memory/bitcast.h"
+
+namespace tint::utils {
+
+constexpr size_t kBlockSize = 64 * 1024;
+
+/// A allocator for chunks of memory. The memory is owned by the BumpAllocator. When the
+/// BumpAllocator is freed all of the allocated memory is freed.
+class BumpAllocator {
+    /// Block is linked list of memory blocks.
+    /// Blocks are allocated out of heap memory.
+    struct Block {
+        uint8_t data[kBlockSize];
+        Block* next;
+    };
+
+  public:
+    /// Constructor
+    BumpAllocator() = default;
+
+    /// Move constructor
+    /// @param rhs the BumpAllocator to move
+    BumpAllocator(BumpAllocator&& rhs) { std::swap(data, rhs.data); }
+
+    /// Move assignment operator
+    /// @param rhs the BumpAllocator to move
+    /// @return this BumpAllocator
+    BumpAllocator& operator=(BumpAllocator&& rhs) {
+        if (this != &rhs) {
+            Reset();
+            std::swap(data, rhs.data);
+        }
+        return *this;
+    }
+
+    /// Destructor
+    ~BumpAllocator() { Reset(); }
+
+    /// Allocates @p size_in_bytes from the current block, or from a newly allocated block if the
+    /// current block is full.
+    /// @param size_in_bytes the number of bytes to allocate
+    /// @returns the pointer to the allocated memory or |nullptr| if the memory can not be allocated
+    char* Allocate(size_t size_in_bytes) {
+        auto& block = data.block;
+        if (block.current_offset + size_in_bytes > kBlockSize) {
+            // Allocate a new block from the heap
+            auto* prev_block = block.current;
+            block.current = new Block;
+            if (!block.current) {
+                return nullptr;  // out of memory
+            }
+            block.current->next = nullptr;
+            block.current_offset = 0;
+            if (prev_block) {
+                prev_block->next = block.current;
+            } else {
+                block.root = block.current;
+            }
+        }
+
+        auto* base = &block.current->data[0];
+        auto* ptr = reinterpret_cast<char*>(base + block.current_offset);
+        block.current_offset += size_in_bytes;
+        data.count++;
+        return ptr;
+    }
+
+    /// Frees all allocations from the allocator.
+    void Reset() {
+        auto* block = data.block.root;
+        while (block != nullptr) {
+            auto* next = block->next;
+            delete block;
+            block = next;
+        }
+        data = {};
+    }
+
+    /// @returns the total number of allocations
+    size_t Count() const { return data.count; }
+
+  private:
+    BumpAllocator(const BumpAllocator&) = delete;
+    BumpAllocator& operator=(const BumpAllocator&) = delete;
+
+    struct {
+        struct {
+            /// The root block of the block linked list
+            Block* root = nullptr;
+            /// The current (end) block of the blocked linked list.
+            /// New allocations come from this block
+            Block* current = nullptr;
+            /// The byte offset in #current for the next allocation.
+            /// Initialized with kBlockSize so that the first allocation triggers a block
+            /// allocation.
+            size_t current_offset = kBlockSize;
+        } block;
+
+        size_t count = 0;
+    } data;
+};
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_MEMORY_BUMP_ALLOCATOR_H_
diff --git a/src/tint/utils/memory/bump_allocator_test.cc b/src/tint/utils/memory/bump_allocator_test.cc
new file mode 100644
index 0000000..0212586
--- /dev/null
+++ b/src/tint/utils/memory/bump_allocator_test.cc
@@ -0,0 +1,49 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/memory/bump_allocator.h"
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+using BumpAllocatorTest = testing::Test;
+
+TEST_F(BumpAllocatorTest, Count) {
+    for (size_t n : {0u, 1u, 10u, 16u, 20u, 32u, 50u, 64u, 100u, 256u, 300u, 512u, 500u, 512u}) {
+        BumpAllocator allocator;
+        EXPECT_EQ(allocator.Count(), 0u);
+        for (size_t i = 0; i < n; i++) {
+            allocator.Allocate(5);
+        }
+        EXPECT_EQ(allocator.Count(), n);
+    }
+}
+
+TEST_F(BumpAllocatorTest, MoveConstruct) {
+    for (size_t n : {0u, 1u, 10u, 16u, 20u, 32u, 50u, 64u, 100u, 256u, 300u, 512u, 500u, 512u}) {
+        BumpAllocator allocator_a;
+        for (size_t i = 0; i < n; i++) {
+            allocator_a.Allocate(5);
+        }
+        EXPECT_EQ(allocator_a.Count(), n);
+
+        BumpAllocator allocator_b{std::move(allocator_a)};
+        EXPECT_EQ(allocator_b.Count(), n);
+    }
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/reflection/reflection.h b/src/tint/utils/reflection/reflection.h
new file mode 100644
index 0000000..c2ba4834
--- /dev/null
+++ b/src/tint/utils/reflection/reflection.h
@@ -0,0 +1,68 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_REFLECTION_REFLECTION_H_
+#define SRC_TINT_UTILS_REFLECTION_REFLECTION_H_
+
+#include <type_traits>
+
+#include "src/tint/utils/macros/concat.h"
+#include "src/tint/utils/macros/foreach.h"
+
+namespace tint {
+
+namespace detail {
+
+/// Helper for detecting whether the type T contains a nested Reflection class.
+template <typename T, typename ENABLE = void>
+struct HasReflection : std::false_type {};
+
+/// Specialization for types that have a nested Reflection class.
+template <typename T>
+struct HasReflection<T, std::void_t<typename T::Reflection>> : std::true_type {};
+
+}  // namespace detail
+
+/// Is true if the class T has reflected its fields with TINT_REFLECT()
+template <typename T>
+static constexpr bool HasReflection = tint::detail::HasReflection<T>::value;
+
+/// Calls @p callback with each field of @p object
+/// @param object the object
+/// @param callback a function that is called for each field of @p object.
+/// @tparam CB a function with the signature `void(FIELD)`
+template <typename OBJECT, typename CB>
+void ForeachField(OBJECT&& object, CB&& callback) {
+    using T = std::decay_t<OBJECT>;
+    static_assert(HasReflection<T>, "object type requires a tint::Reflect<> specialization");
+    T::Reflection::Fields(object, callback);
+}
+
+/// Macro used by TINT_FOREACH() in TINT_REFLECT() to call the callback function with each field in
+/// the variadic.
+#define TINT_REFLECT_CALLBACK_FIELD(field) callback(object.field);
+
+// TINT_REFLECT(...) reflects each of the fields arguments so that the types can be used with
+// tint::ForeachField().
+#define TINT_REFLECT(...)                                          \
+    struct Reflection {                                            \
+        template <typename OBJECT, typename CB>                    \
+        static void Fields(OBJECT&& object, CB&& callback) {       \
+            TINT_FOREACH(TINT_REFLECT_CALLBACK_FIELD, __VA_ARGS__) \
+        }                                                          \
+    }
+
+}  // namespace tint
+
+#endif  // SRC_TINT_UTILS_REFLECTION_REFLECTION_H_
diff --git a/src/tint/utils/reflection/reflection_test.cc b/src/tint/utils/reflection/reflection_test.cc
new file mode 100644
index 0000000..e55beea
--- /dev/null
+++ b/src/tint/utils/reflection/reflection_test.cc
@@ -0,0 +1,91 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/reflection/reflection.h"
+#include "gtest/gtest.h"
+
+namespace tint {
+namespace {
+
+struct S {
+    int i;
+    unsigned u;
+    bool b;
+    TINT_REFLECT(i, u, b);
+};
+
+static_assert(!HasReflection<int>);
+static_assert(HasReflection<S>);
+
+TEST(ReflectionTest, ForeachFieldConst) {
+    const S s{1, 2, true};
+    size_t field_idx = 0;
+    ForeachField(s, [&](auto& field) {
+        using T = std::decay_t<decltype(field)>;
+        switch (field_idx) {
+            case 0:
+                EXPECT_TRUE((std::is_same_v<T, int>));
+                EXPECT_EQ(field, static_cast<T>(1));
+                break;
+            case 1:
+                EXPECT_TRUE((std::is_same_v<T, unsigned>));
+                EXPECT_EQ(field, static_cast<T>(2));
+                break;
+            case 2:
+                EXPECT_TRUE((std::is_same_v<T, bool>));
+                EXPECT_EQ(field, static_cast<T>(true));
+                break;
+            default:
+                FAIL() << "unexpected field";
+                break;
+        }
+        field_idx++;
+    });
+}
+
+TEST(ReflectionTest, ForeachFieldNonConst) {
+    S s{1, 2, true};
+    size_t field_idx = 0;
+    ForeachField(s, [&](auto& field) {
+        using T = std::decay_t<decltype(field)>;
+        switch (field_idx) {
+            case 0:
+                EXPECT_TRUE((std::is_same_v<T, int>));
+                EXPECT_EQ(field, static_cast<T>(1));
+                field = static_cast<T>(10);
+                break;
+            case 1:
+                EXPECT_TRUE((std::is_same_v<T, unsigned>));
+                EXPECT_EQ(field, static_cast<T>(2));
+                field = static_cast<T>(20);
+                break;
+            case 2:
+                EXPECT_TRUE((std::is_same_v<T, bool>));
+                EXPECT_EQ(field, static_cast<T>(true));
+                field = static_cast<T>(false);
+                break;
+            default:
+                FAIL() << "unexpected field";
+                break;
+        }
+        field_idx++;
+    });
+
+    EXPECT_EQ(s.i, 10);
+    EXPECT_EQ(s.u, 20u);
+    EXPECT_EQ(s.b, false);
+}
+
+}  // namespace
+}  // namespace tint
diff --git a/src/tint/utils/result/result.h b/src/tint/utils/result/result.h
new file mode 100644
index 0000000..8117ad1
--- /dev/null
+++ b/src/tint/utils/result/result.h
@@ -0,0 +1,166 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_RESULT_RESULT_H_
+#define SRC_TINT_UTILS_RESULT_RESULT_H_
+
+#include <utility>
+#include <variant>
+
+#include "src/tint/utils/debug/debug.h"
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint::utils {
+
+/// Empty structure used as the default FAILURE_TYPE for a Result.
+struct FailureType {};
+
+static constexpr const FailureType Failure;
+
+/// Result is a helper for functions that need to return a value, or an failure value.
+/// Result can be constructed with either a 'success' or 'failure' value.
+/// @tparam SUCCESS_TYPE the 'success' value type.
+/// @tparam FAILURE_TYPE the 'failure' value type. Defaults to FailureType which provides no
+///         information about the failure, except that something failed. Must not be the same type
+///         as SUCCESS_TYPE.
+template <typename SUCCESS_TYPE, typename FAILURE_TYPE = FailureType>
+struct [[nodiscard]] Result {
+    static_assert(!std::is_same_v<SUCCESS_TYPE, FAILURE_TYPE>,
+                  "Result must not have the same type for SUCCESS_TYPE and FAILURE_TYPE");
+
+    /// Default constructor initializes to invalid state
+    Result() : value(std::monostate{}) {}
+
+    /// Constructor
+    /// @param success the success result
+    Result(const SUCCESS_TYPE& success)  // NOLINT(runtime/explicit):
+        : value{success} {}
+
+    /// Constructor
+    /// @param success the success result
+    Result(SUCCESS_TYPE&& success)  // NOLINT(runtime/explicit):
+        : value(std::move(SUCCESS_TYPE(std::move(success)))) {}
+
+    /// Constructor
+    /// @param failure the failure result
+    Result(const FAILURE_TYPE& failure)  // NOLINT(runtime/explicit):
+        : value{failure} {}
+
+    /// Constructor
+    /// @param failure the failure result
+    Result(FAILURE_TYPE&& failure)  // NOLINT(runtime/explicit):
+        : value{std::move(failure)} {}
+
+    /// Copy constructor with success / failure casting
+    /// @param other the Result to copy
+    template <typename S,
+              typename F,
+              typename = std::void_t<decltype(SUCCESS_TYPE{std::declval<S>()}),
+                                     decltype(FAILURE_TYPE{std::declval<F>()})>>
+    Result(const Result<S, F>& other) {  // NOLINT(runtime/explicit):
+        if (other) {
+            value = SUCCESS_TYPE{other.Get()};
+        } else {
+            value = FAILURE_TYPE{other.Failure()};
+        }
+    }
+
+    /// @returns true if the result was a success
+    operator bool() const {
+        Validate();
+        return std::holds_alternative<SUCCESS_TYPE>(value);
+    }
+
+    /// @returns true if the result was a failure
+    bool operator!() const {
+        Validate();
+        return std::holds_alternative<FAILURE_TYPE>(value);
+    }
+
+    /// @returns the success value
+    /// @warning attempting to call this when the Result holds an failure will result in UB.
+    const SUCCESS_TYPE* operator->() const {
+        Validate();
+        return &(Get());
+    }
+
+    /// @returns the success value
+    /// @warning attempting to call this when the Result holds an failure value will result in UB.
+    const SUCCESS_TYPE& Get() const {
+        Validate();
+        return std::get<SUCCESS_TYPE>(value);
+    }
+
+    /// @returns the success value
+    /// @warning attempting to call this when the Result holds an failure value will result in UB.
+    SUCCESS_TYPE& Get() {
+        Validate();
+        return std::get<SUCCESS_TYPE>(value);
+    }
+
+    /// @returns the success value
+    /// @warning attempting to call this when the Result holds an failure value will result in UB.
+    SUCCESS_TYPE&& Move() {
+        Validate();
+        return std::get<SUCCESS_TYPE>(std::move(value));
+    }
+
+    /// @returns the failure value
+    /// @warning attempting to call this when the Result holds a success value will result in UB.
+    const FAILURE_TYPE& Failure() const {
+        Validate();
+        return std::get<FAILURE_TYPE>(value);
+    }
+
+    /// Equality operator
+    /// @param val the value to compare this Result to
+    /// @returns true if this result holds a success value equal to `value`
+    bool operator==(SUCCESS_TYPE val) const {
+        Validate();
+        if (auto* v = std::get_if<SUCCESS_TYPE>(&value)) {
+            return *v == val;
+        }
+        return false;
+    }
+
+    /// Equality operator
+    /// @param val the value to compare this Result to
+    /// @returns true if this result holds a failure value equal to `value`
+    bool operator==(FAILURE_TYPE val) const {
+        Validate();
+        if (auto* v = std::get_if<FAILURE_TYPE>(&value)) {
+            return *v == val;
+        }
+        return false;
+    }
+
+  private:
+    void Validate() const { TINT_ASSERT(Utils, !std::holds_alternative<std::monostate>(value)); }
+
+    /// The result. Either a success of failure value.
+    std::variant<std::monostate, SUCCESS_TYPE, FAILURE_TYPE> value;
+};
+
+/// Writes the result to the stream.
+/// @param out the stream to write to
+/// @param res the result
+/// @return the stream so calls can be chained
+template <typename SUCCESS, typename FAILURE>
+inline utils::StringStream& operator<<(utils::StringStream& out, Result<SUCCESS, FAILURE> res) {
+    return res ? (out << "success: " << res.Get()) : (out << "failure: " << res.Failure());
+}
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_RESULT_RESULT_H_
diff --git a/src/tint/utils/result/result_test.cc b/src/tint/utils/result/result_test.cc
new file mode 100644
index 0000000..e7039cc
--- /dev/null
+++ b/src/tint/utils/result/result_test.cc
@@ -0,0 +1,67 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/result/result.h"
+
+#include <string>
+
+#include "gmock/gmock.h"
+
+namespace tint::utils {
+namespace {
+
+TEST(ResultTest, SuccessInt) {
+    auto r = Result<int>(123);
+    EXPECT_TRUE(r);
+    EXPECT_FALSE(!r);
+    EXPECT_EQ(r.Get(), 123);
+}
+
+TEST(ResultTest, SuccessStruct) {
+    struct S {
+        int value;
+    };
+    auto r = Result<S>({123});
+    EXPECT_TRUE(r);
+    EXPECT_FALSE(!r);
+    EXPECT_EQ(r->value, 123);
+}
+
+TEST(ResultTest, Failure) {
+    auto r = Result<int>(Failure);
+    EXPECT_FALSE(r);
+    EXPECT_TRUE(!r);
+}
+
+TEST(ResultTest, CustomFailure) {
+    auto r = Result<int, std::string>("oh noes!");
+    EXPECT_FALSE(r);
+    EXPECT_TRUE(!r);
+    EXPECT_EQ(r.Failure(), "oh noes!");
+}
+
+TEST(ResultTest, ValueCast) {
+    struct X {};
+    struct Y : X {};
+
+    Y* y = nullptr;
+    auto r_y = Result<Y*>{y};
+    auto r_x = Result<X*>{r_y};
+
+    (void)r_x;
+    (void)r_y;
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/rtti/castable.cc b/src/tint/utils/rtti/castable.cc
new file mode 100644
index 0000000..dd4114a
--- /dev/null
+++ b/src/tint/utils/rtti/castable.cc
@@ -0,0 +1,33 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/rtti/castable.h"
+
+namespace tint::utils {
+
+/// The unique TypeInfo for the CastableBase type
+/// @return doxygen-thinks-this-static-field-is-a-function :(
+template <>
+const TypeInfo utils::detail::TypeInfoOf<CastableBase>::info{
+    nullptr,
+    "CastableBase",
+    tint::utils::TypeInfo::HashCodeOf<CastableBase>(),
+    tint::utils::TypeInfo::FullHashCodeOf<CastableBase>(),
+};
+
+CastableBase::CastableBase(const CastableBase&) = default;
+
+CastableBase::~CastableBase() = default;
+
+}  // namespace tint::utils
diff --git a/src/tint/utils/rtti/castable.h b/src/tint/utils/rtti/castable.h
new file mode 100644
index 0000000..7b0c97e
--- /dev/null
+++ b/src/tint/utils/rtti/castable.h
@@ -0,0 +1,553 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_RTTI_CASTABLE_H_
+#define SRC_TINT_UTILS_RTTI_CASTABLE_H_
+
+#include <stdint.h>
+#include <functional>
+#include <tuple>
+#include <type_traits>
+#include <utility>
+
+#include "src/tint/utils/math/crc32.h"
+#include "src/tint/utils/traits/traits.h"
+
+#if defined(__clang__)
+/// Temporarily disable certain warnings when using Castable API
+#define TINT_CASTABLE_PUSH_DISABLE_WARNINGS()                                 \
+    _Pragma("clang diagnostic push")                                     /**/ \
+        _Pragma("clang diagnostic ignored \"-Wundefined-var-template\"") /**/ \
+        static_assert(true, "require extra semicolon")
+
+/// Restore disabled warnings
+#define TINT_CASTABLE_POP_DISABLE_WARNINGS() \
+    _Pragma("clang diagnostic pop") /**/     \
+        static_assert(true, "require extra semicolon")
+#else
+#define TINT_CASTABLE_PUSH_DISABLE_WARNINGS() static_assert(true, "require extra semicolon")
+#define TINT_CASTABLE_POP_DISABLE_WARNINGS() static_assert(true, "require extra semicolon")
+#endif
+
+TINT_CASTABLE_PUSH_DISABLE_WARNINGS();
+
+// Forward declarations
+namespace tint::utils {
+class CastableBase;
+
+/// Ignore is used as a special type used for skipping over types for trait
+/// helper functions.
+class Ignore {};
+}  // namespace tint::utils
+
+namespace tint::utils::detail {
+template <typename T>
+struct TypeInfoOf;
+}  // namespace tint::utils::detail
+
+namespace tint::utils {
+
+/// True if all template types that are not Ignore derive from CastableBase
+template <typename... TYPES>
+static constexpr bool IsCastable =
+    ((utils::traits::IsTypeOrDerived<TYPES, CastableBase> || std::is_same_v<TYPES, Ignore>)&&...) &&
+    !(std::is_same_v<TYPES, Ignore> && ...);
+
+/// Helper macro to instantiate the TypeInfo<T> template for `CLASS`.
+#define TINT_INSTANTIATE_TYPEINFO(CLASS)                                      \
+    TINT_CASTABLE_PUSH_DISABLE_WARNINGS();                                    \
+    template <>                                                               \
+    const tint::utils::TypeInfo tint::utils::detail::TypeInfoOf<CLASS>::info{ \
+        &tint::utils::detail::TypeInfoOf<CLASS::TrueBase>::info,              \
+        #CLASS,                                                               \
+        tint::utils::TypeInfo::HashCodeOf<CLASS>(),                           \
+        tint::utils::TypeInfo::FullHashCodeOf<CLASS>(),                       \
+    };                                                                        \
+    TINT_CASTABLE_POP_DISABLE_WARNINGS();                                     \
+    static_assert(std::is_same_v<CLASS, CLASS::Base::Class>,                  \
+                  #CLASS " does not derive from Castable<" #CLASS "[, BASE]>")
+
+/// Bit flags that can be passed to the template parameter `FLAGS` of Is() and As().
+enum CastFlags {
+    /// Disables the static_assert() inside Is(), that compile-time-verifies that the cast is
+    /// possible. This flag may be useful for highly-generic template
+    /// code that needs to compile for template permutations that generate
+    /// impossible casts.
+    kDontErrorOnImpossibleCast = 1,
+};
+
+/// The type of a hash code
+using HashCode = uint64_t;
+
+/// Maybe checks to see if an object with the full hashcode @p object_full_hashcode could
+/// potentially be of, or derive from the type with the hashcode @p query_hashcode.
+/// @param type_hashcode the hashcode of the type
+/// @param object_full_hashcode the full hashcode of the object being queried
+/// @returns true if the object with the given full hashcode could be one of the template types.
+inline bool Maybe(HashCode type_hashcode, HashCode object_full_hashcode) {
+    return (object_full_hashcode & type_hashcode) == type_hashcode;
+}
+
+/// MaybeAnyOf checks to see if an object with the full hashcode @p object_full_hashcode could
+/// potentially be of, or derive from the types with the combined hashcode @p combined_hashcode.
+/// @param combined_hashcode the bitwise OR'd hashcodes of the types
+/// @param object_full_hashcode the full hashcode of the object being queried
+/// @returns true if the object with the given full hashcode could be one of the template types.
+inline bool MaybeAnyOf(HashCode combined_hashcode, HashCode object_full_hashcode) {
+    // Compare the object's hashcode to the bitwise-or of all the tested type's hashcodes. If
+    // there's no intersection of bits in the two masks, then we can guarantee that the type is not
+    // in `TO`.
+    HashCode mask = object_full_hashcode & combined_hashcode;
+    // HashCodeOf() ensures that two bits are always set for every hash, so we can quickly
+    // eliminate the bitmask where only one bit is set.
+    HashCode two_bits = mask & (mask - 1);
+    return two_bits != 0;
+}
+
+/// TypeInfo holds type information for a Castable type.
+struct TypeInfo {
+    /// The base class of this type
+    const TypeInfo* base;
+    /// The type name
+    const char* name;
+    /// The type hash code
+    const HashCode hashcode;
+    /// The type hash code bitwise-or'd with all ancestor's hashcodes.
+    const HashCode full_hashcode;
+
+    /// @returns true if `type` derives from the class `TO`
+    /// @param object the object type to test from, which must be, or derive from type `FROM`.
+    /// @see CastFlags
+    template <typename TO, typename FROM, int FLAGS = 0>
+    static inline bool Is(const tint::utils::TypeInfo* object) {
+        constexpr const bool downcast = std::is_base_of<FROM, TO>::value;
+        constexpr const bool upcast = std::is_base_of<TO, FROM>::value;
+        constexpr const bool nocast = std::is_same<FROM, TO>::value;
+        constexpr const bool assert_is_castable = (FLAGS & kDontErrorOnImpossibleCast) == 0;
+
+        static_assert(upcast || downcast || nocast || !assert_is_castable, "impossible cast");
+
+        return upcast || nocast || object->Is<TO>();
+    }
+
+    /// @returns true if this type derives from the class `T`
+    template <typename T>
+    inline bool Is() const {
+        auto* type = &Of<std::remove_cv_t<T>>();
+
+        if constexpr (std::is_final_v<T>) {
+            // T is final, so nothing can derive from T.
+            // We do not need to check ancestors, only whether this type is equal to the type T.
+            return type == this;
+        } else {
+            return Is(type);
+        }
+    }
+
+    /// @param type the test type info
+    /// @returns true if the class with this TypeInfo is of, or derives from the
+    /// class with the given TypeInfo.
+    inline bool Is(const tint::utils::TypeInfo* type) const {
+        if (!Maybe(type->hashcode, full_hashcode)) {
+            return false;
+        }
+
+        // Walk the base types, starting with this TypeInfo, to see if any of the pointers match
+        // `type`.
+        for (auto* ti = this; ti != nullptr; ti = ti->base) {
+            if (ti == type) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /// @returns the static TypeInfo for the type T
+    template <typename T>
+    static const TypeInfo& Of() {
+        return utils::detail::TypeInfoOf<std::remove_cv_t<T>>::info;
+    }
+
+    /// @returns a compile-time hashcode for the type `T`.
+    /// @note the returned hashcode will have exactly 2 bits set, as the hashes are expected to be
+    /// used in bloom-filters which will quickly saturate when multiple hashcodes are bitwise-or'd
+    /// together.
+    template <typename T>
+    static constexpr HashCode HashCodeOf() {
+        static_assert(IsCastable<T>, "T is not Castable");
+        static_assert(std::is_same_v<T, std::remove_cv_t<T>>,
+                      "Strip const / volatile decorations before calling HashCodeOf");
+        /// Use the compiler's "pretty" function name, which includes the template
+        /// type, to obtain a unique hash value.
+#ifdef _MSC_VER
+        constexpr uint32_t crc = utils::CRC32(__FUNCSIG__);
+#else
+        constexpr uint32_t crc = utils::CRC32(__PRETTY_FUNCTION__);
+#endif
+        constexpr uint32_t bit_a = (crc & 63);
+        constexpr uint32_t bit_b = ((crc >> 6) & 63);
+        constexpr uint32_t bit_c = (bit_a == bit_b) ? ((bit_a + 1) & 63) : bit_b;
+        return (static_cast<HashCode>(1) << bit_a) | (static_cast<HashCode>(1) << bit_c);
+    }
+
+    /// @returns the hashcode of the given type, bitwise-or'd with the hashcodes of all base
+    /// classes.
+    template <typename T>
+    static constexpr HashCode FullHashCodeOf() {
+        if constexpr (std::is_same_v<T, CastableBase>) {
+            return HashCodeOf<CastableBase>();
+        } else {
+            return HashCodeOf<T>() | FullHashCodeOf<typename T::TrueBase>();
+        }
+    }
+
+    /// @returns the bitwise-or'd hashcodes of all the types of the tuple `TUPLE`.
+    /// @see HashCodeOf
+    template <typename TUPLE>
+    static constexpr HashCode CombinedHashCodeOfTuple() {
+        constexpr auto kCount = std::tuple_size_v<TUPLE>;
+        if constexpr (kCount == 0) {
+            return 0;
+        } else if constexpr (kCount == 1) {
+            return HashCodeOf<std::remove_cv_t<std::tuple_element_t<0, TUPLE>>>();
+        } else {
+            constexpr auto kMid = kCount / 2;
+            return CombinedHashCodeOfTuple<utils::traits::SliceTuple<0, kMid, TUPLE>>() |
+                   CombinedHashCodeOfTuple<utils::traits::SliceTuple<kMid, kCount - kMid, TUPLE>>();
+        }
+    }
+
+    /// @returns the bitwise-or'd hashcodes of all the template parameter types.
+    /// @see HashCodeOf
+    template <typename... TYPES>
+    static constexpr HashCode CombinedHashCodeOf() {
+        return CombinedHashCodeOfTuple<std::tuple<TYPES...>>();
+    }
+
+    /// @returns true if this TypeInfo is of, or derives from any of the types in `TUPLE`.
+    template <typename TUPLE>
+    inline bool IsAnyOfTuple() const {
+        constexpr auto kCount = std::tuple_size_v<TUPLE>;
+        if constexpr (kCount == 0) {
+            return false;
+        } else if constexpr (kCount == 1) {
+            return Is(&Of<std::tuple_element_t<0, TUPLE>>());
+        } else {
+            if (MaybeAnyOf(TypeInfo::CombinedHashCodeOfTuple<TUPLE>(), full_hashcode)) {
+                // Possibly one of the types in `TUPLE`.
+                // Split the search in two, and scan each block.
+                static constexpr auto kMid = kCount / 2;
+                return IsAnyOfTuple<utils::traits::SliceTuple<0, kMid, TUPLE>>() ||
+                       IsAnyOfTuple<utils::traits::SliceTuple<kMid, kCount - kMid, TUPLE>>();
+            }
+            return false;
+        }
+    }
+
+    /// @returns true if this TypeInfo is of, or derives from any of the types in `TYPES`.
+    template <typename... TYPES>
+    inline bool IsAnyOf() const {
+        return IsAnyOfTuple<std::tuple<TYPES...>>();
+    }
+};
+
+namespace detail {
+
+/// TypeInfoOf contains a single TypeInfo field for the type T.
+/// TINT_INSTANTIATE_TYPEINFO() must be defined in a .cpp file for each type `T`.
+template <typename T>
+struct TypeInfoOf {
+    /// The unique TypeInfo for the type T.
+    static const TypeInfo info;
+};
+
+/// A placeholder structure used for template parameters that need a default type, but can always be
+/// automatically inferred.
+struct Infer;
+
+}  // namespace detail
+
+/// @returns true if `obj` is a valid pointer, and is of, or derives from the class `TO`
+/// @param obj the object to test from
+/// @see CastFlags
+template <typename TO, int FLAGS = 0, typename FROM = utils::detail::Infer>
+inline bool Is(FROM* obj) {
+    if (obj == nullptr) {
+        return false;
+    }
+    return TypeInfo::Is<TO, FROM, FLAGS>(&obj->TypeInfo());
+}
+
+/// @returns true if `obj` is a valid pointer, and is of, or derives from the type `TYPE`, and
+/// pred(const TYPE*) returns true
+/// @param obj the object to test from
+/// @param pred predicate function with signature `bool(const TYPE*)` called iff object is of, or
+/// derives from the class `TYPE`.
+/// @see CastFlags
+template <typename TYPE,
+          int FLAGS = 0,
+          typename OBJ = utils::detail::Infer,
+          typename Pred = utils::detail::Infer>
+inline bool Is(OBJ* obj, Pred&& pred) {
+    return Is<TYPE, FLAGS, OBJ>(obj) && pred(static_cast<std::add_const_t<TYPE>*>(obj));
+}
+
+/// @returns true if `obj` is a valid pointer, and is of, or derives from any of the types in
+/// `TYPES`.
+/// @param obj the object to query.
+template <typename... TYPES, typename OBJ>
+inline bool IsAnyOf(OBJ* obj) {
+    if (!obj) {
+        return false;
+    }
+    return obj->TypeInfo().template IsAnyOf<TYPES...>();
+}
+
+/// @returns obj dynamically cast to the type `TO` or `nullptr` if this object does not derive from
+/// `TO`.
+/// @param obj the object to cast from
+/// @see CastFlags
+template <typename TO, int FLAGS = 0, typename FROM = utils::detail::Infer>
+inline TO* As(FROM* obj) {
+    auto* as_castable = static_cast<CastableBase*>(obj);
+    return Is<TO, FLAGS>(obj) ? static_cast<TO*>(as_castable) : nullptr;
+}
+
+/// @returns obj dynamically cast to the type `TO` or `nullptr` if this object does not derive from
+/// `TO`.
+/// @param obj the object to cast from
+/// @see CastFlags
+template <typename TO, int FLAGS = 0, typename FROM = utils::detail::Infer>
+inline const TO* As(const FROM* obj) {
+    auto* as_castable = static_cast<const CastableBase*>(obj);
+    return Is<TO, FLAGS>(obj) ? static_cast<const TO*>(as_castable) : nullptr;
+}
+
+/// CastableBase is the base class for all Castable objects.
+/// It is not encouraged to directly derive from CastableBase without using the Castable helper
+/// template.
+/// @see Castable
+class CastableBase {
+  public:
+    /// Copy constructor
+    CastableBase(const CastableBase&);
+
+    /// Destructor
+    virtual ~CastableBase();
+
+    /// Copy assignment
+    /// @param other the CastableBase to copy
+    /// @returns the new CastableBase
+    CastableBase& operator=(const CastableBase& other) = default;
+
+    /// @returns the TypeInfo of the object
+    inline const tint::utils::TypeInfo& TypeInfo() const { return *type_info_; }
+
+    /// @returns true if this object is of, or derives from the class `TO`
+    template <typename TO>
+    inline bool Is() const {
+        return tint::utils::Is<TO>(this);
+    }
+
+    /// @returns true if this object is of, or derives from the class `TO` and pred(const TO*)
+    /// returns true
+    /// @param pred predicate function with signature `bool(const TO*)` called iff object is of, or
+    /// derives from the class `TO`.
+    template <typename TO, int FLAGS = 0, typename Pred = utils::detail::Infer>
+    inline bool Is(Pred&& pred) const {
+        return tint::utils::Is<TO, FLAGS>(this, std::forward<Pred>(pred));
+    }
+
+    /// @returns true if this object is of, or derives from any of the `TO` classes.
+    template <typename... TO>
+    inline bool IsAnyOf() const {
+        return tint::utils::IsAnyOf<TO...>(this);
+    }
+
+    /// @returns this object dynamically cast to the type `TO` or `nullptr` if this object does not
+    /// derive from `TO`.
+    /// @see CastFlags
+    template <typename TO, int FLAGS = 0>
+    inline TO* As() {
+        return tint::utils::As<TO, FLAGS>(this);
+    }
+
+    /// @returns this object dynamically cast to the type `TO` or `nullptr` if this object does not
+    /// derive from `TO`.
+    /// @see CastFlags
+    template <typename TO, int FLAGS = 0>
+    inline const TO* As() const {
+        return tint::utils::As<const TO, FLAGS>(this);
+    }
+
+  protected:
+    CastableBase() = default;
+
+    /// The type information for the object
+    const tint::utils::TypeInfo* type_info_ = nullptr;
+};
+
+/// Castable is a helper to derive `CLASS` from `BASE`, automatically implementing the Is() and As()
+/// methods, along with a #Base type alias.
+///
+/// Example usage:
+///
+/// ```
+/// class Animal : public Castable<Animal> {};
+///
+/// class Sheep : public Castable<Sheep, Animal> {};
+///
+/// Sheep* cast_to_sheep(Animal* animal) {
+///    // You can query whether a Castable is of the given type with Is<T>():
+///    printf("animal is a sheep? %s", animal->Is<Sheep>() ? "yes" : "no");
+///
+///    // You can always just try the cast with As<T>().
+///    // If the object is not of the correct type, As<T>() will return nullptr:
+///    return animal->As<Sheep>();
+/// }
+/// ```
+template <typename CLASS, typename BASE = CastableBase>
+class Castable : public BASE {
+  public:
+    /// A type alias to this Castable. Commonly used in derived type constructors to forward
+    /// constructor arguments to BASE.
+    using Base = Castable;
+
+    /// A type alias for `BASE`.
+    using TrueBase = BASE;
+
+    /// A type alias for `CLASS`.
+    using Class = CLASS;
+
+    /// Constructor
+    /// @param arguments the arguments to forward to the base class.
+    template <typename... ARGS>
+    inline explicit Castable(ARGS&&... arguments) : TrueBase(std::forward<ARGS>(arguments)...) {
+        this->type_info_ = &TypeInfo::Of<CLASS>();
+    }
+
+    /// @returns true if this object is of, or derives from the class `TO`
+    /// @see CastFlags
+    template <typename TO, int FLAGS = 0>
+    inline bool Is() const {
+        return tint::utils::Is<TO, FLAGS>(static_cast<const CLASS*>(this));
+    }
+
+    /// @returns true if this object is of, or derives from the class `TO` and
+    /// pred(const TO*) returns true
+    /// @param pred predicate function with signature `bool(const TO*)` called iff
+    /// object is of, or derives from the class `TO`.
+    template <int FLAGS = 0, typename Pred = utils::detail::Infer>
+    inline bool Is(Pred&& pred) const {
+        using TO = typename std::remove_pointer<utils::traits::ParameterType<Pred, 0>>::type;
+        return tint::utils::Is<TO, FLAGS>(static_cast<const CLASS*>(this),
+                                          std::forward<Pred>(pred));
+    }
+
+    /// @returns true if this object is of, or derives from any of the `TO`
+    /// classes.
+    template <typename... TO>
+    inline bool IsAnyOf() const {
+        return tint::utils::IsAnyOf<TO...>(static_cast<const CLASS*>(this));
+    }
+
+    /// @returns this object dynamically cast to the type `TO` or `nullptr` if
+    /// this object does not derive from `TO`.
+    /// @see CastFlags
+    template <typename TO, int FLAGS = 0>
+    inline TO* As() {
+        return tint::utils::As<TO, FLAGS>(this);
+    }
+
+    /// @returns this object dynamically cast to the type `TO` or `nullptr` if
+    /// this object does not derive from `TO`.
+    /// @see CastFlags
+    template <typename TO, int FLAGS = 0>
+    inline const TO* As() const {
+        return tint::utils::As<const TO, FLAGS>(this);
+    }
+};
+
+namespace detail {
+/// <code>typename CastableCommonBaseImpl<TYPES>::type</code> resolves to the common base class for
+/// all of TYPES.
+template <typename... TYPES>
+struct CastableCommonBaseImpl {};
+
+/// Alias to typename CastableCommonBaseImpl<TYPES>::type
+template <typename... TYPES>
+using CastableCommonBase = typename CastableCommonBaseImpl<TYPES...>::type;
+
+/// CastableCommonBaseImpl template specialization for a single type
+template <typename T>
+struct CastableCommonBaseImpl<T> {
+    /// Common base class of a single type is itself
+    using type = T;
+};
+
+/// CastableCommonBaseImpl A <-> CastableBase specialization
+template <typename A>
+struct CastableCommonBaseImpl<A, CastableBase> {
+    /// Common base class for A and CastableBase is CastableBase
+    using type = CastableBase;
+};
+
+/// CastableCommonBaseImpl T <-> Ignore specialization
+template <typename T>
+struct CastableCommonBaseImpl<T, Ignore> {
+    /// Resolves to T as the other type is ignored
+    using type = T;
+};
+
+/// CastableCommonBaseImpl Ignore <-> T specialization
+template <typename T>
+struct CastableCommonBaseImpl<Ignore, T> {
+    /// Resolves to T as the other type is ignored
+    using type = T;
+};
+
+/// CastableCommonBaseImpl A <-> B specialization
+template <typename A, typename B>
+struct CastableCommonBaseImpl<A, B> {
+    /// The common base class for A, B and OTHERS
+    using type = std::conditional_t<utils::traits::IsTypeOrDerived<A, B>,
+                                    B,  // A derives from B
+                                    CastableCommonBase<A, typename B::TrueBase>>;
+};
+
+/// CastableCommonBaseImpl 3+ types specialization
+template <typename A, typename B, typename... OTHERS>
+struct CastableCommonBaseImpl<A, B, OTHERS...> {
+    /// The common base class for A, B and OTHERS
+    using type = CastableCommonBase<CastableCommonBase<A, B>, OTHERS...>;
+};
+
+}  // namespace detail
+
+/// Resolves to the common most derived type that each of the types in `TYPES` derives from.
+template <typename... TYPES>
+using CastableCommonBase = utils::detail::CastableCommonBase<TYPES...>;
+
+}  // namespace tint::utils
+
+namespace tint {
+
+using utils::As;
+using utils::Is;
+
+}  // namespace tint
+
+TINT_CASTABLE_POP_DISABLE_WARNINGS();
+
+#endif  // SRC_TINT_UTILS_RTTI_CASTABLE_H_
diff --git a/src/tint/utils/rtti/castable_test.cc b/src/tint/utils/rtti/castable_test.cc
new file mode 100644
index 0000000..847602d
--- /dev/null
+++ b/src/tint/utils/rtti/castable_test.cc
@@ -0,0 +1,294 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/rtti/castable.h"
+
+#include <memory>
+#include <string>
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+struct Animal : public tint::utils::Castable<Animal> {};
+struct Amphibian : public tint::utils::Castable<Amphibian, Animal> {};
+struct Mammal : public tint::utils::Castable<Mammal, Animal> {};
+struct Reptile : public tint::utils::Castable<Reptile, Animal> {};
+struct Frog : public tint::utils::Castable<Frog, Amphibian> {};
+struct Bear : public tint::utils::Castable<Bear, Mammal> {};
+struct Lizard : public tint::utils::Castable<Lizard, Reptile> {};
+struct Gecko : public tint::utils::Castable<Gecko, Lizard> {};
+struct Iguana : public tint::utils::Castable<Iguana, Lizard> {};
+
+TEST(CastableBase, Is) {
+    std::unique_ptr<CastableBase> frog = std::make_unique<Frog>();
+    std::unique_ptr<CastableBase> bear = std::make_unique<Bear>();
+    std::unique_ptr<CastableBase> gecko = std::make_unique<Gecko>();
+
+    ASSERT_TRUE(frog->Is<Animal>());
+    ASSERT_TRUE(bear->Is<Animal>());
+    ASSERT_TRUE(gecko->Is<Animal>());
+
+    ASSERT_TRUE(frog->Is<Amphibian>());
+    ASSERT_FALSE(bear->Is<Amphibian>());
+    ASSERT_FALSE(gecko->Is<Amphibian>());
+
+    ASSERT_FALSE(frog->Is<Mammal>());
+    ASSERT_TRUE(bear->Is<Mammal>());
+    ASSERT_FALSE(gecko->Is<Mammal>());
+
+    ASSERT_FALSE(frog->Is<Reptile>());
+    ASSERT_FALSE(bear->Is<Reptile>());
+    ASSERT_TRUE(gecko->Is<Reptile>());
+}
+
+TEST(CastableBase, Is_kDontErrorOnImpossibleCast) {
+    // Unlike TEST(CastableBase, Is), we're dynamically querying [A -> B] without
+    // going via CastableBase.
+    auto frog = std::make_unique<Frog>();
+    auto bear = std::make_unique<Bear>();
+    auto gecko = std::make_unique<Gecko>();
+
+    ASSERT_TRUE((frog->Is<Animal, kDontErrorOnImpossibleCast>()));
+    ASSERT_TRUE((bear->Is<Animal, kDontErrorOnImpossibleCast>()));
+    ASSERT_TRUE((gecko->Is<Animal, kDontErrorOnImpossibleCast>()));
+
+    ASSERT_TRUE((frog->Is<Amphibian, kDontErrorOnImpossibleCast>()));
+    ASSERT_FALSE((bear->Is<Amphibian, kDontErrorOnImpossibleCast>()));
+    ASSERT_FALSE((gecko->Is<Amphibian, kDontErrorOnImpossibleCast>()));
+
+    ASSERT_FALSE((frog->Is<Mammal, kDontErrorOnImpossibleCast>()));
+    ASSERT_TRUE((bear->Is<Mammal, kDontErrorOnImpossibleCast>()));
+    ASSERT_FALSE((gecko->Is<Mammal, kDontErrorOnImpossibleCast>()));
+
+    ASSERT_FALSE((frog->Is<Reptile, kDontErrorOnImpossibleCast>()));
+    ASSERT_FALSE((bear->Is<Reptile, kDontErrorOnImpossibleCast>()));
+    ASSERT_TRUE((gecko->Is<Reptile, kDontErrorOnImpossibleCast>()));
+}
+
+TEST(CastableBase, IsWithPredicate) {
+    std::unique_ptr<CastableBase> frog = std::make_unique<Frog>();
+
+    frog->Is<Animal>([&frog](const Animal* a) {
+        EXPECT_EQ(a, frog.get());
+        return true;
+    });
+
+    ASSERT_TRUE((frog->Is<Animal>([](const Animal*) { return true; })));
+    ASSERT_FALSE((frog->Is<Animal>([](const Animal*) { return false; })));
+
+    // Predicate not called if cast is invalid
+    auto expect_not_called = [] { FAIL() << "Should not be called"; };
+    ASSERT_FALSE((frog->Is<Bear>([&](const Animal*) {
+        expect_not_called();
+        return true;
+    })));
+}
+
+TEST(CastableBase, IsAnyOf) {
+    std::unique_ptr<CastableBase> frog = std::make_unique<Frog>();
+    std::unique_ptr<CastableBase> bear = std::make_unique<Bear>();
+    std::unique_ptr<CastableBase> gecko = std::make_unique<Gecko>();
+
+    ASSERT_TRUE((frog->IsAnyOf<Animal, Mammal, Amphibian, Reptile>()));
+    ASSERT_TRUE((frog->IsAnyOf<Mammal, Amphibian>()));
+    ASSERT_TRUE((frog->IsAnyOf<Amphibian, Reptile>()));
+    ASSERT_FALSE((frog->IsAnyOf<Mammal, Reptile>()));
+
+    ASSERT_TRUE((bear->IsAnyOf<Animal, Mammal, Amphibian, Reptile>()));
+    ASSERT_TRUE((bear->IsAnyOf<Mammal, Amphibian>()));
+    ASSERT_TRUE((bear->IsAnyOf<Mammal, Reptile>()));
+    ASSERT_FALSE((bear->IsAnyOf<Amphibian, Reptile>()));
+
+    ASSERT_TRUE((gecko->IsAnyOf<Animal, Mammal, Amphibian, Reptile>()));
+    ASSERT_TRUE((gecko->IsAnyOf<Mammal, Reptile>()));
+    ASSERT_TRUE((gecko->IsAnyOf<Amphibian, Reptile>()));
+    ASSERT_FALSE((gecko->IsAnyOf<Mammal, Amphibian>()));
+}
+
+TEST(CastableBase, As) {
+    std::unique_ptr<CastableBase> frog = std::make_unique<Frog>();
+    std::unique_ptr<CastableBase> bear = std::make_unique<Bear>();
+    std::unique_ptr<CastableBase> gecko = std::make_unique<Gecko>();
+
+    ASSERT_EQ(frog->As<Animal>(), static_cast<Animal*>(frog.get()));
+    ASSERT_EQ(bear->As<Animal>(), static_cast<Animal*>(bear.get()));
+    ASSERT_EQ(gecko->As<Animal>(), static_cast<Animal*>(gecko.get()));
+
+    ASSERT_EQ(frog->As<Amphibian>(), static_cast<Amphibian*>(frog.get()));
+    ASSERT_EQ(bear->As<Amphibian>(), nullptr);
+    ASSERT_EQ(gecko->As<Amphibian>(), nullptr);
+
+    ASSERT_EQ(frog->As<Mammal>(), nullptr);
+    ASSERT_EQ(bear->As<Mammal>(), static_cast<Mammal*>(bear.get()));
+    ASSERT_EQ(gecko->As<Mammal>(), nullptr);
+
+    ASSERT_EQ(frog->As<Reptile>(), nullptr);
+    ASSERT_EQ(bear->As<Reptile>(), nullptr);
+    ASSERT_EQ(gecko->As<Reptile>(), static_cast<Reptile*>(gecko.get()));
+}
+
+TEST(CastableBase, As_kDontErrorOnImpossibleCast) {
+    // Unlike TEST(CastableBase, As), we're dynamically casting [A -> B] without
+    // going via CastableBase.
+    auto frog = std::make_unique<Frog>();
+    auto bear = std::make_unique<Bear>();
+    auto gecko = std::make_unique<Gecko>();
+
+    ASSERT_EQ((frog->As<Animal, kDontErrorOnImpossibleCast>()), static_cast<Animal*>(frog.get()));
+    ASSERT_EQ((bear->As<Animal, kDontErrorOnImpossibleCast>()), static_cast<Animal*>(bear.get()));
+    ASSERT_EQ((gecko->As<Animal, kDontErrorOnImpossibleCast>()), static_cast<Animal*>(gecko.get()));
+
+    ASSERT_EQ((frog->As<Amphibian, kDontErrorOnImpossibleCast>()),
+              static_cast<Amphibian*>(frog.get()));
+    ASSERT_EQ((bear->As<Amphibian, kDontErrorOnImpossibleCast>()), nullptr);
+    ASSERT_EQ((gecko->As<Amphibian, kDontErrorOnImpossibleCast>()), nullptr);
+
+    ASSERT_EQ((frog->As<Mammal, kDontErrorOnImpossibleCast>()), nullptr);
+    ASSERT_EQ((bear->As<Mammal, kDontErrorOnImpossibleCast>()), static_cast<Mammal*>(bear.get()));
+    ASSERT_EQ((gecko->As<Mammal, kDontErrorOnImpossibleCast>()), nullptr);
+
+    ASSERT_EQ((frog->As<Reptile, kDontErrorOnImpossibleCast>()), nullptr);
+    ASSERT_EQ((bear->As<Reptile, kDontErrorOnImpossibleCast>()), nullptr);
+    ASSERT_EQ((gecko->As<Reptile, kDontErrorOnImpossibleCast>()),
+              static_cast<Reptile*>(gecko.get()));
+}
+
+TEST(Castable, Is) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+
+    ASSERT_TRUE(frog->Is<Animal>());
+    ASSERT_TRUE(bear->Is<Animal>());
+    ASSERT_TRUE(gecko->Is<Animal>());
+
+    ASSERT_TRUE(frog->Is<Amphibian>());
+    ASSERT_FALSE(bear->Is<Amphibian>());
+    ASSERT_FALSE(gecko->Is<Amphibian>());
+
+    ASSERT_FALSE(frog->Is<Mammal>());
+    ASSERT_TRUE(bear->Is<Mammal>());
+    ASSERT_FALSE(gecko->Is<Mammal>());
+
+    ASSERT_FALSE(frog->Is<Reptile>());
+    ASSERT_FALSE(bear->Is<Reptile>());
+    ASSERT_TRUE(gecko->Is<Reptile>());
+}
+
+TEST(Castable, IsWithPredicate) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+
+    frog->Is([&frog](const Animal* a) {
+        EXPECT_EQ(a, frog.get());
+        return true;
+    });
+
+    ASSERT_TRUE((frog->Is([](const Animal*) { return true; })));
+    ASSERT_FALSE((frog->Is([](const Animal*) { return false; })));
+
+    // Predicate not called if cast is invalid
+    auto expect_not_called = [] { FAIL() << "Should not be called"; };
+    ASSERT_FALSE((frog->Is([&](const Bear*) {
+        expect_not_called();
+        return true;
+    })));
+}
+
+TEST(Castable, As) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+
+    ASSERT_EQ(frog->As<Animal>(), static_cast<Animal*>(frog.get()));
+    ASSERT_EQ(bear->As<Animal>(), static_cast<Animal*>(bear.get()));
+    ASSERT_EQ(gecko->As<Animal>(), static_cast<Animal*>(gecko.get()));
+
+    ASSERT_EQ(frog->As<Amphibian>(), static_cast<Amphibian*>(frog.get()));
+    ASSERT_EQ(bear->As<Amphibian>(), nullptr);
+    ASSERT_EQ(gecko->As<Amphibian>(), nullptr);
+
+    ASSERT_EQ(frog->As<Mammal>(), nullptr);
+    ASSERT_EQ(bear->As<Mammal>(), static_cast<Mammal*>(bear.get()));
+    ASSERT_EQ(gecko->As<Mammal>(), nullptr);
+
+    ASSERT_EQ(frog->As<Reptile>(), nullptr);
+    ASSERT_EQ(bear->As<Reptile>(), nullptr);
+    ASSERT_EQ(gecko->As<Reptile>(), static_cast<Reptile*>(gecko.get()));
+}
+
+// IsCastable static tests
+static_assert(IsCastable<CastableBase>);
+static_assert(IsCastable<Animal>);
+static_assert(IsCastable<Ignore, Frog, Bear>);
+static_assert(IsCastable<Mammal, Ignore, Amphibian, Gecko>);
+static_assert(!IsCastable<Mammal, int, Amphibian, Ignore, Gecko>);
+static_assert(!IsCastable<bool>);
+static_assert(!IsCastable<int, float>);
+static_assert(!IsCastable<Ignore>);
+
+// CastableCommonBase static tests
+static_assert(std::is_same_v<Animal, CastableCommonBase<Animal>>);
+static_assert(std::is_same_v<Amphibian, CastableCommonBase<Amphibian>>);
+static_assert(std::is_same_v<Mammal, CastableCommonBase<Mammal>>);
+static_assert(std::is_same_v<Reptile, CastableCommonBase<Reptile>>);
+static_assert(std::is_same_v<Frog, CastableCommonBase<Frog>>);
+static_assert(std::is_same_v<Bear, CastableCommonBase<Bear>>);
+static_assert(std::is_same_v<Lizard, CastableCommonBase<Lizard>>);
+static_assert(std::is_same_v<Gecko, CastableCommonBase<Gecko>>);
+static_assert(std::is_same_v<Iguana, CastableCommonBase<Iguana>>);
+
+static_assert(std::is_same_v<Animal, CastableCommonBase<Animal, Animal>>);
+static_assert(std::is_same_v<Amphibian, CastableCommonBase<Amphibian, Amphibian>>);
+static_assert(std::is_same_v<Mammal, CastableCommonBase<Mammal, Mammal>>);
+static_assert(std::is_same_v<Reptile, CastableCommonBase<Reptile, Reptile>>);
+static_assert(std::is_same_v<Frog, CastableCommonBase<Frog, Frog>>);
+static_assert(std::is_same_v<Bear, CastableCommonBase<Bear, Bear>>);
+static_assert(std::is_same_v<Lizard, CastableCommonBase<Lizard, Lizard>>);
+static_assert(std::is_same_v<Gecko, CastableCommonBase<Gecko, Gecko>>);
+static_assert(std::is_same_v<Iguana, CastableCommonBase<Iguana, Iguana>>);
+
+static_assert(std::is_same_v<CastableBase, CastableCommonBase<CastableBase, Animal>>);
+static_assert(std::is_same_v<CastableBase, CastableCommonBase<Animal, CastableBase>>);
+static_assert(std::is_same_v<Amphibian, CastableCommonBase<Amphibian, Frog>>);
+static_assert(std::is_same_v<Amphibian, CastableCommonBase<Frog, Amphibian>>);
+static_assert(std::is_same_v<Animal, CastableCommonBase<Reptile, Frog>>);
+static_assert(std::is_same_v<Animal, CastableCommonBase<Frog, Reptile>>);
+static_assert(std::is_same_v<Animal, CastableCommonBase<Bear, Frog>>);
+static_assert(std::is_same_v<Animal, CastableCommonBase<Frog, Bear>>);
+static_assert(std::is_same_v<Lizard, CastableCommonBase<Gecko, Iguana>>);
+
+static_assert(std::is_same_v<Animal, CastableCommonBase<Bear, Frog, Iguana>>);
+static_assert(std::is_same_v<Lizard, CastableCommonBase<Lizard, Gecko, Iguana>>);
+static_assert(std::is_same_v<Lizard, CastableCommonBase<Gecko, Iguana, Lizard>>);
+static_assert(std::is_same_v<Lizard, CastableCommonBase<Gecko, Lizard, Iguana>>);
+static_assert(std::is_same_v<Animal, CastableCommonBase<Frog, Gecko, Iguana>>);
+static_assert(std::is_same_v<Animal, CastableCommonBase<Gecko, Iguana, Frog>>);
+static_assert(std::is_same_v<Animal, CastableCommonBase<Gecko, Frog, Iguana>>);
+
+static_assert(std::is_same_v<CastableBase, CastableCommonBase<Bear, Frog, Iguana, CastableBase>>);
+
+}  // namespace
+
+TINT_INSTANTIATE_TYPEINFO(Animal);
+TINT_INSTANTIATE_TYPEINFO(Amphibian);
+TINT_INSTANTIATE_TYPEINFO(Mammal);
+TINT_INSTANTIATE_TYPEINFO(Reptile);
+TINT_INSTANTIATE_TYPEINFO(Frog);
+TINT_INSTANTIATE_TYPEINFO(Bear);
+TINT_INSTANTIATE_TYPEINFO(Lizard);
+TINT_INSTANTIATE_TYPEINFO(Gecko);
+
+}  // namespace tint::utils
diff --git a/src/tint/utils/rtti/switch.h b/src/tint/utils/rtti/switch.h
new file mode 100644
index 0000000..7c42eda
--- /dev/null
+++ b/src/tint/utils/rtti/switch.h
@@ -0,0 +1,266 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_RTTI_SWITCH_H_
+#define SRC_TINT_UTILS_RTTI_SWITCH_H_
+
+#include <tuple>
+#include <utility>
+
+#include "src/tint/utils/macros/defer.h"
+#include "src/tint/utils/memory/bitcast.h"
+#include "src/tint/utils/rtti/castable.h"
+
+namespace tint {
+
+/// Default can be used as the default case for a Switch(), when all previous cases failed to match.
+///
+/// Example:
+/// ```
+/// Switch(object,
+///     [&](TypeA*) { /* ... */ },
+///     [&](TypeB*) { /* ... */ },
+///     [&](Default) { /* If not TypeA or TypeB */ });
+/// ```
+struct Default {};
+
+}  // namespace tint
+
+namespace tint::detail {
+
+/// Evaluates to the Switch case type being matched by the switch case function `FN`.
+/// @note does not handle the Default case
+/// @see Switch().
+template <typename FN>
+using SwitchCaseType =
+    std::remove_pointer_t<utils::traits::ParameterType<std::remove_reference_t<FN>, 0>>;
+
+/// Evaluates to true if the function `FN` has the signature of a Default case in a Switch().
+/// @see Switch().
+template <typename FN>
+inline constexpr bool IsDefaultCase =
+    std::is_same_v<utils::traits::ParameterType<std::remove_reference_t<FN>, 0>, Default>;
+
+/// Searches the list of Switch cases for a Default case, returning the index of the Default case.
+/// If the a Default case is not found in the tuple, then -1 is returned.
+template <typename TUPLE, std::size_t START_IDX = 0>
+constexpr int IndexOfDefaultCase() {
+    if constexpr (START_IDX < std::tuple_size_v<TUPLE>) {
+        return IsDefaultCase<std::tuple_element_t<START_IDX, TUPLE>>
+                   ? static_cast<int>(START_IDX)
+                   : IndexOfDefaultCase<TUPLE, START_IDX + 1>();
+    } else {
+        return -1;
+    }
+}
+
+/// Resolves to T if T is not nullptr_t, otherwise resolves to Ignore.
+template <typename T>
+using NullptrToIgnore = std::conditional_t<std::is_same_v<T, std::nullptr_t>, utils::Ignore, T>;
+
+/// Resolves to `const TYPE` if any of `CASE_RETURN_TYPES` are const or pointer-to-const, otherwise
+/// resolves to TYPE.
+template <typename TYPE, typename... CASE_RETURN_TYPES>
+using PropagateReturnConst = std::conditional_t<
+    // Are any of the pointer-stripped types const?
+    (std::is_const_v<std::remove_pointer_t<CASE_RETURN_TYPES>> || ...),
+    const TYPE,  // Yes: Apply const to TYPE
+    TYPE>;       // No:  Passthrough
+
+/// SwitchReturnTypeImpl is the implementation of SwitchReturnType
+template <bool IS_CASTABLE, typename REQUESTED_TYPE, typename... CASE_RETURN_TYPES>
+struct SwitchReturnTypeImpl;
+
+/// SwitchReturnTypeImpl specialization for non-castable case types and an explicitly specified
+/// return type.
+template <typename REQUESTED_TYPE, typename... CASE_RETURN_TYPES>
+struct SwitchReturnTypeImpl</*IS_CASTABLE*/ false, REQUESTED_TYPE, CASE_RETURN_TYPES...> {
+    /// Resolves to `REQUESTED_TYPE`
+    using type = REQUESTED_TYPE;
+};
+
+/// SwitchReturnTypeImpl specialization for non-castable case types and an inferred return type.
+template <typename... CASE_RETURN_TYPES>
+struct SwitchReturnTypeImpl</*IS_CASTABLE*/ false, utils::detail::Infer, CASE_RETURN_TYPES...> {
+    /// Resolves to the common type for all the cases return types.
+    using type = std::common_type_t<CASE_RETURN_TYPES...>;
+};
+
+/// SwitchReturnTypeImpl specialization for castable case types and an explicitly specified return
+/// type.
+template <typename REQUESTED_TYPE, typename... CASE_RETURN_TYPES>
+struct SwitchReturnTypeImpl</*IS_CASTABLE*/ true, REQUESTED_TYPE, CASE_RETURN_TYPES...> {
+  public:
+    /// Resolves to `const REQUESTED_TYPE*` or `REQUESTED_TYPE*`
+    using type = PropagateReturnConst<std::remove_pointer_t<REQUESTED_TYPE>, CASE_RETURN_TYPES...>*;
+};
+
+/// SwitchReturnTypeImpl specialization for castable case types and an inferred return type.
+template <typename... CASE_RETURN_TYPES>
+struct SwitchReturnTypeImpl</*IS_CASTABLE*/ true, utils::detail::Infer, CASE_RETURN_TYPES...> {
+  private:
+    using InferredType =
+        utils::CastableCommonBase<NullptrToIgnore<std::remove_pointer_t<CASE_RETURN_TYPES>>...>;
+
+  public:
+    /// `const T*` or `T*`, where T is the common base type for all the castable case types.
+    using type = PropagateReturnConst<InferredType, CASE_RETURN_TYPES...>*;
+};
+
+/// Resolves to the return type for a Switch() with the requested return type `REQUESTED_TYPE` and
+/// case statement return types. If `REQUESTED_TYPE` is Infer then the return type will be inferred
+/// from the case return types.
+template <typename REQUESTED_TYPE, typename... CASE_RETURN_TYPES>
+using SwitchReturnType = typename SwitchReturnTypeImpl<
+    utils::IsCastable<NullptrToIgnore<std::remove_pointer_t<CASE_RETURN_TYPES>>...>,
+    REQUESTED_TYPE,
+    CASE_RETURN_TYPES...>::type;
+
+}  // namespace tint::detail
+
+namespace tint {
+
+/// Switch is used to dispatch one of the provided callback case handler functions based on the type
+/// of `object` and the parameter type of the case handlers. Switch will sequentially check the type
+/// of `object` against each of the switch case handler functions, and will invoke the first case
+/// handler function which has a parameter type that matches the object type. When a case handler is
+/// matched, it will be called with the single argument of `object` cast to the case handler's
+/// parameter type. Switch will invoke at most one case handler. Each of the case functions must
+/// have the signature `R(T*)` or `R(const T*)`, where `T` is the type matched by that case and `R`
+/// is the return type, consistent across all case handlers.
+///
+/// An optional default case function with the signature `R(Default)` can be used as the last case.
+/// This default case will be called if all previous cases failed to match.
+///
+/// If `object` is nullptr and a default case is provided, then the default case will be called. If
+/// `object` is nullptr and no default case is provided, then no cases will be called.
+///
+/// Example:
+/// ```
+/// Switch(object,
+///     [&](TypeA*) { /* ... */ },
+///     [&](TypeB*) { /* ... */ });
+///
+/// Switch(object,
+///     [&](TypeA*) { /* ... */ },
+///     [&](TypeB*) { /* ... */ },
+///     [&](Default) { /* Called if object is not TypeA or TypeB */ });
+/// ```
+///
+/// @param object the object who's type is used to
+/// @param cases the switch cases
+/// @return the value returned by the called case. If no cases matched, then the zero value for the
+/// consistent case type.
+template <typename RETURN_TYPE = utils::detail::Infer,
+          typename T = utils::CastableBase,
+          typename... CASES>
+inline auto Switch(T* object, CASES&&... cases) {
+    using ReturnType =
+        tint::detail::SwitchReturnType<RETURN_TYPE, utils::traits::ReturnType<CASES>...>;
+    static constexpr int kDefaultIndex = tint::detail::IndexOfDefaultCase<std::tuple<CASES...>>();
+    static constexpr bool kHasDefaultCase = kDefaultIndex >= 0;
+    static constexpr bool kHasReturnType = !std::is_same_v<ReturnType, void>;
+
+    // Static assertions
+    static constexpr bool kDefaultIsOK =
+        kDefaultIndex == -1 || kDefaultIndex == static_cast<int>(sizeof...(CASES) - 1);
+    static constexpr bool kReturnIsOK =
+        kHasDefaultCase || !kHasReturnType || std::is_constructible_v<ReturnType>;
+    static_assert(kDefaultIsOK, "Default case must be last in Switch()");
+    static_assert(kReturnIsOK,
+                  "Switch() requires either a Default case or a return type that is either void or "
+                  "default-constructable");
+
+    if (!object) {  // Object is nullptr, so no cases can match
+        if constexpr (kHasDefaultCase) {
+            // Evaluate default case.
+            auto&& default_case =
+                std::get<kDefaultIndex>(std::forward_as_tuple(std::forward<CASES>(cases)...));
+            return static_cast<ReturnType>(default_case(Default{}));
+        } else {
+            // No default case, no case can match.
+            if constexpr (kHasReturnType) {
+                return ReturnType{};
+            } else {
+                return;
+            }
+        }
+    }
+
+    // Replacement for std::aligned_storage as this is broken on earlier versions of MSVC.
+    using ReturnTypeOrU8 = std::conditional_t<kHasReturnType, ReturnType, uint8_t>;
+    struct alignas(alignof(ReturnTypeOrU8)) ReturnStorage {
+        uint8_t data[sizeof(ReturnTypeOrU8)];
+    };
+    ReturnStorage return_storage;
+    auto* result = utils::Bitcast<ReturnTypeOrU8*>(&return_storage);
+
+    const utils::TypeInfo& type_info = object->TypeInfo();
+
+    // Examines the parameter type of the case function.
+    // If the parameter is a pointer type that `object` is of, or derives from, then that case
+    // function is called with `object` cast to that type, and `try_case` returns true.
+    // If the parameter is of type `Default`, then that case function is called and `try_case`
+    // returns true.
+    // Otherwise `try_case` returns false.
+    // If the case function is called and it returns a value, then this is copy constructed to the
+    // `result` pointer.
+    auto try_case = [&](auto&& case_fn) {
+        using CaseFunc = std::decay_t<decltype(case_fn)>;
+        using CaseType = tint::detail::SwitchCaseType<CaseFunc>;
+        bool success = false;
+        if constexpr (std::is_same_v<CaseType, Default>) {
+            if constexpr (kHasReturnType) {
+                new (result) ReturnType(static_cast<ReturnType>(case_fn(Default{})));
+            } else {
+                case_fn(Default{});
+            }
+            success = true;
+        } else {
+            if (type_info.Is<CaseType>()) {
+                auto* v = static_cast<CaseType*>(object);
+                if constexpr (kHasReturnType) {
+                    new (result) ReturnType(static_cast<ReturnType>(case_fn(v)));
+                } else {
+                    case_fn(v);
+                }
+                success = true;
+            }
+        }
+        return success;
+    };
+
+    // Use a logical-or fold expression to try each of the cases in turn, until one matches the
+    // object type or a Default is reached. `handled` is true if a case function was called.
+    bool handled = ((try_case(std::forward<CASES>(cases)) || ...));
+
+    if constexpr (kHasReturnType) {
+        if constexpr (kHasDefaultCase) {
+            // Default case means there must be a returned value.
+            // No need to check handled, no requirement for a zero-initializer of ReturnType.
+            TINT_DEFER(result->~ReturnType());
+            return *result;
+        } else {
+            if (handled) {
+                TINT_DEFER(result->~ReturnType());
+                return *result;
+            }
+            return ReturnType{};
+        }
+    }
+}
+
+}  // namespace tint
+
+#endif  // SRC_TINT_UTILS_RTTI_SWITCH_H_
diff --git a/src/tint/utils/rtti/switch_bench.cc b/src/tint/utils/rtti/switch_bench.cc
new file mode 100644
index 0000000..d2bf9f1
--- /dev/null
+++ b/src/tint/utils/rtti/switch_bench.cc
@@ -0,0 +1,274 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <memory>
+
+#include "benchmark/benchmark.h"
+
+#include "src/tint/utils/rtti/switch.h"
+
+namespace tint {
+namespace {
+
+struct Base : public tint::utils::Castable<Base> {};
+struct A : public tint::utils::Castable<A, Base> {};
+struct AA : public tint::utils::Castable<AA, A> {};
+struct AAA : public tint::utils::Castable<AAA, AA> {};
+struct AAB : public tint::utils::Castable<AAB, AA> {};
+struct AAC : public tint::utils::Castable<AAC, AA> {};
+struct AB : public tint::utils::Castable<AB, A> {};
+struct ABA : public tint::utils::Castable<ABA, AB> {};
+struct ABB : public tint::utils::Castable<ABB, AB> {};
+struct ABC : public tint::utils::Castable<ABC, AB> {};
+struct AC : public tint::utils::Castable<AC, A> {};
+struct ACA : public tint::utils::Castable<ACA, AC> {};
+struct ACB : public tint::utils::Castable<ACB, AC> {};
+struct ACC : public tint::utils::Castable<ACC, AC> {};
+struct B : public tint::utils::Castable<B, Base> {};
+struct BA : public tint::utils::Castable<BA, B> {};
+struct BAA : public tint::utils::Castable<BAA, BA> {};
+struct BAB : public tint::utils::Castable<BAB, BA> {};
+struct BAC : public tint::utils::Castable<BAC, BA> {};
+struct BB : public tint::utils::Castable<BB, B> {};
+struct BBA : public tint::utils::Castable<BBA, BB> {};
+struct BBB : public tint::utils::Castable<BBB, BB> {};
+struct BBC : public tint::utils::Castable<BBC, BB> {};
+struct BC : public tint::utils::Castable<BC, B> {};
+struct BCA : public tint::utils::Castable<BCA, BC> {};
+struct BCB : public tint::utils::Castable<BCB, BC> {};
+struct BCC : public tint::utils::Castable<BCC, BC> {};
+struct C : public tint::utils::Castable<C, Base> {};
+struct CA : public tint::utils::Castable<CA, C> {};
+struct CAA : public tint::utils::Castable<CAA, CA> {};
+struct CAB : public tint::utils::Castable<CAB, CA> {};
+struct CAC : public tint::utils::Castable<CAC, CA> {};
+struct CB : public tint::utils::Castable<CB, C> {};
+struct CBA : public tint::utils::Castable<CBA, CB> {};
+struct CBB : public tint::utils::Castable<CBB, CB> {};
+struct CBC : public tint::utils::Castable<CBC, CB> {};
+struct CC : public tint::utils::Castable<CC, C> {};
+struct CCA : public tint::utils::Castable<CCA, CC> {};
+struct CCB : public tint::utils::Castable<CCB, CC> {};
+struct CCC : public tint::utils::Castable<CCC, CC> {};
+
+using AllTypes = std::tuple<Base,
+                            A,
+                            AA,
+                            AAA,
+                            AAB,
+                            AAC,
+                            AB,
+                            ABA,
+                            ABB,
+                            ABC,
+                            AC,
+                            ACA,
+                            ACB,
+                            ACC,
+                            B,
+                            BA,
+                            BAA,
+                            BAB,
+                            BAC,
+                            BB,
+                            BBA,
+                            BBB,
+                            BBC,
+                            BC,
+                            BCA,
+                            BCB,
+                            BCC,
+                            C,
+                            CA,
+                            CAA,
+                            CAB,
+                            CAC,
+                            CB,
+                            CBA,
+                            CBB,
+                            CBC,
+                            CC,
+                            CCA,
+                            CCB,
+                            CCC>;
+
+std::vector<std::unique_ptr<Base>> MakeObjects() {
+    std::vector<std::unique_ptr<Base>> out;
+    out.emplace_back(std::make_unique<Base>());
+    out.emplace_back(std::make_unique<A>());
+    out.emplace_back(std::make_unique<AA>());
+    out.emplace_back(std::make_unique<AAA>());
+    out.emplace_back(std::make_unique<AAB>());
+    out.emplace_back(std::make_unique<AAC>());
+    out.emplace_back(std::make_unique<AB>());
+    out.emplace_back(std::make_unique<ABA>());
+    out.emplace_back(std::make_unique<ABB>());
+    out.emplace_back(std::make_unique<ABC>());
+    out.emplace_back(std::make_unique<AC>());
+    out.emplace_back(std::make_unique<ACA>());
+    out.emplace_back(std::make_unique<ACB>());
+    out.emplace_back(std::make_unique<ACC>());
+    out.emplace_back(std::make_unique<B>());
+    out.emplace_back(std::make_unique<BA>());
+    out.emplace_back(std::make_unique<BAA>());
+    out.emplace_back(std::make_unique<BAB>());
+    out.emplace_back(std::make_unique<BAC>());
+    out.emplace_back(std::make_unique<BB>());
+    out.emplace_back(std::make_unique<BBA>());
+    out.emplace_back(std::make_unique<BBB>());
+    out.emplace_back(std::make_unique<BBC>());
+    out.emplace_back(std::make_unique<BC>());
+    out.emplace_back(std::make_unique<BCA>());
+    out.emplace_back(std::make_unique<BCB>());
+    out.emplace_back(std::make_unique<BCC>());
+    out.emplace_back(std::make_unique<C>());
+    out.emplace_back(std::make_unique<CA>());
+    out.emplace_back(std::make_unique<CAA>());
+    out.emplace_back(std::make_unique<CAB>());
+    out.emplace_back(std::make_unique<CAC>());
+    out.emplace_back(std::make_unique<CB>());
+    out.emplace_back(std::make_unique<CBA>());
+    out.emplace_back(std::make_unique<CBB>());
+    out.emplace_back(std::make_unique<CBC>());
+    out.emplace_back(std::make_unique<CC>());
+    out.emplace_back(std::make_unique<CCA>());
+    out.emplace_back(std::make_unique<CCB>());
+    out.emplace_back(std::make_unique<CCC>());
+    return out;
+}
+
+void CastableLargeSwitch(::benchmark::State& state) {
+    auto objects = MakeObjects();
+    size_t i = 0;
+    for (auto _ : state) {
+        auto* object = objects[i % objects.size()].get();
+        Switch(
+            object,  //
+            [&](const AAA*) { ::benchmark::DoNotOptimize(i += 40); },
+            [&](const AAB*) { ::benchmark::DoNotOptimize(i += 50); },
+            [&](const AAC*) { ::benchmark::DoNotOptimize(i += 60); },
+            [&](const ABA*) { ::benchmark::DoNotOptimize(i += 80); },
+            [&](const ABB*) { ::benchmark::DoNotOptimize(i += 90); },
+            [&](const ABC*) { ::benchmark::DoNotOptimize(i += 100); },
+            [&](const ACA*) { ::benchmark::DoNotOptimize(i += 120); },
+            [&](const ACB*) { ::benchmark::DoNotOptimize(i += 130); },
+            [&](const ACC*) { ::benchmark::DoNotOptimize(i += 140); },
+            [&](const BAA*) { ::benchmark::DoNotOptimize(i += 170); },
+            [&](const BAB*) { ::benchmark::DoNotOptimize(i += 180); },
+            [&](const BAC*) { ::benchmark::DoNotOptimize(i += 190); },
+            [&](const BBA*) { ::benchmark::DoNotOptimize(i += 210); },
+            [&](const BBB*) { ::benchmark::DoNotOptimize(i += 220); },
+            [&](const BBC*) { ::benchmark::DoNotOptimize(i += 230); },
+            [&](const BCA*) { ::benchmark::DoNotOptimize(i += 250); },
+            [&](const BCB*) { ::benchmark::DoNotOptimize(i += 260); },
+            [&](const BCC*) { ::benchmark::DoNotOptimize(i += 270); },
+            [&](const CA*) { ::benchmark::DoNotOptimize(i += 290); },
+            [&](const CAA*) { ::benchmark::DoNotOptimize(i += 300); },
+            [&](const CAB*) { ::benchmark::DoNotOptimize(i += 310); },
+            [&](const CAC*) { ::benchmark::DoNotOptimize(i += 320); },
+            [&](const CBA*) { ::benchmark::DoNotOptimize(i += 340); },
+            [&](const CBB*) { ::benchmark::DoNotOptimize(i += 350); },
+            [&](const CBC*) { ::benchmark::DoNotOptimize(i += 360); },
+            [&](const CCA*) { ::benchmark::DoNotOptimize(i += 380); },
+            [&](const CCB*) { ::benchmark::DoNotOptimize(i += 390); },
+            [&](const CCC*) { ::benchmark::DoNotOptimize(i += 400); },
+            [&](Default) { ::benchmark::DoNotOptimize(i += 123); });
+        i = (i * 31) ^ (i << 5);
+    }
+}
+
+BENCHMARK(CastableLargeSwitch);
+
+void CastableMediumSwitch(::benchmark::State& state) {
+    auto objects = MakeObjects();
+    size_t i = 0;
+    for (auto _ : state) {
+        auto* object = objects[i % objects.size()].get();
+        Switch(
+            object,  //
+            [&](const ACB*) { ::benchmark::DoNotOptimize(i += 130); },
+            [&](const BAA*) { ::benchmark::DoNotOptimize(i += 170); },
+            [&](const BAB*) { ::benchmark::DoNotOptimize(i += 180); },
+            [&](const BBA*) { ::benchmark::DoNotOptimize(i += 210); },
+            [&](const BBB*) { ::benchmark::DoNotOptimize(i += 220); },
+            [&](const CAA*) { ::benchmark::DoNotOptimize(i += 300); },
+            [&](const CCA*) { ::benchmark::DoNotOptimize(i += 380); },
+            [&](const CCB*) { ::benchmark::DoNotOptimize(i += 390); },
+            [&](const CCC*) { ::benchmark::DoNotOptimize(i += 400); },
+            [&](Default) { ::benchmark::DoNotOptimize(i += 123); });
+        i = (i * 31) ^ (i << 5);
+    }
+}
+
+BENCHMARK(CastableMediumSwitch);
+
+void CastableSmallSwitch(::benchmark::State& state) {
+    auto objects = MakeObjects();
+    size_t i = 0;
+    for (auto _ : state) {
+        auto* object = objects[i % objects.size()].get();
+        Switch(
+            object,  //
+            [&](const AAB*) { ::benchmark::DoNotOptimize(i += 30); },
+            [&](const CAC*) { ::benchmark::DoNotOptimize(i += 290); },
+            [&](const CAA*) { ::benchmark::DoNotOptimize(i += 300); });
+        i = (i * 31) ^ (i << 5);
+    }
+}
+
+BENCHMARK(CastableSmallSwitch);
+
+}  // namespace
+}  // namespace tint
+
+TINT_INSTANTIATE_TYPEINFO(tint::Base);
+TINT_INSTANTIATE_TYPEINFO(tint::A);
+TINT_INSTANTIATE_TYPEINFO(tint::AA);
+TINT_INSTANTIATE_TYPEINFO(tint::AAA);
+TINT_INSTANTIATE_TYPEINFO(tint::AAB);
+TINT_INSTANTIATE_TYPEINFO(tint::AAC);
+TINT_INSTANTIATE_TYPEINFO(tint::AB);
+TINT_INSTANTIATE_TYPEINFO(tint::ABA);
+TINT_INSTANTIATE_TYPEINFO(tint::ABB);
+TINT_INSTANTIATE_TYPEINFO(tint::ABC);
+TINT_INSTANTIATE_TYPEINFO(tint::AC);
+TINT_INSTANTIATE_TYPEINFO(tint::ACA);
+TINT_INSTANTIATE_TYPEINFO(tint::ACB);
+TINT_INSTANTIATE_TYPEINFO(tint::ACC);
+TINT_INSTANTIATE_TYPEINFO(tint::B);
+TINT_INSTANTIATE_TYPEINFO(tint::BA);
+TINT_INSTANTIATE_TYPEINFO(tint::BAA);
+TINT_INSTANTIATE_TYPEINFO(tint::BAB);
+TINT_INSTANTIATE_TYPEINFO(tint::BAC);
+TINT_INSTANTIATE_TYPEINFO(tint::BB);
+TINT_INSTANTIATE_TYPEINFO(tint::BBA);
+TINT_INSTANTIATE_TYPEINFO(tint::BBB);
+TINT_INSTANTIATE_TYPEINFO(tint::BBC);
+TINT_INSTANTIATE_TYPEINFO(tint::BC);
+TINT_INSTANTIATE_TYPEINFO(tint::BCA);
+TINT_INSTANTIATE_TYPEINFO(tint::BCB);
+TINT_INSTANTIATE_TYPEINFO(tint::BCC);
+TINT_INSTANTIATE_TYPEINFO(tint::C);
+TINT_INSTANTIATE_TYPEINFO(tint::CA);
+TINT_INSTANTIATE_TYPEINFO(tint::CAA);
+TINT_INSTANTIATE_TYPEINFO(tint::CAB);
+TINT_INSTANTIATE_TYPEINFO(tint::CAC);
+TINT_INSTANTIATE_TYPEINFO(tint::CB);
+TINT_INSTANTIATE_TYPEINFO(tint::CBA);
+TINT_INSTANTIATE_TYPEINFO(tint::CBB);
+TINT_INSTANTIATE_TYPEINFO(tint::CBC);
+TINT_INSTANTIATE_TYPEINFO(tint::CC);
+TINT_INSTANTIATE_TYPEINFO(tint::CCA);
+TINT_INSTANTIATE_TYPEINFO(tint::CCB);
+TINT_INSTANTIATE_TYPEINFO(tint::CCC);
diff --git a/src/tint/utils/rtti/switch_test.cc b/src/tint/utils/rtti/switch_test.cc
new file mode 100644
index 0000000..252fb18
--- /dev/null
+++ b/src/tint/utils/rtti/switch_test.cc
@@ -0,0 +1,552 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/rtti/switch.h"
+
+#include <memory>
+#include <string>
+
+#include "gtest/gtest.h"
+
+namespace tint {
+namespace {
+
+struct Animal : public tint::utils::Castable<Animal> {};
+struct Amphibian : public tint::utils::Castable<Amphibian, Animal> {};
+struct Mammal : public tint::utils::Castable<Mammal, Animal> {};
+struct Reptile : public tint::utils::Castable<Reptile, Animal> {};
+struct Frog : public tint::utils::Castable<Frog, Amphibian> {};
+struct Bear : public tint::utils::Castable<Bear, Mammal> {};
+struct Lizard : public tint::utils::Castable<Lizard, Reptile> {};
+struct Gecko : public tint::utils::Castable<Gecko, Lizard> {};
+struct Iguana : public tint::utils::Castable<Iguana, Lizard> {};
+
+TEST(Castable, SwitchNoDefault) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+    {
+        bool frog_matched_amphibian = false;
+        Switch(
+            frog.get(),  //
+            [&](Reptile*) { FAIL() << "frog is not reptile"; },
+            [&](Mammal*) { FAIL() << "frog is not mammal"; },
+            [&](Amphibian* amphibian) {
+                EXPECT_EQ(amphibian, frog.get());
+                frog_matched_amphibian = true;
+            });
+        EXPECT_TRUE(frog_matched_amphibian);
+    }
+    {
+        bool bear_matched_mammal = false;
+        Switch(
+            bear.get(),  //
+            [&](Reptile*) { FAIL() << "bear is not reptile"; },
+            [&](Amphibian*) { FAIL() << "bear is not amphibian"; },
+            [&](Mammal* mammal) {
+                EXPECT_EQ(mammal, bear.get());
+                bear_matched_mammal = true;
+            });
+        EXPECT_TRUE(bear_matched_mammal);
+    }
+    {
+        bool gecko_matched_reptile = false;
+        Switch(
+            gecko.get(),  //
+            [&](Mammal*) { FAIL() << "gecko is not mammal"; },
+            [&](Amphibian*) { FAIL() << "gecko is not amphibian"; },
+            [&](Reptile* reptile) {
+                EXPECT_EQ(reptile, gecko.get());
+                gecko_matched_reptile = true;
+            });
+        EXPECT_TRUE(gecko_matched_reptile);
+    }
+}
+
+TEST(Castable, SwitchWithUnusedDefault) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+    {
+        bool frog_matched_amphibian = false;
+        Switch(
+            frog.get(),  //
+            [&](Reptile*) { FAIL() << "frog is not reptile"; },
+            [&](Mammal*) { FAIL() << "frog is not mammal"; },
+            [&](Amphibian* amphibian) {
+                EXPECT_EQ(amphibian, frog.get());
+                frog_matched_amphibian = true;
+            },
+            [&](Default) { FAIL() << "default should not have been selected"; });
+        EXPECT_TRUE(frog_matched_amphibian);
+    }
+    {
+        bool bear_matched_mammal = false;
+        Switch(
+            bear.get(),  //
+            [&](Reptile*) { FAIL() << "bear is not reptile"; },
+            [&](Amphibian*) { FAIL() << "bear is not amphibian"; },
+            [&](Mammal* mammal) {
+                EXPECT_EQ(mammal, bear.get());
+                bear_matched_mammal = true;
+            },
+            [&](Default) { FAIL() << "default should not have been selected"; });
+        EXPECT_TRUE(bear_matched_mammal);
+    }
+    {
+        bool gecko_matched_reptile = false;
+        Switch(
+            gecko.get(),  //
+            [&](Mammal*) { FAIL() << "gecko is not mammal"; },
+            [&](Amphibian*) { FAIL() << "gecko is not amphibian"; },
+            [&](Reptile* reptile) {
+                EXPECT_EQ(reptile, gecko.get());
+                gecko_matched_reptile = true;
+            },
+            [&](Default) { FAIL() << "default should not have been selected"; });
+        EXPECT_TRUE(gecko_matched_reptile);
+    }
+}
+
+TEST(Castable, SwitchDefault) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+    {
+        bool frog_matched_default = false;
+        Switch(
+            frog.get(),  //
+            [&](Reptile*) { FAIL() << "frog is not reptile"; },
+            [&](Mammal*) { FAIL() << "frog is not mammal"; },
+            [&](Default) { frog_matched_default = true; });
+        EXPECT_TRUE(frog_matched_default);
+    }
+    {
+        bool bear_matched_default = false;
+        Switch(
+            bear.get(),  //
+            [&](Reptile*) { FAIL() << "bear is not reptile"; },
+            [&](Amphibian*) { FAIL() << "bear is not amphibian"; },
+            [&](Default) { bear_matched_default = true; });
+        EXPECT_TRUE(bear_matched_default);
+    }
+    {
+        bool gecko_matched_default = false;
+        Switch(
+            gecko.get(),  //
+            [&](Mammal*) { FAIL() << "gecko is not mammal"; },
+            [&](Amphibian*) { FAIL() << "gecko is not amphibian"; },
+            [&](Default) { gecko_matched_default = true; });
+        EXPECT_TRUE(gecko_matched_default);
+    }
+}
+
+TEST(Castable, SwitchMatchFirst) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    {
+        bool frog_matched_animal = false;
+        Switch(
+            frog.get(),
+            [&](Animal* animal) {
+                EXPECT_EQ(animal, frog.get());
+                frog_matched_animal = true;
+            },
+            [&](Amphibian*) { FAIL() << "animal should have been matched first"; });
+        EXPECT_TRUE(frog_matched_animal);
+    }
+    {
+        bool frog_matched_amphibian = false;
+        Switch(
+            frog.get(),
+            [&](Amphibian* amphibain) {
+                EXPECT_EQ(amphibain, frog.get());
+                frog_matched_amphibian = true;
+            },
+            [&](Animal*) { FAIL() << "amphibian should have been matched first"; });
+        EXPECT_TRUE(frog_matched_amphibian);
+    }
+}
+
+TEST(Castable, SwitchReturnValueWithDefault) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+    {
+        const char* result = Switch(
+            frog.get(),                              //
+            [](Mammal*) { return "mammal"; },        //
+            [](Amphibian*) { return "amphibian"; },  //
+            [](Default) { return "unknown"; });
+        static_assert(std::is_same_v<decltype(result), const char*>);
+        EXPECT_EQ(std::string(result), "amphibian");
+    }
+    {
+        const char* result = Switch(
+            bear.get(),                              //
+            [](Mammal*) { return "mammal"; },        //
+            [](Amphibian*) { return "amphibian"; },  //
+            [](Default) { return "unknown"; });
+        static_assert(std::is_same_v<decltype(result), const char*>);
+        EXPECT_EQ(std::string(result), "mammal");
+    }
+    {
+        const char* result = Switch(
+            gecko.get(),                             //
+            [](Mammal*) { return "mammal"; },        //
+            [](Amphibian*) { return "amphibian"; },  //
+            [](Default) { return "unknown"; });
+        static_assert(std::is_same_v<decltype(result), const char*>);
+        EXPECT_EQ(std::string(result), "unknown");
+    }
+}
+
+TEST(Castable, SwitchReturnValueWithoutDefault) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+    {
+        const char* result = Switch(
+            frog.get(),                        //
+            [](Mammal*) { return "mammal"; },  //
+            [](Amphibian*) { return "amphibian"; });
+        static_assert(std::is_same_v<decltype(result), const char*>);
+        EXPECT_EQ(std::string(result), "amphibian");
+    }
+    {
+        const char* result = Switch(
+            bear.get(),                        //
+            [](Mammal*) { return "mammal"; },  //
+            [](Amphibian*) { return "amphibian"; });
+        static_assert(std::is_same_v<decltype(result), const char*>);
+        EXPECT_EQ(std::string(result), "mammal");
+    }
+    {
+        auto* result = Switch(
+            gecko.get(),                       //
+            [](Mammal*) { return "mammal"; },  //
+            [](Amphibian*) { return "amphibian"; });
+        static_assert(std::is_same_v<decltype(result), const char*>);
+        EXPECT_EQ(result, nullptr);
+    }
+}
+
+TEST(Castable, SwitchInferPODReturnTypeWithDefault) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+    {
+        auto result = Switch(
+            frog.get(),                       //
+            [](Mammal*) { return 1; },        //
+            [](Amphibian*) { return 2.0f; },  //
+            [](Default) { return 3.0; });
+        static_assert(std::is_same_v<decltype(result), double>);
+        EXPECT_EQ(result, 2.0);
+    }
+    {
+        auto result = Switch(
+            bear.get(),                       //
+            [](Mammal*) { return 1.0; },      //
+            [](Amphibian*) { return 2.0f; },  //
+            [](Default) { return 3; });
+        static_assert(std::is_same_v<decltype(result), double>);
+        EXPECT_EQ(result, 1.0);
+    }
+    {
+        auto result = Switch(
+            gecko.get(),                   //
+            [](Mammal*) { return 1.0f; },  //
+            [](Amphibian*) { return 2; },  //
+            [](Default) { return 3.0; });
+        static_assert(std::is_same_v<decltype(result), double>);
+        EXPECT_EQ(result, 3.0);
+    }
+}
+
+TEST(Castable, SwitchInferPODReturnTypeWithoutDefault) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+    {
+        auto result = Switch(
+            frog.get(),                 //
+            [](Mammal*) { return 1; },  //
+            [](Amphibian*) { return 2.0f; });
+        static_assert(std::is_same_v<decltype(result), float>);
+        EXPECT_EQ(result, 2.0f);
+    }
+    {
+        auto result = Switch(
+            bear.get(),                    //
+            [](Mammal*) { return 1.0f; },  //
+            [](Amphibian*) { return 2; });
+        static_assert(std::is_same_v<decltype(result), float>);
+        EXPECT_EQ(result, 1.0f);
+    }
+    {
+        auto result = Switch(
+            gecko.get(),                  //
+            [](Mammal*) { return 1.0; },  //
+            [](Amphibian*) { return 2.0f; });
+        static_assert(std::is_same_v<decltype(result), double>);
+        EXPECT_EQ(result, 0.0);
+    }
+}
+
+TEST(Castable, SwitchInferCastableReturnTypeWithDefault) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+    {
+        auto* result = Switch(
+            frog.get(),                          //
+            [](Mammal* p) { return p; },         //
+            [](Amphibian*) { return nullptr; },  //
+            [](Default) { return nullptr; });
+        static_assert(std::is_same_v<decltype(result), Mammal*>);
+        EXPECT_EQ(result, nullptr);
+    }
+    {
+        auto* result = Switch(
+            bear.get(),                   //
+            [](Mammal* p) { return p; },  //
+            [](Amphibian* p) { return const_cast<const Amphibian*>(p); },
+            [](Default) { return nullptr; });
+        static_assert(std::is_same_v<decltype(result), const Animal*>);
+        EXPECT_EQ(result, bear.get());
+    }
+    {
+        auto* result = Switch(
+            gecko.get(),                     //
+            [](Mammal* p) { return p; },     //
+            [](Amphibian* p) { return p; },  //
+            [](Default) -> utils::CastableBase* { return nullptr; });
+        static_assert(std::is_same_v<decltype(result), utils::CastableBase*>);
+        EXPECT_EQ(result, nullptr);
+    }
+}
+
+TEST(Castable, SwitchInferCastableReturnTypeWithoutDefault) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+    {
+        auto* result = Switch(
+            frog.get(),                   //
+            [](Mammal* p) { return p; },  //
+            [](Amphibian*) { return nullptr; });
+        static_assert(std::is_same_v<decltype(result), Mammal*>);
+        EXPECT_EQ(result, nullptr);
+    }
+    {
+        auto* result = Switch(
+            bear.get(),                                                     //
+            [](Mammal* p) { return p; },                                    //
+            [](Amphibian* p) { return const_cast<const Amphibian*>(p); });  //
+        static_assert(std::is_same_v<decltype(result), const Animal*>);
+        EXPECT_EQ(result, bear.get());
+    }
+    {
+        auto* result = Switch(
+            gecko.get(),                  //
+            [](Mammal* p) { return p; },  //
+            [](Amphibian* p) { return p; });
+        static_assert(std::is_same_v<decltype(result), Animal*>);
+        EXPECT_EQ(result, nullptr);
+    }
+}
+
+TEST(Castable, SwitchExplicitPODReturnTypeWithDefault) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+    {
+        auto result = Switch<double>(
+            frog.get(),                       //
+            [](Mammal*) { return 1; },        //
+            [](Amphibian*) { return 2.0f; },  //
+            [](Default) { return 3.0; });
+        static_assert(std::is_same_v<decltype(result), double>);
+        EXPECT_EQ(result, 2.0f);
+    }
+    {
+        auto result = Switch<double>(
+            bear.get(),                    //
+            [](Mammal*) { return 1; },     //
+            [](Amphibian*) { return 2; },  //
+            [](Default) { return 3; });
+        static_assert(std::is_same_v<decltype(result), double>);
+        EXPECT_EQ(result, 1.0f);
+    }
+    {
+        auto result = Switch<double>(
+            gecko.get(),                      //
+            [](Mammal*) { return 1.0f; },     //
+            [](Amphibian*) { return 2.0f; },  //
+            [](Default) { return 3.0f; });
+        static_assert(std::is_same_v<decltype(result), double>);
+        EXPECT_EQ(result, 3.0f);
+    }
+}
+
+TEST(Castable, SwitchExplicitPODReturnTypeWithoutDefault) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+    {
+        auto result = Switch<double>(
+            frog.get(),                 //
+            [](Mammal*) { return 1; },  //
+            [](Amphibian*) { return 2.0f; });
+        static_assert(std::is_same_v<decltype(result), double>);
+        EXPECT_EQ(result, 2.0f);
+    }
+    {
+        auto result = Switch<double>(
+            bear.get(),                    //
+            [](Mammal*) { return 1.0f; },  //
+            [](Amphibian*) { return 2; });
+        static_assert(std::is_same_v<decltype(result), double>);
+        EXPECT_EQ(result, 1.0f);
+    }
+    {
+        auto result = Switch<double>(
+            gecko.get(),                  //
+            [](Mammal*) { return 1.0; },  //
+            [](Amphibian*) { return 2.0f; });
+        static_assert(std::is_same_v<decltype(result), double>);
+        EXPECT_EQ(result, 0.0);
+    }
+}
+
+TEST(Castable, SwitchExplicitCastableReturnTypeWithDefault) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+    {
+        auto* result = Switch<Animal>(
+            frog.get(),                          //
+            [](Mammal* p) { return p; },         //
+            [](Amphibian*) { return nullptr; },  //
+            [](Default) { return nullptr; });
+        static_assert(std::is_same_v<decltype(result), Animal*>);
+        EXPECT_EQ(result, nullptr);
+    }
+    {
+        auto* result = Switch<utils::CastableBase>(
+            bear.get(),                   //
+            [](Mammal* p) { return p; },  //
+            [](Amphibian* p) { return const_cast<const Amphibian*>(p); },
+            [](Default) { return nullptr; });
+        static_assert(std::is_same_v<decltype(result), const utils::CastableBase*>);
+        EXPECT_EQ(result, bear.get());
+    }
+    {
+        auto* result = Switch<const Animal>(
+            gecko.get(),                     //
+            [](Mammal* p) { return p; },     //
+            [](Amphibian* p) { return p; },  //
+            [](Default) { return nullptr; });
+        static_assert(std::is_same_v<decltype(result), const Animal*>);
+        EXPECT_EQ(result, nullptr);
+    }
+}
+
+TEST(Castable, SwitchExplicitCastableReturnTypeWithoutDefault) {
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    std::unique_ptr<Animal> bear = std::make_unique<Bear>();
+    std::unique_ptr<Animal> gecko = std::make_unique<Gecko>();
+    {
+        auto* result = Switch<Animal>(
+            frog.get(),                   //
+            [](Mammal* p) { return p; },  //
+            [](Amphibian*) { return nullptr; });
+        static_assert(std::is_same_v<decltype(result), Animal*>);
+        EXPECT_EQ(result, nullptr);
+    }
+    {
+        auto* result = Switch<utils::CastableBase>(
+            bear.get(),                                                     //
+            [](Mammal* p) { return p; },                                    //
+            [](Amphibian* p) { return const_cast<const Amphibian*>(p); });  //
+        static_assert(std::is_same_v<decltype(result), const utils::CastableBase*>);
+        EXPECT_EQ(result, bear.get());
+    }
+    {
+        auto* result = Switch<const Animal*>(
+            gecko.get(),                  //
+            [](Mammal* p) { return p; },  //
+            [](Amphibian* p) { return p; });
+        static_assert(std::is_same_v<decltype(result), const Animal*>);
+        EXPECT_EQ(result, nullptr);
+    }
+}
+
+TEST(Castable, SwitchNull) {
+    Animal* null = nullptr;
+    Switch(
+        null,  //
+        [&](Amphibian*) { FAIL() << "should not be called"; },
+        [&](Animal*) { FAIL() << "should not be called"; });
+}
+
+TEST(Castable, SwitchNullNoDefault) {
+    Animal* null = nullptr;
+    bool default_called = false;
+    Switch(
+        null,  //
+        [&](Amphibian*) { FAIL() << "should not be called"; },
+        [&](Animal*) { FAIL() << "should not be called"; },
+        [&](Default) { default_called = true; });
+    EXPECT_TRUE(default_called);
+}
+
+TEST(Castable, SwitchReturnNoDefaultInitializer) {
+    struct Object {
+        explicit Object(int v) : value(v) {}
+        int value;
+    };
+
+    std::unique_ptr<Animal> frog = std::make_unique<Frog>();
+    {
+        auto result = Switch(
+            frog.get(),                            //
+            [](Mammal*) { return Object(1); },     //
+            [](Amphibian*) { return Object(2); },  //
+            [](Default) { return Object(3); });
+        static_assert(std::is_same_v<decltype(result), Object>);
+        EXPECT_EQ(result.value, 2);
+    }
+    {
+        auto result = Switch(
+            frog.get(),                         //
+            [](Mammal*) { return Object(1); },  //
+            [](Default) { return Object(3); });
+        static_assert(std::is_same_v<decltype(result), Object>);
+        EXPECT_EQ(result.value, 3);
+    }
+}
+
+}  // namespace
+
+TINT_INSTANTIATE_TYPEINFO(Animal);
+TINT_INSTANTIATE_TYPEINFO(Amphibian);
+TINT_INSTANTIATE_TYPEINFO(Mammal);
+TINT_INSTANTIATE_TYPEINFO(Reptile);
+TINT_INSTANTIATE_TYPEINFO(Frog);
+TINT_INSTANTIATE_TYPEINFO(Bear);
+TINT_INSTANTIATE_TYPEINFO(Lizard);
+TINT_INSTANTIATE_TYPEINFO(Gecko);
+
+}  // namespace tint
diff --git a/src/tint/utils/templates/enums.tmpl.inc b/src/tint/utils/templates/enums.tmpl.inc
new file mode 100644
index 0000000..adc06e0
--- /dev/null
+++ b/src/tint/utils/templates/enums.tmpl.inc
@@ -0,0 +1,197 @@
+{{- (Globals).Put "enum_override_names" Map -}}
+
+{{- /* ------------------------------------------------------------------ */ -}}
+{{-                      define "OverrideEnumName"                           -}}
+{{- /* Overrides the C++ name for a sem.Enum.                             */ -}}
+{{- /* Arguments:                                                         */ -}}
+{{- /* * 'Enum' the sem::Enum                                             */ -}}
+{{- /* * 'Name' the new C++ name for enum                                 */ -}}
+{{- /* ------------------------------------------------------------------ */ -}}
+{{-   $enum_override_names := (Globals).Get "enum_override_names" -}}
+{{-   $enum_override_names.Put $.Enum $.Name -}}
+{{- end -}}
+
+
+{{- /* ------------------------------------------------------------------ */ -}}
+{{-                         define "EnumName"                                -}}
+{{- /* Prints the C++ name for the given sem.Enum argument.               */ -}}
+{{- /* ------------------------------------------------------------------ */ -}}
+{{-   $enum_override_names := (Globals).Get "enum_override_names" -}}
+{{-   $override := $enum_override_names.Get $ -}}
+{{    if $override -}}
+{{        $override  -}}
+{{    else -}}
+{{        PascalCase $.Name}}
+{{-   end -}}
+{{- end -}}
+
+
+{{- /* ------------------------------------------------------------------ */ -}}
+{{-                         define "EnumCase"                                -}}
+{{- /* Prints the 'Enum::kEntry' name for the provided sem.EnumEntry      */ -}}
+{{- /* argument.                                                          */ -}}
+{{- /* ------------------------------------------------------------------ */ -}}
+{{-   Eval "EnumName" $.Enum}}::k{{PascalCase $.Name}}
+{{- end -}}
+
+
+{{- /* ------------------------------------------------------------------ */ -}}
+{{-                         define "DeclareEnum"                             -}}
+{{- /* Declares the 'enum class' for the provided sem.Enum argument.      */ -}}
+{{- /* ------------------------------------------------------------------ */ -}}
+{{- $enum := Eval "EnumName" $ -}}
+enum class {{$enum}} {
+    kUndefined,
+{{-   range $entry := $.Entries }}
+    k{{PascalCase $entry.Name}},{{if $entry.IsInternal}}  // Tint-internal enum entry - not parsed{{end}}
+{{-   end }}
+};
+
+/// @param out the stream to write to
+/// @param value the {{$enum}}
+/// @returns `out` so calls can be chained
+utils::StringStream& operator<<(utils::StringStream& out, {{$enum}} value);
+
+/// Parse{{$enum}} parses a {{$enum}} from a string.
+/// @param str the string to parse
+/// @returns the parsed enum, or {{$enum}}::kUndefined if the string could not be parsed.
+{{$enum}} Parse{{$enum}}(std::string_view str);
+
+constexpr const char* k{{$enum}}Strings[] = {
+{{-   range $entry := $.Entries }}
+{{-     if not $entry.IsInternal}}
+    "{{$entry.Name}}",
+{{-     end }}
+{{-   end }}
+};
+
+{{- end -}}
+
+
+{{- /* ------------------------------------------------------------------ */ -}}
+{{-                          define "ParseEnum"                              -}}
+{{- /* Implements the 'ParseEnum' function for the provided sem.Enum      */ -}}
+{{- /* argument.                                                          */ -}}
+{{- /* ------------------------------------------------------------------ */ -}}
+{{- $enum := Eval "EnumName" $ -}}
+/// Parse{{$enum}} parses a {{$enum}} from a string.
+/// @param str the string to parse
+/// @returns the parsed enum, or {{$enum}}::kUndefined if the string could not be parsed.
+{{$enum}} Parse{{$enum}}(std::string_view str) {
+{{-   range $entry := $.PublicEntries }}
+    if (str == "{{$entry.Name}}") {
+        return {{template "EnumCase" $entry}};
+    }
+{{-   end }}
+    return {{$enum}}::kUndefined;
+}
+{{- end -}}
+
+
+{{- /* ------------------------------------------------------------------ */ -}}
+{{-                         define "EnumOStream"                             -}}
+{{- /* Implements the stream 'operator<<()' function to print the         */ -}}
+{{- /* provided sem.Enum.                                                 */ -}}
+{{- /* ------------------------------------------------------------------ */ -}}
+{{- $enum := Eval "EnumName" $ -}}
+    utils::StringStream& operator<<(utils::StringStream& out, {{$enum}} value) {
+    switch (value) {
+        case {{$enum}}::kUndefined:
+            return out << "undefined";
+{{-   range $entry := $.Entries }}
+        case {{template "EnumCase" $entry}}:
+            return out << "{{$entry.Name}}";
+{{-   end }}
+    }
+    return out << "<unknown>";
+}
+{{- end -}}
+
+
+{{- /* ------------------------------------------------------------------ */ -}}
+{{-                        define "TestParsePrintEnum"                       -}}
+{{- /* Implements unit tests for parsing and printing the provided        */ -}}
+{{- /* sem.Enum argument.                                                 */ -}}
+{{- /* ------------------------------------------------------------------ */ -}}
+{{- $enum := Eval "EnumName" $ -}}
+namespace parse_print_tests {
+
+struct Case {
+    const char* string;
+    {{$enum}} value;
+};
+
+inline std::ostream& operator<<(std::ostream& out, Case c) {
+    return out << "'" << std::string(c.string) << "'";
+}
+
+static constexpr Case kValidCases[] = {
+{{-   range $entry := $.PublicEntries }}
+    {"{{$entry.Name}}", {{template "EnumCase" $entry}}},
+{{-   end }}
+};
+
+static constexpr Case kInvalidCases[] = {
+{{-   $exclude := $.NameSet -}}
+{{-   range $entry := $.PublicEntries }}
+    {"{{Scramble $entry.Name $exclude}}", {{$enum}}::kUndefined},
+    {"{{Scramble $entry.Name $exclude}}", {{$enum}}::kUndefined},
+    {"{{Scramble $entry.Name $exclude}}", {{$enum}}::kUndefined},
+{{-   end }}
+};
+
+using {{$enum}}ParseTest = testing::TestWithParam<Case>;
+
+TEST_P({{$enum}}ParseTest, Parse) {
+    const char* string = GetParam().string;
+    {{$enum}} expect = GetParam().value;
+    EXPECT_EQ(expect, Parse{{$enum}}(string));
+}
+
+INSTANTIATE_TEST_SUITE_P(ValidCases, {{$enum}}ParseTest, testing::ValuesIn(kValidCases));
+INSTANTIATE_TEST_SUITE_P(InvalidCases, {{$enum}}ParseTest, testing::ValuesIn(kInvalidCases));
+
+using {{$enum}}PrintTest = testing::TestWithParam<Case>;
+
+TEST_P({{$enum}}PrintTest, Print) {
+    {{$enum}} value = GetParam().value;
+    const char* expect = GetParam().string;
+    EXPECT_EQ(expect, utils::ToString(value));
+}
+
+INSTANTIATE_TEST_SUITE_P(ValidCases, {{$enum}}PrintTest, testing::ValuesIn(kValidCases));
+
+}  // namespace parse_print_tests
+
+{{- end -}}
+
+
+{{- /* ------------------------------------------------------------------ */ -}}
+{{-                     define "BenchmarkParseEnum"                          -}}
+{{- /* Implements a micro-benchmark for parsing the provided sem.Enum     */ -}}
+{{- /* argument.                                                          */ -}}
+{{- /* ------------------------------------------------------------------ */ -}}
+{{- $enum := Eval "EnumName" $ -}}
+void {{$enum}}Parser(::benchmark::State& state) {
+    const char* kStrings[] = {
+{{-   $exclude := $.NameSet -}}
+{{-   range $entry := $.PublicEntries }}
+        "{{Scramble $entry.Name $exclude}}",
+        "{{Scramble $entry.Name $exclude}}",
+        "{{Scramble $entry.Name $exclude}}",
+        "{{$entry.Name}}",
+        "{{Scramble $entry.Name $exclude}}",
+        "{{Scramble $entry.Name $exclude}}",
+        "{{Scramble $entry.Name $exclude}}",
+{{-   end }}
+    };
+    for (auto _ : state) {
+        for (auto* str : kStrings) {
+            auto result = Parse{{$enum}}(str);
+            benchmark::DoNotOptimize(result);
+        }
+    }
+} // NOLINT(readability/fn_size)
+
+BENCHMARK({{$enum}}Parser);
+{{- end -}}
diff --git a/src/tint/utils/text/float_to_string.cc b/src/tint/utils/text/float_to_string.cc
new file mode 100644
index 0000000..9f9878c
--- /dev/null
+++ b/src/tint/utils/text/float_to_string.cc
@@ -0,0 +1,177 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/text/float_to_string.h"
+
+#include <cmath>
+#include <cstring>
+#include <functional>
+#include <iomanip>
+#include <limits>
+
+#include "src/tint/utils/debug/debug.h"
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint::writer {
+
+namespace {
+
+template <typename T>
+struct Traits;
+
+template <>
+struct Traits<float> {
+    using uint_t = uint32_t;
+    static constexpr int kExponentBias = 127;
+    static constexpr uint_t kExponentMask = 0x7f800000;
+    static constexpr uint_t kMantissaMask = 0x007fffff;
+    static constexpr uint_t kSignMask = 0x80000000;
+    static constexpr int kMantissaBits = 23;
+};
+
+template <>
+struct Traits<double> {
+    using uint_t = uint64_t;
+    static constexpr int kExponentBias = 1023;
+    static constexpr uint_t kExponentMask = 0x7ff0000000000000;
+    static constexpr uint_t kMantissaMask = 0x000fffffffffffff;
+    static constexpr uint_t kSignMask = 0x8000000000000000;
+    static constexpr int kMantissaBits = 52;
+};
+
+template <typename F>
+std::string ToString(F f) {
+    utils::StringStream s;
+    s << f;
+    return s.str();
+}
+
+template <typename F>
+std::string ToBitPreservingString(F f) {
+    using T = Traits<F>;
+    using uint_t = typename T::uint_t;
+
+    // For the NaN case, avoid handling the number as a floating point value.
+    // Some machines will modify the top bit in the mantissa of a NaN.
+
+    std::stringstream ss;
+
+    typename T::uint_t float_bits = 0u;
+    static_assert(sizeof(float_bits) == sizeof(f));
+    std::memcpy(&float_bits, &f, sizeof(float_bits));
+
+    // Handle the sign.
+    if (float_bits & T::kSignMask) {
+        // If `f` is -0.0 print -0.0.
+        ss << '-';
+        // Strip sign bit.
+        float_bits = float_bits & (~T::kSignMask);
+    }
+
+    switch (std::fpclassify(f)) {
+        case FP_ZERO:
+        case FP_NORMAL:
+            std::memcpy(&f, &float_bits, sizeof(float_bits));
+            ss << ToString(f);
+            break;
+
+        default: {
+            // Infinity, NaN, and Subnormal
+            // TODO(dneto): It's unclear how Infinity and NaN should be handled.
+            // See https://github.com/gpuweb/gpuweb/issues/1769
+
+            // std::hexfloat prints 'nan' and 'inf' instead of an explicit representation like we
+            // want. Split it out manually.
+            int mantissa_nibbles = (T::kMantissaBits + 3) / 4;
+
+            const int biased_exponent =
+                static_cast<int>((float_bits & T::kExponentMask) >> T::kMantissaBits);
+            int exponent = biased_exponent - T::kExponentBias;
+            uint_t mantissa = float_bits & T::kMantissaMask;
+
+            ss << "0x";
+
+            if (exponent == T::kExponentBias + 1) {
+                if (mantissa == 0) {
+                    //  Infinity case.
+                    ss << "1p+" << exponent;
+                } else {
+                    // NaN case.
+                    // Emit the mantissa bits as if they are left-justified after the binary point.
+                    // This is what SPIRV-Tools hex float emitter does, and it's a justifiable
+                    // choice independent of the bit width of the mantissa.
+                    mantissa <<= (4 - (T::kMantissaBits % 4));
+                    // Remove trailing zeroes, for tidiness.
+                    while (0 == (0xf & mantissa)) {
+                        mantissa >>= 4;
+                        mantissa_nibbles--;
+                    }
+                    ss << "1." << std::hex << std::setfill('0') << std::setw(mantissa_nibbles)
+                       << mantissa << "p+" << std::dec << exponent;
+                }
+            } else {
+                // Subnormal, and not zero.
+                TINT_ASSERT(Writer, mantissa != 0);
+                const auto kTopBit = static_cast<uint_t>(1u) << T::kMantissaBits;
+
+                // Shift left until we get 1.x
+                while (0 == (kTopBit & mantissa)) {
+                    mantissa <<= 1;
+                    exponent--;
+                }
+                // Emit the leading 1, and remove it from the mantissa.
+                ss << "1";
+                mantissa = mantissa ^ kTopBit;
+                exponent++;
+
+                // Left-justify mantissa to whole nibble.
+                mantissa <<= (4 - (T::kMantissaBits % 4));
+
+                // Emit the fractional part.
+                if (mantissa) {
+                    // Remove trailing zeroes, for tidiness
+                    while (0 == (0xf & mantissa)) {
+                        mantissa >>= 4;
+                        mantissa_nibbles--;
+                    }
+                    ss << "." << std::hex << std::setfill('0') << std::setw(mantissa_nibbles)
+                       << mantissa;
+                }
+                // Emit the exponent
+                ss << "p" << std::showpos << std::dec << exponent;
+            }
+        }
+    }
+    return ss.str();
+}
+
+}  // namespace
+
+std::string FloatToString(float f) {
+    return ToString(f);
+}
+
+std::string FloatToBitPreservingString(float f) {
+    return ToBitPreservingString(f);
+}
+
+std::string DoubleToString(double f) {
+    return ToString(f);
+}
+
+std::string DoubleToBitPreservingString(double f) {
+    return ToBitPreservingString(f);
+}
+
+}  // namespace tint::writer
diff --git a/src/tint/utils/text/float_to_string.h b/src/tint/utils/text/float_to_string.h
new file mode 100644
index 0000000..dd4a2a3
--- /dev/null
+++ b/src/tint/utils/text/float_to_string.h
@@ -0,0 +1,50 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_TEXT_FLOAT_TO_STRING_H_
+#define SRC_TINT_UTILS_TEXT_FLOAT_TO_STRING_H_
+
+#include <string>
+
+namespace tint::writer {
+
+/// Converts the float `f` to a string using fixed-point notation (not
+/// scientific). The float will be printed with the full precision required to
+/// describe the float. All trailing `0`s will be omitted after the last
+/// non-zero fractional number, unless the fractional is zero, in which case the
+/// number will end with `.0`.
+/// @return the float f formatted to a string
+std::string FloatToString(float f);
+
+/// Converts the double `f` to a string using fixed-point notation (not
+/// scientific). The double will be printed with the full precision required to
+/// describe the double. All trailing `0`s will be omitted after the last
+/// non-zero fractional number, unless the fractional is zero, in which case the
+/// number will end with `.0`.
+/// @return the double f formatted to a string
+std::string DoubleToString(double f);
+
+/// Converts the float `f` to a string, using hex float notation for infinities,
+/// NaNs, or subnormal numbers. Otherwise behaves as FloatToString.
+/// @return the float f formatted to a string
+std::string FloatToBitPreservingString(float f);
+
+/// Converts the double `f` to a string, using hex double notation for infinities,
+/// NaNs, or subnormal numbers. Otherwise behaves as FloatToString.
+/// @return the double f formatted to a string
+std::string DoubleToBitPreservingString(double f);
+
+}  // namespace tint::writer
+
+#endif  // SRC_TINT_UTILS_TEXT_FLOAT_TO_STRING_H_
diff --git a/src/tint/utils/text/float_to_string_test.cc b/src/tint/utils/text/float_to_string_test.cc
new file mode 100644
index 0000000..20beb16
--- /dev/null
+++ b/src/tint/utils/text/float_to_string_test.cc
@@ -0,0 +1,335 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/text/float_to_string.h"
+
+#include <math.h>
+#include <cstring>
+#include <limits>
+
+#include "gtest/gtest.h"
+#include "src/tint/utils/memory/bitcast.h"
+
+namespace tint::writer {
+namespace {
+
+////////////////////////////////////////////////////////////////////////////////
+// FloatToString                                                              //
+////////////////////////////////////////////////////////////////////////////////
+
+TEST(FloatToStringTest, Zero) {
+    EXPECT_EQ(FloatToString(0.0f), "0.0");
+}
+
+TEST(FloatToStringTest, One) {
+    EXPECT_EQ(FloatToString(1.0f), "1.0");
+}
+
+TEST(FloatToStringTest, MinusOne) {
+    EXPECT_EQ(FloatToString(-1.0f), "-1.0");
+}
+
+TEST(FloatToStringTest, Billion) {
+    EXPECT_EQ(FloatToString(1e9f), "1000000000.0");
+}
+
+TEST(FloatToStringTest, Small) {
+    EXPECT_NE(FloatToString(std::numeric_limits<float>::epsilon()), "0.0");
+}
+
+TEST(FloatToStringTest, Highest) {
+    const auto highest = std::numeric_limits<float>::max();
+    const auto expected_highest = 340282346638528859811704183484516925440.0f;
+    if (highest < expected_highest || highest > expected_highest) {
+        GTEST_SKIP() << "std::numeric_limits<float>::max() is not as expected for "
+                        "this target";
+    }
+    EXPECT_EQ(FloatToString(std::numeric_limits<float>::max()),
+              "340282346638528859811704183484516925440.0");
+}
+
+TEST(FloatToStringTest, Lowest) {
+    // Some compilers complain if you test floating point numbers for equality.
+    // So say it via two inequalities.
+    const auto lowest = std::numeric_limits<float>::lowest();
+    const auto expected_lowest = -340282346638528859811704183484516925440.0f;
+    if (lowest < expected_lowest || lowest > expected_lowest) {
+        GTEST_SKIP() << "std::numeric_limits<float>::lowest() is not as expected for "
+                        "this target";
+    }
+    EXPECT_EQ(FloatToString(std::numeric_limits<float>::lowest()),
+              "-340282346638528859811704183484516925440.0");
+}
+
+TEST(FloatToStringTest, Precision) {
+    EXPECT_EQ(FloatToString(1e-8f), "0.00000000999999993923");
+    EXPECT_EQ(FloatToString(1e-9f), "0.00000000099999997172");
+    EXPECT_EQ(FloatToString(1e-10f), "0.00000000010000000134");
+    EXPECT_EQ(FloatToString(1e-20f), "0.00000000000000000001");
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// FloatToBitPreservingString                                                 //
+////////////////////////////////////////////////////////////////////////////////
+
+TEST(FloatToBitPreservingStringTest, Zero) {
+    EXPECT_EQ(FloatToBitPreservingString(0.0f), "0.0");
+}
+
+TEST(FloatToBitPreservingStringTest, NegativeZero) {
+    EXPECT_EQ(FloatToBitPreservingString(-0.0f), "-0.0");
+}
+
+TEST(FloatToBitPreservingStringTest, One) {
+    EXPECT_EQ(FloatToBitPreservingString(1.0f), "1.0");
+}
+
+TEST(FloatToBitPreservingStringTest, MinusOne) {
+    EXPECT_EQ(FloatToBitPreservingString(-1.0f), "-1.0");
+}
+
+TEST(FloatToBitPreservingStringTest, Billion) {
+    EXPECT_EQ(FloatToBitPreservingString(1e9f), "1000000000.0");
+}
+
+TEST(FloatToBitPreservingStringTest, Small) {
+    EXPECT_NE(FloatToBitPreservingString(std::numeric_limits<float>::epsilon()), "0.0");
+}
+
+TEST(FloatToBitPreservingStringTest, Highest) {
+    const auto highest = std::numeric_limits<float>::max();
+    const auto expected_highest = 340282346638528859811704183484516925440.0f;
+    if (highest < expected_highest || highest > expected_highest) {
+        GTEST_SKIP() << "std::numeric_limits<float>::max() is not as expected for "
+                        "this target";
+    }
+    EXPECT_EQ(FloatToBitPreservingString(std::numeric_limits<float>::max()),
+              "340282346638528859811704183484516925440.0");
+}
+
+TEST(FloatToBitPreservingStringTest, Lowest) {
+    // Some compilers complain if you test floating point numbers for equality.
+    // So say it via two inequalities.
+    const auto lowest = std::numeric_limits<float>::lowest();
+    const auto expected_lowest = -340282346638528859811704183484516925440.0f;
+    if (lowest < expected_lowest || lowest > expected_lowest) {
+        GTEST_SKIP() << "std::numeric_limits<float>::lowest() is not as expected for "
+                        "this target";
+    }
+    EXPECT_EQ(FloatToBitPreservingString(std::numeric_limits<float>::lowest()),
+              "-340282346638528859811704183484516925440.0");
+}
+
+TEST(FloatToBitPreservingStringTest, SmallestDenormal) {
+    EXPECT_EQ(FloatToBitPreservingString(0x1p-149f), "0x1p-149");
+    EXPECT_EQ(FloatToBitPreservingString(-0x1p-149f), "-0x1p-149");
+}
+
+TEST(FloatToBitPreservingStringTest, BiggerDenormal) {
+    EXPECT_EQ(FloatToBitPreservingString(0x1p-148f), "0x1p-148");
+    EXPECT_EQ(FloatToBitPreservingString(-0x1p-148f), "-0x1p-148");
+}
+
+TEST(FloatToBitPreservingStringTest, LargestDenormal) {
+    static_assert(0x0.fffffep-126f == 0x1.fffffcp-127f);
+    EXPECT_EQ(FloatToBitPreservingString(0x0.fffffep-126f), "0x1.fffffcp-127");
+}
+
+TEST(FloatToBitPreservingStringTest, Subnormal_cafebe) {
+    EXPECT_EQ(FloatToBitPreservingString(0x1.2bfaf8p-127f), "0x1.2bfaf8p-127");
+    EXPECT_EQ(FloatToBitPreservingString(-0x1.2bfaf8p-127f), "-0x1.2bfaf8p-127");
+}
+
+TEST(FloatToBitPreservingStringTest, Subnormal_aaaaa) {
+    EXPECT_EQ(FloatToBitPreservingString(0x1.55554p-130f), "0x1.55554p-130");
+    EXPECT_EQ(FloatToBitPreservingString(-0x1.55554p-130f), "-0x1.55554p-130");
+}
+
+TEST(FloatToBitPreservingStringTest, Infinity) {
+    EXPECT_EQ(FloatToBitPreservingString(INFINITY), "0x1p+128");
+    EXPECT_EQ(FloatToBitPreservingString(-INFINITY), "-0x1p+128");
+}
+
+TEST(FloatToBitPreservingStringTest, NaN) {
+    // TODO(crbug.com/tint/1714): On x86, this bitcast will set bit 22 (the highest mantissa bit) to
+    // 1, regardless of the bit value in the integer. This is likely due to IEEE 754's
+    // recommendation that that the highest mantissa bit differentiates quiet NaNs from signalling
+    // NaNs. On x86, float return values usually go via the FPU which can transform the signalling
+    // NaN bit (0) to quiet NaN (1). As NaN floating point numbers can be silently modified by the
+    // architecture, and the signalling bit is architecture defined, this test may fail on other
+    // architectures.
+    auto nan = utils::Bitcast<float>(0x7fc0beef);
+    EXPECT_EQ(FloatToBitPreservingString(nan), "0x1.817ddep+128");
+    EXPECT_EQ(FloatToBitPreservingString(-nan), "-0x1.817ddep+128");
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// DoubleToString                                                              //
+////////////////////////////////////////////////////////////////////////////////
+
+TEST(DoubleToStringTest, Zero) {
+    EXPECT_EQ(DoubleToString(0.000000000), "0.0");
+}
+
+TEST(DoubleToStringTest, One) {
+    EXPECT_EQ(DoubleToString(1.000000000), "1.0");
+}
+
+TEST(DoubleToStringTest, MinusOne) {
+    EXPECT_EQ(DoubleToString(-1.000000000), "-1.0");
+}
+
+TEST(DoubleToStringTest, Billion) {
+    EXPECT_EQ(DoubleToString(1e9), "1000000000.0");
+}
+
+TEST(DoubleToStringTest, Small) {
+    EXPECT_NE(DoubleToString(std::numeric_limits<double>::epsilon()), "0.0");
+}
+
+TEST(DoubleToStringTest, Highest) {
+    const auto highest = std::numeric_limits<double>::max();
+    const auto expected_highest = 1.797693134862315708e+308;
+    if (highest < expected_highest || highest > expected_highest) {
+        GTEST_SKIP() << "std::numeric_limits<double>::max() is not as expected for "
+                        "this target";
+    }
+    EXPECT_EQ(DoubleToString(std::numeric_limits<double>::max()),
+              "179769313486231570814527423731704356798070567525844996598917476803157260780028538760"
+              "589558632766878171540458953514382464234321326889464182768467546703537516986049910576"
+              "551282076245490090389328944075868508455133942304583236903222948165808559332123348274"
+              "797826204144723168738177180919299881250404026184124858368.0");
+}
+
+TEST(DoubleToStringTest, Lowest) {
+    // Some compilers complain if you test floating point numbers for equality.
+    // So say it via two inequalities.
+    const auto lowest = std::numeric_limits<double>::lowest();
+    const auto expected_lowest = -1.797693134862315708e+308;
+    if (lowest < expected_lowest || lowest > expected_lowest) {
+        GTEST_SKIP() << "std::numeric_limits<double>::lowest() is not as expected for "
+                        "this target";
+    }
+    EXPECT_EQ(DoubleToString(std::numeric_limits<double>::lowest()),
+              "-17976931348623157081452742373170435679807056752584499659891747680315726078002853876"
+              "058955863276687817154045895351438246423432132688946418276846754670353751698604991057"
+              "655128207624549009038932894407586850845513394230458323690322294816580855933212334827"
+              "4797826204144723168738177180919299881250404026184124858368.0");
+}
+
+TEST(DoubleToStringTest, Precision) {
+    EXPECT_EQ(DoubleToString(1e-8), "0.00000001");
+    EXPECT_EQ(DoubleToString(1e-9), "0.000000001");
+    EXPECT_EQ(DoubleToString(1e-10), "0.0000000001");
+    EXPECT_EQ(DoubleToString(1e-15), "0.000000000000001");
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// DoubleToBitPreservingString                                                 //
+////////////////////////////////////////////////////////////////////////////////
+
+TEST(DoubleToBitPreservingStringTest, Zero) {
+    EXPECT_EQ(DoubleToBitPreservingString(0.0), "0.0");
+}
+
+TEST(DoubleToBitPreservingStringTest, NegativeZero) {
+    EXPECT_EQ(DoubleToBitPreservingString(-0.0), "-0.0");
+}
+
+TEST(DoubleToBitPreservingStringTest, One) {
+    EXPECT_EQ(DoubleToBitPreservingString(1.0), "1.0");
+}
+
+TEST(DoubleToBitPreservingStringTest, MinusOne) {
+    EXPECT_EQ(DoubleToBitPreservingString(-1.0), "-1.0");
+}
+
+TEST(DoubleToBitPreservingStringTest, Billion) {
+    EXPECT_EQ(DoubleToBitPreservingString(1e9), "1000000000.0");
+}
+
+TEST(DoubleToBitPreservingStringTest, Small) {
+    EXPECT_NE(DoubleToBitPreservingString(std::numeric_limits<double>::epsilon()), "0.0");
+}
+
+TEST(DoubleToBitPreservingStringTest, Highest) {
+    const auto highest = std::numeric_limits<double>::max();
+    const auto expected_highest = 1.797693134862315708e+308;
+    if (highest < expected_highest || highest > expected_highest) {
+        GTEST_SKIP() << "std::numeric_limits<float>::max() is not as expected for "
+                        "this target";
+    }
+    EXPECT_EQ(DoubleToBitPreservingString(std::numeric_limits<double>::max()),
+              "179769313486231570814527423731704356798070567525844996598917476803157260780028538760"
+              "589558632766878171540458953514382464234321326889464182768467546703537516986049910576"
+              "551282076245490090389328944075868508455133942304583236903222948165808559332123348274"
+              "797826204144723168738177180919299881250404026184124858368.0");
+}
+
+TEST(DoubleToBitPreservingStringTest, Lowest) {
+    // Some compilers complain if you test floating point numbers for equality.
+    // So say it via two inequalities.
+    const auto lowest = std::numeric_limits<double>::lowest();
+    const auto expected_lowest = -1.797693134862315708e+308;
+    if (lowest < expected_lowest || lowest > expected_lowest) {
+        GTEST_SKIP() << "std::numeric_limits<float>::lowest() is not as expected for "
+                        "this target";
+    }
+    EXPECT_EQ(DoubleToBitPreservingString(std::numeric_limits<double>::lowest()),
+              "-17976931348623157081452742373170435679807056752584499659891747680315726078002853876"
+              "058955863276687817154045895351438246423432132688946418276846754670353751698604991057"
+              "655128207624549009038932894407586850845513394230458323690322294816580855933212334827"
+              "4797826204144723168738177180919299881250404026184124858368.0");
+}
+
+TEST(DoubleToBitPreservingStringTest, SmallestDenormal) {
+    EXPECT_EQ(DoubleToBitPreservingString(0x1p-1074), "0x1p-1074");
+    EXPECT_EQ(DoubleToBitPreservingString(-0x1p-1074), "-0x1p-1074");
+}
+
+TEST(DoubleToBitPreservingStringTest, BiggerDenormal) {
+    EXPECT_EQ(DoubleToBitPreservingString(0x1p-1073), "0x1p-1073");
+    EXPECT_EQ(DoubleToBitPreservingString(-0x1p-1073), "-0x1p-1073");
+}
+
+TEST(DoubleToBitPreservingStringTest, LargestDenormal) {
+    static_assert(0x0.fffffffffffffp-1022 == 0x1.ffffffffffffep-1023);
+    EXPECT_EQ(DoubleToBitPreservingString(0x0.fffffffffffffp-1022), "0x1.ffffffffffffep-1023");
+    EXPECT_EQ(DoubleToBitPreservingString(-0x0.fffffffffffffp-1022), "-0x1.ffffffffffffep-1023");
+}
+
+TEST(DoubleToBitPreservingStringTest, Subnormal_cafef00dbeef) {
+    EXPECT_EQ(DoubleToBitPreservingString(0x1.cafef00dbeefp-1023), "0x1.cafef00dbeefp-1023");
+    EXPECT_EQ(DoubleToBitPreservingString(-0x1.cafef00dbeefp-1023), "-0x1.cafef00dbeefp-1023");
+}
+
+TEST(DoubleToBitPreservingStringTest, Subnormal_aaaaaaaaaaaaap) {
+    static_assert(0x0.aaaaaaaaaaaaap-1023 == 0x1.5555555555554p-1024);
+    EXPECT_EQ(DoubleToBitPreservingString(0x0.aaaaaaaaaaaaap-1023), "0x1.5555555555554p-1024");
+    EXPECT_EQ(DoubleToBitPreservingString(-0x0.aaaaaaaaaaaaap-1023), "-0x1.5555555555554p-1024");
+}
+
+TEST(DoubleToBitPreservingStringTest, Infinity) {
+    EXPECT_EQ(DoubleToBitPreservingString(static_cast<double>(INFINITY)), "0x1p+1024");
+    EXPECT_EQ(DoubleToBitPreservingString(static_cast<double>(-INFINITY)), "-0x1p+1024");
+}
+
+TEST(DoubleToBitPreservingStringTest, NaN) {
+    auto nan = utils::Bitcast<double>(0x7ff8cafef00dbeefull);
+    EXPECT_EQ(DoubleToBitPreservingString(static_cast<double>(nan)), "0x1.8cafef00dbeefp+1024");
+    EXPECT_EQ(DoubleToBitPreservingString(static_cast<double>(-nan)), "-0x1.8cafef00dbeefp+1024");
+}
+
+}  // namespace
+}  // namespace tint::writer
diff --git a/src/tint/utils/text/parse_num.cc b/src/tint/utils/text/parse_num.cc
new file mode 100644
index 0000000..c2a0eea
--- /dev/null
+++ b/src/tint/utils/text/parse_num.cc
@@ -0,0 +1,98 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/text/parse_num.h"
+
+#include <charconv>
+
+#include "absl/strings/charconv.h"
+
+namespace tint::utils {
+
+namespace {
+
+template <typename T>
+Result<T, ParseNumberError> Parse(std::string_view number) {
+    T val = 0;
+    if constexpr (std::is_floating_point_v<T>) {
+        auto result = absl::from_chars(number.data(), number.data() + number.size(), val);
+        if (result.ec == std::errc::result_out_of_range) {
+            return ParseNumberError::kResultOutOfRange;
+        }
+        if (result.ec != std::errc() || result.ptr != number.data() + number.size()) {
+            return ParseNumberError::kUnparsable;
+        }
+    } else {
+        auto result = std::from_chars(number.data(), number.data() + number.size(), val);
+        if (result.ec == std::errc::result_out_of_range) {
+            return ParseNumberError::kResultOutOfRange;
+        }
+        if (result.ec != std::errc() || result.ptr != number.data() + number.size()) {
+            return ParseNumberError::kUnparsable;
+        }
+    }
+    return val;
+}
+
+}  // namespace
+
+Result<float, ParseNumberError> ParseFloat(std::string_view str) {
+    return Parse<float>(str);
+}
+
+Result<double, ParseNumberError> ParseDouble(std::string_view str) {
+    return Parse<double>(str);
+}
+
+Result<int, ParseNumberError> ParseInt(std::string_view str) {
+    return Parse<int>(str);
+}
+
+Result<unsigned int, ParseNumberError> ParseUint(std::string_view str) {
+    return Parse<unsigned int>(str);
+}
+
+Result<int64_t, ParseNumberError> ParseInt64(std::string_view str) {
+    return Parse<int64_t>(str);
+}
+
+Result<uint64_t, ParseNumberError> ParseUint64(std::string_view str) {
+    return Parse<uint64_t>(str);
+}
+
+Result<int32_t, ParseNumberError> ParseInt32(std::string_view str) {
+    return Parse<int32_t>(str);
+}
+
+Result<uint32_t, ParseNumberError> ParseUint32(std::string_view str) {
+    return Parse<uint32_t>(str);
+}
+
+Result<int16_t, ParseNumberError> ParseInt16(std::string_view str) {
+    return Parse<int16_t>(str);
+}
+
+Result<uint16_t, ParseNumberError> ParseUint16(std::string_view str) {
+    return Parse<uint16_t>(str);
+}
+
+Result<int8_t, ParseNumberError> ParseInt8(std::string_view str) {
+    return Parse<int8_t>(str);
+}
+
+Result<uint8_t, ParseNumberError> ParseUint8(std::string_view str) {
+    return Parse<uint8_t>(str);
+}
+
+}  // namespace tint::utils
diff --git a/src/tint/utils/text/parse_num.h b/src/tint/utils/text/parse_num.h
new file mode 100644
index 0000000..936fd0f
--- /dev/null
+++ b/src/tint/utils/text/parse_num.h
@@ -0,0 +1,131 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_TEXT_PARSE_NUM_H_
+#define SRC_TINT_UTILS_TEXT_PARSE_NUM_H_
+
+#include <optional>
+#include <string>
+
+#include "src/tint/utils/macros/compiler.h"
+#include "src/tint/utils/result/result.h"
+
+namespace tint::utils {
+
+/// Error returned by the number parsing functions
+enum class ParseNumberError {
+    /// The number was unparsable
+    kUnparsable,
+    /// The parsed number is not representable by the target datatype
+    kResultOutOfRange,
+};
+
+/// @param str the string
+/// @returns the string @p str parsed as a float
+Result<float, ParseNumberError> ParseFloat(std::string_view str);
+
+/// @param str the string
+/// @returns the string @p str parsed as a double
+Result<double, ParseNumberError> ParseDouble(std::string_view str);
+
+/// @param str the string
+/// @returns the string @p str parsed as a int
+Result<int, ParseNumberError> ParseInt(std::string_view str);
+
+/// @param str the string
+/// @returns the string @p str parsed as a unsigned int
+Result<unsigned int, ParseNumberError> ParseUint(std::string_view str);
+
+/// @param str the string
+/// @returns the string @p str parsed as a int64_t
+Result<int64_t, ParseNumberError> ParseInt64(std::string_view str);
+
+/// @param str the string
+/// @returns the string @p str parsed as a uint64_t
+Result<uint64_t, ParseNumberError> ParseUint64(std::string_view str);
+
+/// @param str the string
+/// @returns the string @p str parsed as a int32_t
+Result<int32_t, ParseNumberError> ParseInt32(std::string_view str);
+
+/// @param str the string
+/// @returns the string @p str parsed as a uint32_t
+Result<uint32_t, ParseNumberError> ParseUint32(std::string_view str);
+
+/// @param str the string
+/// @returns the string @p str parsed as a int16_t
+Result<int16_t, ParseNumberError> ParseInt16(std::string_view str);
+
+/// @param str the string
+/// @returns the string @p str parsed as a uint16_t
+Result<uint16_t, ParseNumberError> ParseUint16(std::string_view str);
+
+/// @param str the string
+/// @returns the string @p str parsed as a int8_t
+Result<int8_t, ParseNumberError> ParseInt8(std::string_view str);
+
+/// @param str the string
+/// @returns the string @p str parsed as a uint8_t
+Result<uint8_t, ParseNumberError> ParseUint8(std::string_view str);
+
+TINT_BEGIN_DISABLE_WARNING(UNREACHABLE_CODE);
+
+/// @param str the string
+/// @returns the string @p str parsed as a the number @p T
+template <typename T>
+inline Result<T, ParseNumberError> ParseNumber(std::string_view str) {
+    if constexpr (std::is_same_v<T, float>) {
+        return ParseFloat(str);
+    }
+    if constexpr (std::is_same_v<T, double>) {
+        return ParseDouble(str);
+    }
+    if constexpr (std::is_same_v<T, int>) {
+        return ParseInt(str);
+    }
+    if constexpr (std::is_same_v<T, unsigned int>) {
+        return ParseUint(str);
+    }
+    if constexpr (std::is_same_v<T, int64_t>) {
+        return ParseInt64(str);
+    }
+    if constexpr (std::is_same_v<T, uint64_t>) {
+        return ParseUint64(str);
+    }
+    if constexpr (std::is_same_v<T, int32_t>) {
+        return ParseInt32(str);
+    }
+    if constexpr (std::is_same_v<T, uint32_t>) {
+        return ParseUint32(str);
+    }
+    if constexpr (std::is_same_v<T, int16_t>) {
+        return ParseInt16(str);
+    }
+    if constexpr (std::is_same_v<T, uint16_t>) {
+        return ParseUint16(str);
+    }
+    if constexpr (std::is_same_v<T, int8_t>) {
+        return ParseInt8(str);
+    }
+    if constexpr (std::is_same_v<T, uint8_t>) {
+        return ParseUint8(str);
+    }
+    return ParseNumberError::kUnparsable;
+}
+
+TINT_END_DISABLE_WARNING(UNREACHABLE_CODE);
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_TEXT_PARSE_NUM_H_
diff --git a/src/tint/utils/text/string.cc b/src/tint/utils/text/string.cc
new file mode 100644
index 0000000..91644f8
--- /dev/null
+++ b/src/tint/utils/text/string.cc
@@ -0,0 +1,98 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <algorithm>
+
+#include "src/tint/utils/containers/transform.h"
+#include "src/tint/utils/containers/vector.h"
+#include "src/tint/utils/text/string.h"
+
+namespace tint::utils {
+
+size_t Distance(std::string_view str_a, std::string_view str_b) {
+    const auto len_a = str_a.size();
+    const auto len_b = str_b.size();
+
+    Vector<size_t, 64> mat;
+    mat.Resize((len_a + 1) * (len_b + 1));
+
+    auto at = [&](size_t a, size_t b) -> size_t& { return mat[a + b * (len_a + 1)]; };
+
+    at(0, 0) = 0;
+    for (size_t a = 1; a <= len_a; a++) {
+        at(a, 0) = a;
+    }
+    for (size_t b = 1; b <= len_b; b++) {
+        at(0, b) = b;
+    }
+    for (size_t b = 1; b <= len_b; b++) {
+        for (size_t a = 1; a <= len_a; a++) {
+            bool eq = str_a[a - 1] == str_b[b - 1];
+            at(a, b) = std::min({
+                at(a - 1, b) + 1,
+                at(a, b - 1) + 1,
+                at(a - 1, b - 1) + (eq ? 0 : 1),
+            });
+        }
+    }
+    return at(len_a, len_b);
+}
+
+void SuggestAlternatives(std::string_view got,
+                         Slice<char const* const> strings,
+                         utils::StringStream& ss,
+                         const SuggestAlternativeOptions& options /* = {} */) {
+    auto views = Transform<8>(strings, [](char const* const str) { return std::string_view(str); });
+    SuggestAlternatives(got, views.Slice(), ss, options);
+}
+
+void SuggestAlternatives(std::string_view got,
+                         Slice<std::string_view> strings,
+                         utils::StringStream& ss,
+                         const SuggestAlternativeOptions& options /* = {} */) {
+    // If the string typed was within kSuggestionDistance of one of the possible enum values,
+    // suggest that. Don't bother with suggestions if the string was extremely long.
+    constexpr size_t kSuggestionDistance = 5;
+    constexpr size_t kSuggestionMaxLength = 64;
+    if (!got.empty() && got.size() < kSuggestionMaxLength) {
+        size_t candidate_dist = kSuggestionDistance;
+        std::string_view candidate;
+        for (auto str : strings) {
+            auto dist = utils::Distance(str, got);
+            if (dist < candidate_dist) {
+                candidate = str;
+                candidate_dist = dist;
+            }
+        }
+        if (!candidate.empty()) {
+            ss << "Did you mean '" << options.prefix << candidate << "'?";
+            if (options.list_possible_values) {
+                ss << "\n";
+            }
+        }
+    }
+
+    if (options.list_possible_values) {
+        // List all the possible enumerator values
+        ss << "Possible values: ";
+        for (auto str : strings) {
+            if (str != strings[0]) {
+                ss << ", ";
+            }
+            ss << "'" << options.prefix << str << "'";
+        }
+    }
+}
+
+}  // namespace tint::utils
diff --git a/src/tint/utils/text/string.h b/src/tint/utils/text/string.h
new file mode 100644
index 0000000..96df884
--- /dev/null
+++ b/src/tint/utils/text/string.h
@@ -0,0 +1,225 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_TEXT_STRING_H_
+#define SRC_TINT_UTILS_TEXT_STRING_H_
+
+#include <string>
+#include <variant>
+
+#include "src/tint/utils/containers/slice.h"
+#include "src/tint/utils/containers/vector.h"
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint::utils {
+
+/// @param str the string to apply replacements to
+/// @param substr the string to search for
+/// @param replacement the replacement string to use instead of `substr`
+/// @returns `str` with all occurrences of `substr` replaced with `replacement`
+[[nodiscard]] inline std::string ReplaceAll(std::string str,
+                                            std::string_view substr,
+                                            std::string_view replacement) {
+    size_t pos = 0;
+    while ((pos = str.find(substr, pos)) != std::string_view::npos) {
+        str.replace(pos, substr.length(), replacement);
+        pos += replacement.length();
+    }
+    return str;
+}
+
+/// @param value the boolean value to be printed as a string
+/// @returns value printed as a string via the stream `<<` operator
+inline std::string ToString(bool value) {
+    return value ? "true" : "false";
+}
+
+/// @param value the value to be printed as a string
+/// @returns value printed as a string via the stream `<<` operator
+template <typename T>
+std::string ToString(const T& value) {
+    utils::StringStream s;
+    s << value;
+    return s.str();
+}
+
+/// @param value the variant to be printed as a string
+/// @returns value printed as a string via the stream `<<` operator
+template <typename... TYs>
+std::string ToString(const std::variant<TYs...>& value) {
+    utils::StringStream s;
+    s << std::visit([&](auto& v) { return ToString(v); }, value);
+    return s.str();
+}
+
+/// @param str the input string
+/// @param prefix the prefix string
+/// @returns true iff @p str has the prefix @p prefix
+inline size_t HasPrefix(std::string_view str, std::string_view prefix) {
+    return str.length() >= prefix.length() && str.substr(0, prefix.length()) == prefix;
+}
+
+/// @param str the input string
+/// @param suffix the suffix string
+/// @returns true iff @p str has the suffix @p suffix
+inline size_t HasSuffix(std::string_view str, std::string_view suffix) {
+    return str.length() >= suffix.length() && str.substr(str.length() - suffix.length()) == suffix;
+}
+
+/// @param a the first string
+/// @param b the second string
+/// @returns the Levenshtein distance between @p a and @p b
+size_t Distance(std::string_view a, std::string_view b);
+
+/// Options for SuggestAlternatives()
+struct SuggestAlternativeOptions {
+    /// The prefix to apply to the strings when printing
+    std::string_view prefix;
+    /// List all the possible values
+    bool list_possible_values = true;
+};
+
+/// Suggest alternatives for an unrecognized string from a list of possible values.
+/// @param got the unrecognized string
+/// @param strings the list of possible values
+/// @param ss the stream to write the suggest and list of possible values to
+/// @param options options for the suggestion
+void SuggestAlternatives(std::string_view got,
+                         Slice<char const* const> strings,
+                         utils::StringStream& ss,
+                         const SuggestAlternativeOptions& options = {});
+
+/// Suggest alternatives for an unrecognized string from a list of possible values.
+/// @param got the unrecognized string
+/// @param strings the list of possible values
+/// @param ss the stream to write the suggest and list of possible values to
+/// @param options options for the suggestion
+void SuggestAlternatives(std::string_view got,
+                         Slice<std::string_view> strings,
+                         utils::StringStream& ss,
+                         const SuggestAlternativeOptions& options = {});
+
+/// @param str the input string
+/// @param pred the predicate function
+/// @return @p str with characters passing the predicate function @p pred removed from the start of
+/// the string.
+template <typename PREDICATE>
+std::string_view TrimLeft(std::string_view str, PREDICATE&& pred) {
+    while (!str.empty() && pred(str.front())) {
+        str = str.substr(1);
+    }
+    return str;
+}
+
+/// @param str the input string
+/// @param pred the predicate function
+/// @return @p str with characters passing the predicate function @p pred removed from the end of
+/// the string.
+template <typename PREDICATE>
+std::string_view TrimRight(std::string_view str, PREDICATE&& pred) {
+    while (!str.empty() && pred(str.back())) {
+        str = str.substr(0, str.length() - 1);
+    }
+    return str;
+}
+
+/// @param str the input string
+/// @param prefix the prefix to trim from @p str
+/// @return @p str with the prefix removed, if @p str has the prefix.
+inline std::string_view TrimPrefix(std::string_view str, std::string_view prefix) {
+    return HasPrefix(str, prefix) ? str.substr(prefix.length()) : str;
+}
+
+/// @param str the input string
+/// @param suffix the suffix to trim from @p str
+/// @return @p str with the suffix removed, if @p str has the suffix.
+inline std::string_view TrimSuffix(std::string_view str, std::string_view suffix) {
+    return HasSuffix(str, suffix) ? str.substr(0, str.length() - suffix.length()) : str;
+}
+
+/// @param str the input string
+/// @param pred the predicate function
+/// @return @p str with characters passing the predicate function @p pred removed from the start and
+/// end of the string.
+template <typename PREDICATE>
+std::string_view Trim(std::string_view str, PREDICATE&& pred) {
+    return TrimLeft(TrimRight(str, pred), pred);
+}
+
+/// @param c the character to test
+/// @returns true if @p c is one of the following:
+/// * space (' ')
+/// * form feed ('\f')
+/// * line feed ('\n')
+/// * carriage return ('\r')
+/// * horizontal tab ('\t')
+/// * vertical tab ('\v')
+inline bool IsSpace(char c) {
+    return c == ' ' || c == '\f' || c == '\n' || c == '\r' || c == '\t' || c == '\v';
+}
+
+/// @param str the input string
+/// @return @p str with all whitespace (' ') removed from the start and end of the string.
+inline std::string_view TrimSpace(std::string_view str) {
+    return Trim(str, IsSpace);
+}
+
+/// @param str the input string
+/// @param delimiter the delimiter
+/// @return @p str split at each occurrence of @p delimiter
+inline utils::Vector<std::string_view, 8> Split(std::string_view str, std::string_view delimiter) {
+    utils::Vector<std::string_view, 8> out;
+    while (str.length() > delimiter.length()) {
+        auto pos = str.find(delimiter);
+        if (pos == std::string_view::npos) {
+            break;
+        }
+        out.Push(str.substr(0, pos));
+        str = str.substr(pos + delimiter.length());
+    }
+    out.Push(str);
+    return out;
+}
+
+/// @returns @p str quoted with <code>'</code>
+inline std::string Quote(std::string_view str) {
+    return "'" + std::string(str) + "'";
+}
+
+/// @param parts the input parts
+/// @param delimiter the delimiter
+/// @return @p parts joined as a string, delimited with @p delimiter
+template <typename T>
+inline std::string Join(utils::VectorRef<T> parts, std::string_view delimiter) {
+    utils::StringStream s;
+    for (auto& part : parts) {
+        if (part != parts.Front()) {
+            s << delimiter;
+        }
+        s << part;
+    }
+    return s.str();
+}
+
+/// @param parts the input parts
+/// @param delimiter the delimiter
+/// @return @p parts joined as a string, delimited with @p delimiter
+template <typename T, size_t N>
+inline std::string Join(const utils::Vector<T, N>& parts, std::string_view delimiter) {
+    return Join(utils::VectorRef<T>(parts), delimiter);
+}
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_TEXT_STRING_H_
diff --git a/src/tint/utils/text/string_stream.cc b/src/tint/utils/text/string_stream.cc
new file mode 100644
index 0000000..ca84686
--- /dev/null
+++ b/src/tint/utils/text/string_stream.cc
@@ -0,0 +1,51 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint::utils {
+
+StringStream::StringStream() {
+    sstream_.flags(sstream_.flags() | std::ios_base::showpoint | std::ios_base::fixed);
+    sstream_.imbue(std::locale::classic());
+    sstream_.precision(9);
+}
+
+StringStream::~StringStream() = default;
+
+utils::StringStream& operator<<(utils::StringStream& out, CodePoint code_point) {
+    if (code_point < 0x7f) {
+        // See https://en.cppreference.com/w/cpp/language/escape
+        switch (code_point) {
+            case '\a':
+                return out << R"('\a')";
+            case '\b':
+                return out << R"('\b')";
+            case '\f':
+                return out << R"('\f')";
+            case '\n':
+                return out << R"('\n')";
+            case '\r':
+                return out << R"('\r')";
+            case '\t':
+                return out << R"('\t')";
+            case '\v':
+                return out << R"('\v')";
+        }
+        return out << "'" << static_cast<char>(code_point) << "'";
+    }
+    return out << "'U+" << std::hex << code_point.value << "'";
+}
+
+}  // namespace tint::utils
diff --git a/src/tint/utils/text/string_stream.h b/src/tint/utils/text/string_stream.h
new file mode 100644
index 0000000..3d65972
--- /dev/null
+++ b/src/tint/utils/text/string_stream.h
@@ -0,0 +1,197 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_TEXT_STRING_STREAM_H_
+#define SRC_TINT_UTILS_TEXT_STRING_STREAM_H_
+
+#include <functional>
+#include <iomanip>
+#include <iterator>
+#include <limits>
+#include <sstream>
+#include <string>
+#include <utility>
+
+#include "src/tint/utils/text/unicode.h"
+
+namespace tint::utils {
+
+/// Stringstream wrapper which automatically resets the locale and sets floating point emission
+/// settings needed for Tint.
+class StringStream {
+    using SetWRetTy = decltype(std::setw(std::declval<int>()));
+    using SetPrecisionRetTy = decltype(std::setprecision(std::declval<int>()));
+    using SetFillRetTy = decltype(std::setfill(std::declval<char>()));
+
+    /// Evaluates to true if `T` is the return type of std::setw, std:setprecision or std::setfill.
+    template <typename T>
+    static constexpr bool IsSetType = std::is_same_v<SetWRetTy, std::decay_t<T>> ||
+                                      std::is_same_v<SetPrecisionRetTy, std::decay_t<T>> ||
+                                      std::is_same_v<SetFillRetTy, std::decay_t<T>>;
+
+  public:
+    /// Constructor
+    StringStream();
+    /// Destructor
+    ~StringStream();
+
+    /// @returns the format flags for the stream
+    std::ios_base::fmtflags flags() const { return sstream_.flags(); }
+
+    /// @param flags the flags to set
+    /// @returns the original format flags
+    std::ios_base::fmtflags flags(std::ios_base::fmtflags flags) { return sstream_.flags(flags); }
+
+    /// Emit `value` to the stream
+    /// @param value the value to emit
+    /// @returns a reference to this
+    template <typename T,
+              typename std::enable_if_t<std::is_integral_v<std::decay_t<T>>, bool> = true>
+    StringStream& operator<<(T&& value) {
+        return EmitValue(std::forward<T>(value));
+    }
+
+    /// Emit `value` to the stream
+    /// @param value the value to emit
+    /// @returns a reference to this
+    StringStream& operator<<(const char* value) { return EmitValue(value); }
+    /// Emit `value` to the stream
+    /// @param value the value to emit
+    /// @returns a reference to this
+    StringStream& operator<<(const std::string& value) { return EmitValue(value); }
+    /// Emit `value` to the stream
+    /// @param value the value to emit
+    /// @returns a reference to this
+    StringStream& operator<<(std::string_view value) { return EmitValue(value); }
+
+    /// Emit `value` to the stream
+    /// @param value the value to emit
+    /// @returns a reference to this
+    StringStream& operator<<(const void* value) { return EmitValue(value); }
+
+    /// Emit `value` to the stream
+    /// @param value the value to emit
+    /// @returns a reference to this
+    template <typename T,
+              typename std::enable_if_t<std::is_floating_point_v<std::decay_t<T>>, bool> = true>
+    StringStream& operator<<(T&& value) {
+        return EmitFloat(std::forward<T>(value));
+    }
+
+    /// Emit `value` to the stream
+    /// @param value the value to emit
+    /// @returns a reference to this
+    template <typename T>
+    StringStream& EmitValue(T&& value) {
+        sstream_ << std::forward<T>(value);
+        return *this;
+    }
+
+    /// Emit `value` to the stream
+    /// @param value the value to emit
+    /// @returns a reference to this
+    template <typename T>
+    StringStream& EmitFloat(const T& value) {
+        // Try printing the float in fixed point, with a smallish limit on the precision
+        std::stringstream fixed;
+        fixed.flags(fixed.flags() | std::ios_base::showpoint | std::ios_base::fixed);
+        fixed.imbue(std::locale::classic());
+        fixed.precision(20);
+        fixed << value;
+
+        std::string str = fixed.str();
+
+        // If this string can be parsed without loss of information, use it.
+        // (Use double here to dodge a bug in older libc++ versions which would incorrectly read
+        // back FLT_MAX as INF.)
+        double roundtripped;
+        fixed >> roundtripped;
+
+        // Strip trailing zeros from the number.
+        auto float_equal_no_warning = std::equal_to<T>();
+        if (float_equal_no_warning(value, static_cast<T>(roundtripped))) {
+            while (str.length() >= 2 && str[str.size() - 1] == '0' && str[str.size() - 2] != '.') {
+                str.pop_back();
+            }
+
+            sstream_ << str;
+            return *this;
+        }
+
+        // Resort to scientific, with the minimum precision needed to preserve the whole float
+        std::stringstream sci;
+        sci.imbue(std::locale::classic());
+        sci.precision(std::numeric_limits<T>::max_digits10);
+        sci << value;
+        sstream_ << sci.str();
+
+        return *this;
+    }
+
+    /// Swaps streams
+    /// @param other stream to swap too
+    void swap(StringStream& other) { sstream_.swap(other.sstream_); }
+
+    /// repeat queues the character c to be written to the printer n times.
+    /// @param c the character to print `n` times
+    /// @param n the number of times to print character `c`
+    void repeat(char c, size_t n) { std::fill_n(std::ostream_iterator<char>(sstream_), n, c); }
+
+    /// The callback to emit a `endl` to the stream
+    using StdEndl = std::ostream& (*)(std::ostream&);
+
+    /// @param manipulator the callback to emit too
+    /// @returns a reference to this
+    StringStream& operator<<(StdEndl manipulator) {
+        // call the function, and return it's value
+        manipulator(sstream_);
+        return *this;
+    }
+
+    /// @param manipulator the callback to emit too
+    /// @returns a reference to this
+    StringStream& operator<<(decltype(std::hex) manipulator) {
+        // call the function, and return it's value
+        manipulator(sstream_);
+        return *this;
+    }
+
+    /// @param value the value to emit
+    /// @returns a reference to this
+    template <typename T, typename std::enable_if_t<IsSetType<T>, int> = 0>
+    StringStream& operator<<(T&& value) {
+        // call the function, and return it's value
+        sstream_ << std::forward<T>(value);
+        return *this;
+    }
+
+    /// @returns the current location in the output stream
+    uint32_t tellp() { return static_cast<uint32_t>(sstream_.tellp()); }
+
+    /// @returns the string contents of the stream
+    std::string str() const { return sstream_.str(); }
+
+  private:
+    std::stringstream sstream_;
+};
+
+/// Writes the CodePoint to the stream.
+/// @param out the stream to write to
+/// @param codepoint the CodePoint to write
+/// @returns out so calls can be chained
+utils::StringStream& operator<<(utils::StringStream& out, CodePoint codepoint);
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_TEXT_STRING_STREAM_H_
diff --git a/src/tint/utils/text/string_stream_test.cc b/src/tint/utils/text/string_stream_test.cc
new file mode 100644
index 0000000..b18ebb2
--- /dev/null
+++ b/src/tint/utils/text/string_stream_test.cc
@@ -0,0 +1,111 @@
+// Copyright 2023 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/text/string_stream.h"
+
+#include <math.h>
+#include <cstring>
+#include <limits>
+
+#include "gtest/gtest.h"
+
+namespace tint::utils {
+namespace {
+
+using StringStreamTest = testing::Test;
+
+TEST_F(StringStreamTest, Zero) {
+    StringStream s;
+    s << 0.0f;
+    EXPECT_EQ(s.str(), "0.0");
+}
+
+TEST_F(StringStreamTest, One) {
+    StringStream s;
+    s << 1.0f;
+    EXPECT_EQ(s.str(), "1.0");
+}
+
+TEST_F(StringStreamTest, MinusOne) {
+    StringStream s;
+    s << -1.0f;
+    EXPECT_EQ(s.str(), "-1.0");
+}
+
+TEST_F(StringStreamTest, Billion) {
+    StringStream s;
+    s << 1e9f;
+    EXPECT_EQ(s.str(), "1000000000.0");
+}
+
+TEST_F(StringStreamTest, Small) {
+    StringStream s;
+    s << std::numeric_limits<float>::epsilon();
+    EXPECT_NE(s.str(), "0.0");
+}
+
+TEST_F(StringStreamTest, Highest) {
+    const auto highest = std::numeric_limits<float>::max();
+    const auto expected_highest = 340282346638528859811704183484516925440.0f;
+
+    if (highest < expected_highest || highest > expected_highest) {
+        GTEST_SKIP() << "std::numeric_limits<float>::max() is not as expected for "
+                        "this target";
+    }
+
+    StringStream s;
+    s << std::numeric_limits<float>::max();
+    EXPECT_EQ(s.str(), "340282346638528859811704183484516925440.0");
+}
+
+TEST_F(StringStreamTest, Lowest) {
+    // Some compilers complain if you test floating point numbers for equality.
+    // So say it via two inequalities.
+    const auto lowest = std::numeric_limits<float>::lowest();
+    const auto expected_lowest = -340282346638528859811704183484516925440.0f;
+    if (lowest < expected_lowest || lowest > expected_lowest) {
+        GTEST_SKIP() << "std::numeric_limits<float>::lowest() is not as expected for "
+                        "this target";
+    }
+
+    StringStream s;
+    s << std::numeric_limits<float>::lowest();
+    EXPECT_EQ(s.str(), "-340282346638528859811704183484516925440.0");
+}
+
+TEST_F(StringStreamTest, Precision) {
+    {
+        StringStream s;
+        s << 1e-8f;
+        EXPECT_EQ(s.str(), "0.00000000999999993923");
+    }
+    {
+        StringStream s;
+        s << 1e-9f;
+        EXPECT_EQ(s.str(), "0.00000000099999997172");
+    }
+    {
+        StringStream s;
+        s << 1e-10f;
+        EXPECT_EQ(s.str(), "0.00000000010000000134");
+    }
+    {
+        StringStream s;
+        s << 1e-20f;
+        EXPECT_EQ(s.str(), "0.00000000000000000001");
+    }
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/text/string_test.cc b/src/tint/utils/text/string_test.cc
new file mode 100644
index 0000000..1caa879
--- /dev/null
+++ b/src/tint/utils/text/string_test.cc
@@ -0,0 +1,206 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/text/string.h"
+
+#include "gmock/gmock.h"
+#include "src/tint/utils/text/string_stream.h"
+
+#include "src/tint/utils/containers/transform.h"  // Used by ToStringList()
+
+namespace tint::utils {
+namespace {
+
+// Workaround for https://github.com/google/googletest/issues/3081
+// Remove when using C++20
+template <size_t N>
+utils::Vector<std::string, N> ToStringList(const utils::Vector<std::string_view, N>& views) {
+    return Transform(views, [](std::string_view view) { return std::string(view); });
+}
+
+TEST(StringTest, ReplaceAll) {
+    EXPECT_EQ("xybbcc", ReplaceAll("aabbcc", "aa", "xy"));
+    EXPECT_EQ("aaxycc", ReplaceAll("aabbcc", "bb", "xy"));
+    EXPECT_EQ("aabbxy", ReplaceAll("aabbcc", "cc", "xy"));
+    EXPECT_EQ("xyxybbcc", ReplaceAll("aabbcc", "a", "xy"));
+    EXPECT_EQ("aaxyxycc", ReplaceAll("aabbcc", "b", "xy"));
+    EXPECT_EQ("aabbxyxy", ReplaceAll("aabbcc", "c", "xy"));
+    // Replacement string includes the searched-for string.
+    // This proves that the algorithm needs to advance 'pos'
+    // past the replacement.
+    EXPECT_EQ("aabxybbxybcc", ReplaceAll("aabbcc", "b", "bxyb"));
+}
+
+TEST(StringTest, ToString) {
+    EXPECT_EQ("true", ToString(true));
+    EXPECT_EQ("false", ToString(false));
+    EXPECT_EQ("123", ToString(123));
+    EXPECT_EQ("hello", ToString("hello"));
+}
+
+TEST(StringTest, HasPrefix) {
+    EXPECT_TRUE(HasPrefix("abc", "a"));
+    EXPECT_TRUE(HasPrefix("abc", "ab"));
+    EXPECT_TRUE(HasPrefix("abc", "abc"));
+    EXPECT_FALSE(HasPrefix("abc", "abc1"));
+    EXPECT_FALSE(HasPrefix("abc", "ac"));
+    EXPECT_FALSE(HasPrefix("abc", "b"));
+}
+
+TEST(StringTest, HasSuffix) {
+    EXPECT_TRUE(HasSuffix("abc", "c"));
+    EXPECT_TRUE(HasSuffix("abc", "bc"));
+    EXPECT_TRUE(HasSuffix("abc", "abc"));
+    EXPECT_FALSE(HasSuffix("abc", "1abc"));
+    EXPECT_FALSE(HasSuffix("abc", "ac"));
+    EXPECT_FALSE(HasSuffix("abc", "b"));
+}
+
+TEST(StringTest, Distance) {
+    EXPECT_EQ(Distance("hello world", "hello world"), 0u);
+    EXPECT_EQ(Distance("hello world", "helloworld"), 1u);
+    EXPECT_EQ(Distance("helloworld", "hello world"), 1u);
+    EXPECT_EQ(Distance("hello world", "hello  world"), 1u);
+    EXPECT_EQ(Distance("hello  world", "hello world"), 1u);
+    EXPECT_EQ(Distance("Hello World", "hello world"), 2u);
+    EXPECT_EQ(Distance("hello world", "Hello World"), 2u);
+    EXPECT_EQ(Distance("Hello world", ""), 11u);
+    EXPECT_EQ(Distance("", "Hello world"), 11u);
+}
+
+TEST(StringTest, SuggestAlternatives) {
+    {
+        const char* alternatives[] = {"hello world", "Hello World"};
+        utils::StringStream ss;
+        SuggestAlternatives("hello wordl", alternatives, ss);
+        EXPECT_EQ(ss.str(), R"(Did you mean 'hello world'?
+Possible values: 'hello world', 'Hello World')");
+    }
+    {
+        const char* alternatives[] = {"foobar", "something else"};
+        utils::StringStream ss;
+        SuggestAlternatives("hello world", alternatives, ss);
+        EXPECT_EQ(ss.str(), R"(Possible values: 'foobar', 'something else')");
+    }
+    {
+        const char* alternatives[] = {"hello world", "Hello World"};
+        utils::StringStream ss;
+        SuggestAlternativeOptions opts;
+        opts.prefix = "$";
+        SuggestAlternatives("hello wordl", alternatives, ss, opts);
+        EXPECT_EQ(ss.str(), R"(Did you mean '$hello world'?
+Possible values: '$hello world', '$Hello World')");
+    }
+    {
+        const char* alternatives[] = {"hello world", "Hello World"};
+        utils::StringStream ss;
+        SuggestAlternativeOptions opts;
+        opts.list_possible_values = false;
+        SuggestAlternatives("hello world", alternatives, ss, opts);
+        EXPECT_EQ(ss.str(), R"(Did you mean 'hello world'?)");
+    }
+}
+
+TEST(StringTest, TrimLeft) {
+    EXPECT_EQ(TrimLeft("hello world", [](char) { return false; }), "hello world");
+    EXPECT_EQ(TrimLeft("hello world", [](char c) { return c == 'h'; }), "ello world");
+    EXPECT_EQ(TrimLeft("hello world", [](char c) { return c == 'h' || c == 'e'; }), "llo world");
+    EXPECT_EQ(TrimLeft("hello world", [](char c) { return c == 'e'; }), "hello world");
+    EXPECT_EQ(TrimLeft("hello world", [](char) { return true; }), "");
+    EXPECT_EQ(TrimLeft("", [](char) { return false; }), "");
+    EXPECT_EQ(TrimLeft("", [](char) { return true; }), "");
+}
+
+TEST(StringTest, TrimRight) {
+    EXPECT_EQ(TrimRight("hello world", [](char) { return false; }), "hello world");
+    EXPECT_EQ(TrimRight("hello world", [](char c) { return c == 'd'; }), "hello worl");
+    EXPECT_EQ(TrimRight("hello world", [](char c) { return c == 'd' || c == 'l'; }), "hello wor");
+    EXPECT_EQ(TrimRight("hello world", [](char c) { return c == 'l'; }), "hello world");
+    EXPECT_EQ(TrimRight("hello world", [](char) { return true; }), "");
+    EXPECT_EQ(TrimRight("", [](char) { return false; }), "");
+    EXPECT_EQ(TrimRight("", [](char) { return true; }), "");
+}
+
+TEST(StringTest, TrimPrefix) {
+    EXPECT_EQ(TrimPrefix("abc", "a"), "bc");
+    EXPECT_EQ(TrimPrefix("abc", "ab"), "c");
+    EXPECT_EQ(TrimPrefix("abc", "abc"), "");
+    EXPECT_EQ(TrimPrefix("abc", "abc1"), "abc");
+    EXPECT_EQ(TrimPrefix("abc", "ac"), "abc");
+    EXPECT_EQ(TrimPrefix("abc", "b"), "abc");
+    EXPECT_EQ(TrimPrefix("abc", "c"), "abc");
+}
+
+TEST(StringTest, TrimSuffix) {
+    EXPECT_EQ(TrimSuffix("abc", "c"), "ab");
+    EXPECT_EQ(TrimSuffix("abc", "bc"), "a");
+    EXPECT_EQ(TrimSuffix("abc", "abc"), "");
+    EXPECT_EQ(TrimSuffix("abc", "1abc"), "abc");
+    EXPECT_EQ(TrimSuffix("abc", "ac"), "abc");
+    EXPECT_EQ(TrimSuffix("abc", "b"), "abc");
+    EXPECT_EQ(TrimSuffix("abc", "a"), "abc");
+}
+
+TEST(StringTest, Trim) {
+    EXPECT_EQ(Trim("hello world", [](char) { return false; }), "hello world");
+    EXPECT_EQ(Trim("hello world", [](char c) { return c == 'h'; }), "ello world");
+    EXPECT_EQ(Trim("hello world", [](char c) { return c == 'd'; }), "hello worl");
+    EXPECT_EQ(Trim("hello world", [](char c) { return c == 'h' || c == 'd'; }), "ello worl");
+    EXPECT_EQ(Trim("hello world", [](char) { return true; }), "");
+    EXPECT_EQ(Trim("", [](char) { return false; }), "");
+    EXPECT_EQ(Trim("", [](char) { return true; }), "");
+}
+
+TEST(StringTest, IsSpace) {
+    EXPECT_FALSE(IsSpace('a'));
+    EXPECT_FALSE(IsSpace('z'));
+    EXPECT_FALSE(IsSpace('\0'));
+    EXPECT_TRUE(IsSpace(' '));
+    EXPECT_TRUE(IsSpace('\f'));
+    EXPECT_TRUE(IsSpace('\n'));
+    EXPECT_TRUE(IsSpace('\r'));
+    EXPECT_TRUE(IsSpace('\t'));
+    EXPECT_TRUE(IsSpace('\v'));
+}
+
+TEST(StringTest, TrimSpace) {
+    EXPECT_EQ(TrimSpace("hello world"), "hello world");
+    EXPECT_EQ(TrimSpace(" \t hello world\v\f"), "hello world");
+    EXPECT_EQ(TrimSpace("hello \t world"), "hello \t world");
+    EXPECT_EQ(TrimSpace(""), "");
+}
+
+TEST(StringTest, Quote) {
+    EXPECT_EQ("'meow'", Quote("meow"));
+}
+
+TEST(StringTest, Split) {
+    EXPECT_THAT(ToStringList(Split("", ",")), testing::ElementsAre(""));
+    EXPECT_THAT(ToStringList(Split("cat", ",")), testing::ElementsAre("cat"));
+    EXPECT_THAT(ToStringList(Split("cat,", ",")), testing::ElementsAre("cat", ""));
+    EXPECT_THAT(ToStringList(Split(",cat", ",")), testing::ElementsAre("", "cat"));
+    EXPECT_THAT(ToStringList(Split("cat,dog,fish", ",")),
+                testing::ElementsAre("cat", "dog", "fish"));
+    EXPECT_THAT(ToStringList(Split("catdogfish", "dog")), testing::ElementsAre("cat", "fish"));
+}
+
+TEST(StringTest, Join) {
+    EXPECT_EQ(Join(utils::Vector<int, 1>{}, ","), "");
+    EXPECT_EQ(Join(utils::Vector{1, 2, 3}, ","), "1,2,3");
+    EXPECT_EQ(Join(utils::Vector{"cat"}, ","), "cat");
+    EXPECT_EQ(Join(utils::Vector{"cat", "dog"}, ","), "cat,dog");
+}
+
+}  // namespace
+}  // namespace tint::utils
diff --git a/src/tint/utils/text/symbol.cc b/src/tint/utils/text/symbol.cc
new file mode 100644
index 0000000..731f66e
--- /dev/null
+++ b/src/tint/utils/text/symbol.cc
@@ -0,0 +1,63 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/text/symbol.h"
+
+#include <utility>
+
+namespace tint {
+
+Symbol::Symbol() = default;
+
+Symbol::Symbol(uint32_t val, tint::ProgramID pid, std::string_view name)
+    : val_(val), program_id_(pid), name_(name) {}
+
+Symbol::Symbol(const Symbol& o) = default;
+
+Symbol::Symbol(Symbol&& o) = default;
+
+Symbol::~Symbol() = default;
+
+Symbol& Symbol::operator=(const Symbol& o) = default;
+
+Symbol& Symbol::operator=(Symbol&& o) = default;
+
+bool Symbol::operator==(const Symbol& other) const {
+    TINT_ASSERT_PROGRAM_IDS_EQUAL_IF_VALID(Symbol, program_id_, other.program_id_);
+    return val_ == other.val_;
+}
+
+bool Symbol::operator!=(const Symbol& other) const {
+    TINT_ASSERT_PROGRAM_IDS_EQUAL_IF_VALID(Symbol, program_id_, other.program_id_);
+    return val_ != other.val_;
+}
+
+bool Symbol::operator<(const Symbol& other) const {
+    TINT_ASSERT_PROGRAM_IDS_EQUAL_IF_VALID(Symbol, program_id_, other.program_id_);
+    return val_ < other.val_;
+}
+
+std::string Symbol::to_str() const {
+    return "$" + std::to_string(val_);
+}
+
+std::string_view Symbol::NameView() const {
+    return name_;
+}
+
+std::string Symbol::Name() const {
+    return std::string(name_);
+}
+
+}  // namespace tint
diff --git a/src/tint/utils/text/symbol.h b/src/tint/utils/text/symbol.h
new file mode 100644
index 0000000..381f745
--- /dev/null
+++ b/src/tint/utils/text/symbol.h
@@ -0,0 +1,123 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_TEXT_SYMBOL_H_
+#define SRC_TINT_UTILS_TEXT_SYMBOL_H_
+
+#include <string>
+
+#include "src/tint/program_id.h"
+
+namespace tint {
+
+/// A symbol representing a string in the system
+class Symbol {
+  public:
+    /// Constructor
+    /// An invalid symbol
+    Symbol();
+    /// Constructor
+    /// @param val the symbol value
+    /// @param pid the identifier of the program that owns this Symbol
+    /// @param name the name this symbol represents
+    Symbol(uint32_t val, tint::ProgramID pid, std::string_view name);
+
+    /// Copy constructor
+    /// @param o the symbol to copy
+    Symbol(const Symbol& o);
+    /// Move constructor
+    /// @param o the symbol to move
+    Symbol(Symbol&& o);
+    /// Destructor
+    ~Symbol();
+
+    /// Copy assignment
+    /// @param o the other symbol
+    /// @returns the symbol after doing the copy
+    Symbol& operator=(const Symbol& o);
+    /// Move assignment
+    /// @param o the other symbol
+    /// @returns teh symbol after doing the move
+    Symbol& operator=(Symbol&& o);
+
+    /// Equality operator
+    /// @param o the other symbol
+    /// @returns true if the symbols are the same
+    bool operator==(const Symbol& o) const;
+
+    /// Inequality operator
+    /// @param o the other symbol
+    /// @returns true if the symbols are the different
+    bool operator!=(const Symbol& o) const;
+
+    /// Less-than operator
+    /// @param o the other symbol
+    /// @returns true if this symbol is ordered before symbol `o`
+    bool operator<(const Symbol& o) const;
+
+    /// @returns true if the symbol is valid
+    bool IsValid() const { return val_ != static_cast<uint32_t>(-1); }
+
+    /// @returns true if the symbol is valid
+    operator bool() const { return IsValid(); }
+
+    /// @returns the value for the symbol
+    uint32_t value() const { return val_; }
+
+    /// Convert the symbol to a string
+    /// @return the string representation of the symbol
+    std::string to_str() const;
+
+    /// Converts the symbol to the registered name
+    /// @returns the string_view representing the name of the symbol
+    std::string_view NameView() const;
+
+    /// Converts the symbol to the registered name
+    /// @returns the string representing the name of the symbol
+    std::string Name() const;
+
+    /// @returns the identifier of the Program that owns this symbol.
+    tint::ProgramID ProgramID() const { return program_id_; }
+
+  private:
+    uint32_t val_ = static_cast<uint32_t>(-1);
+    tint::ProgramID program_id_;
+    std::string_view name_;
+};
+
+/// @param sym the Symbol
+/// @returns the ProgramID that owns the given Symbol
+inline ProgramID ProgramIDOf(Symbol sym) {
+    return sym.IsValid() ? sym.ProgramID() : ProgramID();
+}
+
+}  // namespace tint
+
+namespace std {
+
+/// Custom std::hash specialization for tint::Symbol so symbols can be used as
+/// keys for std::unordered_map and std::unordered_set.
+template <>
+class hash<tint::Symbol> {
+  public:
+    /// @param sym the symbol to return
+    /// @return the Symbol internal value
+    inline std::size_t operator()(const tint::Symbol& sym) const {
+        return static_cast<std::size_t>(sym.value());
+    }
+};
+
+}  // namespace std
+
+#endif  // SRC_TINT_UTILS_TEXT_SYMBOL_H_
diff --git a/src/tint/utils/text/symbol_table.cc b/src/tint/utils/text/symbol_table.cc
new file mode 100644
index 0000000..cc11724
--- /dev/null
+++ b/src/tint/utils/text/symbol_table.cc
@@ -0,0 +1,94 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/text/symbol_table.h"
+
+#include "src/tint/utils/debug/debug.h"
+
+namespace tint {
+
+SymbolTable::SymbolTable(tint::ProgramID program_id) : program_id_(program_id) {}
+
+SymbolTable::SymbolTable(SymbolTable&&) = default;
+
+SymbolTable::~SymbolTable() = default;
+
+SymbolTable& SymbolTable::operator=(SymbolTable&&) = default;
+
+Symbol SymbolTable::Register(std::string_view name) {
+    TINT_ASSERT(Symbol, !name.empty());
+
+    auto it = name_to_symbol_.Find(name);
+    if (it) {
+        return *it;
+    }
+    return RegisterInternal(name);
+}
+
+Symbol SymbolTable::RegisterInternal(std::string_view name) {
+    char* name_mem = name_allocator_.Allocate(name.length() + 1);
+    if (name_mem == nullptr) {
+        return Symbol();
+    }
+
+    memcpy(name_mem, name.data(), name.length() + 1);
+    std::string_view nv(name_mem, name.length());
+
+    Symbol sym(next_symbol_, program_id_, nv);
+    ++next_symbol_;
+    name_to_symbol_.Add(sym.NameView(), sym);
+
+    return sym;
+}
+
+Symbol SymbolTable::Get(std::string_view name) const {
+    auto it = name_to_symbol_.Find(name);
+    return it ? *it : Symbol();
+}
+
+Symbol SymbolTable::New(std::string_view prefix_view /* = "" */) {
+    std::string prefix;
+    if (prefix_view.empty()) {
+        prefix = "tint_symbol";
+    } else {
+        prefix = std::string(prefix_view);
+    }
+
+    auto it = name_to_symbol_.Find(prefix);
+    if (!it) {
+        return RegisterInternal(prefix);
+    }
+
+    size_t i = 0;
+    auto last_prefix = last_prefix_to_index_.Find(prefix);
+    if (last_prefix) {
+        i = *last_prefix;
+    }
+
+    std::string name;
+    do {
+        ++i;
+        name = prefix + "_" + std::to_string(i);
+    } while (name_to_symbol_.Contains(name));
+
+    auto sym = RegisterInternal(name);
+    if (last_prefix) {
+        *last_prefix = i;
+    } else {
+        last_prefix_to_index_.Add(prefix, i);
+    }
+    return sym;
+}
+
+}  // namespace tint
diff --git a/src/tint/utils/text/symbol_table.h b/src/tint/utils/text/symbol_table.h
new file mode 100644
index 0000000..419f632
--- /dev/null
+++ b/src/tint/utils/text/symbol_table.h
@@ -0,0 +1,111 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_TEXT_SYMBOL_TABLE_H_
+#define SRC_TINT_UTILS_TEXT_SYMBOL_TABLE_H_
+
+#include <string>
+
+#include "src/tint/utils/containers/hashmap.h"
+#include "src/tint/utils/memory/bump_allocator.h"
+#include "src/tint/utils/text/symbol.h"
+
+namespace tint {
+
+/// Holds mappings from symbols to their associated string names
+class SymbolTable {
+  public:
+    /// Constructor
+    /// @param program_id the identifier of the program that owns this symbol
+    /// table
+    explicit SymbolTable(tint::ProgramID program_id);
+    /// Move Constructor
+    SymbolTable(SymbolTable&&);
+    /// Destructor
+    ~SymbolTable();
+
+    /// Move assignment
+    /// @param other the symbol table to move
+    /// @returns the symbol table
+    SymbolTable& operator=(SymbolTable&& other);
+
+    /// Wrap sets this symbol table to hold symbols which point to the allocated names in @p o.
+    /// The symbol table after Wrap is intended to temporarily extend the objects
+    /// of an existing immutable SymbolTable
+    /// As the copied objects are owned by @p o, @p o must not be destructed
+    /// or assigned while using this symbol table.
+    /// @param o the immutable SymbolTable to extend
+    void Wrap(const SymbolTable& o) {
+        next_symbol_ = o.next_symbol_;
+        name_to_symbol_ = o.name_to_symbol_;
+        last_prefix_to_index_ = o.last_prefix_to_index_;
+        program_id_ = o.program_id_;
+    }
+
+    /// Registers a name into the symbol table, returning the Symbol.
+    /// @param name the name to register
+    /// @returns the symbol representing the given name
+    Symbol Register(std::string_view name);
+
+    /// Returns the symbol for the given `name`
+    /// @param name the name to lookup
+    /// @returns the symbol for the name or Symbol() if not found.
+    Symbol Get(std::string_view name) const;
+
+    /// Returns a new unique symbol with the given name, possibly suffixed with a
+    /// unique number.
+    /// @param name the symbol name
+    /// @returns a new, unnamed symbol with the given name. If the name is already
+    /// taken then this will be suffixed with an underscore and a unique numerical
+    /// value
+    Symbol New(std::string_view name = "");
+
+    /// Foreach calls the callback function `F` for each symbol in the table.
+    /// @param callback must be a function or function-like object with the
+    /// signature: `void(Symbol)`
+    template <typename F>
+    void Foreach(F&& callback) const {
+        for (auto it : name_to_symbol_) {
+            callback(it.value);
+        }
+    }
+
+    /// @returns the identifier of the Program that owns this symbol table.
+    tint::ProgramID ProgramID() const { return program_id_; }
+
+  private:
+    SymbolTable(const SymbolTable&) = delete;
+    SymbolTable& operator=(const SymbolTable& other) = delete;
+
+    Symbol RegisterInternal(std::string_view name);
+
+    // The value to be associated to the next registered symbol table entry.
+    uint32_t next_symbol_ = 1;
+
+    utils::Hashmap<std::string_view, Symbol, 0> name_to_symbol_;
+    utils::Hashmap<std::string, size_t, 0> last_prefix_to_index_;
+    tint::ProgramID program_id_;
+
+    utils::BumpAllocator name_allocator_;
+};
+
+/// @param symbol_table the SymbolTable
+/// @returns the ProgramID that owns the given SymbolTable
+inline ProgramID ProgramIDOf(const SymbolTable& symbol_table) {
+    return symbol_table.ProgramID();
+}
+
+}  // namespace tint
+
+#endif  // SRC_TINT_UTILS_TEXT_SYMBOL_TABLE_H_
diff --git a/src/tint/utils/text/symbol_table_test.cc b/src/tint/utils/text/symbol_table_test.cc
new file mode 100644
index 0000000..ee36a66
--- /dev/null
+++ b/src/tint/utils/text/symbol_table_test.cc
@@ -0,0 +1,50 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/text/symbol_table.h"
+
+#include "gtest/gtest-spi.h"
+
+namespace tint {
+namespace {
+
+using SymbolTableTest = testing::Test;
+
+TEST_F(SymbolTableTest, GeneratesSymbolForName) {
+    auto program_id = ProgramID::New();
+    SymbolTable s{program_id};
+    EXPECT_EQ(Symbol(1, program_id, "name"), s.Register("name"));
+    EXPECT_EQ(Symbol(2, program_id, "another_name"), s.Register("another_name"));
+}
+
+TEST_F(SymbolTableTest, DeduplicatesNames) {
+    auto program_id = ProgramID::New();
+    SymbolTable s{program_id};
+    EXPECT_EQ(Symbol(1, program_id, "name"), s.Register("name"));
+    EXPECT_EQ(Symbol(2, program_id, "another_name"), s.Register("another_name"));
+    EXPECT_EQ(Symbol(1, program_id, "name"), s.Register("name"));
+}
+
+TEST_F(SymbolTableTest, AssertsForBlankString) {
+    EXPECT_FATAL_FAILURE(
+        {
+            auto program_id = ProgramID::New();
+            SymbolTable s{program_id};
+            s.Register("");
+        },
+        "internal compiler error");
+}
+
+}  // namespace
+}  // namespace tint
diff --git a/src/tint/utils/text/symbol_test.cc b/src/tint/utils/text/symbol_test.cc
new file mode 100644
index 0000000..8fbafea
--- /dev/null
+++ b/src/tint/utils/text/symbol_test.cc
@@ -0,0 +1,54 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/text/symbol.h"
+
+#include "gtest/gtest.h"
+
+namespace tint {
+namespace {
+
+using SymbolTest = testing::Test;
+
+TEST_F(SymbolTest, ToStr) {
+    Symbol sym(1, ProgramID::New(), "");
+    EXPECT_EQ("$1", sym.to_str());
+}
+
+TEST_F(SymbolTest, CopyAssign) {
+    Symbol sym1(1, ProgramID::New(), "");
+    Symbol sym2;
+
+    EXPECT_FALSE(sym2.IsValid());
+    sym2 = sym1;
+    EXPECT_TRUE(sym2.IsValid());
+    EXPECT_EQ(sym2, sym1);
+}
+
+TEST_F(SymbolTest, Comparison) {
+    auto program_id = ProgramID::New();
+    Symbol sym1(1, program_id, "1");
+    Symbol sym2(2, program_id, "2");
+    Symbol sym3(1, program_id, "3");
+
+    EXPECT_TRUE(sym1 == sym3);
+    EXPECT_FALSE(sym1 != sym3);
+    EXPECT_FALSE(sym1 == sym2);
+    EXPECT_TRUE(sym1 != sym2);
+    EXPECT_FALSE(sym3 == sym2);
+    EXPECT_TRUE(sym3 != sym2);
+}
+
+}  // namespace
+}  // namespace tint
diff --git a/src/tint/utils/text/text_generator.cc b/src/tint/utils/text/text_generator.cc
new file mode 100644
index 0000000..13fecb4
--- /dev/null
+++ b/src/tint/utils/text/text_generator.cc
@@ -0,0 +1,135 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/text/text_generator.h"
+
+#include <algorithm>
+#include <limits>
+
+#include "src/tint/utils/containers/map.h"
+#include "src/tint/utils/debug/debug.h"
+
+namespace tint::writer {
+
+TextGenerator::TextGenerator() = default;
+
+TextGenerator::~TextGenerator() = default;
+
+std::string TextGenerator::StructName(const type::Struct* s) {
+    auto name = s->Name().Name();
+    if (name.size() > 1 && name[0] == '_' && name[1] == '_') {
+        name = utils::GetOrCreate(builtin_struct_names_, s,
+                                  [&] { return UniqueIdentifier(name.substr(2)); });
+    }
+    return name;
+}
+
+TextGenerator::LineWriter::LineWriter(TextBuffer* buf) : buffer(buf) {}
+
+TextGenerator::LineWriter::LineWriter(LineWriter&& other) {
+    buffer = other.buffer;
+    other.buffer = nullptr;
+}
+
+TextGenerator::LineWriter::~LineWriter() {
+    if (buffer) {
+        buffer->Append(os.str());
+    }
+}
+
+TextGenerator::TextBuffer::TextBuffer() = default;
+TextGenerator::TextBuffer::~TextBuffer() = default;
+
+void TextGenerator::TextBuffer::IncrementIndent() {
+    current_indent += 2;
+}
+
+void TextGenerator::TextBuffer::DecrementIndent() {
+    current_indent = std::max(2u, current_indent) - 2u;
+}
+
+void TextGenerator::TextBuffer::Append(const std::string& line) {
+    lines.emplace_back(LineInfo{current_indent, line});
+}
+
+void TextGenerator::TextBuffer::Insert(const std::string& line, size_t before, uint32_t indent) {
+    if (TINT_UNLIKELY(before >= lines.size())) {
+        diag::List d;
+        TINT_ICE(Writer, d) << "TextBuffer::Insert() called with before >= lines.size()\n"
+                            << "  before:" << before << "\n"
+                            << "  lines.size(): " << lines.size();
+        return;
+    }
+    using DT = decltype(lines)::difference_type;
+    lines.insert(lines.begin() + static_cast<DT>(before), LineInfo{indent, line});
+}
+
+void TextGenerator::TextBuffer::Append(const TextBuffer& tb) {
+    for (auto& line : tb.lines) {
+        // TODO(bclayton): inefficient, consider optimizing
+        lines.emplace_back(LineInfo{current_indent + line.indent, line.content});
+    }
+}
+
+void TextGenerator::TextBuffer::Insert(const TextBuffer& tb, size_t before, uint32_t indent) {
+    if (TINT_UNLIKELY(before >= lines.size())) {
+        diag::List d;
+        TINT_ICE(Writer, d) << "TextBuffer::Insert() called with before >= lines.size()\n"
+                            << "  before:" << before << "\n"
+                            << "  lines.size(): " << lines.size();
+        return;
+    }
+    size_t idx = 0;
+    for (auto& line : tb.lines) {
+        // TODO(bclayton): inefficient, consider optimizing
+        using DT = decltype(lines)::difference_type;
+        lines.insert(lines.begin() + static_cast<DT>(before + idx),
+                     LineInfo{indent + line.indent, line.content});
+        idx++;
+    }
+}
+
+std::string TextGenerator::TextBuffer::String(uint32_t indent /* = 0 */) const {
+    utils::StringStream ss;
+    for (auto& line : lines) {
+        if (!line.content.empty()) {
+            for (uint32_t i = 0; i < indent + line.indent; i++) {
+                ss << " ";
+            }
+            ss << line.content;
+        }
+        ss << std::endl;
+    }
+    return ss.str();
+}
+
+TextGenerator::ScopedParen::ScopedParen(utils::StringStream& stream) : s(stream) {
+    s << "(";
+}
+
+TextGenerator::ScopedParen::~ScopedParen() {
+    s << ")";
+}
+
+TextGenerator::ScopedIndent::ScopedIndent(TextGenerator* generator)
+    : ScopedIndent(generator->current_buffer_) {}
+
+TextGenerator::ScopedIndent::ScopedIndent(TextBuffer* buffer) : buffer_(buffer) {
+    buffer_->IncrementIndent();
+}
+TextGenerator::ScopedIndent::~ScopedIndent() {
+    buffer_->DecrementIndent();
+}
+
+}  // namespace tint::writer
diff --git a/src/tint/utils/text/text_generator.h b/src/tint/utils/text/text_generator.h
new file mode 100644
index 0000000..68ca893
--- /dev/null
+++ b/src/tint/utils/text/text_generator.h
@@ -0,0 +1,206 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_TEXT_TEXT_GENERATOR_H_
+#define SRC_TINT_UTILS_TEXT_TEXT_GENERATOR_H_
+
+#include <string>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+#include "src/tint/lang/base/type/struct.h"
+#include "src/tint/utils/diagnostic/diagnostic.h"
+#include "src/tint/utils/text/string_stream.h"
+
+namespace tint::writer {
+
+/// Helper methods for generators which are creating text output
+class TextGenerator {
+  public:
+    /// LineInfo holds a single line of text
+    struct LineInfo {
+        /// The indentation of the line in blankspace
+        uint32_t indent = 0;
+        /// The content of the line, without a trailing newline character
+        std::string content;
+    };
+
+    /// TextBuffer holds a list of lines of text.
+    struct TextBuffer {
+        // Constructor
+        TextBuffer();
+
+        // Destructor
+        ~TextBuffer();
+
+        /// IncrementIndent increases the indentation of lines that will be written
+        /// to the TextBuffer
+        void IncrementIndent();
+
+        /// DecrementIndent decreases the indentation of lines that will be written
+        /// to the TextBuffer
+        void DecrementIndent();
+
+        /// Appends the line to the end of the TextBuffer
+        /// @param line the line to append to the TextBuffer
+        void Append(const std::string& line);
+
+        /// Inserts the line to the TextBuffer before the line with index `before`
+        /// @param line the line to append to the TextBuffer
+        /// @param before the zero-based index of the line to insert the text before
+        /// @param indent the indentation to apply to the inserted lines
+        void Insert(const std::string& line, size_t before, uint32_t indent);
+
+        /// Appends the lines of `tb` to the end of this TextBuffer
+        /// @param tb the TextBuffer to append to the end of this TextBuffer
+        void Append(const TextBuffer& tb);
+
+        /// Inserts the lines of `tb` to the TextBuffer before the line with index
+        /// `before`
+        /// @param tb the TextBuffer to insert into this TextBuffer
+        /// @param before the zero-based index of the line to insert the text before
+        /// @param indent the indentation to apply to the inserted lines
+        void Insert(const TextBuffer& tb, size_t before, uint32_t indent);
+
+        /// @returns the buffer's content as a single string
+        /// @param indent additional indentation to apply to each line
+        std::string String(uint32_t indent = 0) const;
+
+        /// The current indentation of the TextBuffer. Lines appended to the
+        /// TextBuffer will use this indentation.
+        uint32_t current_indent = 0;
+
+        /// The lines
+        std::vector<LineInfo> lines;
+    };
+    /// LineWriter is a helper that acts as a string buffer, who's content is
+    /// emitted to the TextBuffer as a single line on destruction.
+    struct LineWriter {
+      public:
+        /// Constructor
+        /// @param buffer the TextBuffer that the LineWriter will append its
+        /// content to on destruction, at the end of the buffer.
+        explicit LineWriter(TextBuffer* buffer);
+
+        /// Move constructor
+        /// @param rhs the LineWriter to move
+        LineWriter(LineWriter&& rhs);
+        /// Destructor
+        ~LineWriter();
+
+        /// @returns the utils::StringStream
+        operator utils::StringStream&() { return os; }
+
+        /// @param rhs the value to write to the line
+        /// @returns the utils::StringStream so calls can be chained
+        template <typename T>
+        utils::StringStream& operator<<(T&& rhs) {
+            return os << std::forward<T>(rhs);
+        }
+
+      private:
+        LineWriter(const LineWriter&) = delete;
+        LineWriter& operator=(const LineWriter&) = delete;
+
+        utils::StringStream os;
+        TextBuffer* buffer;
+    };
+
+    /// Increment the emitter indent level
+    void IncrementIndent() { current_buffer_->IncrementIndent(); }
+    /// Decrement the emitter indent level
+    void DecrementIndent() { current_buffer_->DecrementIndent(); }
+
+    /// @returns a new LineWriter, used for buffering and writing a line to
+    /// the end of #current_buffer_.
+    LineWriter Line() { return LineWriter(current_buffer_); }
+    /// @param buffer the TextBuffer to write the line to
+    /// @returns a new LineWriter, used for buffering and writing a line to
+    /// the end of `buffer`.
+    static LineWriter Line(TextBuffer* buffer) { return LineWriter(buffer); }
+
+    /// @return a new, unique identifier with the given prefix.
+    /// @param prefix optional prefix to apply to the generated identifier. If
+    /// empty "tint_symbol" will be used.
+    virtual std::string UniqueIdentifier(const std::string& prefix = "") = 0;
+
+    /// @param s the structure
+    /// @returns the name of the structure, taking special care of builtin structures that start
+    /// with double underscores. If the structure is a builtin, then the returned name will be a
+    /// unique name without the leading underscores.
+    std::string StructName(const type::Struct* s);
+
+    /// @returns the result data
+    virtual std::string Result() const { return main_buffer_.String(); }
+
+    /// @returns the list of diagnostics raised by the generator.
+    const diag::List& Diagnostics() const { return diagnostics_; }
+
+  protected:
+    /// Helper for writing a '(' on construction and a ')' destruction.
+    struct ScopedParen {
+        /// Constructor
+        /// @param stream the utils::StringStream that will be written to
+        explicit ScopedParen(utils::StringStream& stream);
+        /// Destructor
+        ~ScopedParen();
+
+      private:
+        ScopedParen(ScopedParen&& rhs) = delete;
+        ScopedParen(const ScopedParen&) = delete;
+        ScopedParen& operator=(const ScopedParen&) = delete;
+        utils::StringStream& s;
+    };
+
+    /// Helper for incrementing indentation on construction and decrementing
+    /// indentation on destruction.
+    struct ScopedIndent {
+        /// Constructor
+        /// @param buffer the TextBuffer that the ScopedIndent will indent
+        explicit ScopedIndent(TextBuffer* buffer);
+        /// Constructor
+        /// @param generator ScopedIndent will indent the generator's
+        /// `current_buffer_`
+        explicit ScopedIndent(TextGenerator* generator);
+        /// Destructor
+        ~ScopedIndent();
+
+      private:
+        ScopedIndent(ScopedIndent&& rhs) = delete;
+        ScopedIndent(const ScopedIndent&) = delete;
+        ScopedIndent& operator=(const ScopedIndent&) = delete;
+        TextBuffer* buffer_;
+    };
+
+    /// Constructor
+    TextGenerator();
+    virtual ~TextGenerator();
+
+    /// Diagnostics generated by the generator
+    diag::List diagnostics_;
+    /// The buffer the TextGenerator is currently appending lines to
+    TextBuffer* current_buffer_ = &main_buffer_;
+
+    /// The primary text buffer that the generator will emit
+    TextBuffer main_buffer_;
+
+  private:
+    /// Map of builtin structure to unique generated name
+    std::unordered_map<const type::Struct*, std::string> builtin_struct_names_;
+};
+
+}  // namespace tint::writer
+
+#endif  // SRC_TINT_UTILS_TEXT_TEXT_GENERATOR_H_
diff --git a/src/tint/utils/text/unicode.cc b/src/tint/utils/text/unicode.cc
new file mode 100644
index 0000000..7bb64b5
--- /dev/null
+++ b/src/tint/utils/text/unicode.cc
@@ -0,0 +1,425 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/text/unicode.h"
+
+#include <algorithm>
+
+namespace tint::utils {
+namespace {
+
+struct CodePointRange {
+    uint32_t first;  // First code point in the interval
+    uint32_t last;   // Last code point in the interval (inclusive)
+};
+
+inline bool operator<(CodePoint code_point, CodePointRange range) {
+    return code_point < range.first;
+}
+inline bool operator<(CodePointRange range, CodePoint code_point) {
+    return range.last < code_point;
+}
+
+// Interval ranges of all code points in the Unicode 14 XID_Start set
+// This array needs to be in ascending order.
+constexpr CodePointRange kXIDStartRanges[] = {
+    {0x00041, 0x0005a}, {0x00061, 0x0007a}, {0x000aa, 0x000aa}, {0x000b5, 0x000b5},
+    {0x000ba, 0x000ba}, {0x000c0, 0x000d6}, {0x000d8, 0x000f6}, {0x000f8, 0x002c1},
+    {0x002c6, 0x002d1}, {0x002e0, 0x002e4}, {0x002ec, 0x002ec}, {0x002ee, 0x002ee},
+    {0x00370, 0x00374}, {0x00376, 0x00377}, {0x0037b, 0x0037d}, {0x0037f, 0x0037f},
+    {0x00386, 0x00386}, {0x00388, 0x0038a}, {0x0038c, 0x0038c}, {0x0038e, 0x003a1},
+    {0x003a3, 0x003f5}, {0x003f7, 0x00481}, {0x0048a, 0x0052f}, {0x00531, 0x00556},
+    {0x00559, 0x00559}, {0x00560, 0x00588}, {0x005d0, 0x005ea}, {0x005ef, 0x005f2},
+    {0x00620, 0x0064a}, {0x0066e, 0x0066f}, {0x00671, 0x006d3}, {0x006d5, 0x006d5},
+    {0x006e5, 0x006e6}, {0x006ee, 0x006ef}, {0x006fa, 0x006fc}, {0x006ff, 0x006ff},
+    {0x00710, 0x00710}, {0x00712, 0x0072f}, {0x0074d, 0x007a5}, {0x007b1, 0x007b1},
+    {0x007ca, 0x007ea}, {0x007f4, 0x007f5}, {0x007fa, 0x007fa}, {0x00800, 0x00815},
+    {0x0081a, 0x0081a}, {0x00824, 0x00824}, {0x00828, 0x00828}, {0x00840, 0x00858},
+    {0x00860, 0x0086a}, {0x00870, 0x00887}, {0x00889, 0x0088e}, {0x008a0, 0x008c9},
+    {0x00904, 0x00939}, {0x0093d, 0x0093d}, {0x00950, 0x00950}, {0x00958, 0x00961},
+    {0x00971, 0x00980}, {0x00985, 0x0098c}, {0x0098f, 0x00990}, {0x00993, 0x009a8},
+    {0x009aa, 0x009b0}, {0x009b2, 0x009b2}, {0x009b6, 0x009b9}, {0x009bd, 0x009bd},
+    {0x009ce, 0x009ce}, {0x009dc, 0x009dd}, {0x009df, 0x009e1}, {0x009f0, 0x009f1},
+    {0x009fc, 0x009fc}, {0x00a05, 0x00a0a}, {0x00a0f, 0x00a10}, {0x00a13, 0x00a28},
+    {0x00a2a, 0x00a30}, {0x00a32, 0x00a33}, {0x00a35, 0x00a36}, {0x00a38, 0x00a39},
+    {0x00a59, 0x00a5c}, {0x00a5e, 0x00a5e}, {0x00a72, 0x00a74}, {0x00a85, 0x00a8d},
+    {0x00a8f, 0x00a91}, {0x00a93, 0x00aa8}, {0x00aaa, 0x00ab0}, {0x00ab2, 0x00ab3},
+    {0x00ab5, 0x00ab9}, {0x00abd, 0x00abd}, {0x00ad0, 0x00ad0}, {0x00ae0, 0x00ae1},
+    {0x00af9, 0x00af9}, {0x00b05, 0x00b0c}, {0x00b0f, 0x00b10}, {0x00b13, 0x00b28},
+    {0x00b2a, 0x00b30}, {0x00b32, 0x00b33}, {0x00b35, 0x00b39}, {0x00b3d, 0x00b3d},
+    {0x00b5c, 0x00b5d}, {0x00b5f, 0x00b61}, {0x00b71, 0x00b71}, {0x00b83, 0x00b83},
+    {0x00b85, 0x00b8a}, {0x00b8e, 0x00b90}, {0x00b92, 0x00b95}, {0x00b99, 0x00b9a},
+    {0x00b9c, 0x00b9c}, {0x00b9e, 0x00b9f}, {0x00ba3, 0x00ba4}, {0x00ba8, 0x00baa},
+    {0x00bae, 0x00bb9}, {0x00bd0, 0x00bd0}, {0x00c05, 0x00c0c}, {0x00c0e, 0x00c10},
+    {0x00c12, 0x00c28}, {0x00c2a, 0x00c39}, {0x00c3d, 0x00c3d}, {0x00c58, 0x00c5a},
+    {0x00c5d, 0x00c5d}, {0x00c60, 0x00c61}, {0x00c80, 0x00c80}, {0x00c85, 0x00c8c},
+    {0x00c8e, 0x00c90}, {0x00c92, 0x00ca8}, {0x00caa, 0x00cb3}, {0x00cb5, 0x00cb9},
+    {0x00cbd, 0x00cbd}, {0x00cdd, 0x00cde}, {0x00ce0, 0x00ce1}, {0x00cf1, 0x00cf2},
+    {0x00d04, 0x00d0c}, {0x00d0e, 0x00d10}, {0x00d12, 0x00d3a}, {0x00d3d, 0x00d3d},
+    {0x00d4e, 0x00d4e}, {0x00d54, 0x00d56}, {0x00d5f, 0x00d61}, {0x00d7a, 0x00d7f},
+    {0x00d85, 0x00d96}, {0x00d9a, 0x00db1}, {0x00db3, 0x00dbb}, {0x00dbd, 0x00dbd},
+    {0x00dc0, 0x00dc6}, {0x00e01, 0x00e30}, {0x00e32, 0x00e32}, {0x00e40, 0x00e46},
+    {0x00e81, 0x00e82}, {0x00e84, 0x00e84}, {0x00e86, 0x00e8a}, {0x00e8c, 0x00ea3},
+    {0x00ea5, 0x00ea5}, {0x00ea7, 0x00eb0}, {0x00eb2, 0x00eb2}, {0x00ebd, 0x00ebd},
+    {0x00ec0, 0x00ec4}, {0x00ec6, 0x00ec6}, {0x00edc, 0x00edf}, {0x00f00, 0x00f00},
+    {0x00f40, 0x00f47}, {0x00f49, 0x00f6c}, {0x00f88, 0x00f8c}, {0x01000, 0x0102a},
+    {0x0103f, 0x0103f}, {0x01050, 0x01055}, {0x0105a, 0x0105d}, {0x01061, 0x01061},
+    {0x01065, 0x01066}, {0x0106e, 0x01070}, {0x01075, 0x01081}, {0x0108e, 0x0108e},
+    {0x010a0, 0x010c5}, {0x010c7, 0x010c7}, {0x010cd, 0x010cd}, {0x010d0, 0x010fa},
+    {0x010fc, 0x01248}, {0x0124a, 0x0124d}, {0x01250, 0x01256}, {0x01258, 0x01258},
+    {0x0125a, 0x0125d}, {0x01260, 0x01288}, {0x0128a, 0x0128d}, {0x01290, 0x012b0},
+    {0x012b2, 0x012b5}, {0x012b8, 0x012be}, {0x012c0, 0x012c0}, {0x012c2, 0x012c5},
+    {0x012c8, 0x012d6}, {0x012d8, 0x01310}, {0x01312, 0x01315}, {0x01318, 0x0135a},
+    {0x01380, 0x0138f}, {0x013a0, 0x013f5}, {0x013f8, 0x013fd}, {0x01401, 0x0166c},
+    {0x0166f, 0x0167f}, {0x01681, 0x0169a}, {0x016a0, 0x016ea}, {0x016ee, 0x016f8},
+    {0x01700, 0x01711}, {0x0171f, 0x01731}, {0x01740, 0x01751}, {0x01760, 0x0176c},
+    {0x0176e, 0x01770}, {0x01780, 0x017b3}, {0x017d7, 0x017d7}, {0x017dc, 0x017dc},
+    {0x01820, 0x01878}, {0x01880, 0x018a8}, {0x018aa, 0x018aa}, {0x018b0, 0x018f5},
+    {0x01900, 0x0191e}, {0x01950, 0x0196d}, {0x01970, 0x01974}, {0x01980, 0x019ab},
+    {0x019b0, 0x019c9}, {0x01a00, 0x01a16}, {0x01a20, 0x01a54}, {0x01aa7, 0x01aa7},
+    {0x01b05, 0x01b33}, {0x01b45, 0x01b4c}, {0x01b83, 0x01ba0}, {0x01bae, 0x01baf},
+    {0x01bba, 0x01be5}, {0x01c00, 0x01c23}, {0x01c4d, 0x01c4f}, {0x01c5a, 0x01c7d},
+    {0x01c80, 0x01c88}, {0x01c90, 0x01cba}, {0x01cbd, 0x01cbf}, {0x01ce9, 0x01cec},
+    {0x01cee, 0x01cf3}, {0x01cf5, 0x01cf6}, {0x01cfa, 0x01cfa}, {0x01d00, 0x01dbf},
+    {0x01e00, 0x01f15}, {0x01f18, 0x01f1d}, {0x01f20, 0x01f45}, {0x01f48, 0x01f4d},
+    {0x01f50, 0x01f57}, {0x01f59, 0x01f59}, {0x01f5b, 0x01f5b}, {0x01f5d, 0x01f5d},
+    {0x01f5f, 0x01f7d}, {0x01f80, 0x01fb4}, {0x01fb6, 0x01fbc}, {0x01fbe, 0x01fbe},
+    {0x01fc2, 0x01fc4}, {0x01fc6, 0x01fcc}, {0x01fd0, 0x01fd3}, {0x01fd6, 0x01fdb},
+    {0x01fe0, 0x01fec}, {0x01ff2, 0x01ff4}, {0x01ff6, 0x01ffc}, {0x02071, 0x02071},
+    {0x0207f, 0x0207f}, {0x02090, 0x0209c}, {0x02102, 0x02102}, {0x02107, 0x02107},
+    {0x0210a, 0x02113}, {0x02115, 0x02115}, {0x02118, 0x0211d}, {0x02124, 0x02124},
+    {0x02126, 0x02126}, {0x02128, 0x02128}, {0x0212a, 0x02139}, {0x0213c, 0x0213f},
+    {0x02145, 0x02149}, {0x0214e, 0x0214e}, {0x02160, 0x02188}, {0x02c00, 0x02ce4},
+    {0x02ceb, 0x02cee}, {0x02cf2, 0x02cf3}, {0x02d00, 0x02d25}, {0x02d27, 0x02d27},
+    {0x02d2d, 0x02d2d}, {0x02d30, 0x02d67}, {0x02d6f, 0x02d6f}, {0x02d80, 0x02d96},
+    {0x02da0, 0x02da6}, {0x02da8, 0x02dae}, {0x02db0, 0x02db6}, {0x02db8, 0x02dbe},
+    {0x02dc0, 0x02dc6}, {0x02dc8, 0x02dce}, {0x02dd0, 0x02dd6}, {0x02dd8, 0x02dde},
+    {0x03005, 0x03007}, {0x03021, 0x03029}, {0x03031, 0x03035}, {0x03038, 0x0303c},
+    {0x03041, 0x03096}, {0x0309d, 0x0309f}, {0x030a1, 0x030fa}, {0x030fc, 0x030ff},
+    {0x03105, 0x0312f}, {0x03131, 0x0318e}, {0x031a0, 0x031bf}, {0x031f0, 0x031ff},
+    {0x03400, 0x04dbf}, {0x04e00, 0x0a48c}, {0x0a4d0, 0x0a4fd}, {0x0a500, 0x0a60c},
+    {0x0a610, 0x0a61f}, {0x0a62a, 0x0a62b}, {0x0a640, 0x0a66e}, {0x0a67f, 0x0a69d},
+    {0x0a6a0, 0x0a6ef}, {0x0a717, 0x0a71f}, {0x0a722, 0x0a788}, {0x0a78b, 0x0a7ca},
+    {0x0a7d0, 0x0a7d1}, {0x0a7d3, 0x0a7d3}, {0x0a7d5, 0x0a7d9}, {0x0a7f2, 0x0a801},
+    {0x0a803, 0x0a805}, {0x0a807, 0x0a80a}, {0x0a80c, 0x0a822}, {0x0a840, 0x0a873},
+    {0x0a882, 0x0a8b3}, {0x0a8f2, 0x0a8f7}, {0x0a8fb, 0x0a8fb}, {0x0a8fd, 0x0a8fe},
+    {0x0a90a, 0x0a925}, {0x0a930, 0x0a946}, {0x0a960, 0x0a97c}, {0x0a984, 0x0a9b2},
+    {0x0a9cf, 0x0a9cf}, {0x0a9e0, 0x0a9e4}, {0x0a9e6, 0x0a9ef}, {0x0a9fa, 0x0a9fe},
+    {0x0aa00, 0x0aa28}, {0x0aa40, 0x0aa42}, {0x0aa44, 0x0aa4b}, {0x0aa60, 0x0aa76},
+    {0x0aa7a, 0x0aa7a}, {0x0aa7e, 0x0aaaf}, {0x0aab1, 0x0aab1}, {0x0aab5, 0x0aab6},
+    {0x0aab9, 0x0aabd}, {0x0aac0, 0x0aac0}, {0x0aac2, 0x0aac2}, {0x0aadb, 0x0aadd},
+    {0x0aae0, 0x0aaea}, {0x0aaf2, 0x0aaf4}, {0x0ab01, 0x0ab06}, {0x0ab09, 0x0ab0e},
+    {0x0ab11, 0x0ab16}, {0x0ab20, 0x0ab26}, {0x0ab28, 0x0ab2e}, {0x0ab30, 0x0ab5a},
+    {0x0ab5c, 0x0ab69}, {0x0ab70, 0x0abe2}, {0x0ac00, 0x0d7a3}, {0x0d7b0, 0x0d7c6},
+    {0x0d7cb, 0x0d7fb}, {0x0f900, 0x0fa6d}, {0x0fa70, 0x0fad9}, {0x0fb00, 0x0fb06},
+    {0x0fb13, 0x0fb17}, {0x0fb1d, 0x0fb1d}, {0x0fb1f, 0x0fb28}, {0x0fb2a, 0x0fb36},
+    {0x0fb38, 0x0fb3c}, {0x0fb3e, 0x0fb3e}, {0x0fb40, 0x0fb41}, {0x0fb43, 0x0fb44},
+    {0x0fb46, 0x0fbb1}, {0x0fbd3, 0x0fc5d}, {0x0fc64, 0x0fd3d}, {0x0fd50, 0x0fd8f},
+    {0x0fd92, 0x0fdc7}, {0x0fdf0, 0x0fdf9}, {0x0fe71, 0x0fe71}, {0x0fe73, 0x0fe73},
+    {0x0fe77, 0x0fe77}, {0x0fe79, 0x0fe79}, {0x0fe7b, 0x0fe7b}, {0x0fe7d, 0x0fe7d},
+    {0x0fe7f, 0x0fefc}, {0x0ff21, 0x0ff3a}, {0x0ff41, 0x0ff5a}, {0x0ff66, 0x0ff9d},
+    {0x0ffa0, 0x0ffbe}, {0x0ffc2, 0x0ffc7}, {0x0ffca, 0x0ffcf}, {0x0ffd2, 0x0ffd7},
+    {0x0ffda, 0x0ffdc}, {0x10000, 0x1000b}, {0x1000d, 0x10026}, {0x10028, 0x1003a},
+    {0x1003c, 0x1003d}, {0x1003f, 0x1004d}, {0x10050, 0x1005d}, {0x10080, 0x100fa},
+    {0x10140, 0x10174}, {0x10280, 0x1029c}, {0x102a0, 0x102d0}, {0x10300, 0x1031f},
+    {0x1032d, 0x1034a}, {0x10350, 0x10375}, {0x10380, 0x1039d}, {0x103a0, 0x103c3},
+    {0x103c8, 0x103cf}, {0x103d1, 0x103d5}, {0x10400, 0x1049d}, {0x104b0, 0x104d3},
+    {0x104d8, 0x104fb}, {0x10500, 0x10527}, {0x10530, 0x10563}, {0x10570, 0x1057a},
+    {0x1057c, 0x1058a}, {0x1058c, 0x10592}, {0x10594, 0x10595}, {0x10597, 0x105a1},
+    {0x105a3, 0x105b1}, {0x105b3, 0x105b9}, {0x105bb, 0x105bc}, {0x10600, 0x10736},
+    {0x10740, 0x10755}, {0x10760, 0x10767}, {0x10780, 0x10785}, {0x10787, 0x107b0},
+    {0x107b2, 0x107ba}, {0x10800, 0x10805}, {0x10808, 0x10808}, {0x1080a, 0x10835},
+    {0x10837, 0x10838}, {0x1083c, 0x1083c}, {0x1083f, 0x10855}, {0x10860, 0x10876},
+    {0x10880, 0x1089e}, {0x108e0, 0x108f2}, {0x108f4, 0x108f5}, {0x10900, 0x10915},
+    {0x10920, 0x10939}, {0x10980, 0x109b7}, {0x109be, 0x109bf}, {0x10a00, 0x10a00},
+    {0x10a10, 0x10a13}, {0x10a15, 0x10a17}, {0x10a19, 0x10a35}, {0x10a60, 0x10a7c},
+    {0x10a80, 0x10a9c}, {0x10ac0, 0x10ac7}, {0x10ac9, 0x10ae4}, {0x10b00, 0x10b35},
+    {0x10b40, 0x10b55}, {0x10b60, 0x10b72}, {0x10b80, 0x10b91}, {0x10c00, 0x10c48},
+    {0x10c80, 0x10cb2}, {0x10cc0, 0x10cf2}, {0x10d00, 0x10d23}, {0x10e80, 0x10ea9},
+    {0x10eb0, 0x10eb1}, {0x10f00, 0x10f1c}, {0x10f27, 0x10f27}, {0x10f30, 0x10f45},
+    {0x10f70, 0x10f81}, {0x10fb0, 0x10fc4}, {0x10fe0, 0x10ff6}, {0x11003, 0x11037},
+    {0x11071, 0x11072}, {0x11075, 0x11075}, {0x11083, 0x110af}, {0x110d0, 0x110e8},
+    {0x11103, 0x11126}, {0x11144, 0x11144}, {0x11147, 0x11147}, {0x11150, 0x11172},
+    {0x11176, 0x11176}, {0x11183, 0x111b2}, {0x111c1, 0x111c4}, {0x111da, 0x111da},
+    {0x111dc, 0x111dc}, {0x11200, 0x11211}, {0x11213, 0x1122b}, {0x11280, 0x11286},
+    {0x11288, 0x11288}, {0x1128a, 0x1128d}, {0x1128f, 0x1129d}, {0x1129f, 0x112a8},
+    {0x112b0, 0x112de}, {0x11305, 0x1130c}, {0x1130f, 0x11310}, {0x11313, 0x11328},
+    {0x1132a, 0x11330}, {0x11332, 0x11333}, {0x11335, 0x11339}, {0x1133d, 0x1133d},
+    {0x11350, 0x11350}, {0x1135d, 0x11361}, {0x11400, 0x11434}, {0x11447, 0x1144a},
+    {0x1145f, 0x11461}, {0x11480, 0x114af}, {0x114c4, 0x114c5}, {0x114c7, 0x114c7},
+    {0x11580, 0x115ae}, {0x115d8, 0x115db}, {0x11600, 0x1162f}, {0x11644, 0x11644},
+    {0x11680, 0x116aa}, {0x116b8, 0x116b8}, {0x11700, 0x1171a}, {0x11740, 0x11746},
+    {0x11800, 0x1182b}, {0x118a0, 0x118df}, {0x118ff, 0x11906}, {0x11909, 0x11909},
+    {0x1190c, 0x11913}, {0x11915, 0x11916}, {0x11918, 0x1192f}, {0x1193f, 0x1193f},
+    {0x11941, 0x11941}, {0x119a0, 0x119a7}, {0x119aa, 0x119d0}, {0x119e1, 0x119e1},
+    {0x119e3, 0x119e3}, {0x11a00, 0x11a00}, {0x11a0b, 0x11a32}, {0x11a3a, 0x11a3a},
+    {0x11a50, 0x11a50}, {0x11a5c, 0x11a89}, {0x11a9d, 0x11a9d}, {0x11ab0, 0x11af8},
+    {0x11c00, 0x11c08}, {0x11c0a, 0x11c2e}, {0x11c40, 0x11c40}, {0x11c72, 0x11c8f},
+    {0x11d00, 0x11d06}, {0x11d08, 0x11d09}, {0x11d0b, 0x11d30}, {0x11d46, 0x11d46},
+    {0x11d60, 0x11d65}, {0x11d67, 0x11d68}, {0x11d6a, 0x11d89}, {0x11d98, 0x11d98},
+    {0x11ee0, 0x11ef2}, {0x11fb0, 0x11fb0}, {0x12000, 0x12399}, {0x12400, 0x1246e},
+    {0x12480, 0x12543}, {0x12f90, 0x12ff0}, {0x13000, 0x1342e}, {0x14400, 0x14646},
+    {0x16800, 0x16a38}, {0x16a40, 0x16a5e}, {0x16a70, 0x16abe}, {0x16ad0, 0x16aed},
+    {0x16b00, 0x16b2f}, {0x16b40, 0x16b43}, {0x16b63, 0x16b77}, {0x16b7d, 0x16b8f},
+    {0x16e40, 0x16e7f}, {0x16f00, 0x16f4a}, {0x16f50, 0x16f50}, {0x16f93, 0x16f9f},
+    {0x16fe0, 0x16fe1}, {0x16fe3, 0x16fe3}, {0x17000, 0x187f7}, {0x18800, 0x18cd5},
+    {0x18d00, 0x18d08}, {0x1aff0, 0x1aff3}, {0x1aff5, 0x1affb}, {0x1affd, 0x1affe},
+    {0x1b000, 0x1b122}, {0x1b150, 0x1b152}, {0x1b164, 0x1b167}, {0x1b170, 0x1b2fb},
+    {0x1bc00, 0x1bc6a}, {0x1bc70, 0x1bc7c}, {0x1bc80, 0x1bc88}, {0x1bc90, 0x1bc99},
+    {0x1d400, 0x1d454}, {0x1d456, 0x1d49c}, {0x1d49e, 0x1d49f}, {0x1d4a2, 0x1d4a2},
+    {0x1d4a5, 0x1d4a6}, {0x1d4a9, 0x1d4ac}, {0x1d4ae, 0x1d4b9}, {0x1d4bb, 0x1d4bb},
+    {0x1d4bd, 0x1d4c3}, {0x1d4c5, 0x1d505}, {0x1d507, 0x1d50a}, {0x1d50d, 0x1d514},
+    {0x1d516, 0x1d51c}, {0x1d51e, 0x1d539}, {0x1d53b, 0x1d53e}, {0x1d540, 0x1d544},
+    {0x1d546, 0x1d546}, {0x1d54a, 0x1d550}, {0x1d552, 0x1d6a5}, {0x1d6a8, 0x1d6c0},
+    {0x1d6c2, 0x1d6da}, {0x1d6dc, 0x1d6fa}, {0x1d6fc, 0x1d714}, {0x1d716, 0x1d734},
+    {0x1d736, 0x1d74e}, {0x1d750, 0x1d76e}, {0x1d770, 0x1d788}, {0x1d78a, 0x1d7a8},
+    {0x1d7aa, 0x1d7c2}, {0x1d7c4, 0x1d7cb}, {0x1df00, 0x1df1e}, {0x1e100, 0x1e12c},
+    {0x1e137, 0x1e13d}, {0x1e14e, 0x1e14e}, {0x1e290, 0x1e2ad}, {0x1e2c0, 0x1e2eb},
+    {0x1e7e0, 0x1e7e6}, {0x1e7e8, 0x1e7eb}, {0x1e7ed, 0x1e7ee}, {0x1e7f0, 0x1e7fe},
+    {0x1e800, 0x1e8c4}, {0x1e900, 0x1e943}, {0x1e94b, 0x1e94b}, {0x1ee00, 0x1ee03},
+    {0x1ee05, 0x1ee1f}, {0x1ee21, 0x1ee22}, {0x1ee24, 0x1ee24}, {0x1ee27, 0x1ee27},
+    {0x1ee29, 0x1ee32}, {0x1ee34, 0x1ee37}, {0x1ee39, 0x1ee39}, {0x1ee3b, 0x1ee3b},
+    {0x1ee42, 0x1ee42}, {0x1ee47, 0x1ee47}, {0x1ee49, 0x1ee49}, {0x1ee4b, 0x1ee4b},
+    {0x1ee4d, 0x1ee4f}, {0x1ee51, 0x1ee52}, {0x1ee54, 0x1ee54}, {0x1ee57, 0x1ee57},
+    {0x1ee59, 0x1ee59}, {0x1ee5b, 0x1ee5b}, {0x1ee5d, 0x1ee5d}, {0x1ee5f, 0x1ee5f},
+    {0x1ee61, 0x1ee62}, {0x1ee64, 0x1ee64}, {0x1ee67, 0x1ee6a}, {0x1ee6c, 0x1ee72},
+    {0x1ee74, 0x1ee77}, {0x1ee79, 0x1ee7c}, {0x1ee7e, 0x1ee7e}, {0x1ee80, 0x1ee89},
+    {0x1ee8b, 0x1ee9b}, {0x1eea1, 0x1eea3}, {0x1eea5, 0x1eea9}, {0x1eeab, 0x1eebb},
+    {0x20000, 0x2a6df}, {0x2a700, 0x2b738}, {0x2b740, 0x2b81d}, {0x2b820, 0x2cea1},
+    {0x2ceb0, 0x2ebe0}, {0x2f800, 0x2fa1d}, {0x30000, 0x3134a},
+};
+
+// Number of ranges in kXIDStartRanges
+constexpr size_t kNumXIDStartRanges = sizeof(kXIDStartRanges) / sizeof(kXIDStartRanges[0]);
+
+// The additional code point interval ranges for the Unicode 14 XID_Continue
+// set. This extends the values in kXIDStartRanges.
+// This array needs to be in ascending order.
+constexpr CodePointRange kXIDContinueRanges[] = {
+    {0x00030, 0x00039}, {0x0005f, 0x0005f}, {0x000b7, 0x000b7}, {0x00300, 0x0036f},
+    {0x00387, 0x00387}, {0x00483, 0x00487}, {0x00591, 0x005bd}, {0x005bf, 0x005bf},
+    {0x005c1, 0x005c2}, {0x005c4, 0x005c5}, {0x005c7, 0x005c7}, {0x00610, 0x0061a},
+    {0x0064b, 0x00669}, {0x00670, 0x00670}, {0x006d6, 0x006dc}, {0x006df, 0x006e4},
+    {0x006e7, 0x006e8}, {0x006ea, 0x006ed}, {0x006f0, 0x006f9}, {0x00711, 0x00711},
+    {0x00730, 0x0074a}, {0x007a6, 0x007b0}, {0x007c0, 0x007c9}, {0x007eb, 0x007f3},
+    {0x007fd, 0x007fd}, {0x00816, 0x00819}, {0x0081b, 0x00823}, {0x00825, 0x00827},
+    {0x00829, 0x0082d}, {0x00859, 0x0085b}, {0x00898, 0x0089f}, {0x008ca, 0x008e1},
+    {0x008e3, 0x00903}, {0x0093a, 0x0093c}, {0x0093e, 0x0094f}, {0x00951, 0x00957},
+    {0x00962, 0x00963}, {0x00966, 0x0096f}, {0x00981, 0x00983}, {0x009bc, 0x009bc},
+    {0x009be, 0x009c4}, {0x009c7, 0x009c8}, {0x009cb, 0x009cd}, {0x009d7, 0x009d7},
+    {0x009e2, 0x009e3}, {0x009e6, 0x009ef}, {0x009fe, 0x009fe}, {0x00a01, 0x00a03},
+    {0x00a3c, 0x00a3c}, {0x00a3e, 0x00a42}, {0x00a47, 0x00a48}, {0x00a4b, 0x00a4d},
+    {0x00a51, 0x00a51}, {0x00a66, 0x00a71}, {0x00a75, 0x00a75}, {0x00a81, 0x00a83},
+    {0x00abc, 0x00abc}, {0x00abe, 0x00ac5}, {0x00ac7, 0x00ac9}, {0x00acb, 0x00acd},
+    {0x00ae2, 0x00ae3}, {0x00ae6, 0x00aef}, {0x00afa, 0x00aff}, {0x00b01, 0x00b03},
+    {0x00b3c, 0x00b3c}, {0x00b3e, 0x00b44}, {0x00b47, 0x00b48}, {0x00b4b, 0x00b4d},
+    {0x00b55, 0x00b57}, {0x00b62, 0x00b63}, {0x00b66, 0x00b6f}, {0x00b82, 0x00b82},
+    {0x00bbe, 0x00bc2}, {0x00bc6, 0x00bc8}, {0x00bca, 0x00bcd}, {0x00bd7, 0x00bd7},
+    {0x00be6, 0x00bef}, {0x00c00, 0x00c04}, {0x00c3c, 0x00c3c}, {0x00c3e, 0x00c44},
+    {0x00c46, 0x00c48}, {0x00c4a, 0x00c4d}, {0x00c55, 0x00c56}, {0x00c62, 0x00c63},
+    {0x00c66, 0x00c6f}, {0x00c81, 0x00c83}, {0x00cbc, 0x00cbc}, {0x00cbe, 0x00cc4},
+    {0x00cc6, 0x00cc8}, {0x00cca, 0x00ccd}, {0x00cd5, 0x00cd6}, {0x00ce2, 0x00ce3},
+    {0x00ce6, 0x00cef}, {0x00d00, 0x00d03}, {0x00d3b, 0x00d3c}, {0x00d3e, 0x00d44},
+    {0x00d46, 0x00d48}, {0x00d4a, 0x00d4d}, {0x00d57, 0x00d57}, {0x00d62, 0x00d63},
+    {0x00d66, 0x00d6f}, {0x00d81, 0x00d83}, {0x00dca, 0x00dca}, {0x00dcf, 0x00dd4},
+    {0x00dd6, 0x00dd6}, {0x00dd8, 0x00ddf}, {0x00de6, 0x00def}, {0x00df2, 0x00df3},
+    {0x00e31, 0x00e31}, {0x00e33, 0x00e3a}, {0x00e47, 0x00e4e}, {0x00e50, 0x00e59},
+    {0x00eb1, 0x00eb1}, {0x00eb3, 0x00ebc}, {0x00ec8, 0x00ecd}, {0x00ed0, 0x00ed9},
+    {0x00f18, 0x00f19}, {0x00f20, 0x00f29}, {0x00f35, 0x00f35}, {0x00f37, 0x00f37},
+    {0x00f39, 0x00f39}, {0x00f3e, 0x00f3f}, {0x00f71, 0x00f84}, {0x00f86, 0x00f87},
+    {0x00f8d, 0x00f97}, {0x00f99, 0x00fbc}, {0x00fc6, 0x00fc6}, {0x0102b, 0x0103e},
+    {0x01040, 0x01049}, {0x01056, 0x01059}, {0x0105e, 0x01060}, {0x01062, 0x01064},
+    {0x01067, 0x0106d}, {0x01071, 0x01074}, {0x01082, 0x0108d}, {0x0108f, 0x0109d},
+    {0x0135d, 0x0135f}, {0x01369, 0x01371}, {0x01712, 0x01715}, {0x01732, 0x01734},
+    {0x01752, 0x01753}, {0x01772, 0x01773}, {0x017b4, 0x017d3}, {0x017dd, 0x017dd},
+    {0x017e0, 0x017e9}, {0x0180b, 0x0180d}, {0x0180f, 0x01819}, {0x018a9, 0x018a9},
+    {0x01920, 0x0192b}, {0x01930, 0x0193b}, {0x01946, 0x0194f}, {0x019d0, 0x019da},
+    {0x01a17, 0x01a1b}, {0x01a55, 0x01a5e}, {0x01a60, 0x01a7c}, {0x01a7f, 0x01a89},
+    {0x01a90, 0x01a99}, {0x01ab0, 0x01abd}, {0x01abf, 0x01ace}, {0x01b00, 0x01b04},
+    {0x01b34, 0x01b44}, {0x01b50, 0x01b59}, {0x01b6b, 0x01b73}, {0x01b80, 0x01b82},
+    {0x01ba1, 0x01bad}, {0x01bb0, 0x01bb9}, {0x01be6, 0x01bf3}, {0x01c24, 0x01c37},
+    {0x01c40, 0x01c49}, {0x01c50, 0x01c59}, {0x01cd0, 0x01cd2}, {0x01cd4, 0x01ce8},
+    {0x01ced, 0x01ced}, {0x01cf4, 0x01cf4}, {0x01cf7, 0x01cf9}, {0x01dc0, 0x01dff},
+    {0x0203f, 0x02040}, {0x02054, 0x02054}, {0x020d0, 0x020dc}, {0x020e1, 0x020e1},
+    {0x020e5, 0x020f0}, {0x02cef, 0x02cf1}, {0x02d7f, 0x02d7f}, {0x02de0, 0x02dff},
+    {0x0302a, 0x0302f}, {0x03099, 0x0309a}, {0x0a620, 0x0a629}, {0x0a66f, 0x0a66f},
+    {0x0a674, 0x0a67d}, {0x0a69e, 0x0a69f}, {0x0a6f0, 0x0a6f1}, {0x0a802, 0x0a802},
+    {0x0a806, 0x0a806}, {0x0a80b, 0x0a80b}, {0x0a823, 0x0a827}, {0x0a82c, 0x0a82c},
+    {0x0a880, 0x0a881}, {0x0a8b4, 0x0a8c5}, {0x0a8d0, 0x0a8d9}, {0x0a8e0, 0x0a8f1},
+    {0x0a8ff, 0x0a909}, {0x0a926, 0x0a92d}, {0x0a947, 0x0a953}, {0x0a980, 0x0a983},
+    {0x0a9b3, 0x0a9c0}, {0x0a9d0, 0x0a9d9}, {0x0a9e5, 0x0a9e5}, {0x0a9f0, 0x0a9f9},
+    {0x0aa29, 0x0aa36}, {0x0aa43, 0x0aa43}, {0x0aa4c, 0x0aa4d}, {0x0aa50, 0x0aa59},
+    {0x0aa7b, 0x0aa7d}, {0x0aab0, 0x0aab0}, {0x0aab2, 0x0aab4}, {0x0aab7, 0x0aab8},
+    {0x0aabe, 0x0aabf}, {0x0aac1, 0x0aac1}, {0x0aaeb, 0x0aaef}, {0x0aaf5, 0x0aaf6},
+    {0x0abe3, 0x0abea}, {0x0abec, 0x0abed}, {0x0abf0, 0x0abf9}, {0x0fb1e, 0x0fb1e},
+    {0x0fe00, 0x0fe0f}, {0x0fe20, 0x0fe2f}, {0x0fe33, 0x0fe34}, {0x0fe4d, 0x0fe4f},
+    {0x0ff10, 0x0ff19}, {0x0ff3f, 0x0ff3f}, {0x0ff9e, 0x0ff9f}, {0x101fd, 0x101fd},
+    {0x102e0, 0x102e0}, {0x10376, 0x1037a}, {0x104a0, 0x104a9}, {0x10a01, 0x10a03},
+    {0x10a05, 0x10a06}, {0x10a0c, 0x10a0f}, {0x10a38, 0x10a3a}, {0x10a3f, 0x10a3f},
+    {0x10ae5, 0x10ae6}, {0x10d24, 0x10d27}, {0x10d30, 0x10d39}, {0x10eab, 0x10eac},
+    {0x10f46, 0x10f50}, {0x10f82, 0x10f85}, {0x11000, 0x11002}, {0x11038, 0x11046},
+    {0x11066, 0x11070}, {0x11073, 0x11074}, {0x1107f, 0x11082}, {0x110b0, 0x110ba},
+    {0x110c2, 0x110c2}, {0x110f0, 0x110f9}, {0x11100, 0x11102}, {0x11127, 0x11134},
+    {0x11136, 0x1113f}, {0x11145, 0x11146}, {0x11173, 0x11173}, {0x11180, 0x11182},
+    {0x111b3, 0x111c0}, {0x111c9, 0x111cc}, {0x111ce, 0x111d9}, {0x1122c, 0x11237},
+    {0x1123e, 0x1123e}, {0x112df, 0x112ea}, {0x112f0, 0x112f9}, {0x11300, 0x11303},
+    {0x1133b, 0x1133c}, {0x1133e, 0x11344}, {0x11347, 0x11348}, {0x1134b, 0x1134d},
+    {0x11357, 0x11357}, {0x11362, 0x11363}, {0x11366, 0x1136c}, {0x11370, 0x11374},
+    {0x11435, 0x11446}, {0x11450, 0x11459}, {0x1145e, 0x1145e}, {0x114b0, 0x114c3},
+    {0x114d0, 0x114d9}, {0x115af, 0x115b5}, {0x115b8, 0x115c0}, {0x115dc, 0x115dd},
+    {0x11630, 0x11640}, {0x11650, 0x11659}, {0x116ab, 0x116b7}, {0x116c0, 0x116c9},
+    {0x1171d, 0x1172b}, {0x11730, 0x11739}, {0x1182c, 0x1183a}, {0x118e0, 0x118e9},
+    {0x11930, 0x11935}, {0x11937, 0x11938}, {0x1193b, 0x1193e}, {0x11940, 0x11940},
+    {0x11942, 0x11943}, {0x11950, 0x11959}, {0x119d1, 0x119d7}, {0x119da, 0x119e0},
+    {0x119e4, 0x119e4}, {0x11a01, 0x11a0a}, {0x11a33, 0x11a39}, {0x11a3b, 0x11a3e},
+    {0x11a47, 0x11a47}, {0x11a51, 0x11a5b}, {0x11a8a, 0x11a99}, {0x11c2f, 0x11c36},
+    {0x11c38, 0x11c3f}, {0x11c50, 0x11c59}, {0x11c92, 0x11ca7}, {0x11ca9, 0x11cb6},
+    {0x11d31, 0x11d36}, {0x11d3a, 0x11d3a}, {0x11d3c, 0x11d3d}, {0x11d3f, 0x11d45},
+    {0x11d47, 0x11d47}, {0x11d50, 0x11d59}, {0x11d8a, 0x11d8e}, {0x11d90, 0x11d91},
+    {0x11d93, 0x11d97}, {0x11da0, 0x11da9}, {0x11ef3, 0x11ef6}, {0x16a60, 0x16a69},
+    {0x16ac0, 0x16ac9}, {0x16af0, 0x16af4}, {0x16b30, 0x16b36}, {0x16b50, 0x16b59},
+    {0x16f4f, 0x16f4f}, {0x16f51, 0x16f87}, {0x16f8f, 0x16f92}, {0x16fe4, 0x16fe4},
+    {0x16ff0, 0x16ff1}, {0x1bc9d, 0x1bc9e}, {0x1cf00, 0x1cf2d}, {0x1cf30, 0x1cf46},
+    {0x1d165, 0x1d169}, {0x1d16d, 0x1d172}, {0x1d17b, 0x1d182}, {0x1d185, 0x1d18b},
+    {0x1d1aa, 0x1d1ad}, {0x1d242, 0x1d244}, {0x1d7ce, 0x1d7ff}, {0x1da00, 0x1da36},
+    {0x1da3b, 0x1da6c}, {0x1da75, 0x1da75}, {0x1da84, 0x1da84}, {0x1da9b, 0x1da9f},
+    {0x1daa1, 0x1daaf}, {0x1e000, 0x1e006}, {0x1e008, 0x1e018}, {0x1e01b, 0x1e021},
+    {0x1e023, 0x1e024}, {0x1e026, 0x1e02a}, {0x1e130, 0x1e136}, {0x1e140, 0x1e149},
+    {0x1e2ae, 0x1e2ae}, {0x1e2ec, 0x1e2f9}, {0x1e8d0, 0x1e8d6}, {0x1e944, 0x1e94a},
+    {0x1e950, 0x1e959}, {0x1fbf0, 0x1fbf9}, {0xe0100, 0xe01ef},
+};
+
+// Number of ranges in kXIDContinueRanges
+constexpr size_t kNumXIDContinueRanges = sizeof(kXIDContinueRanges) / sizeof(kXIDContinueRanges[0]);
+
+}  // namespace
+
+bool CodePoint::IsXIDStart() const {
+    // Short circuit ASCII. The binary search will find these last, but most
+    // of our current source is ASCII, so handle them quicker.
+    if ((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z')) {
+        return true;
+    }
+    // With [a-zA-Z] handled, nothing less then the next sequence start can be
+    // XIDStart, so filter them all out. This catches most of the common symbols
+    // that are used in ASCII.
+    if (value < 0x000aa) {
+        return false;
+    }
+    return std::binary_search(kXIDStartRanges, kXIDStartRanges + kNumXIDStartRanges, *this);
+}
+
+bool CodePoint::IsXIDContinue() const {
+    // Short circuit ASCII. The binary search will find these last, but most
+    // of our current source is ASCII, so handle them quicker.
+    if ((value >= '0' && value <= '9') || value == '_') {
+        return true;
+    }
+    return IsXIDStart() || std::binary_search(kXIDContinueRanges,
+                                              kXIDContinueRanges + kNumXIDContinueRanges, *this);
+}
+
+namespace utf8 {
+
+std::pair<CodePoint, size_t> Decode(const uint8_t* ptr, size_t len) {
+    if (len < 1) {
+        return {};
+    }
+    // Fast-path ASCII characters as they're always valid
+    if (ptr[0] <= 0x7f) {
+        return {CodePoint{ptr[0]}, 1};
+    }
+
+    // Lookup table for the first byte of a UTF-8 sequence.
+    // 0 indicates an invalid length.
+    // Note that bit encodings that can fit in a smaller number of bytes are
+    // invalid (e.g. 0xc0). Code points that exceed the unicode maximum of
+    // 0x10FFFF are also invalid (0xf5+).
+    // See: https://en.wikipedia.org/wiki/UTF-8#Encoding and
+    //      https://datatracker.ietf.org/doc/html/rfc3629#section-3
+    static constexpr uint8_t kSequenceLength[256] = {
+        //         0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
+        /* 0x00 */ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
+        /* 0x10 */ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
+        /* 0x20 */ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
+        /* 0x30 */ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
+        /* 0x40 */ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
+        /* 0x50 */ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
+        /* 0x60 */ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
+        /* 0x70 */ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
+        /* 0x80 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+        /* 0x90 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+        /* 0xa0 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+        /* 0xb0 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+        /* 0xc0 */ 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
+        /* 0xd0 */ 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
+        /* 0xe0 */ 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
+        /* 0xf0 */ 4, 4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+    };
+
+    uint8_t n = kSequenceLength[ptr[0]];
+    if (n > len) {
+        return {};
+    }
+
+    CodePoint c;
+
+    uint8_t valid = 0x80;
+    switch (n) {
+        // Note: n=0 (invalid) is correctly handled without a case.
+        case 1:
+            c = CodePoint{ptr[0]};
+            break;
+        case 2:
+            valid &= ptr[1];
+            c = CodePoint{(static_cast<uint32_t>(ptr[0] & 0b00011111) << 6) |
+                          (static_cast<uint32_t>(ptr[1] & 0b00111111))};
+            break;
+        case 3:
+            valid &= ptr[1] & ptr[2];
+            c = CodePoint{(static_cast<uint32_t>(ptr[0] & 0b00001111) << 12) |
+                          (static_cast<uint32_t>(ptr[1] & 0b00111111) << 6) |
+                          (static_cast<uint32_t>(ptr[2] & 0b00111111))};
+            break;
+        case 4:
+            valid &= ptr[1] & ptr[2] & ptr[3];
+            c = CodePoint{(static_cast<uint32_t>(ptr[0] & 0b00000111) << 18) |
+                          (static_cast<uint32_t>(ptr[1] & 0b00111111) << 12) |
+                          (static_cast<uint32_t>(ptr[2] & 0b00111111) << 6) |
+                          (static_cast<uint32_t>(ptr[3] & 0b00111111))};
+            break;
+    }
+    if (!valid) {
+        n = 0;
+        c = 0;
+    }
+    return {c, n};
+}
+
+std::pair<CodePoint, size_t> Decode(std::string_view utf8_string) {
+    return Decode(reinterpret_cast<const uint8_t*>(utf8_string.data()), utf8_string.size());
+}
+
+bool IsASCII(std::string_view str) {
+    for (auto c : str) {
+        if (c & 0x80) {
+            return false;
+        }
+    }
+    return true;
+}
+
+}  // namespace utf8
+
+}  // namespace tint::utils
diff --git a/src/tint/utils/text/unicode.h b/src/tint/utils/text/unicode.h
new file mode 100644
index 0000000..e5e32c0
--- /dev/null
+++ b/src/tint/utils/text/unicode.h
@@ -0,0 +1,80 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_TEXT_UNICODE_H_
+#define SRC_TINT_UTILS_TEXT_UNICODE_H_
+
+#include <cstddef>
+#include <cstdint>
+#include <string_view>
+#include <utility>
+
+namespace tint::utils {
+
+/// CodePoint is a unicode code point.
+struct CodePoint {
+    /// Constructor
+    inline CodePoint() = default;
+
+    /// Constructor
+    /// @param v the code point value
+    inline explicit CodePoint(uint32_t v) : value(v) {}
+
+    /// @returns the code point value
+    inline operator uint32_t() const { return value; }
+
+    /// Assignment operator
+    /// @param v the new value for the code point
+    /// @returns this CodePoint
+    inline CodePoint& operator=(uint32_t v) {
+        value = v;
+        return *this;
+    }
+
+    /// @returns true if this CodePoint is in the XID_Start set.
+    /// @see https://unicode.org/reports/tr31/
+    bool IsXIDStart() const;
+
+    /// @returns true if this CodePoint is in the XID_Continue set.
+    /// @see https://unicode.org/reports/tr31/
+    bool IsXIDContinue() const;
+
+    /// The code point value
+    uint32_t value = 0;
+};
+
+namespace utf8 {
+
+/// Decodes the first code point in the utf8 string.
+/// @param ptr the pointer to the first byte of the utf8 sequence
+/// @param len the maximum number of bytes to read
+/// @returns a pair of CodePoint and width in code units (bytes).
+///          If the next code point cannot be decoded then returns [0,0].
+std::pair<CodePoint, size_t> Decode(const uint8_t* ptr, size_t len);
+
+/// Decodes the first code point in the utf8 string.
+/// @param utf8_string the string view that contains the utf8 sequence
+/// @returns a pair of CodePoint and width in code units (bytes).
+///          If the next code point cannot be decoded then returns [0,0].
+std::pair<CodePoint, size_t> Decode(std::string_view utf8_string);
+
+/// @returns true if all the utf-8 code points in the string are ASCII
+/// (code-points 0x00..0x7f).
+bool IsASCII(std::string_view);
+
+}  // namespace utf8
+
+}  // namespace tint::utils
+
+#endif  // SRC_TINT_UTILS_TEXT_UNICODE_H_
diff --git a/src/tint/utils/text/unicode_test.cc b/src/tint/utils/text/unicode_test.cc
new file mode 100644
index 0000000..9f87b04
--- /dev/null
+++ b/src/tint/utils/text/unicode_test.cc
@@ -0,0 +1,490 @@
+// Copyright 2022 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/text/unicode.h"
+
+#include <string>
+#include <vector>
+
+#include "gmock/gmock.h"
+
+/// Helper for constructing a CodePoint
+#define C(x) CodePoint(x)
+
+namespace tint::utils {
+
+////////////////////////////////////////////////////////////////////////////////
+// CodePoint character set tests
+////////////////////////////////////////////////////////////////////////////////
+namespace {
+
+struct CodePointCase {
+    CodePoint code_point;
+    bool is_xid_start;
+    bool is_xid_continue;
+};
+
+std::ostream& operator<<(std::ostream& out, CodePointCase c) {
+    return out << c.code_point;
+}
+
+class CodePointTest : public testing::TestWithParam<CodePointCase> {};
+
+TEST_P(CodePointTest, CharacterSets) {
+    auto param = GetParam();
+    EXPECT_EQ(param.code_point.IsXIDStart(), param.is_xid_start);
+    EXPECT_EQ(param.code_point.IsXIDContinue(), param.is_xid_continue);
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    CodePointTest,
+    CodePointTest,
+    ::testing::ValuesIn({
+        CodePointCase{C(' '), /* start */ false, /* continue */ false},
+        CodePointCase{C('\t'), /* start */ false, /* continue */ false},
+        CodePointCase{C('\n'), /* start */ false, /* continue */ false},
+        CodePointCase{C('\r'), /* start */ false, /* continue */ false},
+        CodePointCase{C('!'), /* start */ false, /* continue */ false},
+        CodePointCase{C('"'), /* start */ false, /* continue */ false},
+        CodePointCase{C('#'), /* start */ false, /* continue */ false},
+        CodePointCase{C('$'), /* start */ false, /* continue */ false},
+        CodePointCase{C('%'), /* start */ false, /* continue */ false},
+        CodePointCase{C('&'), /* start */ false, /* continue */ false},
+        CodePointCase{C('\\'), /* start */ false, /* continue */ false},
+        CodePointCase{C('/'), /* start */ false, /* continue */ false},
+        CodePointCase{C('('), /* start */ false, /* continue */ false},
+        CodePointCase{C(')'), /* start */ false, /* continue */ false},
+        CodePointCase{C('*'), /* start */ false, /* continue */ false},
+        CodePointCase{C(','), /* start */ false, /* continue */ false},
+        CodePointCase{C('-'), /* start */ false, /* continue */ false},
+        CodePointCase{C('/'), /* start */ false, /* continue */ false},
+        CodePointCase{C('`'), /* start */ false, /* continue */ false},
+        CodePointCase{C('@'), /* start */ false, /* continue */ false},
+        CodePointCase{C('^'), /* start */ false, /* continue */ false},
+        CodePointCase{C('['), /* start */ false, /* continue */ false},
+        CodePointCase{C(']'), /* start */ false, /* continue */ false},
+        CodePointCase{C('|'), /* start */ false, /* continue */ false},
+        CodePointCase{C('('), /* start */ false, /* continue */ false},
+        CodePointCase{C(','), /* start */ false, /* continue */ false},
+        CodePointCase{C('}'), /* start */ false, /* continue */ false},
+        CodePointCase{C('a'), /* start */ true, /* continue */ true},
+        CodePointCase{C('b'), /* start */ true, /* continue */ true},
+        CodePointCase{C('c'), /* start */ true, /* continue */ true},
+        CodePointCase{C('x'), /* start */ true, /* continue */ true},
+        CodePointCase{C('y'), /* start */ true, /* continue */ true},
+        CodePointCase{C('z'), /* start */ true, /* continue */ true},
+        CodePointCase{C('A'), /* start */ true, /* continue */ true},
+        CodePointCase{C('B'), /* start */ true, /* continue */ true},
+        CodePointCase{C('C'), /* start */ true, /* continue */ true},
+        CodePointCase{C('X'), /* start */ true, /* continue */ true},
+        CodePointCase{C('Y'), /* start */ true, /* continue */ true},
+        CodePointCase{C('Z'), /* start */ true, /* continue */ true},
+        CodePointCase{C('_'), /* start */ false, /* continue */ true},
+        CodePointCase{C('0'), /* start */ false, /* continue */ true},
+        CodePointCase{C('1'), /* start */ false, /* continue */ true},
+        CodePointCase{C('2'), /* start */ false, /* continue */ true},
+        CodePointCase{C('8'), /* start */ false, /* continue */ true},
+        CodePointCase{C('9'), /* start */ false, /* continue */ true},
+        CodePointCase{C('0'), /* start */ false, /* continue */ true},
+
+        // First in XID_Start
+        CodePointCase{C(0x00041), /* start */ true, /* continue */ true},
+        // Last in XID_Start
+        CodePointCase{C(0x3134a), /* start */ true, /* continue */ true},
+
+        // Random selection from XID_Start, using the interval's first
+        CodePointCase{C(0x002ee), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x005ef), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x009f0), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x00d3d), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x00d54), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x00e86), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x00edc), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x01c00), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x01c80), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x02071), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x02dd0), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x0a4d0), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x0aac0), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x0ab5c), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x0ffda), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x11313), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x1ee49), /* start */ true, /* continue */ true},
+
+        // Random selection from XID_Start, using the interval's last
+        CodePointCase{C(0x00710), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x00b83), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x00b9a), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x00ec4), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x01081), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x012be), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x02107), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x03029), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x03035), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x0aadd), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x10805), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x11075), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x1d4a2), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x1e7fe), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x1ee27), /* start */ true, /* continue */ true},
+        CodePointCase{C(0x2b738), /* start */ true, /* continue */ true},
+
+        // Random selection from XID_Continue, using the interval's first
+        CodePointCase{C(0x16ac0), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x00dca), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x16f4f), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x0fe00), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x00ec8), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x009be), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x11d47), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x11d50), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x0a926), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x0aac1), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x00f18), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x11145), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x017dd), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x0aaeb), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x11173), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x00a51), /* start */ false, /* continue */ true},
+
+        // Random selection from XID_Continue, using the interval's last
+        CodePointCase{C(0x00f84), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x10a3a), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x1e018), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x0a827), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x01abd), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x009d7), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x00b6f), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x0096f), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x11146), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x10eac), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x00f39), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x1e136), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x00def), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x0fe34), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x009c8), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x00fbc), /* start */ false, /* continue */ true},
+
+        // Random code points that are one less than an interval of XID_Start
+        CodePointCase{C(0x003f6), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x005ee), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x009ef), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x00d3c), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x00d53), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x00e85), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x00edb), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x01bff), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x02070), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x02dcf), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x0a4cf), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x0aabf), /* start */ false, /* continue */ true},
+        CodePointCase{C(0x0ab5b), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x0ffd9), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x11312), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x1ee48), /* start */ false, /* continue */ false},
+
+        // Random code points that are one more than an interval of XID_Continue
+        CodePointCase{C(0x00060), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x00a4e), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x00a84), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x00cce), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x00eda), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x00f85), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x01b74), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x01c38), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x0fe30), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x11174), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x112eb), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x115de), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x1172c), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x11a3f), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x11c37), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x11d92), /* start */ false, /* continue */ false},
+        CodePointCase{C(0x1e2af), /* start */ false, /* continue */ false},
+    }));
+
+}  // namespace
+
+////////////////////////////////////////////////////////////////////////////////
+// DecodeUTF8 valid tests
+////////////////////////////////////////////////////////////////////////////////
+namespace {
+
+struct CodePointAndWidth {
+    CodePoint code_point;
+    size_t width;
+};
+
+bool operator==(const CodePointAndWidth& a, const CodePointAndWidth& b) {
+    return a.code_point == b.code_point && a.width == b.width;
+}
+
+std::ostream& operator<<(std::ostream& out, CodePointAndWidth cpw) {
+    return out << "code_point: " << cpw.code_point << ", width: " << cpw.width;
+}
+
+struct DecodeUTF8Case {
+    std::string string;
+    std::vector<CodePointAndWidth> expected;
+};
+
+std::ostream& operator<<(std::ostream& out, DecodeUTF8Case c) {
+    return out << "'" << c.string << "'";
+}
+
+class DecodeUTF8Test : public testing::TestWithParam<DecodeUTF8Case> {};
+
+TEST_P(DecodeUTF8Test, Valid) {
+    auto param = GetParam();
+
+    const uint8_t* data = reinterpret_cast<const uint8_t*>(param.string.data());
+    const size_t len = param.string.size();
+
+    std::vector<CodePointAndWidth> got;
+    size_t offset = 0;
+    while (offset < len) {
+        auto [code_point, width] = utf8::Decode(data + offset, len - offset);
+        if (width == 0) {
+            FAIL() << "Decode() failed at byte offset " << offset;
+        }
+        offset += width;
+        got.emplace_back(CodePointAndWidth{code_point, width});
+    }
+
+    EXPECT_THAT(got, ::testing::ElementsAreArray(param.expected));
+}
+
+INSTANTIATE_TEST_SUITE_P(AsciiLetters,
+                         DecodeUTF8Test,
+                         ::testing::ValuesIn({
+                             DecodeUTF8Case{"a", {{C('a'), 1}}},
+                             DecodeUTF8Case{"abc", {{C('a'), 1}, {C('b'), 1}, {C('c'), 1}}},
+                             DecodeUTF8Case{"def", {{C('d'), 1}, {C('e'), 1}, {C('f'), 1}}},
+                             DecodeUTF8Case{"gh", {{C('g'), 1}, {C('h'), 1}}},
+                             DecodeUTF8Case{"ij", {{C('i'), 1}, {C('j'), 1}}},
+                             DecodeUTF8Case{"klm", {{C('k'), 1}, {C('l'), 1}, {C('m'), 1}}},
+                             DecodeUTF8Case{"nop", {{C('n'), 1}, {C('o'), 1}, {C('p'), 1}}},
+                             DecodeUTF8Case{"qr", {{C('q'), 1}, {C('r'), 1}}},
+                             DecodeUTF8Case{"stu", {{C('s'), 1}, {C('t'), 1}, {C('u'), 1}}},
+                             DecodeUTF8Case{"vw", {{C('v'), 1}, {C('w'), 1}}},
+                             DecodeUTF8Case{"xyz", {{C('x'), 1}, {C('y'), 1}, {C('z'), 1}}},
+                             DecodeUTF8Case{"A", {{C('A'), 1}}},
+                             DecodeUTF8Case{"ABC", {{C('A'), 1}, {C('B'), 1}, {C('C'), 1}}},
+                             DecodeUTF8Case{"DEF", {{C('D'), 1}, {C('E'), 1}, {C('F'), 1}}},
+                             DecodeUTF8Case{"GH", {{C('G'), 1}, {C('H'), 1}}},
+                             DecodeUTF8Case{"IJ", {{C('I'), 1}, {C('J'), 1}}},
+                             DecodeUTF8Case{"KLM", {{C('K'), 1}, {C('L'), 1}, {C('M'), 1}}},
+                             DecodeUTF8Case{"NOP", {{C('N'), 1}, {C('O'), 1}, {C('P'), 1}}},
+                             DecodeUTF8Case{"QR", {{C('Q'), 1}, {C('R'), 1}}},
+                             DecodeUTF8Case{"STU", {{C('S'), 1}, {C('T'), 1}, {C('U'), 1}}},
+                             DecodeUTF8Case{"VW", {{C('V'), 1}, {C('W'), 1}}},
+                             DecodeUTF8Case{"XYZ", {{C('X'), 1}, {C('Y'), 1}, {C('Z'), 1}}},
+                         }));
+
+INSTANTIATE_TEST_SUITE_P(AsciiNumbers,
+                         DecodeUTF8Test,
+                         ::testing::ValuesIn({
+                             DecodeUTF8Case{"012", {{C('0'), 1}, {C('1'), 1}, {C('2'), 1}}},
+                             DecodeUTF8Case{"345", {{C('3'), 1}, {C('4'), 1}, {C('5'), 1}}},
+                             DecodeUTF8Case{"678", {{C('6'), 1}, {C('7'), 1}, {C('8'), 1}}},
+                             DecodeUTF8Case{"9", {{C('9'), 1}}},
+                         }));
+
+INSTANTIATE_TEST_SUITE_P(AsciiSymbols,
+                         DecodeUTF8Test,
+                         ::testing::ValuesIn({
+                             DecodeUTF8Case{"!\"#", {{C('!'), 1}, {C('"'), 1}, {C('#'), 1}}},
+                             DecodeUTF8Case{"$%&", {{C('$'), 1}, {C('%'), 1}, {C('&'), 1}}},
+                             DecodeUTF8Case{"'()", {{C('\''), 1}, {C('('), 1}, {C(')'), 1}}},
+                             DecodeUTF8Case{"*,-", {{C('*'), 1}, {C(','), 1}, {C('-'), 1}}},
+                             DecodeUTF8Case{"/`@", {{C('/'), 1}, {C('`'), 1}, {C('@'), 1}}},
+                             DecodeUTF8Case{"^\\[", {{C('^'), 1}, {C('\\'), 1}, {C('['), 1}}},
+                             DecodeUTF8Case{"]_|", {{C(']'), 1}, {C('_'), 1}, {C('|'), 1}}},
+                             DecodeUTF8Case{"{}", {{C('{'), 1}, {C('}'), 1}}},
+                         }));
+
+INSTANTIATE_TEST_SUITE_P(AsciiSpecial,
+                         DecodeUTF8Test,
+                         ::testing::ValuesIn({
+                             DecodeUTF8Case{"", {}},
+                             DecodeUTF8Case{" \t\n", {{C(' '), 1}, {C('\t'), 1}, {C('\n'), 1}}},
+                             DecodeUTF8Case{"\a\b\f", {{C('\a'), 1}, {C('\b'), 1}, {C('\f'), 1}}},
+                             DecodeUTF8Case{"\n\r\t", {{C('\n'), 1}, {C('\r'), 1}, {C('\t'), 1}}},
+                             DecodeUTF8Case{"\v", {{C('\v'), 1}}},
+                         }));
+
+INSTANTIATE_TEST_SUITE_P(Hindi,
+                         DecodeUTF8Test,
+                         ::testing::ValuesIn({DecodeUTF8Case{
+                             // नमस्ते दुनिया
+                             "\xe0\xa4\xa8\xe0\xa4\xae\xe0\xa4\xb8\xe0\xa5\x8d\xe0\xa4\xa4\xe0\xa5"
+                             "\x87\x20\xe0\xa4\xa6\xe0\xa5\x81\xe0\xa4\xa8\xe0\xa4\xbf\xe0\xa4\xaf"
+                             "\xe0\xa4\xbe",
+                             {
+                                 {C(0x0928), 3},  // न
+                                 {C(0x092e), 3},  // म
+                                 {C(0x0938), 3},  // स
+                                 {C(0x094d), 3},  // ् //
+                                 {C(0x0924), 3},  // त
+                                 {C(0x0947), 3},  // े //
+                                 {C(' '), 1},
+                                 {C(0x0926), 3},  // द
+                                 {C(0x0941), 3},  // ु //
+                                 {C(0x0928), 3},  // न
+                                 {C(0x093f), 3},  // ि //
+                                 {C(0x092f), 3},  // य
+                                 {C(0x093e), 3},  // ा //
+                             },
+                         }}));
+
+INSTANTIATE_TEST_SUITE_P(Mandarin,
+                         DecodeUTF8Test,
+                         ::testing::ValuesIn({DecodeUTF8Case{
+                             // 你好世界
+                             "\xe4\xbd\xa0\xe5\xa5\xbd\xe4\xb8\x96\xe7\x95\x8c",
+                             {
+                                 {C(0x4f60), 3},  // 你
+                                 {C(0x597d), 3},  // 好
+                                 {C(0x4e16), 3},  // 世
+                                 {C(0x754c), 3},  // 界
+                             },
+                         }}));
+
+INSTANTIATE_TEST_SUITE_P(Japanese,
+                         DecodeUTF8Test,
+                         ::testing::ValuesIn({DecodeUTF8Case{
+                             // こんにちは世界
+                             "\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab\xe3\x81\xa1"
+                             "\xe3\x81\xaf\xe4\xb8\x96\xe7\x95\x8c",
+                             {
+                                 {C(0x3053), 3},  // こ
+                                 {C(0x3093), 3},  // ん
+                                 {C(0x306B), 3},  // に
+                                 {C(0x3061), 3},  // ち
+                                 {C(0x306F), 3},  // は
+                                 {C(0x4E16), 3},  // 世
+                                 {C(0x754C), 3},  // 界
+                             },
+                         }}));
+
+INSTANTIATE_TEST_SUITE_P(Korean,
+                         DecodeUTF8Test,
+                         ::testing::ValuesIn({DecodeUTF8Case{
+                             // 안녕하세요 세계
+                             "\xec\x95\x88\xeb\x85\x95\xed\x95\x98\xec\x84\xb8"
+                             "\xec\x9a\x94\x20\xec\x84\xb8\xea\xb3\x84",
+                             {
+                                 {C(0xc548), 3},  // 안
+                                 {C(0xb155), 3},  // 녕
+                                 {C(0xd558), 3},  // 하
+                                 {C(0xc138), 3},  // 세
+                                 {C(0xc694), 3},  // 요
+                                 {C(' '), 1},     //
+                                 {C(0xc138), 3},  // 세
+                                 {C(0xacc4), 3},  // 계
+                             },
+                         }}));
+
+INSTANTIATE_TEST_SUITE_P(Emoji,
+                         DecodeUTF8Test,
+                         ::testing::ValuesIn({DecodeUTF8Case{
+                             // 👋🌎
+                             "\xf0\x9f\x91\x8b\xf0\x9f\x8c\x8e",
+                             {
+                                 {C(0x1f44b), 4},  // 👋
+                                 {C(0x1f30e), 4},  // 🌎
+                             },
+                         }}));
+
+INSTANTIATE_TEST_SUITE_P(Random,
+                         DecodeUTF8Test,
+                         ::testing::ValuesIn({DecodeUTF8Case{
+                             // Øⓑꚫ쁹Ǵ𐌒岾🥍ⴵ㍨又ᮗ
+                             "\xc3\x98\xe2\x93\x91\xea\x9a\xab\xec\x81\xb9\xc7\xb4\xf0\x90\x8c\x92"
+                             "\xe5\xb2\xbe\xf0\x9f\xa5\x8d\xe2\xb4\xb5\xe3\x8d\xa8\xe5\x8f\x88\xe1"
+                             "\xae\x97",
+                             {
+                                 {C(0x000d8), 2},  // Ø
+                                 {C(0x024d1), 3},  // ⓑ
+                                 {C(0x0a6ab), 3},  // ꚫ
+                                 {C(0x0c079), 3},  // 쁹
+                                 {C(0x001f4), 2},  // Ǵ
+                                 {C(0x10312), 4},  // 𐌒
+                                 {C(0x05cbe), 3},  // 岾
+                                 {C(0x1f94d), 4},  // 🥍
+                                 {C(0x02d35), 3},  // ⴵ
+                                 {C(0x03368), 3},  // ㍨
+                                 {C(0x053c8), 3},  // 又
+                                 {C(0x01b97), 3},  // ᮗ
+                             },
+                         }}));
+
+}  // namespace
+
+////////////////////////////////////////////////////////////////////////////////
+// DecodeUTF8 invalid tests
+////////////////////////////////////////////////////////////////////////////////
+namespace {
+class DecodeUTF8InvalidTest : public testing::TestWithParam<const char*> {};
+
+TEST_P(DecodeUTF8InvalidTest, Invalid) {
+    auto* param = GetParam();
+
+    const uint8_t* data = reinterpret_cast<const uint8_t*>(param);
+    const size_t len = std::string(param).size();
+
+    auto [code_point, width] = utf8::Decode(data, len);
+    EXPECT_EQ(code_point, CodePoint(0));
+    EXPECT_EQ(width, 0u);
+}
+
+INSTANTIATE_TEST_SUITE_P(Invalid,
+                         DecodeUTF8InvalidTest,
+                         ::testing::ValuesIn({
+                             "\x80\x80\x80\x80",  // 10000000
+                             "\x81\x80\x80\x80",  // 10000001
+                             "\x8f\x80\x80\x80",  // 10001111
+                             "\x90\x80\x80\x80",  // 10010000
+                             "\x91\x80\x80\x80",  // 10010001
+                             "\x9f\x80\x80\x80",  // 10011111
+                             "\xa0\x80\x80\x80",  // 10100000
+                             "\xa1\x80\x80\x80",  // 10100001
+                             "\xaf\x80\x80\x80",  // 10101111
+                             "\xb0\x80\x80\x80",  // 10110000
+                             "\xb1\x80\x80\x80",  // 10110001
+                             "\xbf\x80\x80\x80",  // 10111111
+                             "\xc0\x80\x80\x80",  // 11000000
+                             "\xc1\x80\x80\x80",  // 11000001
+                             "\xf5\x80\x80\x80",  // 11110101
+                             "\xf6\x80\x80\x80",  // 11110110
+                             "\xf7\x80\x80\x80",  // 11110111
+                             "\xf8\x80\x80\x80",  // 11111000
+                             "\xfe\x80\x80\x80",  // 11111110
+                             "\xff\x80\x80\x80",  // 11111111
+
+                             "\xd0",          // 2-bytes, missing second byte
+                             "\xe8\x8f",      // 3-bytes, missing third byte
+                             "\xf4\x8f\x8f",  // 4-bytes, missing fourth byte
+
+                             "\xd0\x7f",          // 2-bytes, second byte MSB unset
+                             "\xe8\x7f\x8f",      // 3-bytes, second byte MSB unset
+                             "\xe8\x8f\x7f",      // 3-bytes, third byte MSB unset
+                             "\xf4\x7f\x8f\x8f",  // 4-bytes, second byte MSB unset
+                             "\xf4\x8f\x7f\x8f",  // 4-bytes, third byte MSB unset
+                             "\xf4\x8f\x8f\x7f",  // 4-bytes, fourth byte MSB unset
+                         }));
+
+}  // namespace
+
+}  // namespace tint::utils
diff --git a/src/tint/utils/traits/traits.h b/src/tint/utils/traits/traits.h
new file mode 100644
index 0000000..f9c9a91
--- /dev/null
+++ b/src/tint/utils/traits/traits.h
@@ -0,0 +1,214 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef SRC_TINT_UTILS_TRAITS_TRAITS_H_
+#define SRC_TINT_UTILS_TRAITS_TRAITS_H_
+
+#include <string>
+#include <tuple>
+#include <type_traits>
+#include <utility>
+
+namespace tint::utils::traits {
+
+/// Convience type definition for std::decay<T>::type
+template <typename T>
+using Decay = typename std::decay<T>::type;
+
+/// NthTypeOf returns the `N`th type in `Types`
+template <int N, typename... Types>
+using NthTypeOf = typename std::tuple_element<N, std::tuple<Types...>>::type;
+
+/// Signature describes the signature of a function.
+template <typename RETURN, typename... PARAMETERS>
+struct Signature {
+    /// The return type of the function signature
+    using ret = RETURN;
+    /// The parameters of the function signature held in a std::tuple
+    using parameters = std::tuple<PARAMETERS...>;
+    /// The type of the Nth parameter of function signature
+    template <std::size_t N>
+    using parameter = NthTypeOf<N, PARAMETERS...>;
+    /// The total number of parameters
+    static constexpr std::size_t parameter_count = sizeof...(PARAMETERS);
+};
+
+/// SignatureOf is a traits helper that infers the signature of the function,
+/// method, static method, lambda, or function-like object `F`.
+template <typename F>
+struct SignatureOf {
+    /// The signature of the function-like object `F`
+    using type = typename SignatureOf<decltype(&F::operator())>::type;
+};
+
+/// SignatureOf specialization for a regular function or static method.
+template <typename R, typename... ARGS>
+struct SignatureOf<R (*)(ARGS...)> {
+    /// The signature of the function-like object `F`
+    using type = Signature<typename std::decay<R>::type, typename std::decay<ARGS>::type...>;
+};
+
+/// SignatureOf specialization for a non-static method.
+template <typename R, typename C, typename... ARGS>
+struct SignatureOf<R (C::*)(ARGS...)> {
+    /// The signature of the function-like object `F`
+    using type = Signature<typename std::decay<R>::type, typename std::decay<ARGS>::type...>;
+};
+
+/// SignatureOf specialization for a non-static, const method.
+template <typename R, typename C, typename... ARGS>
+struct SignatureOf<R (C::*)(ARGS...) const> {
+    /// The signature of the function-like object `F`
+    using type = Signature<typename std::decay<R>::type, typename std::decay<ARGS>::type...>;
+};
+
+/// SignatureOfT is an alias to `typename SignatureOf<F>::type`.
+template <typename F>
+using SignatureOfT = typename SignatureOf<Decay<F>>::type;
+
+/// ParameterType is an alias to `typename SignatureOf<F>::type::parameter<N>`.
+template <typename F, std::size_t N>
+using ParameterType = typename SignatureOfT<Decay<F>>::template parameter<N>;
+
+/// LastParameterType returns the type of the last parameter of `F`. `F` must have at least one
+/// parameter.
+template <typename F>
+using LastParameterType = ParameterType<F, SignatureOfT<Decay<F>>::parameter_count - 1>;
+
+/// ReturnType is an alias to `typename SignatureOf<F>::type::ret`.
+template <typename F>
+using ReturnType = typename SignatureOfT<Decay<F>>::ret;
+
+/// Returns true iff decayed T and decayed U are the same.
+template <typename T, typename U>
+static constexpr bool IsType = std::is_same<Decay<T>, Decay<U>>::value;
+
+/// IsTypeOrDerived<T, BASE> is true iff `T` is of type `BASE`, or derives from
+/// `BASE`.
+template <typename T, typename BASE>
+static constexpr bool IsTypeOrDerived =
+    std::is_base_of<BASE, Decay<T>>::value || std::is_same<BASE, Decay<T>>::value;
+
+/// If `CONDITION` is true then EnableIf resolves to type T, otherwise an
+/// invalid type.
+template <bool CONDITION, typename T = void>
+using EnableIf = typename std::enable_if<CONDITION, T>::type;
+
+/// If `T` is of type `BASE`, or derives from `BASE`, then EnableIfIsType
+/// resolves to type `T`, otherwise an invalid type.
+template <typename T, typename BASE>
+using EnableIfIsType = EnableIf<IsTypeOrDerived<T, BASE>, T>;
+
+/// @returns the std::index_sequence with all the indices shifted by OFFSET.
+template <std::size_t OFFSET, std::size_t... INDICES>
+constexpr auto Shift(std::index_sequence<INDICES...>) {
+    return std::integer_sequence<std::size_t, OFFSET + INDICES...>{};
+}
+
+/// @returns a std::integer_sequence with the integers `[OFFSET..OFFSET+COUNT)`
+template <std::size_t OFFSET, std::size_t COUNT>
+constexpr auto Range() {
+    return Shift<OFFSET>(std::make_index_sequence<COUNT>{});
+}
+
+namespace detail {
+
+/// @returns the tuple `t` swizzled by `INDICES`
+template <typename TUPLE, std::size_t... INDICES>
+constexpr auto Swizzle(TUPLE&& t, std::index_sequence<INDICES...>)
+    -> std::tuple<std::tuple_element_t<INDICES, std::remove_reference_t<TUPLE>>...> {
+    return {std::forward<std::tuple_element_t<INDICES, std::remove_reference_t<TUPLE>>>(
+        std::get<INDICES>(std::forward<TUPLE>(t)))...};
+}
+
+/// @returns a nullptr of the tuple type `TUPLE` swizzled by `INDICES`.
+/// @note: This function is intended to be used in a `decltype()` expression,
+/// and returns a pointer-to-tuple as the tuple may hold non-constructable
+/// types.
+template <typename TUPLE, std::size_t... INDICES>
+constexpr auto* SwizzlePtrTy(std::index_sequence<INDICES...>) {
+    using Swizzled = std::tuple<std::tuple_element_t<INDICES, TUPLE>...>;
+    return static_cast<Swizzled*>(nullptr);
+}
+
+}  // namespace detail
+
+/// @returns the slice of the tuple `t` with the tuple elements
+/// `[OFFSET..OFFSET+COUNT)`
+template <std::size_t OFFSET, std::size_t COUNT, typename TUPLE>
+constexpr auto Slice(TUPLE&& t) {
+    return traits::detail::Swizzle<TUPLE>(std::forward<TUPLE>(t), Range<OFFSET, COUNT>());
+}
+
+/// Resolves to the slice of the tuple `t` with the tuple elements
+/// `[OFFSET..OFFSET+COUNT)`
+template <std::size_t OFFSET, std::size_t COUNT, typename TUPLE>
+using SliceTuple =
+    std::remove_pointer_t<decltype(traits::detail::SwizzlePtrTy<TUPLE>(Range<OFFSET, COUNT>()))>;
+
+namespace detail {
+/// Base template for IsTypeIn
+template <typename T, typename TypeList>
+struct IsTypeIn;
+
+/// Specialization for IsTypeIn
+template <typename T, template <typename...> typename TypeContainer, typename... Ts>
+struct IsTypeIn<T, TypeContainer<Ts...>> : std::disjunction<std::is_same<T, Ts>...> {};
+}  // namespace detail
+
+/// Evaluates to true if T is one of the types in the TypeContainer's template arguments.
+/// Works for std::variant, std::tuple, std::pair, or any typename template where all parameters are
+/// types.
+template <typename T, typename TypeContainer>
+static constexpr bool IsTypeIn = traits::detail::IsTypeIn<T, TypeContainer>::value;
+
+/// Evaluates to the decayed pointer element type, or the decayed type T if T is not a pointer.
+template <typename T>
+using PtrElTy = Decay<std::remove_pointer_t<Decay<T>>>;
+
+/// Evaluates to true if `T` decayed is a `std::string`, `std::string_view` or `const char*`
+template <typename T>
+static constexpr bool IsStringLike =
+    std::is_same_v<Decay<T>, std::string> || std::is_same_v<Decay<T>, std::string_view> ||
+    std::is_same_v<Decay<T>, const char*>;
+
+namespace detail {
+/// Helper for CharArrayToCharPtr
+template <typename T>
+struct CharArrayToCharPtrImpl {
+    /// Evaluates to T
+    using type = T;
+};
+/// Specialization of CharArrayToCharPtrImpl for `char[N]`
+template <size_t N>
+struct CharArrayToCharPtrImpl<char[N]> {
+    /// Evaluates to `char*`
+    using type = char*;
+};
+/// Specialization of CharArrayToCharPtrImpl for `const char[N]`
+template <size_t N>
+struct CharArrayToCharPtrImpl<const char[N]> {
+    /// Evaluates to `const char*`
+    using type = const char*;
+};
+}  // namespace detail
+
+/// Evaluates to `char*` or `const char*` if `T` is `char[N]` or `const char[N]`, respectively,
+/// otherwise T.
+template <typename T>
+using CharArrayToCharPtr = typename traits::detail::CharArrayToCharPtrImpl<T>::type;
+
+}  // namespace tint::utils::traits
+
+#endif  // SRC_TINT_UTILS_TRAITS_TRAITS_H_
diff --git a/src/tint/utils/traits/traits_test.cc b/src/tint/utils/traits/traits_test.cc
new file mode 100644
index 0000000..da78f27
--- /dev/null
+++ b/src/tint/utils/traits/traits_test.cc
@@ -0,0 +1,249 @@
+// Copyright 2020 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "src/tint/utils/traits/traits.h"
+
+#include "gtest/gtest.h"
+
+namespace tint::utils::traits {
+
+namespace {
+
+static_assert(std::is_same_v<PtrElTy<int*>, int>);
+static_assert(std::is_same_v<PtrElTy<int const*>, int>);
+static_assert(std::is_same_v<PtrElTy<int const* const>, int>);
+static_assert(std::is_same_v<PtrElTy<int const* const volatile>, int>);
+static_assert(std::is_same_v<PtrElTy<int>, int>);
+static_assert(std::is_same_v<PtrElTy<int const>, int>);
+static_assert(std::is_same_v<PtrElTy<int const volatile>, int>);
+
+static_assert(IsStringLike<std::string>);
+static_assert(IsStringLike<std::string_view>);
+static_assert(IsStringLike<const char*>);
+static_assert(IsStringLike<const std::string&>);
+static_assert(IsStringLike<const std::string_view&>);
+static_assert(IsStringLike<const char*>);
+static_assert(!IsStringLike<bool>);
+static_assert(!IsStringLike<int>);
+static_assert(!IsStringLike<const char**>);
+
+struct S {};
+void F1(S) {}
+void F3(int, S, float) {}
+}  // namespace
+
+TEST(ParamType, Function) {
+    F1({});        // Avoid unused method warning
+    F3(0, {}, 0);  // Avoid unused method warning
+    static_assert(std::is_same_v<ParameterType<decltype(&F1), 0>, S>);
+    static_assert(std::is_same_v<ParameterType<decltype(&F3), 0>, int>);
+    static_assert(std::is_same_v<ParameterType<decltype(&F3), 1>, S>);
+    static_assert(std::is_same_v<ParameterType<decltype(&F3), 2>, float>);
+    static_assert(std::is_same_v<ReturnType<decltype(&F1)>, void>);
+    static_assert(std::is_same_v<ReturnType<decltype(&F3)>, void>);
+    static_assert(SignatureOfT<decltype(&F1)>::parameter_count == 1);
+    static_assert(SignatureOfT<decltype(&F3)>::parameter_count == 3);
+}
+
+TEST(ParamType, Method) {
+    class C {
+      public:
+        void F1(S) {}
+        void F3(int, S, float) {}
+    };
+    C().F1({});        // Avoid unused method warning
+    C().F3(0, {}, 0);  // Avoid unused method warning
+    static_assert(std::is_same_v<ParameterType<decltype(&C::F1), 0>, S>);
+    static_assert(std::is_same_v<ParameterType<decltype(&C::F3), 0>, int>);
+    static_assert(std::is_same_v<ParameterType<decltype(&C::F3), 1>, S>);
+    static_assert(std::is_same_v<ParameterType<decltype(&C::F3), 2>, float>);
+    static_assert(std::is_same_v<ReturnType<decltype(&C::F1)>, void>);
+    static_assert(std::is_same_v<ReturnType<decltype(&C::F3)>, void>);
+    static_assert(SignatureOfT<decltype(&C::F1)>::parameter_count == 1);
+    static_assert(SignatureOfT<decltype(&C::F3)>::parameter_count == 3);
+}
+
+TEST(ParamType, ConstMethod) {
+    class C {
+      public:
+        void F1(S) const {}
+        void F3(int, S, float) const {}
+    };
+    C().F1({});        // Avoid unused method warning
+    C().F3(0, {}, 0);  // Avoid unused method warning
+    static_assert(std::is_same_v<ParameterType<decltype(&C::F1), 0>, S>);
+    static_assert(std::is_same_v<ParameterType<decltype(&C::F3), 0>, int>);
+    static_assert(std::is_same_v<ParameterType<decltype(&C::F3), 1>, S>);
+    static_assert(std::is_same_v<ParameterType<decltype(&C::F3), 2>, float>);
+    static_assert(std::is_same_v<ReturnType<decltype(&C::F1)>, void>);
+    static_assert(std::is_same_v<ReturnType<decltype(&C::F3)>, void>);
+    static_assert(SignatureOfT<decltype(&C::F1)>::parameter_count == 1);
+    static_assert(SignatureOfT<decltype(&C::F3)>::parameter_count == 3);
+}
+
+TEST(ParamType, StaticMethod) {
+    class C {
+      public:
+        static void F1(S) {}
+        static void F3(int, S, float) {}
+    };
+    C::F1({});        // Avoid unused method warning
+    C::F3(0, {}, 0);  // Avoid unused method warning
+    static_assert(std::is_same_v<ParameterType<decltype(&C::F1), 0>, S>);
+    static_assert(std::is_same_v<ParameterType<decltype(&C::F3), 0>, int>);
+    static_assert(std::is_same_v<ParameterType<decltype(&C::F3), 1>, S>);
+    static_assert(std::is_same_v<ParameterType<decltype(&C::F3), 2>, float>);
+    static_assert(std::is_same_v<ReturnType<decltype(&C::F1)>, void>);
+    static_assert(std::is_same_v<ReturnType<decltype(&C::F3)>, void>);
+    static_assert(SignatureOfT<decltype(&C::F1)>::parameter_count == 1);
+    static_assert(SignatureOfT<decltype(&C::F3)>::parameter_count == 3);
+}
+
+TEST(ParamType, FunctionLike) {
+    using F1 = std::function<void(S)>;
+    using F3 = std::function<void(int, S, float)>;
+    static_assert(std::is_same_v<ParameterType<F1, 0>, S>);
+    static_assert(std::is_same_v<ParameterType<F3, 0>, int>);
+    static_assert(std::is_same_v<ParameterType<F3, 1>, S>);
+    static_assert(std::is_same_v<ParameterType<F3, 2>, float>);
+    static_assert(std::is_same_v<ReturnType<F1>, void>);
+    static_assert(std::is_same_v<ReturnType<F3>, void>);
+    static_assert(SignatureOfT<F1>::parameter_count == 1);
+    static_assert(SignatureOfT<F3>::parameter_count == 3);
+}
+
+TEST(ParamType, Lambda) {
+    auto l1 = [](S) {};
+    auto l3 = [](int, S, float) {};
+    static_assert(std::is_same_v<ParameterType<decltype(l1), 0>, S>);
+    static_assert(std::is_same_v<ParameterType<decltype(l3), 0>, int>);
+    static_assert(std::is_same_v<ParameterType<decltype(l3), 1>, S>);
+    static_assert(std::is_same_v<ParameterType<decltype(l3), 2>, float>);
+    static_assert(std::is_same_v<ReturnType<decltype(l1)>, void>);
+    static_assert(std::is_same_v<ReturnType<decltype(l3)>, void>);
+    static_assert(SignatureOfT<decltype(l1)>::parameter_count == 1);
+    static_assert(SignatureOfT<decltype(l3)>::parameter_count == 3);
+}
+
+TEST(Slice, Empty) {
+    auto sliced = Slice<0, 0>(std::make_tuple<>());
+    static_assert(std::tuple_size_v<decltype(sliced)> == 0);
+}
+
+TEST(Slice, SingleElementSliceEmpty) {
+    auto sliced = Slice<0, 0>(std::make_tuple<int>(1));
+    static_assert(std::tuple_size_v<decltype(sliced)> == 0);
+}
+
+TEST(Slice, SingleElementSliceFull) {
+    auto sliced = Slice<0, 1>(std::make_tuple<int>(1));
+    static_assert(std::tuple_size_v<decltype(sliced)> == 1);
+    static_assert(std::is_same_v<std::tuple_element_t<0, decltype(sliced)>, int>, "");
+    EXPECT_EQ(std::get<0>(sliced), 1);
+}
+
+TEST(Slice, MixedTupleSliceEmpty) {
+    auto sliced = Slice<1, 0>(std::make_tuple<int, bool, float>(1, true, 2.0f));
+    static_assert(std::tuple_size_v<decltype(sliced)> == 0);
+}
+
+TEST(Slice, MixedTupleSliceFull) {
+    auto sliced = Slice<0, 3>(std::make_tuple<int, bool, float>(1, true, 2.0f));
+    static_assert(std::tuple_size_v<decltype(sliced)> == 3);
+    static_assert(std::is_same_v<std::tuple_element_t<0, decltype(sliced)>, int>, "");
+    static_assert(std::is_same_v<std::tuple_element_t<1, decltype(sliced)>, bool>, "");
+    static_assert(std::is_same_v<std::tuple_element_t<2, decltype(sliced)>, float>);
+    EXPECT_EQ(std::get<0>(sliced), 1);
+    EXPECT_EQ(std::get<1>(sliced), true);
+    EXPECT_EQ(std::get<2>(sliced), 2.0f);
+}
+
+TEST(Slice, MixedTupleSliceLowPart) {
+    auto sliced = Slice<0, 2>(std::make_tuple<int, bool, float>(1, true, 2.0f));
+    static_assert(std::tuple_size_v<decltype(sliced)> == 2);
+    static_assert(std::is_same_v<std::tuple_element_t<0, decltype(sliced)>, int>, "");
+    static_assert(std::is_same_v<std::tuple_element_t<1, decltype(sliced)>, bool>, "");
+    EXPECT_EQ(std::get<0>(sliced), 1);
+    EXPECT_EQ(std::get<1>(sliced), true);
+}
+
+TEST(Slice, MixedTupleSliceHighPart) {
+    auto sliced = Slice<1, 2>(std::make_tuple<int, bool, float>(1, true, 2.0f));
+    static_assert(std::tuple_size_v<decltype(sliced)> == 2);
+    static_assert(std::is_same_v<std::tuple_element_t<0, decltype(sliced)>, bool>, "");
+    static_assert(std::is_same_v<std::tuple_element_t<1, decltype(sliced)>, float>);
+    EXPECT_EQ(std::get<0>(sliced), true);
+    EXPECT_EQ(std::get<1>(sliced), 2.0f);
+}
+
+TEST(Slice, PreservesRValueRef) {
+    int i;
+    int& int_ref = i;
+    auto tuple = std::forward_as_tuple(std::move(int_ref));
+    static_assert(std::is_same_v<int&&,  //
+                                 std::tuple_element_t<0, decltype(tuple)>>);
+    auto sliced = Slice<0, 1>(std::move(tuple));
+    static_assert(std::is_same_v<int&&,  //
+                                 std::tuple_element_t<0, decltype(sliced)>>);
+}
+
+TEST(SliceTuple, Empty) {
+    using sliced = SliceTuple<0, 0, std::tuple<>>;
+    static_assert(std::tuple_size_v<sliced> == 0);
+}
+
+TEST(SliceTuple, SingleElementSliceEmpty) {
+    using sliced = SliceTuple<0, 0, std::tuple<int>>;
+    static_assert(std::tuple_size_v<sliced> == 0);
+}
+
+TEST(SliceTuple, SingleElementSliceFull) {
+    using sliced = SliceTuple<0, 1, std::tuple<int>>;
+    static_assert(std::tuple_size_v<sliced> == 1);
+    static_assert(std::is_same_v<std::tuple_element_t<0, sliced>, int>);
+}
+
+TEST(SliceTuple, MixedTupleSliceEmpty) {
+    using sliced = SliceTuple<1, 0, std::tuple<int, bool, float>>;
+    static_assert(std::tuple_size_v<sliced> == 0);
+}
+
+TEST(SliceTuple, MixedTupleSliceFull) {
+    using sliced = SliceTuple<0, 3, std::tuple<int, bool, float>>;
+    static_assert(std::tuple_size_v<sliced> == 3);
+    static_assert(std::is_same_v<std::tuple_element_t<0, sliced>, int>);
+    static_assert(std::is_same_v<std::tuple_element_t<1, sliced>, bool>);
+    static_assert(std::is_same_v<std::tuple_element_t<2, sliced>, float>);
+}
+
+TEST(SliceTuple, MixedTupleSliceLowPart) {
+    using sliced = SliceTuple<0, 2, std::tuple<int, bool, float>>;
+    static_assert(std::tuple_size_v<sliced> == 2);
+    static_assert(std::is_same_v<std::tuple_element_t<0, sliced>, int>);
+    static_assert(std::is_same_v<std::tuple_element_t<1, sliced>, bool>);
+}
+
+TEST(SliceTuple, MixedTupleSliceHighPart) {
+    using sliced = SliceTuple<1, 2, std::tuple<int, bool, float>>;
+    static_assert(std::tuple_size_v<sliced> == 2);
+    static_assert(std::is_same_v<std::tuple_element_t<0, sliced>, bool>);
+    static_assert(std::is_same_v<std::tuple_element_t<1, sliced>, float>);
+}
+
+static_assert(std::is_same_v<char*, CharArrayToCharPtr<char[2]>>);
+static_assert(std::is_same_v<const char*, CharArrayToCharPtr<const char[2]>>);
+static_assert(std::is_same_v<int, CharArrayToCharPtr<int>>);
+static_assert(std::is_same_v<int[2], CharArrayToCharPtr<int[2]>>);
+
+}  // namespace tint::utils::traits