Import Tint changes from Dawn

Changes:
  - ffeae7aa50cd1a830634afd4904be04627ed988c tint: const eval of faceForward builtin by Antonio Maiorano <amaiorano@google.com>
  - 95d174a1182dd43fa41dfb84173f0a14d3a2b5d6 tint/resolver: Further simplify test const eval framework by Ben Clayton <bclayton@google.com>
  - c572df265d4fe084004b2f022e661cedb3b13d5a tint/utils: Add operator<<() support to vector by Ben Clayton <bclayton@google.com>
GitOrigin-RevId: ffeae7aa50cd1a830634afd4904be04627ed988c
Change-Id: Ice89a42e62d6c4c6064e372f1e7ed5b320b038fc
Reviewed-on: https://dawn-review.googlesource.com/c/tint/+/111960
Kokoro: Kokoro <noreply+kokoro@google.com>
Reviewed-by: Ben Clayton <bclayton@google.com>
Commit-Queue: Ben Clayton <bclayton@google.com>
diff --git a/src/tint/intrinsics.def b/src/tint/intrinsics.def
index a148d4d..687b8e1 100644
--- a/src/tint/intrinsics.def
+++ b/src/tint/intrinsics.def
@@ -466,7 +466,7 @@
 @const fn exp2<N: num, T: fa_f32_f16>(vec<N, T>) -> vec<N, T>
 @const fn extractBits<T: iu32>(T, u32, u32) -> T
 @const fn extractBits<N: num, T: iu32>(vec<N, T>, u32, u32) -> vec<N, T>
-fn faceForward<N: num, T: f32_f16>(vec<N, T>, vec<N, T>, vec<N, T>) -> vec<N, T>
+@const fn faceForward<N: num, T: fa_f32_f16>(vec<N, T>, vec<N, T>, vec<N, T>) -> vec<N, T>
 @const fn firstLeadingBit<T: iu32>(T) -> T
 @const fn firstLeadingBit<N: num, T: iu32>(vec<N, T>) -> vec<N, T>
 @const fn firstTrailingBit<T: iu32>(T) -> T
diff --git a/src/tint/resolver/const_eval.cc b/src/tint/resolver/const_eval.cc
index 159aaf4..f1197ce 100644
--- a/src/tint/resolver/const_eval.cc
+++ b/src/tint/resolver/const_eval.cc
@@ -2367,6 +2367,25 @@
     return TransformElements(builder, ty, transform, args[0]);
 }
 
+ConstEval::Result ConstEval::faceForward(const sem::Type* ty,
+                                         utils::VectorRef<const sem::Constant*> args,
+                                         const Source& source) {
+    // Returns e1 if dot(e2, e3) is negative, and -e1 otherwise.
+    auto* e1 = args[0];
+    auto* e2 = args[1];
+    auto* e3 = args[2];
+    auto r = Dot(source, e2, e3);
+    if (!r) {
+        AddNote("when calculating faceForward", source);
+        return utils::Failure;
+    }
+    auto is_negative = [](auto v) { return v < 0; };
+    if (Dispatch_fa_f32_f16(is_negative, r.Get())) {
+        return e1;
+    }
+    return OpUnaryMinus(ty, utils::Vector{e1}, source);
+}
+
 ConstEval::Result ConstEval::firstLeadingBit(const sem::Type* ty,
                                              utils::VectorRef<const sem::Constant*> args,
                                              const Source& source) {
diff --git a/src/tint/resolver/const_eval.h b/src/tint/resolver/const_eval.h
index 0c8ec6b..df1db17 100644
--- a/src/tint/resolver/const_eval.h
+++ b/src/tint/resolver/const_eval.h
@@ -602,6 +602,15 @@
                        utils::VectorRef<const sem::Constant*> args,
                        const Source& source);
 
+    /// faceForward builtin
+    /// @param ty the expression type
+    /// @param args the input arguments
+    /// @param source the source location
+    /// @return the result value, or null if the value cannot be calculated
+    Result faceForward(const sem::Type* ty,
+                       utils::VectorRef<const sem::Constant*> args,
+                       const Source& source);
+
     /// firstLeadingBit builtin
     /// @param ty the expression type
     /// @param args the input arguments
diff --git a/src/tint/resolver/const_eval_builtin_test.cc b/src/tint/resolver/const_eval_builtin_test.cc
index dabe7d9..7c5d708 100644
--- a/src/tint/resolver/const_eval_builtin_test.cc
+++ b/src/tint/resolver/const_eval_builtin_test.cc
@@ -954,6 +954,88 @@
                                               DeterminantCases<f16>()))));
 
 template <typename T>
+std::vector<Case> FaceForwardCases() {
+    // Rotate v by degs around Z axis
+    auto rotate = [&](const Value& v, float degs) {
+        auto x = builder::As<T>(v.args[0]);
+        auto y = builder::As<T>(v.args[1]);
+        auto z = builder::As<T>(v.args[2]);
+        auto rads = T(degs) * kPi<T> / T(180);
+        auto x2 = T(x * std::cos(rads) - y * std::sin(rads));
+        auto y2 = T(x * std::sin(rads) + y * std::cos(rads));
+        return Vec(x2, y2, z);
+    };
+
+    // An arbitrary input vector and its negation, used for e1 args to FaceForward
+    auto pos_vec = Vec(T(1), T(2), T(3));
+    auto neg_vec = Vec(-T(1), -T(2), -T(3));
+
+    // An arbitrary vector in the xy plane, used for e2 and e3 args to FaceForward.
+    auto fwd_xy = Vec(T(1.23), T(4.56), T(0));
+
+    std::vector<Case> r = {
+        C({pos_vec, fwd_xy, rotate(fwd_xy, 85)}, neg_vec),
+        C({pos_vec, fwd_xy, rotate(fwd_xy, 85)}, neg_vec),
+        C({pos_vec, fwd_xy, rotate(fwd_xy, 95)}, pos_vec),
+        C({pos_vec, fwd_xy, rotate(fwd_xy, -95)}, pos_vec),
+        C({pos_vec, fwd_xy, rotate(fwd_xy, 180)}, pos_vec),
+
+        C({pos_vec, rotate(fwd_xy, 33), rotate(fwd_xy, 33 + 85)}, neg_vec),
+        C({pos_vec, rotate(fwd_xy, 33), rotate(fwd_xy, 33 - 85)}, neg_vec),
+        C({pos_vec, rotate(fwd_xy, 33), rotate(fwd_xy, 33 + 95)}, pos_vec),
+        C({pos_vec, rotate(fwd_xy, 33), rotate(fwd_xy, 33 - 95)}, pos_vec),
+        C({pos_vec, rotate(fwd_xy, 33), rotate(fwd_xy, 33 + 180)}, pos_vec),
+
+        C({pos_vec, rotate(fwd_xy, 234), rotate(fwd_xy, 234 + 85)}, neg_vec),
+        C({pos_vec, rotate(fwd_xy, 234), rotate(fwd_xy, 234 - 85)}, neg_vec),
+        C({pos_vec, rotate(fwd_xy, 234), rotate(fwd_xy, 234 + 95)}, pos_vec),
+        C({pos_vec, rotate(fwd_xy, 234), rotate(fwd_xy, 234 - 95)}, pos_vec),
+        C({pos_vec, rotate(fwd_xy, 234), rotate(fwd_xy, 234 + 180)}, pos_vec),
+
+        // Same, but swap input and result vectors
+        C({neg_vec, fwd_xy, rotate(fwd_xy, 85)}, pos_vec),
+        C({neg_vec, fwd_xy, rotate(fwd_xy, 85)}, pos_vec),
+        C({neg_vec, fwd_xy, rotate(fwd_xy, 95)}, neg_vec),
+        C({neg_vec, fwd_xy, rotate(fwd_xy, -95)}, neg_vec),
+        C({neg_vec, fwd_xy, rotate(fwd_xy, 180)}, neg_vec),
+
+        C({neg_vec, rotate(fwd_xy, 33), rotate(fwd_xy, 33 + 85)}, pos_vec),
+        C({neg_vec, rotate(fwd_xy, 33), rotate(fwd_xy, 33 - 85)}, pos_vec),
+        C({neg_vec, rotate(fwd_xy, 33), rotate(fwd_xy, 33 + 95)}, neg_vec),
+        C({neg_vec, rotate(fwd_xy, 33), rotate(fwd_xy, 33 - 95)}, neg_vec),
+        C({neg_vec, rotate(fwd_xy, 33), rotate(fwd_xy, 33 + 180)}, neg_vec),
+
+        C({neg_vec, rotate(fwd_xy, 234), rotate(fwd_xy, 234 + 85)}, pos_vec),
+        C({neg_vec, rotate(fwd_xy, 234), rotate(fwd_xy, 234 - 85)}, pos_vec),
+        C({neg_vec, rotate(fwd_xy, 234), rotate(fwd_xy, 234 + 95)}, neg_vec),
+        C({neg_vec, rotate(fwd_xy, 234), rotate(fwd_xy, 234 - 95)}, neg_vec),
+        C({neg_vec, rotate(fwd_xy, 234), rotate(fwd_xy, 234 + 180)}, neg_vec),
+    };
+
+    auto error_msg = [](auto a, const char* op, auto b) {
+        return "12:34 error: " + OverflowErrorMessage(a, op, b) + R"(
+12:34 note: when calculating faceForward)";
+    };
+    ConcatInto(  //
+        r, std::vector<Case>{
+               // Overflow the dot product operation
+               E({pos_vec, Vec(T::Highest(), T::Highest(), T(0)), Vec(T(1), T(1), T(0))},
+                 error_msg(T::Highest(), "+", T::Highest())),
+               E({pos_vec, Vec(T::Lowest(), T::Lowest(), T(0)), Vec(T(1), T(1), T(0))},
+                 error_msg(T::Lowest(), "+", T::Lowest())),
+           });
+
+    return r;
+}
+INSTANTIATE_TEST_SUITE_P(  //
+    FaceForward,
+    ResolverConstEvalBuiltinTest,
+    testing::Combine(testing::Values(sem::BuiltinType::kFaceForward),
+                     testing::ValuesIn(Concat(FaceForwardCases<AFloat>(),  //
+                                              FaceForwardCases<f32>(),     //
+                                              FaceForwardCases<f16>()))));
+
+template <typename T>
 std::vector<Case> FirstLeadingBitCases() {
     using B = BitValues<T>;
     auto r = std::vector<Case>{
diff --git a/src/tint/resolver/const_eval_conversion_test.cc b/src/tint/resolver/const_eval_conversion_test.cc
index da37f3b..8640a1d 100644
--- a/src/tint/resolver/const_eval_conversion_test.cc
+++ b/src/tint/resolver/const_eval_conversion_test.cc
@@ -94,12 +94,12 @@
         ASSERT_NE(sem->ConstantValue(), nullptr);
         EXPECT_TYPE(sem->ConstantValue()->Type(), target_sem_ty);
 
-        auto expected_values = expected.Args();
+        auto expected_values = expected.args;
         if (kind == Kind::kVector) {
-            expected_values.values.Push(expected_values.values[0]);
-            expected_values.values.Push(expected_values.values[0]);
+            expected_values.Push(expected_values[0]);
+            expected_values.Push(expected_values[0]);
         }
-        auto got_values = ScalarArgsFrom(sem->ConstantValue());
+        auto got_values = ScalarsFrom(sem->ConstantValue());
         EXPECT_EQ(expected_values, got_values);
     }
 }
diff --git a/src/tint/resolver/const_eval_test.h b/src/tint/resolver/const_eval_test.h
index dcb91d0..0d27805 100644
--- a/src/tint/resolver/const_eval_test.h
+++ b/src/tint/resolver/const_eval_test.h
@@ -37,28 +37,29 @@
 inline const auto k3PiOver4 = T(UnwrapNumber<T>(2.356194490192344928846));
 
 /// Walks the sem::Constant @p c, accumulating all the inner-most scalar values into @p args
-inline void CollectScalarArgs(const sem::Constant* c, builder::ScalarArgs& args) {
+template <size_t N>
+inline void CollectScalars(const sem::Constant* c, utils::Vector<builder::Scalar, N>& scalars) {
     Switch(
         c->Type(),  //
-        [&](const sem::AbstractInt*) { args.values.Push(c->As<AInt>()); },
-        [&](const sem::AbstractFloat*) { args.values.Push(c->As<AFloat>()); },
-        [&](const sem::Bool*) { args.values.Push(c->As<bool>()); },
-        [&](const sem::I32*) { args.values.Push(c->As<i32>()); },
-        [&](const sem::U32*) { args.values.Push(c->As<u32>()); },
-        [&](const sem::F32*) { args.values.Push(c->As<f32>()); },
-        [&](const sem::F16*) { args.values.Push(c->As<f16>()); },
+        [&](const sem::AbstractInt*) { scalars.Push(c->As<AInt>()); },
+        [&](const sem::AbstractFloat*) { scalars.Push(c->As<AFloat>()); },
+        [&](const sem::Bool*) { scalars.Push(c->As<bool>()); },
+        [&](const sem::I32*) { scalars.Push(c->As<i32>()); },
+        [&](const sem::U32*) { scalars.Push(c->As<u32>()); },
+        [&](const sem::F32*) { scalars.Push(c->As<f32>()); },
+        [&](const sem::F16*) { scalars.Push(c->As<f16>()); },
         [&](Default) {
             size_t i = 0;
             while (auto* child = c->Index(i++)) {
-                CollectScalarArgs(child, args);
+                CollectScalars(child, scalars);
             }
         });
 }
 
 /// Walks the sem::Constant @p c, returning all the inner-most scalar values.
-inline builder::ScalarArgs ScalarArgsFrom(const sem::Constant* c) {
-    builder::ScalarArgs out;
-    CollectScalarArgs(c, out);
+inline utils::Vector<builder::Scalar, 16> ScalarsFrom(const sem::Constant* c) {
+    utils::Vector<builder::Scalar, 16> out;
+    CollectScalars(c, out);
     return out;
 }
 
@@ -90,14 +91,14 @@
 inline void CheckConstant(const sem::Constant* got_constant,
                           const builder::Value& expected_value,
                           CheckConstantFlags flags = {}) {
-    auto values_flat = ScalarArgsFrom(got_constant);
-    auto expected_values_flat = expected_value.Args();
-    ASSERT_EQ(values_flat.values.Length(), expected_values_flat.values.Length());
-    for (size_t i = 0; i < values_flat.values.Length(); ++i) {
-        auto& got_scalar = values_flat.values[i];
-        auto& expected_scalar = expected_values_flat.values[i];
+    auto values_flat = ScalarsFrom(got_constant);
+    auto expected_values_flat = expected_value.args;
+    ASSERT_EQ(values_flat.Length(), expected_values_flat.Length());
+    for (size_t i = 0; i < values_flat.Length(); ++i) {
+        auto& got_scalar = values_flat[i];
+        auto& expected_scalar = expected_values_flat[i];
         std::visit(
-            [&](auto&& expected) {
+            [&](const auto& expected) {
                 using T = std::decay_t<decltype(expected)>;
 
                 ASSERT_TRUE(std::holds_alternative<T>(got_scalar));
diff --git a/src/tint/resolver/const_eval_unary_op_test.cc b/src/tint/resolver/const_eval_unary_op_test.cc
index d24c27b..f7f5928 100644
--- a/src/tint/resolver/const_eval_unary_op_test.cc
+++ b/src/tint/resolver/const_eval_unary_op_test.cc
@@ -61,14 +61,14 @@
     ASSERT_NE(value, nullptr);
     EXPECT_TYPE(value->Type(), sem->Type());
 
-    auto values_flat = ScalarArgsFrom(value);
-    auto expected_values_flat = expected.Args();
-    ASSERT_EQ(values_flat.values.Length(), expected_values_flat.values.Length());
-    for (size_t i = 0; i < values_flat.values.Length(); ++i) {
-        auto& a = values_flat.values[i];
-        auto& b = expected_values_flat.values[i];
+    auto values_flat = ScalarsFrom(value);
+    auto expected_values_flat = expected.args;
+    ASSERT_EQ(values_flat.Length(), expected_values_flat.Length());
+    for (size_t i = 0; i < values_flat.Length(); ++i) {
+        auto& a = values_flat[i];
+        auto& b = expected_values_flat[i];
         EXPECT_EQ(a, b);
-        if (expected.IsIntegral()) {
+        if (expected.is_integral) {
             // Check that the constant's integer doesn't contain unexpected
             // data in the MSBs that are outside of the bit-width of T.
             EXPECT_EQ(builder::As<AInt>(a), builder::As<AInt>(b));
diff --git a/src/tint/resolver/intrinsic_table.inl b/src/tint/resolver/intrinsic_table.inl
index 9d77df3..e521a9c 100644
--- a/src/tint/resolver/intrinsic_table.inl
+++ b/src/tint/resolver/intrinsic_table.inl
@@ -13614,12 +13614,12 @@
     /* num parameters */ 3,
     /* num template types */ 1,
     /* num template numbers */ 1,
-    /* template types */ &kTemplateTypes[26],
+    /* template types */ &kTemplateTypes[23],
     /* template numbers */ &kTemplateNumbers[4],
     /* parameters */ &kParameters[459],
     /* return matcher indices */ &kMatcherIndices[30],
     /* flags */ OverloadFlags(OverloadFlag::kIsBuiltin, OverloadFlag::kSupportsVertexPipeline, OverloadFlag::kSupportsFragmentPipeline, OverloadFlag::kSupportsComputePipeline),
-    /* const eval */ nullptr,
+    /* const eval */ &ConstEval::faceForward,
   },
   {
     /* [442] */
@@ -14218,7 +14218,7 @@
   },
   {
     /* [34] */
-    /* fn faceForward<N : num, T : f32_f16>(vec<N, T>, vec<N, T>, vec<N, T>) -> vec<N, T> */
+    /* fn faceForward<N : num, T : fa_f32_f16>(vec<N, T>, vec<N, T>, vec<N, T>) -> vec<N, T> */
     /* num overloads */ 1,
     /* overloads */ &kOverloads[441],
   },
diff --git a/src/tint/resolver/resolver_test_helper.h b/src/tint/resolver/resolver_test_helper.h
index edbc456..d44c9d8 100644
--- a/src/tint/resolver/resolver_test_helper.h
+++ b/src/tint/resolver/resolver_test_helper.h
@@ -180,63 +180,18 @@
 template <typename TO>
 struct ptr {};
 
-/// Type used to accept scalars as arguments. Can be either a single value that gets splatted for
-/// composite types, or all values required by the composite type.
-struct ScalarArgs {
-    /// Constructor
-    ScalarArgs() = default;
-
-    /// Constructor
-    /// @param single_value single value to initialize with
-    template <typename T>
-    explicit ScalarArgs(T single_value) : values(utils::Vector<Storage, 1>{single_value}) {}
-
-    /// Constructor
-    /// @param all_values all values to initialize the composite type with
-    template <typename T>
-    ScalarArgs(utils::VectorRef<T> all_values)  // NOLINT: implicit on purpose
-    {
-        for (auto& v : all_values) {
-            values.Push(v);
-        }
-    }
-
-    /// @param other the other ScalarArgs to compare against
-    /// @returns true if all values are equal to the values in @p other
-    bool operator==(const ScalarArgs& other) const { return values == other.values; }
-
-    /// Valid scalar types for args
-    using Storage = std::variant<i32, u32, f32, f16, AInt, AFloat, bool>;
-
-    /// The vector of values
-    utils::Vector<Storage, 16> values;
-};
+/// A scalar value
+using Scalar = std::variant<i32, u32, f32, f16, AInt, AFloat, bool>;
 
 /// Returns current variant value in `s` cast to type `T`
 template <typename T>
-T As(ScalarArgs::Storage& s) {
+T As(const Scalar& s) {
     return std::visit([](auto&& v) { return static_cast<T>(v); }, s);
 }
 
-/// @param o the std::ostream to write to
-/// @param args the ScalarArgs
-/// @return the std::ostream so calls can be chained
-inline std::ostream& operator<<(std::ostream& o, const ScalarArgs& args) {
-    o << "[";
-    bool first = true;
-    for (auto& val : args.values) {
-        if (!first) {
-            o << ", ";
-        }
-        first = false;
-        std::visit([&](auto&& v) { o << v; }, val);
-    }
-    o << "]";
-    return o;
-}
-
 using ast_type_func_ptr = const ast::Type* (*)(ProgramBuilder& b);
-using ast_expr_func_ptr = const ast::Expression* (*)(ProgramBuilder& b, ScalarArgs args);
+using ast_expr_func_ptr = const ast::Expression* (*)(ProgramBuilder& b,
+                                                     utils::VectorRef<Scalar> args);
 using ast_expr_from_double_func_ptr = const ast::Expression* (*)(ProgramBuilder& b, double v);
 using sem_type_func_ptr = const sem::Type* (*)(ProgramBuilder& b);
 using type_name_func_ptr = std::string (*)();
@@ -280,14 +235,14 @@
     /// @param b the ProgramBuilder
     /// @param args args of size 1 with the boolean value to init with
     /// @return a new AST expression of the bool type
-    static inline const ast::Expression* Expr(ProgramBuilder& b, ScalarArgs args) {
-        return b.Expr(std::get<bool>(args.values[0]));
+    static inline const ast::Expression* Expr(ProgramBuilder& b, utils::VectorRef<Scalar> args) {
+        return b.Expr(std::get<bool>(args[0]));
     }
     /// @param b the ProgramBuilder
     /// @param v arg of type double that will be cast to bool.
     /// @return a new AST expression of the bool type
     static inline const ast::Expression* ExprFromDouble(ProgramBuilder& b, double v) {
-        return Expr(b, ScalarArgs{static_cast<ElementType>(v)});
+        return Expr(b, utils::Vector<Scalar, 1>{static_cast<ElementType>(v)});
     }
     /// @returns the WGSL name for the type
     static inline std::string Name() { return "bool"; }
@@ -311,14 +266,14 @@
     /// @param b the ProgramBuilder
     /// @param args args of size 1 with the i32 value to init with
     /// @return a new AST i32 literal value expression
-    static inline const ast::Expression* Expr(ProgramBuilder& b, ScalarArgs args) {
-        return b.Expr(std::get<i32>(args.values[0]));
+    static inline const ast::Expression* Expr(ProgramBuilder& b, utils::VectorRef<Scalar> args) {
+        return b.Expr(std::get<i32>(args[0]));
     }
     /// @param b the ProgramBuilder
     /// @param v arg of type double that will be cast to i32.
     /// @return a new AST i32 literal value expression
     static inline const ast::Expression* ExprFromDouble(ProgramBuilder& b, double v) {
-        return Expr(b, ScalarArgs{static_cast<ElementType>(v)});
+        return Expr(b, utils::Vector<Scalar, 1>{static_cast<ElementType>(v)});
     }
     /// @returns the WGSL name for the type
     static inline std::string Name() { return "i32"; }
@@ -342,14 +297,14 @@
     /// @param b the ProgramBuilder
     /// @param args args of size 1 with the u32 value to init with
     /// @return a new AST u32 literal value expression
-    static inline const ast::Expression* Expr(ProgramBuilder& b, ScalarArgs args) {
-        return b.Expr(std::get<u32>(args.values[0]));
+    static inline const ast::Expression* Expr(ProgramBuilder& b, utils::VectorRef<Scalar> args) {
+        return b.Expr(std::get<u32>(args[0]));
     }
     /// @param b the ProgramBuilder
     /// @param v arg of type double that will be cast to u32.
     /// @return a new AST u32 literal value expression
     static inline const ast::Expression* ExprFromDouble(ProgramBuilder& b, double v) {
-        return Expr(b, ScalarArgs{static_cast<ElementType>(v)});
+        return Expr(b, utils::Vector<Scalar, 1>{static_cast<ElementType>(v)});
     }
     /// @returns the WGSL name for the type
     static inline std::string Name() { return "u32"; }
@@ -373,14 +328,14 @@
     /// @param b the ProgramBuilder
     /// @param args args of size 1 with the f32 value to init with
     /// @return a new AST f32 literal value expression
-    static inline const ast::Expression* Expr(ProgramBuilder& b, ScalarArgs args) {
-        return b.Expr(std::get<f32>(args.values[0]));
+    static inline const ast::Expression* Expr(ProgramBuilder& b, utils::VectorRef<Scalar> args) {
+        return b.Expr(std::get<f32>(args[0]));
     }
     /// @param b the ProgramBuilder
     /// @param v arg of type double that will be cast to f32.
     /// @return a new AST f32 literal value expression
     static inline const ast::Expression* ExprFromDouble(ProgramBuilder& b, double v) {
-        return Expr(b, ScalarArgs{static_cast<f32>(v)});
+        return Expr(b, utils::Vector<Scalar, 1>{static_cast<f32>(v)});
     }
     /// @returns the WGSL name for the type
     static inline std::string Name() { return "f32"; }
@@ -404,14 +359,14 @@
     /// @param b the ProgramBuilder
     /// @param args args of size 1 with the f16 value to init with
     /// @return a new AST f16 literal value expression
-    static inline const ast::Expression* Expr(ProgramBuilder& b, ScalarArgs args) {
-        return b.Expr(std::get<f16>(args.values[0]));
+    static inline const ast::Expression* Expr(ProgramBuilder& b, utils::VectorRef<Scalar> args) {
+        return b.Expr(std::get<f16>(args[0]));
     }
     /// @param b the ProgramBuilder
     /// @param v arg of type double that will be cast to f16.
     /// @return a new AST f16 literal value expression
     static inline const ast::Expression* ExprFromDouble(ProgramBuilder& b, double v) {
-        return Expr(b, ScalarArgs{static_cast<ElementType>(v)});
+        return Expr(b, utils::Vector<Scalar, 1>{static_cast<ElementType>(v)});
     }
     /// @returns the WGSL name for the type
     static inline std::string Name() { return "f16"; }
@@ -434,14 +389,14 @@
     /// @param b the ProgramBuilder
     /// @param args args of size 1 with the abstract-float value to init with
     /// @return a new AST abstract-float literal value expression
-    static inline const ast::Expression* Expr(ProgramBuilder& b, ScalarArgs args) {
-        return b.Expr(std::get<AFloat>(args.values[0]));
+    static inline const ast::Expression* Expr(ProgramBuilder& b, utils::VectorRef<Scalar> args) {
+        return b.Expr(std::get<AFloat>(args[0]));
     }
     /// @param b the ProgramBuilder
     /// @param v arg of type double that will be cast to AFloat.
     /// @return a new AST abstract-float literal value expression
     static inline const ast::Expression* ExprFromDouble(ProgramBuilder& b, double v) {
-        return Expr(b, ScalarArgs{static_cast<ElementType>(v)});
+        return Expr(b, utils::Vector<Scalar, 1>{static_cast<ElementType>(v)});
     }
     /// @returns the WGSL name for the type
     static inline std::string Name() { return "abstract-float"; }
@@ -464,14 +419,14 @@
     /// @param b the ProgramBuilder
     /// @param args args of size 1 with the abstract-int value to init with
     /// @return a new AST abstract-int literal value expression
-    static inline const ast::Expression* Expr(ProgramBuilder& b, ScalarArgs args) {
-        return b.Expr(std::get<AInt>(args.values[0]));
+    static inline const ast::Expression* Expr(ProgramBuilder& b, utils::VectorRef<Scalar> args) {
+        return b.Expr(std::get<AInt>(args[0]));
     }
     /// @param b the ProgramBuilder
     /// @param v arg of type double that will be cast to AInt.
     /// @return a new AST abstract-int literal value expression
     static inline const ast::Expression* ExprFromDouble(ProgramBuilder& b, double v) {
-        return Expr(b, ScalarArgs{static_cast<ElementType>(v)});
+        return Expr(b, utils::Vector<Scalar, 1>{static_cast<ElementType>(v)});
     }
     /// @returns the WGSL name for the type
     static inline std::string Name() { return "abstract-int"; }
@@ -499,17 +454,17 @@
     /// @param b the ProgramBuilder
     /// @param args args of size 1 or N with values of type T to initialize with
     /// @return a new AST vector value expression
-    static inline const ast::Expression* Expr(ProgramBuilder& b, ScalarArgs args) {
+    static inline const ast::Expression* Expr(ProgramBuilder& b, utils::VectorRef<Scalar> args) {
         return b.Construct(AST(b), ExprArgs(b, std::move(args)));
     }
     /// @param b the ProgramBuilder
     /// @param args args of size 1 or N with values of type T to initialize with
     /// @return the list of expressions that are used to construct the vector
-    static inline auto ExprArgs(ProgramBuilder& b, ScalarArgs args) {
-        const bool one_value = args.values.Length() == 1;
+    static inline auto ExprArgs(ProgramBuilder& b, utils::VectorRef<Scalar> args) {
+        const bool one_value = args.Length() == 1;
         utils::Vector<const ast::Expression*, N> r;
         for (size_t i = 0; i < N; ++i) {
-            r.Push(DataType<T>::Expr(b, ScalarArgs{one_value ? args.values[0] : args.values[i]}));
+            r.Push(DataType<T>::Expr(b, utils::Vector<Scalar, 1>{one_value ? args[0] : args[i]}));
         }
         return r;
     }
@@ -517,7 +472,7 @@
     /// @param v arg of type double that will be cast to ElementType
     /// @return a new AST vector value expression
     static inline const ast::Expression* ExprFromDouble(ProgramBuilder& b, double v) {
-        return Expr(b, ScalarArgs{static_cast<ElementType>(v)});
+        return Expr(b, utils::Vector<Scalar, 1>{static_cast<ElementType>(v)});
     }
     /// @returns the WGSL name for the type
     static inline std::string Name() {
@@ -548,25 +503,25 @@
     /// @param b the ProgramBuilder
     /// @param args args of size 1 or N*M with values of type T to initialize with
     /// @return a new AST matrix value expression
-    static inline const ast::Expression* Expr(ProgramBuilder& b, ScalarArgs args) {
+    static inline const ast::Expression* Expr(ProgramBuilder& b, utils::VectorRef<Scalar> args) {
         return b.Construct(AST(b), ExprArgs(b, std::move(args)));
     }
     /// @param b the ProgramBuilder
     /// @param args args of size 1 or N*M with values of type T to initialize with
     /// @return a new AST matrix value expression
-    static inline auto ExprArgs(ProgramBuilder& b, ScalarArgs args) {
-        const bool one_value = args.values.Length() == 1;
+    static inline auto ExprArgs(ProgramBuilder& b, utils::VectorRef<Scalar> args) {
+        const bool one_value = args.Length() == 1;
         size_t next = 0;
         utils::Vector<const ast::Expression*, N> r;
         for (uint32_t i = 0; i < N; ++i) {
             if (one_value) {
-                r.Push(DataType<vec<M, T>>::Expr(b, ScalarArgs{args.values[0]}));
+                r.Push(DataType<vec<M, T>>::Expr(b, utils::Vector<Scalar, 1>{args[0]}));
             } else {
-                utils::Vector<T, M> v;
+                utils::Vector<Scalar, M> v;
                 for (size_t j = 0; j < M; ++j) {
-                    v.Push(std::get<T>(args.values[next++]));
+                    v.Push(args[next++]);
                 }
-                r.Push(DataType<vec<M, T>>::Expr(b, utils::VectorRef<T>{v}));
+                r.Push(DataType<vec<M, T>>::Expr(b, std::move(v)));
             }
         }
         return r;
@@ -575,7 +530,7 @@
     /// @param v arg of type double that will be cast to ElementType
     /// @return a new AST matrix value expression
     static inline const ast::Expression* ExprFromDouble(ProgramBuilder& b, double v) {
-        return Expr(b, ScalarArgs{static_cast<ElementType>(v)});
+        return Expr(b, utils::Vector<Scalar, 1>{static_cast<ElementType>(v)});
     }
     /// @returns the WGSL name for the type
     static inline std::string Name() {
@@ -611,8 +566,9 @@
     /// @param args the value nested elements will be initialized with
     /// @return a new AST expression of the alias type
     template <bool IS_COMPOSITE = is_composite>
-    static inline traits::EnableIf<!IS_COMPOSITE, const ast::Expression*> Expr(ProgramBuilder& b,
-                                                                               ScalarArgs args) {
+    static inline traits::EnableIf<!IS_COMPOSITE, const ast::Expression*> Expr(
+        ProgramBuilder& b,
+        utils::VectorRef<Scalar> args) {
         // Cast
         return b.Construct(AST(b), DataType<T>::Expr(b, std::move(args)));
     }
@@ -621,8 +577,9 @@
     /// @param args the value nested elements will be initialized with
     /// @return a new AST expression of the alias type
     template <bool IS_COMPOSITE = is_composite>
-    static inline traits::EnableIf<IS_COMPOSITE, const ast::Expression*> Expr(ProgramBuilder& b,
-                                                                              ScalarArgs args) {
+    static inline traits::EnableIf<IS_COMPOSITE, const ast::Expression*> Expr(
+        ProgramBuilder& b,
+        utils::VectorRef<Scalar> args) {
         // Construct
         return b.Construct(AST(b), DataType<T>::ExprArgs(b, std::move(args)));
     }
@@ -631,7 +588,7 @@
     /// @param v arg of type double that will be cast to ElementType
     /// @return a new AST expression of the alias type
     static inline const ast::Expression* ExprFromDouble(ProgramBuilder& b, double v) {
-        return Expr(b, ScalarArgs{static_cast<ElementType>(v)});
+        return Expr(b, utils::Vector<Scalar, 1>{static_cast<ElementType>(v)});
     }
 
     /// @returns the WGSL name for the type
@@ -662,7 +619,8 @@
 
     /// @param b the ProgramBuilder
     /// @return a new AST expression of the pointer type
-    static inline const ast::Expression* Expr(ProgramBuilder& b, ScalarArgs /*unused*/) {
+    static inline const ast::Expression* Expr(ProgramBuilder& b,
+                                              utils::VectorRef<Scalar> /*unused*/) {
         auto sym = b.Symbols().New("global_for_ptr");
         b.GlobalVar(sym, DataType<T>::AST(b), ast::AddressSpace::kPrivate);
         return b.AddressOf(sym);
@@ -672,7 +630,7 @@
     /// @param v arg of type double that will be cast to ElementType
     /// @return a new AST expression of the pointer type
     static inline const ast::Expression* ExprFromDouble(ProgramBuilder& b, double v) {
-        return Expr(b, ScalarArgs{static_cast<ElementType>(v)});
+        return Expr(b, utils::Vector<Scalar, 1>{static_cast<ElementType>(v)});
     }
 
     /// @returns the WGSL name for the type
@@ -716,17 +674,17 @@
     /// @param args args of size 1 or N with values of type T to initialize with
     /// with
     /// @return a new AST array value expression
-    static inline const ast::Expression* Expr(ProgramBuilder& b, ScalarArgs args) {
+    static inline const ast::Expression* Expr(ProgramBuilder& b, utils::VectorRef<Scalar> args) {
         return b.Construct(AST(b), ExprArgs(b, std::move(args)));
     }
     /// @param b the ProgramBuilder
     /// @param args args of size 1 or N with values of type T to initialize with
     /// @return the list of expressions that are used to construct the array
-    static inline auto ExprArgs(ProgramBuilder& b, ScalarArgs args) {
-        const bool one_value = args.values.Length() == 1;
+    static inline auto ExprArgs(ProgramBuilder& b, utils::VectorRef<Scalar> args) {
+        const bool one_value = args.Length() == 1;
         utils::Vector<const ast::Expression*, N> r;
         for (uint32_t i = 0; i < N; i++) {
-            r.Push(DataType<T>::Expr(b, ScalarArgs{one_value ? args.values[0] : args.values[i]}));
+            r.Push(DataType<T>::Expr(b, utils::Vector<Scalar, 1>{one_value ? args[0] : args[i]}));
         }
         return r;
     }
@@ -734,7 +692,7 @@
     /// @param v arg of type double that will be cast to ElementType
     /// @return a new AST array value expression
     static inline const ast::Expression* ExprFromDouble(ProgramBuilder& b, double v) {
-        return Expr(b, ScalarArgs{static_cast<ElementType>(v)});
+        return Expr(b, utils::Vector<Scalar, 1>{static_cast<ElementType>(v)});
     }
     /// @returns the WGSL name for the type
     static inline std::string Name() {
@@ -776,80 +734,34 @@
 const bool IsDataTypeSpecializedFor =
     !std::is_same_v<typename DataType<T>::ElementType, UnspecializedElementType>;
 
-namespace detail {
-/// ValueBase is a base class of ConcreteValue<T>
-struct ValueBase {
-    /// Constructor
-    ValueBase() = default;
-    /// Destructor
-    virtual ~ValueBase() = default;
-    /// Move constructor
-    ValueBase(ValueBase&&) = default;
-    /// Copy constructor
-    ValueBase(const ValueBase&) = default;
-    /// Copy assignment operator
-    /// @returns this instance
-    ValueBase& operator=(const ValueBase&) = default;
-    /// Creates an `ast::Expression` for the type T passing in previously stored args
-    /// @param b the ProgramBuilder
-    /// @returns an expression node
-    virtual const ast::Expression* Expr(ProgramBuilder& b) const = 0;
-    /// @returns args used to create expression via `Expr`
-    virtual const ScalarArgs& Args() const = 0;
-    /// @returns true if element type is abstract
-    virtual bool IsAbstract() const = 0;
-    /// @returns true if element type is an integral
-    virtual bool IsIntegral() const = 0;
-    /// @returns element type name
-    virtual std::string TypeName() const = 0;
-    /// Prints this value to the output stream
-    /// @param o the output stream
-    /// @returns input argument `o`
-    virtual std::ostream& Print(std::ostream& o) const = 0;
-};
-
-/// ConcreteValue<T> is used to create Values of type DataType<T> with a ScalarArgs initializer.
-template <typename T>
-struct ConcreteValue : ValueBase {
-    /// Constructor
+/// Value is used to create Values with a Scalar vector initializer.
+struct Value {
+    /// Creates a Value for type T initialized with `args`
     /// @param args the scalar args
-    explicit ConcreteValue(ScalarArgs args) : args_(std::move(args)) {}
-
-    /// Alias to T
-    using Type = T;
-    /// Alias to DataType<T>
-    using DataType = builder::DataType<T>;
-    /// Alias to DataType::ElementType
-    using ElementType = typename DataType::ElementType;
-
-    /// Creates an `ast::Expression` for the type T passing in previously stored args
-    /// @param b the ProgramBuilder
-    /// @returns an expression node
-    const ast::Expression* Expr(ProgramBuilder& b) const override {
-        auto create = CreatePtrsFor<T>();
-        return (*create.expr)(b, args_);
+    /// @returns Value
+    template <typename T>
+    static Value Create(utils::VectorRef<Scalar> args) {
+        static_assert(IsDataTypeSpecializedFor<T>, "No DataType<T> specialization exists");
+        using EL_TY = typename builder::DataType<T>::ElementType;
+        return Value{
+            std::move(args),         CreatePtrsFor<T>().expr,     tint::IsAbstract<EL_TY>,
+            tint::IsIntegral<EL_TY>, tint::FriendlyName<EL_TY>(),
+        };
     }
 
-    /// @returns args used to create expression via `Expr`
-    const ScalarArgs& Args() const override { return args_; }
-
-    /// @returns true if element type is abstract
-    bool IsAbstract() const override { return tint::IsAbstract<ElementType>; }
-
-    /// @returns true if element type is an integral
-    bool IsIntegral() const override { return tint::IsIntegral<ElementType>; }
-
-    /// @returns element type name
-    std::string TypeName() const override { return tint::FriendlyName<ElementType>(); }
+    /// Creates an `ast::Expression` for the type T passing in previously stored args
+    /// @param b the ProgramBuilder
+    /// @returns an expression node
+    const ast::Expression* Expr(ProgramBuilder& b) const { return (*create)(b, args); }
 
     /// Prints this value to the output stream
     /// @param o the output stream
     /// @returns input argument `o`
-    std::ostream& Print(std::ostream& o) const override {
-        o << TypeName() << "(";
-        for (auto& a : args_.values) {
-            o << std::get<ElementType>(a);
-            if (&a != &args_.values.Back()) {
+    std::ostream& Print(std::ostream& o) const {
+        o << type_name << "(";
+        for (auto& a : args) {
+            std::visit([&](auto& v) { o << v; }, a);
+            if (&a != &args.Back()) {
                 o << ", ";
             }
         }
@@ -857,54 +769,16 @@
         return o;
     }
 
-  private:
-    /// args to create expression with
-    ScalarArgs args_;
-};
-}  // namespace detail
-
-/// A Value represents a value of type DataType<T> created with ScalarArgs. Useful for storing
-/// values for unit tests.
-class Value {
-  public:
-    /// Creates a Value for type T initialized with `args`
-    /// @param args the scalar args
-    /// @returns Value
-    template <typename T>
-    static Value Create(ScalarArgs args) {
-        static_assert(IsDataTypeSpecializedFor<T>, "No DataType<T> specialization exists");
-        return Value{std::make_shared<detail::ConcreteValue<T>>(std::move(args))};
-    }
-
-    /// Creates an `ast::Expression` for the type T passing in previously stored args
-    /// @param b the ProgramBuilder
-    /// @returns an expression node
-    const ast::Expression* Expr(ProgramBuilder& b) const { return value_->Expr(b); }
-
-    /// @returns args used to create expression via `Expr`
-    const ScalarArgs& Args() const { return value_->Args(); }
-
-    /// @returns true if element type is abstract
-    bool IsAbstract() const { return value_->IsAbstract(); }
-
-    /// @returns true if element type is an integral
-    bool IsIntegral() const { return value_->IsIntegral(); }
-
-    /// @returns element type name
-    std::string TypeName() const { return value_->TypeName(); }
-
-    /// Prints this value to the output stream
-    /// @param o the output stream
-    /// @returns input argument `o`
-    std::ostream& Print(std::ostream& o) const { return value_->Print(o); }
-
-  private:
-    /// Private constructor
-    explicit Value(std::shared_ptr<const detail::ValueBase> value) : value_(std::move(value)) {}
-
-    /// Shared pointer to an immutable value. This type-erasure pattern allows Value to wrap a
-    /// polymorphic type, while being used like a value-type (i.e. copyable).
-    std::shared_ptr<const detail::ValueBase> value_;
+    /// The arguments used to construct the value
+    utils::Vector<Scalar, 4> args;
+    /// Function used to construct an expression with the given value
+    builder::ast_expr_func_ptr create;
+    /// True if the element type is abstract
+    bool is_abstract = false;
+    /// True if the element type is an integer
+    bool is_integral = false;
+    /// The name of the type.
+    const char* type_name = "<invalid>";
 };
 
 /// Prints Value to ostream
@@ -919,7 +793,7 @@
 /// Creates a Value of DataType<T> from a scalar `v`
 template <typename T>
 Value Val(T v) {
-    return Value::Create<T>(ScalarArgs{v});
+    return Value::Create<T>(utils::Vector<Scalar, 1>{v});
 }
 
 /// Creates a Value of DataType<vec<N, T>> from N scalar `args`
@@ -927,41 +801,41 @@
 Value Vec(T... args) {
     using FirstT = std::tuple_element_t<0, std::tuple<T...>>;
     constexpr size_t N = sizeof...(args);
-    utils::Vector v{args...};
-    return Value::Create<vec<N, FirstT>>(utils::VectorRef<FirstT>{v});
+    utils::Vector<Scalar, sizeof...(args)> v{args...};
+    return Value::Create<vec<N, FirstT>>(std::move(v));
 }
 
 /// Creates a Value of DataType<mat<C,R,T> from C*R scalar `args`
 template <size_t C, size_t R, typename T>
 Value Mat(const T (&m_in)[C][R]) {
-    utils::Vector<T, C * R> m;
+    utils::Vector<Scalar, C * R> m;
     for (uint32_t i = 0; i < C; ++i) {
         for (size_t j = 0; j < R; ++j) {
             m.Push(m_in[i][j]);
         }
     }
-    return Value::Create<mat<C, R, T>>(utils::VectorRef<T>{m});
+    return Value::Create<mat<C, R, T>>(std::move(m));
 }
 
 /// Creates a Value of DataType<mat<2,R,T> from column vectors `c0` and `c1`
 template <typename T, size_t R>
 Value Mat(const T (&c0)[R], const T (&c1)[R]) {
     constexpr size_t C = 2;
-    utils::Vector<T, C * R> m;
+    utils::Vector<Scalar, C * R> m;
     for (auto v : c0) {
         m.Push(v);
     }
     for (auto v : c1) {
         m.Push(v);
     }
-    return Value::Create<mat<C, R, T>>(utils::VectorRef<T>{m});
+    return Value::Create<mat<C, R, T>>(std::move(m));
 }
 
 /// Creates a Value of DataType<mat<3,R,T> from column vectors `c0`, `c1`, and `c2`
 template <typename T, size_t R>
 Value Mat(const T (&c0)[R], const T (&c1)[R], const T (&c2)[R]) {
     constexpr size_t C = 3;
-    utils::Vector<T, C * R> m;
+    utils::Vector<Scalar, C * R> m;
     for (auto v : c0) {
         m.Push(v);
     }
@@ -971,14 +845,14 @@
     for (auto v : c2) {
         m.Push(v);
     }
-    return Value::Create<mat<C, R, T>>(utils::VectorRef<T>{m});
+    return Value::Create<mat<C, R, T>>(std::move(m));
 }
 
 /// Creates a Value of DataType<mat<4,R,T> from column vectors `c0`, `c1`, `c2`, and `c3`
 template <typename T, size_t R>
 Value Mat(const T (&c0)[R], const T (&c1)[R], const T (&c2)[R], const T (&c3)[R]) {
     constexpr size_t C = 4;
-    utils::Vector<T, C * R> m;
+    utils::Vector<Scalar, C * R> m;
     for (auto v : c0) {
         m.Push(v);
     }
@@ -991,7 +865,7 @@
     for (auto v : c3) {
         m.Push(v);
     }
-    return Value::Create<mat<C, R, T>>(utils::VectorRef<T>{m});
+    return Value::Create<mat<C, R, T>>(std::move(m));
 }
 
 }  // namespace builder
diff --git a/src/tint/sem/binding_point.h b/src/tint/sem/binding_point.h
index b779b73..78403ab 100644
--- a/src/tint/sem/binding_point.h
+++ b/src/tint/sem/binding_point.h
@@ -18,6 +18,7 @@
 #include <stdint.h>
 
 #include <functional>
+#include <ostream>
 
 #include "src/tint/reflection.h"
 #include "src/tint/utils/hash.h"
@@ -47,6 +48,14 @@
     inline bool operator!=(const BindingPoint& rhs) const { return !(*this == rhs); }
 };
 
+/// Prints the BindingPoint @p bp to @p o
+/// @param o the std::ostream to write to
+/// @param bp the BindingPoint
+/// @return the std::ostream so calls can be chained
+inline std::ostream& operator<<(std::ostream& o, const BindingPoint& bp) {
+    return o << "[group: " << bp.group << ", binding: " << bp.binding << "]";
+}
+
 }  // namespace tint::sem
 
 namespace std {
diff --git a/src/tint/sem/sampler_texture_pair.h b/src/tint/sem/sampler_texture_pair.h
index 71f3e3c..b3cf4f2 100644
--- a/src/tint/sem/sampler_texture_pair.h
+++ b/src/tint/sem/sampler_texture_pair.h
@@ -17,6 +17,7 @@
 
 #include <cstdint>
 #include <functional>
+#include <ostream>
 
 #include "src/tint/sem/binding_point.h"
 
@@ -43,6 +44,15 @@
     inline bool operator!=(const SamplerTexturePair& rhs) const { return !(*this == rhs); }
 };
 
+/// Prints the SamplerTexturePair @p stp to @p o
+/// @param o the std::ostream to write to
+/// @param stp the SamplerTexturePair
+/// @return the std::ostream so calls can be chained
+inline std::ostream& operator<<(std::ostream& o, const SamplerTexturePair& stp) {
+    return o << "[sampler: " << stp.sampler_binding_point
+             << ", texture: " << stp.sampler_binding_point << "]";
+}
+
 }  // namespace tint::sem
 
 namespace std {
diff --git a/src/tint/utils/string.h b/src/tint/utils/string.h
index dd37396..abfe51a 100644
--- a/src/tint/utils/string.h
+++ b/src/tint/utils/string.h
@@ -17,6 +17,7 @@
 
 #include <sstream>
 #include <string>
+#include <variant>
 
 namespace tint::utils {
 
@@ -44,6 +45,15 @@
     return s.str();
 }
 
+/// @param value the variant to be printed as a string
+/// @returns value printed as a string via the std::ostream `<<` operator
+template <typename... TYs>
+std::string ToString(const std::variant<TYs...>& value) {
+    std::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
diff --git a/src/tint/utils/vector.h b/src/tint/utils/vector.h
index 705817a..ecee170 100644
--- a/src/tint/utils/vector.h
+++ b/src/tint/utils/vector.h
@@ -20,12 +20,14 @@
 #include <algorithm>
 #include <array>
 #include <iterator>
+#include <ostream>
 #include <utility>
 #include <vector>
 
 #include "src/tint/castable.h"
 #include "src/tint/traits.h"
 #include "src/tint/utils/bitcast.h"
+#include "src/tint/utils/string.h"
 
 namespace tint::utils {
 
@@ -832,6 +834,44 @@
     return out;
 }
 
+/// Prints the vector @p vec to @p o
+/// @param o the std::ostream to write to
+/// @param vec the vector
+/// @return the std::ostream so calls can be chained
+template <typename T, size_t N>
+inline std::ostream& operator<<(std::ostream& o, const utils::Vector<T, N>& vec) {
+    o << "[";
+    bool first = true;
+    for (auto& el : vec) {
+        if (!first) {
+            o << ", ";
+        }
+        first = false;
+        o << ToString(el);
+    }
+    o << "]";
+    return o;
+}
+
+/// Prints the vector @p vec to @p o
+/// @param o the std::ostream to write to
+/// @param vec the vector reference
+/// @return the std::ostream so calls can be chained
+template <typename T>
+inline std::ostream& operator<<(std::ostream& o, const utils::VectorRef<T>& vec) {
+    o << "[";
+    bool first = true;
+    for (auto& el : vec) {
+        if (!first) {
+            o << ", ";
+        }
+        first = false;
+        o << ToString(el);
+    }
+    o << "]";
+    return o;
+}
+
 }  // namespace tint::utils
 
 #endif  // SRC_TINT_UTILS_VECTOR_H_
diff --git a/src/tint/utils/vector_test.cc b/src/tint/utils/vector_test.cc
index a3cc1f7..9a9bb4b 100644
--- a/src/tint/utils/vector_test.cc
+++ b/src/tint/utils/vector_test.cc
@@ -1795,6 +1795,21 @@
     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, ostream) {
+    std::stringstream ss;
+    ss << Vector{1, 2, 3};
+    EXPECT_EQ(ss.str(), "[1, 2, 3]");
+}
+
 ////////////////////////////////////////////////////////////////////////////////
 // TintVectorRefTest
 ////////////////////////////////////////////////////////////////////////////////
@@ -2060,15 +2075,13 @@
     EXPECT_EQ(vec_ref.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(TintVectorRefTest, ostream) {
+    std::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