[tint] Make LHS of operator << generic.

Breaks a circular dependency in utils, and allows for << to work with
other stream types.

Change-Id: Ia001def89fb1db9dd4aa582ae516911ba8aa71f1
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/143383
Auto-Submit: Ben Clayton <bclayton@google.com>
Commit-Queue: Ben Clayton <bclayton@google.com>
Kokoro: Kokoro <noreply+kokoro@google.com>
Reviewed-by: James Price <jrprice@google.com>
diff --git a/include/tint/binding_point.h b/include/tint/binding_point.h
index e5d3d14..11c47d5 100644
--- a/include/tint/binding_point.h
+++ b/include/tint/binding_point.h
@@ -65,7 +65,8 @@
 /// @param o the stream to write to
 /// @param bp the BindingPoint
 /// @return the stream so calls can be chained
-inline StringStream& operator<<(StringStream& o, const BindingPoint& bp) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& o, const BindingPoint& bp) {
     return o << "[group: " << bp.group << ", binding: " << bp.binding << "]";
 }
 
diff --git a/src/tint/lang/core/builtin/access.cc b/src/tint/lang/core/builtin/access.cc
index 01b1604..9ef6629 100644
--- a/src/tint/lang/core/builtin/access.cc
+++ b/src/tint/lang/core/builtin/access.cc
@@ -40,18 +40,18 @@
     return Access::kUndefined;
 }
 
-StringStream& operator<<(StringStream& out, Access value) {
+std::string_view ToString(Access value) {
     switch (value) {
         case Access::kUndefined:
-            return out << "undefined";
+            return "undefined";
         case Access::kRead:
-            return out << "read";
+            return "read";
         case Access::kReadWrite:
-            return out << "read_write";
+            return "read_write";
         case Access::kWrite:
-            return out << "write";
+            return "write";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 }  // namespace tint::builtin
diff --git a/src/tint/lang/core/builtin/access.h b/src/tint/lang/core/builtin/access.h
index 1b52fec..ab98a42 100644
--- a/src/tint/lang/core/builtin/access.h
+++ b/src/tint/lang/core/builtin/access.h
@@ -23,7 +23,7 @@
 #ifndef SRC_TINT_LANG_CORE_BUILTIN_ACCESS_H_
 #define SRC_TINT_LANG_CORE_BUILTIN_ACCESS_H_
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
@@ -35,10 +35,17 @@
     kWrite,
 };
 
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(Access value);
+
 /// @param out the stream to write to
 /// @param value the Access
-/// @returns `out` so calls can be chained
-StringStream& operator<<(StringStream& out, Access value);
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, Access value) {
+    return out << ToString(value);
+}
 
 /// ParseAccess parses a Access from a string.
 /// @param str the string to parse
diff --git a/src/tint/lang/core/builtin/access.h.tmpl b/src/tint/lang/core/builtin/access.h.tmpl
index e655b3a..8d28e7d 100644
--- a/src/tint/lang/core/builtin/access.h.tmpl
+++ b/src/tint/lang/core/builtin/access.h.tmpl
@@ -17,7 +17,7 @@
 #ifndef SRC_TINT_LANG_CORE_BUILTIN_ACCESS_H_
 #define SRC_TINT_LANG_CORE_BUILTIN_ACCESS_H_
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
diff --git a/src/tint/lang/core/builtin/address_space.cc b/src/tint/lang/core/builtin/address_space.cc
index 7081cab..ad41f1d 100644
--- a/src/tint/lang/core/builtin/address_space.cc
+++ b/src/tint/lang/core/builtin/address_space.cc
@@ -55,30 +55,30 @@
     return AddressSpace::kUndefined;
 }
 
-StringStream& operator<<(StringStream& out, AddressSpace value) {
+std::string_view ToString(AddressSpace value) {
     switch (value) {
         case AddressSpace::kUndefined:
-            return out << "undefined";
+            return "undefined";
         case AddressSpace::kIn:
-            return out << "__in";
+            return "__in";
         case AddressSpace::kOut:
-            return out << "__out";
+            return "__out";
         case AddressSpace::kFunction:
-            return out << "function";
+            return "function";
         case AddressSpace::kHandle:
-            return out << "handle";
+            return "handle";
         case AddressSpace::kPrivate:
-            return out << "private";
+            return "private";
         case AddressSpace::kPushConstant:
-            return out << "push_constant";
+            return "push_constant";
         case AddressSpace::kStorage:
-            return out << "storage";
+            return "storage";
         case AddressSpace::kUniform:
-            return out << "uniform";
+            return "uniform";
         case AddressSpace::kWorkgroup:
-            return out << "workgroup";
+            return "workgroup";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 }  // namespace tint::builtin
diff --git a/src/tint/lang/core/builtin/address_space.h b/src/tint/lang/core/builtin/address_space.h
index 1ac2c21..2f5e350 100644
--- a/src/tint/lang/core/builtin/address_space.h
+++ b/src/tint/lang/core/builtin/address_space.h
@@ -23,7 +23,7 @@
 #ifndef SRC_TINT_LANG_CORE_BUILTIN_ADDRESS_SPACE_H_
 #define SRC_TINT_LANG_CORE_BUILTIN_ADDRESS_SPACE_H_
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
@@ -41,10 +41,17 @@
     kWorkgroup,
 };
 
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(AddressSpace value);
+
 /// @param out the stream to write to
 /// @param value the AddressSpace
-/// @returns `out` so calls can be chained
-StringStream& operator<<(StringStream& out, AddressSpace value);
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, AddressSpace value) {
+    return out << ToString(value);
+}
 
 /// ParseAddressSpace parses a AddressSpace from a string.
 /// @param str the string to parse
diff --git a/src/tint/lang/core/builtin/address_space.h.tmpl b/src/tint/lang/core/builtin/address_space.h.tmpl
index 6c80473..8c9c918 100644
--- a/src/tint/lang/core/builtin/address_space.h.tmpl
+++ b/src/tint/lang/core/builtin/address_space.h.tmpl
@@ -17,7 +17,7 @@
 #ifndef SRC_TINT_LANG_CORE_BUILTIN_ADDRESS_SPACE_H_
 #define SRC_TINT_LANG_CORE_BUILTIN_ADDRESS_SPACE_H_
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
diff --git a/src/tint/lang/core/builtin/attribute.cc b/src/tint/lang/core/builtin/attribute.cc
index f7c7596..de547f7 100644
--- a/src/tint/lang/core/builtin/attribute.cc
+++ b/src/tint/lang/core/builtin/attribute.cc
@@ -79,44 +79,44 @@
     return Attribute::kUndefined;
 }
 
-StringStream& operator<<(StringStream& out, Attribute value) {
+std::string_view ToString(Attribute value) {
     switch (value) {
         case Attribute::kUndefined:
-            return out << "undefined";
+            return "undefined";
         case Attribute::kAlign:
-            return out << "align";
+            return "align";
         case Attribute::kBinding:
-            return out << "binding";
+            return "binding";
         case Attribute::kBuiltin:
-            return out << "builtin";
+            return "builtin";
         case Attribute::kCompute:
-            return out << "compute";
+            return "compute";
         case Attribute::kDiagnostic:
-            return out << "diagnostic";
+            return "diagnostic";
         case Attribute::kFragment:
-            return out << "fragment";
+            return "fragment";
         case Attribute::kGroup:
-            return out << "group";
+            return "group";
         case Attribute::kId:
-            return out << "id";
+            return "id";
         case Attribute::kIndex:
-            return out << "index";
+            return "index";
         case Attribute::kInterpolate:
-            return out << "interpolate";
+            return "interpolate";
         case Attribute::kInvariant:
-            return out << "invariant";
+            return "invariant";
         case Attribute::kLocation:
-            return out << "location";
+            return "location";
         case Attribute::kMustUse:
-            return out << "must_use";
+            return "must_use";
         case Attribute::kSize:
-            return out << "size";
+            return "size";
         case Attribute::kVertex:
-            return out << "vertex";
+            return "vertex";
         case Attribute::kWorkgroupSize:
-            return out << "workgroup_size";
+            return "workgroup_size";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 }  // namespace tint::builtin
diff --git a/src/tint/lang/core/builtin/attribute.h b/src/tint/lang/core/builtin/attribute.h
index 1914f41..df411f1 100644
--- a/src/tint/lang/core/builtin/attribute.h
+++ b/src/tint/lang/core/builtin/attribute.h
@@ -23,7 +23,7 @@
 #ifndef SRC_TINT_LANG_CORE_BUILTIN_ATTRIBUTE_H_
 #define SRC_TINT_LANG_CORE_BUILTIN_ATTRIBUTE_H_
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 /// \cond DO_NOT_DOCUMENT
 /// There is a bug in doxygen where this enum conflicts with the ast::Attribute
@@ -51,10 +51,17 @@
     kWorkgroupSize,
 };
 
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(Attribute value);
+
 /// @param out the stream to write to
 /// @param value the Attribute
-/// @returns `out` so calls can be chained
-StringStream& operator<<(StringStream& out, Attribute value);
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, Attribute value) {
+    return out << ToString(value);
+}
 
 /// ParseAttribute parses a Attribute from a string.
 /// @param str the string to parse
diff --git a/src/tint/lang/core/builtin/attribute.h.tmpl b/src/tint/lang/core/builtin/attribute.h.tmpl
index 34954d1..02def09 100644
--- a/src/tint/lang/core/builtin/attribute.h.tmpl
+++ b/src/tint/lang/core/builtin/attribute.h.tmpl
@@ -17,7 +17,7 @@
 #ifndef SRC_TINT_LANG_CORE_BUILTIN_ATTRIBUTE_H_
 #define SRC_TINT_LANG_CORE_BUILTIN_ATTRIBUTE_H_
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 /// \cond DO_NOT_DOCUMENT
 /// There is a bug in doxygen where this enum conflicts with the ast::Attribute
diff --git a/src/tint/lang/core/builtin/builtin.cc b/src/tint/lang/core/builtin/builtin.cc
index 3cd8fb6..6c9f0e9 100644
--- a/src/tint/lang/core/builtin/builtin.cc
+++ b/src/tint/lang/core/builtin/builtin.cc
@@ -319,204 +319,204 @@
     return Builtin::kUndefined;
 }
 
-StringStream& operator<<(StringStream& out, Builtin value) {
+std::string_view ToString(Builtin value) {
     switch (value) {
         case Builtin::kUndefined:
-            return out << "undefined";
+            return "undefined";
         case Builtin::kAtomicCompareExchangeResultI32:
-            return out << "__atomic_compare_exchange_result_i32";
+            return "__atomic_compare_exchange_result_i32";
         case Builtin::kAtomicCompareExchangeResultU32:
-            return out << "__atomic_compare_exchange_result_u32";
+            return "__atomic_compare_exchange_result_u32";
         case Builtin::kFrexpResultAbstract:
-            return out << "__frexp_result_abstract";
+            return "__frexp_result_abstract";
         case Builtin::kFrexpResultF16:
-            return out << "__frexp_result_f16";
+            return "__frexp_result_f16";
         case Builtin::kFrexpResultF32:
-            return out << "__frexp_result_f32";
+            return "__frexp_result_f32";
         case Builtin::kFrexpResultVec2Abstract:
-            return out << "__frexp_result_vec2_abstract";
+            return "__frexp_result_vec2_abstract";
         case Builtin::kFrexpResultVec2F16:
-            return out << "__frexp_result_vec2_f16";
+            return "__frexp_result_vec2_f16";
         case Builtin::kFrexpResultVec2F32:
-            return out << "__frexp_result_vec2_f32";
+            return "__frexp_result_vec2_f32";
         case Builtin::kFrexpResultVec3Abstract:
-            return out << "__frexp_result_vec3_abstract";
+            return "__frexp_result_vec3_abstract";
         case Builtin::kFrexpResultVec3F16:
-            return out << "__frexp_result_vec3_f16";
+            return "__frexp_result_vec3_f16";
         case Builtin::kFrexpResultVec3F32:
-            return out << "__frexp_result_vec3_f32";
+            return "__frexp_result_vec3_f32";
         case Builtin::kFrexpResultVec4Abstract:
-            return out << "__frexp_result_vec4_abstract";
+            return "__frexp_result_vec4_abstract";
         case Builtin::kFrexpResultVec4F16:
-            return out << "__frexp_result_vec4_f16";
+            return "__frexp_result_vec4_f16";
         case Builtin::kFrexpResultVec4F32:
-            return out << "__frexp_result_vec4_f32";
+            return "__frexp_result_vec4_f32";
         case Builtin::kModfResultAbstract:
-            return out << "__modf_result_abstract";
+            return "__modf_result_abstract";
         case Builtin::kModfResultF16:
-            return out << "__modf_result_f16";
+            return "__modf_result_f16";
         case Builtin::kModfResultF32:
-            return out << "__modf_result_f32";
+            return "__modf_result_f32";
         case Builtin::kModfResultVec2Abstract:
-            return out << "__modf_result_vec2_abstract";
+            return "__modf_result_vec2_abstract";
         case Builtin::kModfResultVec2F16:
-            return out << "__modf_result_vec2_f16";
+            return "__modf_result_vec2_f16";
         case Builtin::kModfResultVec2F32:
-            return out << "__modf_result_vec2_f32";
+            return "__modf_result_vec2_f32";
         case Builtin::kModfResultVec3Abstract:
-            return out << "__modf_result_vec3_abstract";
+            return "__modf_result_vec3_abstract";
         case Builtin::kModfResultVec3F16:
-            return out << "__modf_result_vec3_f16";
+            return "__modf_result_vec3_f16";
         case Builtin::kModfResultVec3F32:
-            return out << "__modf_result_vec3_f32";
+            return "__modf_result_vec3_f32";
         case Builtin::kModfResultVec4Abstract:
-            return out << "__modf_result_vec4_abstract";
+            return "__modf_result_vec4_abstract";
         case Builtin::kModfResultVec4F16:
-            return out << "__modf_result_vec4_f16";
+            return "__modf_result_vec4_f16";
         case Builtin::kModfResultVec4F32:
-            return out << "__modf_result_vec4_f32";
+            return "__modf_result_vec4_f32";
         case Builtin::kPackedVec3:
-            return out << "__packed_vec3";
+            return "__packed_vec3";
         case Builtin::kArray:
-            return out << "array";
+            return "array";
         case Builtin::kAtomic:
-            return out << "atomic";
+            return "atomic";
         case Builtin::kBool:
-            return out << "bool";
+            return "bool";
         case Builtin::kF16:
-            return out << "f16";
+            return "f16";
         case Builtin::kF32:
-            return out << "f32";
+            return "f32";
         case Builtin::kI32:
-            return out << "i32";
+            return "i32";
         case Builtin::kMat2X2:
-            return out << "mat2x2";
+            return "mat2x2";
         case Builtin::kMat2X2F:
-            return out << "mat2x2f";
+            return "mat2x2f";
         case Builtin::kMat2X2H:
-            return out << "mat2x2h";
+            return "mat2x2h";
         case Builtin::kMat2X3:
-            return out << "mat2x3";
+            return "mat2x3";
         case Builtin::kMat2X3F:
-            return out << "mat2x3f";
+            return "mat2x3f";
         case Builtin::kMat2X3H:
-            return out << "mat2x3h";
+            return "mat2x3h";
         case Builtin::kMat2X4:
-            return out << "mat2x4";
+            return "mat2x4";
         case Builtin::kMat2X4F:
-            return out << "mat2x4f";
+            return "mat2x4f";
         case Builtin::kMat2X4H:
-            return out << "mat2x4h";
+            return "mat2x4h";
         case Builtin::kMat3X2:
-            return out << "mat3x2";
+            return "mat3x2";
         case Builtin::kMat3X2F:
-            return out << "mat3x2f";
+            return "mat3x2f";
         case Builtin::kMat3X2H:
-            return out << "mat3x2h";
+            return "mat3x2h";
         case Builtin::kMat3X3:
-            return out << "mat3x3";
+            return "mat3x3";
         case Builtin::kMat3X3F:
-            return out << "mat3x3f";
+            return "mat3x3f";
         case Builtin::kMat3X3H:
-            return out << "mat3x3h";
+            return "mat3x3h";
         case Builtin::kMat3X4:
-            return out << "mat3x4";
+            return "mat3x4";
         case Builtin::kMat3X4F:
-            return out << "mat3x4f";
+            return "mat3x4f";
         case Builtin::kMat3X4H:
-            return out << "mat3x4h";
+            return "mat3x4h";
         case Builtin::kMat4X2:
-            return out << "mat4x2";
+            return "mat4x2";
         case Builtin::kMat4X2F:
-            return out << "mat4x2f";
+            return "mat4x2f";
         case Builtin::kMat4X2H:
-            return out << "mat4x2h";
+            return "mat4x2h";
         case Builtin::kMat4X3:
-            return out << "mat4x3";
+            return "mat4x3";
         case Builtin::kMat4X3F:
-            return out << "mat4x3f";
+            return "mat4x3f";
         case Builtin::kMat4X3H:
-            return out << "mat4x3h";
+            return "mat4x3h";
         case Builtin::kMat4X4:
-            return out << "mat4x4";
+            return "mat4x4";
         case Builtin::kMat4X4F:
-            return out << "mat4x4f";
+            return "mat4x4f";
         case Builtin::kMat4X4H:
-            return out << "mat4x4h";
+            return "mat4x4h";
         case Builtin::kPtr:
-            return out << "ptr";
+            return "ptr";
         case Builtin::kSampler:
-            return out << "sampler";
+            return "sampler";
         case Builtin::kSamplerComparison:
-            return out << "sampler_comparison";
+            return "sampler_comparison";
         case Builtin::kTexture1D:
-            return out << "texture_1d";
+            return "texture_1d";
         case Builtin::kTexture2D:
-            return out << "texture_2d";
+            return "texture_2d";
         case Builtin::kTexture2DArray:
-            return out << "texture_2d_array";
+            return "texture_2d_array";
         case Builtin::kTexture3D:
-            return out << "texture_3d";
+            return "texture_3d";
         case Builtin::kTextureCube:
-            return out << "texture_cube";
+            return "texture_cube";
         case Builtin::kTextureCubeArray:
-            return out << "texture_cube_array";
+            return "texture_cube_array";
         case Builtin::kTextureDepth2D:
-            return out << "texture_depth_2d";
+            return "texture_depth_2d";
         case Builtin::kTextureDepth2DArray:
-            return out << "texture_depth_2d_array";
+            return "texture_depth_2d_array";
         case Builtin::kTextureDepthCube:
-            return out << "texture_depth_cube";
+            return "texture_depth_cube";
         case Builtin::kTextureDepthCubeArray:
-            return out << "texture_depth_cube_array";
+            return "texture_depth_cube_array";
         case Builtin::kTextureDepthMultisampled2D:
-            return out << "texture_depth_multisampled_2d";
+            return "texture_depth_multisampled_2d";
         case Builtin::kTextureExternal:
-            return out << "texture_external";
+            return "texture_external";
         case Builtin::kTextureMultisampled2D:
-            return out << "texture_multisampled_2d";
+            return "texture_multisampled_2d";
         case Builtin::kTextureStorage1D:
-            return out << "texture_storage_1d";
+            return "texture_storage_1d";
         case Builtin::kTextureStorage2D:
-            return out << "texture_storage_2d";
+            return "texture_storage_2d";
         case Builtin::kTextureStorage2DArray:
-            return out << "texture_storage_2d_array";
+            return "texture_storage_2d_array";
         case Builtin::kTextureStorage3D:
-            return out << "texture_storage_3d";
+            return "texture_storage_3d";
         case Builtin::kU32:
-            return out << "u32";
+            return "u32";
         case Builtin::kVec2:
-            return out << "vec2";
+            return "vec2";
         case Builtin::kVec2F:
-            return out << "vec2f";
+            return "vec2f";
         case Builtin::kVec2H:
-            return out << "vec2h";
+            return "vec2h";
         case Builtin::kVec2I:
-            return out << "vec2i";
+            return "vec2i";
         case Builtin::kVec2U:
-            return out << "vec2u";
+            return "vec2u";
         case Builtin::kVec3:
-            return out << "vec3";
+            return "vec3";
         case Builtin::kVec3F:
-            return out << "vec3f";
+            return "vec3f";
         case Builtin::kVec3H:
-            return out << "vec3h";
+            return "vec3h";
         case Builtin::kVec3I:
-            return out << "vec3i";
+            return "vec3i";
         case Builtin::kVec3U:
-            return out << "vec3u";
+            return "vec3u";
         case Builtin::kVec4:
-            return out << "vec4";
+            return "vec4";
         case Builtin::kVec4F:
-            return out << "vec4f";
+            return "vec4f";
         case Builtin::kVec4H:
-            return out << "vec4h";
+            return "vec4h";
         case Builtin::kVec4I:
-            return out << "vec4i";
+            return "vec4i";
         case Builtin::kVec4U:
-            return out << "vec4u";
+            return "vec4u";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 }  // namespace tint::builtin
diff --git a/src/tint/lang/core/builtin/builtin.h b/src/tint/lang/core/builtin/builtin.h
index e22855d..e2bd05a 100644
--- a/src/tint/lang/core/builtin/builtin.h
+++ b/src/tint/lang/core/builtin/builtin.h
@@ -23,7 +23,7 @@
 #ifndef SRC_TINT_LANG_CORE_BUILTIN_BUILTIN_H_
 #define SRC_TINT_LANG_CORE_BUILTIN_BUILTIN_H_
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
@@ -128,10 +128,17 @@
     kVec4U,
 };
 
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(Builtin value);
+
 /// @param out the stream to write to
 /// @param value the Builtin
-/// @returns `out` so calls can be chained
-StringStream& operator<<(StringStream& out, Builtin value);
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, Builtin value) {
+    return out << ToString(value);
+}
 
 /// ParseBuiltin parses a Builtin from a string.
 /// @param str the string to parse
diff --git a/src/tint/lang/core/builtin/builtin.h.tmpl b/src/tint/lang/core/builtin/builtin.h.tmpl
index 3ceeb2e..308fdbb 100644
--- a/src/tint/lang/core/builtin/builtin.h.tmpl
+++ b/src/tint/lang/core/builtin/builtin.h.tmpl
@@ -18,7 +18,7 @@
 #ifndef SRC_TINT_LANG_CORE_BUILTIN_BUILTIN_H_
 #define SRC_TINT_LANG_CORE_BUILTIN_BUILTIN_H_
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
diff --git a/src/tint/lang/core/builtin/builtin_value.cc b/src/tint/lang/core/builtin/builtin_value.cc
index 8107369..644a217 100644
--- a/src/tint/lang/core/builtin/builtin_value.cc
+++ b/src/tint/lang/core/builtin/builtin_value.cc
@@ -70,38 +70,38 @@
     return BuiltinValue::kUndefined;
 }
 
-StringStream& operator<<(StringStream& out, BuiltinValue value) {
+std::string_view ToString(BuiltinValue value) {
     switch (value) {
         case BuiltinValue::kUndefined:
-            return out << "undefined";
+            return "undefined";
         case BuiltinValue::kPointSize:
-            return out << "__point_size";
+            return "__point_size";
         case BuiltinValue::kFragDepth:
-            return out << "frag_depth";
+            return "frag_depth";
         case BuiltinValue::kFrontFacing:
-            return out << "front_facing";
+            return "front_facing";
         case BuiltinValue::kGlobalInvocationId:
-            return out << "global_invocation_id";
+            return "global_invocation_id";
         case BuiltinValue::kInstanceIndex:
-            return out << "instance_index";
+            return "instance_index";
         case BuiltinValue::kLocalInvocationId:
-            return out << "local_invocation_id";
+            return "local_invocation_id";
         case BuiltinValue::kLocalInvocationIndex:
-            return out << "local_invocation_index";
+            return "local_invocation_index";
         case BuiltinValue::kNumWorkgroups:
-            return out << "num_workgroups";
+            return "num_workgroups";
         case BuiltinValue::kPosition:
-            return out << "position";
+            return "position";
         case BuiltinValue::kSampleIndex:
-            return out << "sample_index";
+            return "sample_index";
         case BuiltinValue::kSampleMask:
-            return out << "sample_mask";
+            return "sample_mask";
         case BuiltinValue::kVertexIndex:
-            return out << "vertex_index";
+            return "vertex_index";
         case BuiltinValue::kWorkgroupId:
-            return out << "workgroup_id";
+            return "workgroup_id";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 }  // namespace tint::builtin
diff --git a/src/tint/lang/core/builtin/builtin_value.h b/src/tint/lang/core/builtin/builtin_value.h
index 5d33d96..ef527f3 100644
--- a/src/tint/lang/core/builtin/builtin_value.h
+++ b/src/tint/lang/core/builtin/builtin_value.h
@@ -23,7 +23,7 @@
 #ifndef SRC_TINT_LANG_CORE_BUILTIN_BUILTIN_VALUE_H_
 #define SRC_TINT_LANG_CORE_BUILTIN_BUILTIN_VALUE_H_
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
@@ -45,10 +45,17 @@
     kWorkgroupId,
 };
 
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(BuiltinValue value);
+
 /// @param out the stream to write to
 /// @param value the BuiltinValue
-/// @returns `out` so calls can be chained
-StringStream& operator<<(StringStream& out, BuiltinValue value);
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, BuiltinValue value) {
+    return out << ToString(value);
+}
 
 /// ParseBuiltinValue parses a BuiltinValue from a string.
 /// @param str the string to parse
diff --git a/src/tint/lang/core/builtin/builtin_value.h.tmpl b/src/tint/lang/core/builtin/builtin_value.h.tmpl
index e3b0ccc..96643ed 100644
--- a/src/tint/lang/core/builtin/builtin_value.h.tmpl
+++ b/src/tint/lang/core/builtin/builtin_value.h.tmpl
@@ -14,7 +14,7 @@
 #ifndef SRC_TINT_LANG_CORE_BUILTIN_BUILTIN_VALUE_H_
 #define SRC_TINT_LANG_CORE_BUILTIN_BUILTIN_VALUE_H_
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
diff --git a/src/tint/lang/core/builtin/diagnostic_rule.cc b/src/tint/lang/core/builtin/diagnostic_rule.cc
index 496bf0d..0d1f0e7 100644
--- a/src/tint/lang/core/builtin/diagnostic_rule.cc
+++ b/src/tint/lang/core/builtin/diagnostic_rule.cc
@@ -24,7 +24,7 @@
 
 #include <string>
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
@@ -38,14 +38,14 @@
     return CoreDiagnosticRule::kUndefined;
 }
 
-StringStream& operator<<(StringStream& out, CoreDiagnosticRule value) {
+std::string_view ToString(CoreDiagnosticRule value) {
     switch (value) {
         case CoreDiagnosticRule::kUndefined:
-            return out << "undefined";
+            return "undefined";
         case CoreDiagnosticRule::kDerivativeUniformity:
-            return out << "derivative_uniformity";
+            return "derivative_uniformity";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 /// ParseChromiumDiagnosticRule parses a ChromiumDiagnosticRule from a string.
@@ -59,14 +59,14 @@
     return ChromiumDiagnosticRule::kUndefined;
 }
 
-StringStream& operator<<(StringStream& out, ChromiumDiagnosticRule value) {
+std::string_view ToString(ChromiumDiagnosticRule value) {
     switch (value) {
         case ChromiumDiagnosticRule::kUndefined:
-            return out << "undefined";
+            return "undefined";
         case ChromiumDiagnosticRule::kUnreachableCode:
-            return out << "unreachable_code";
+            return "unreachable_code";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 }  // namespace tint::builtin
diff --git a/src/tint/lang/core/builtin/diagnostic_rule.cc.tmpl b/src/tint/lang/core/builtin/diagnostic_rule.cc.tmpl
index 0094bef..5f40e7c 100644
--- a/src/tint/lang/core/builtin/diagnostic_rule.cc.tmpl
+++ b/src/tint/lang/core/builtin/diagnostic_rule.cc.tmpl
@@ -14,7 +14,7 @@
 
 #include <string>
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
diff --git a/src/tint/lang/core/builtin/diagnostic_rule.h b/src/tint/lang/core/builtin/diagnostic_rule.h
index 63d2839..83d0ff1 100644
--- a/src/tint/lang/core/builtin/diagnostic_rule.h
+++ b/src/tint/lang/core/builtin/diagnostic_rule.h
@@ -26,7 +26,7 @@
 #include <string>
 #include <variant>
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
@@ -36,10 +36,17 @@
     kDerivativeUniformity,
 };
 
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(CoreDiagnosticRule value);
+
 /// @param out the stream to write to
 /// @param value the CoreDiagnosticRule
-/// @returns `out` so calls can be chained
-StringStream& operator<<(StringStream& out, CoreDiagnosticRule value);
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, CoreDiagnosticRule value) {
+    return out << ToString(value);
+}
 
 /// ParseCoreDiagnosticRule parses a CoreDiagnosticRule from a string.
 /// @param str the string to parse
@@ -56,10 +63,17 @@
     kUnreachableCode,
 };
 
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(ChromiumDiagnosticRule value);
+
 /// @param out the stream to write to
 /// @param value the ChromiumDiagnosticRule
-/// @returns `out` so calls can be chained
-StringStream& operator<<(StringStream& out, ChromiumDiagnosticRule value);
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, ChromiumDiagnosticRule value) {
+    return out << ToString(value);
+}
 
 /// ParseChromiumDiagnosticRule parses a ChromiumDiagnosticRule from a string.
 /// @param str the string to parse
diff --git a/src/tint/lang/core/builtin/diagnostic_rule.h.tmpl b/src/tint/lang/core/builtin/diagnostic_rule.h.tmpl
index 58ac3a4..8b6de0e 100644
--- a/src/tint/lang/core/builtin/diagnostic_rule.h.tmpl
+++ b/src/tint/lang/core/builtin/diagnostic_rule.h.tmpl
@@ -16,7 +16,7 @@
 #include <string>
 #include <variant>
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
diff --git a/src/tint/lang/core/builtin/diagnostic_severity.cc b/src/tint/lang/core/builtin/diagnostic_severity.cc
index 543ccc8..fb2621a 100644
--- a/src/tint/lang/core/builtin/diagnostic_severity.cc
+++ b/src/tint/lang/core/builtin/diagnostic_severity.cc
@@ -58,20 +58,20 @@
     return DiagnosticSeverity::kUndefined;
 }
 
-StringStream& operator<<(StringStream& out, DiagnosticSeverity value) {
+std::string_view ToString(DiagnosticSeverity value) {
     switch (value) {
         case DiagnosticSeverity::kUndefined:
-            return out << "undefined";
+            return "undefined";
         case DiagnosticSeverity::kError:
-            return out << "error";
+            return "error";
         case DiagnosticSeverity::kInfo:
-            return out << "info";
+            return "info";
         case DiagnosticSeverity::kOff:
-            return out << "off";
+            return "off";
         case DiagnosticSeverity::kWarning:
-            return out << "warning";
+            return "warning";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 }  // namespace tint::builtin
diff --git a/src/tint/lang/core/builtin/diagnostic_severity.h b/src/tint/lang/core/builtin/diagnostic_severity.h
index e866a29..e0a22eb 100644
--- a/src/tint/lang/core/builtin/diagnostic_severity.h
+++ b/src/tint/lang/core/builtin/diagnostic_severity.h
@@ -28,7 +28,7 @@
 
 #include "src/tint/lang/core/builtin/diagnostic_rule.h"
 #include "src/tint/utils/diagnostic/diagnostic.h"
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
@@ -41,10 +41,17 @@
     kWarning,
 };
 
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(DiagnosticSeverity value);
+
 /// @param out the stream to write to
 /// @param value the DiagnosticSeverity
-/// @returns `out` so calls can be chained
-StringStream& operator<<(StringStream& out, DiagnosticSeverity value);
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, DiagnosticSeverity value) {
+    return out << ToString(value);
+}
 
 /// ParseDiagnosticSeverity parses a DiagnosticSeverity from a string.
 /// @param str the string to parse
diff --git a/src/tint/lang/core/builtin/diagnostic_severity.h.tmpl b/src/tint/lang/core/builtin/diagnostic_severity.h.tmpl
index 7e03cad..f7433bf 100644
--- a/src/tint/lang/core/builtin/diagnostic_severity.h.tmpl
+++ b/src/tint/lang/core/builtin/diagnostic_severity.h.tmpl
@@ -16,7 +16,7 @@
 #include <string>
 #include <unordered_map>
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 #include "src/tint/lang/core/builtin/diagnostic_rule.h"
 #include "src/tint/utils/diagnostic/diagnostic.h"
 
diff --git a/src/tint/lang/core/builtin/extension.cc b/src/tint/lang/core/builtin/extension.cc
index 568743d..5ee7e10 100644
--- a/src/tint/lang/core/builtin/extension.cc
+++ b/src/tint/lang/core/builtin/extension.cc
@@ -52,26 +52,26 @@
     return Extension::kUndefined;
 }
 
-StringStream& operator<<(StringStream& out, Extension value) {
+std::string_view ToString(Extension value) {
     switch (value) {
         case Extension::kUndefined:
-            return out << "undefined";
+            return "undefined";
         case Extension::kChromiumDisableUniformityAnalysis:
-            return out << "chromium_disable_uniformity_analysis";
+            return "chromium_disable_uniformity_analysis";
         case Extension::kChromiumExperimentalDp4A:
-            return out << "chromium_experimental_dp4a";
+            return "chromium_experimental_dp4a";
         case Extension::kChromiumExperimentalFullPtrParameters:
-            return out << "chromium_experimental_full_ptr_parameters";
+            return "chromium_experimental_full_ptr_parameters";
         case Extension::kChromiumExperimentalPushConstant:
-            return out << "chromium_experimental_push_constant";
+            return "chromium_experimental_push_constant";
         case Extension::kChromiumInternalDualSourceBlending:
-            return out << "chromium_internal_dual_source_blending";
+            return "chromium_internal_dual_source_blending";
         case Extension::kChromiumInternalRelaxedUniformLayout:
-            return out << "chromium_internal_relaxed_uniform_layout";
+            return "chromium_internal_relaxed_uniform_layout";
         case Extension::kF16:
-            return out << "f16";
+            return "f16";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 }  // namespace tint::builtin
diff --git a/src/tint/lang/core/builtin/extension.h b/src/tint/lang/core/builtin/extension.h
index cb9f52d..a2640ed 100644
--- a/src/tint/lang/core/builtin/extension.h
+++ b/src/tint/lang/core/builtin/extension.h
@@ -24,7 +24,7 @@
 #define SRC_TINT_LANG_CORE_BUILTIN_EXTENSION_H_
 
 #include "src/tint/utils/containers/unique_vector.h"
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
@@ -41,10 +41,17 @@
     kF16,
 };
 
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(Extension value);
+
 /// @param out the stream to write to
 /// @param value the Extension
-/// @returns `out` so calls can be chained
-StringStream& operator<<(StringStream& out, Extension value);
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, Extension value) {
+    return out << ToString(value);
+}
 
 /// ParseExtension parses a Extension from a string.
 /// @param str the string to parse
diff --git a/src/tint/lang/core/builtin/extension.h.tmpl b/src/tint/lang/core/builtin/extension.h.tmpl
index 860acc1..8d301b9 100644
--- a/src/tint/lang/core/builtin/extension.h.tmpl
+++ b/src/tint/lang/core/builtin/extension.h.tmpl
@@ -14,7 +14,7 @@
 #ifndef SRC_TINT_LANG_CORE_BUILTIN_EXTENSION_H_
 #define SRC_TINT_LANG_CORE_BUILTIN_EXTENSION_H_
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 #include "src/tint/utils/containers/unique_vector.h"
 
 namespace tint::builtin {
diff --git a/src/tint/lang/core/builtin/function.cc b/src/tint/lang/core/builtin/function.cc
index d194a6e..3d67b07 100644
--- a/src/tint/lang/core/builtin/function.cc
+++ b/src/tint/lang/core/builtin/function.cc
@@ -606,11 +606,6 @@
     return "<unknown>";
 }
 
-StringStream& operator<<(StringStream& out, Function i) {
-    out << str(i);
-    return out;
-}
-
 bool IsCoarseDerivativeBuiltin(Function f) {
     return f == Function::kDpdxCoarse || f == Function::kDpdyCoarse || f == Function::kFwidthCoarse;
 }
diff --git a/src/tint/lang/core/builtin/function.cc.tmpl b/src/tint/lang/core/builtin/function.cc.tmpl
index c98af1e..3e765d5 100644
--- a/src/tint/lang/core/builtin/function.cc.tmpl
+++ b/src/tint/lang/core/builtin/function.cc.tmpl
@@ -36,11 +36,6 @@
     return "<unknown>";
 }
 
-StringStream& operator<<(StringStream& out, Function i) {
-    out << str(i);
-    return out;
-}
-
 bool IsCoarseDerivativeBuiltin(Function f) {
     return f == Function::kDpdxCoarse || f == Function::kDpdyCoarse ||
            f == Function::kFwidthCoarse;
diff --git a/src/tint/lang/core/builtin/function.h b/src/tint/lang/core/builtin/function.h
index d1f45df..1ead560 100644
--- a/src/tint/lang/core/builtin/function.h
+++ b/src/tint/lang/core/builtin/function.h
@@ -25,7 +25,7 @@
 
 #include <string>
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 // \cond DO_NOT_DOCUMENT
 namespace tint::builtin {
@@ -161,7 +161,10 @@
 
 /// Emits the name of the builtin function type. The spelling, including case,
 /// matches the name in the WGSL spec.
-StringStream& operator<<(StringStream& out, Function i);
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& o, Function i) {
+    return o << str(i);
+}
 
 /// All builtin functions
 constexpr Function kFunctions[] = {
diff --git a/src/tint/lang/core/builtin/function.h.tmpl b/src/tint/lang/core/builtin/function.h.tmpl
index 78d82c4..d6b8fee 100644
--- a/src/tint/lang/core/builtin/function.h.tmpl
+++ b/src/tint/lang/core/builtin/function.h.tmpl
@@ -16,7 +16,7 @@
 
 #include <string>
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 // \cond DO_NOT_DOCUMENT
 namespace tint::builtin {
@@ -41,7 +41,10 @@
 
 /// Emits the name of the builtin function type. The spelling, including case,
 /// matches the name in the WGSL spec.
-StringStream& operator<<(StringStream& out, Function i);
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& o, Function i) {
+  return o << str(i);
+}
 
 /// All builtin functions
 constexpr Function kFunctions[] = {
diff --git a/src/tint/lang/core/builtin/interpolation_sampling.cc b/src/tint/lang/core/builtin/interpolation_sampling.cc
index e3cf02d..8ffc391 100644
--- a/src/tint/lang/core/builtin/interpolation_sampling.cc
+++ b/src/tint/lang/core/builtin/interpolation_sampling.cc
@@ -43,18 +43,18 @@
     return InterpolationSampling::kUndefined;
 }
 
-StringStream& operator<<(StringStream& out, InterpolationSampling value) {
+std::string_view ToString(InterpolationSampling value) {
     switch (value) {
         case InterpolationSampling::kUndefined:
-            return out << "undefined";
+            return "undefined";
         case InterpolationSampling::kCenter:
-            return out << "center";
+            return "center";
         case InterpolationSampling::kCentroid:
-            return out << "centroid";
+            return "centroid";
         case InterpolationSampling::kSample:
-            return out << "sample";
+            return "sample";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 }  // namespace tint::builtin
diff --git a/src/tint/lang/core/builtin/interpolation_sampling.h b/src/tint/lang/core/builtin/interpolation_sampling.h
index 15ef048..e257935 100644
--- a/src/tint/lang/core/builtin/interpolation_sampling.h
+++ b/src/tint/lang/core/builtin/interpolation_sampling.h
@@ -25,7 +25,7 @@
 
 #include <string>
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
@@ -37,10 +37,17 @@
     kSample,
 };
 
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(InterpolationSampling value);
+
 /// @param out the stream to write to
 /// @param value the InterpolationSampling
-/// @returns `out` so calls can be chained
-StringStream& operator<<(StringStream& out, InterpolationSampling value);
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, InterpolationSampling value) {
+    return out << ToString(value);
+}
 
 /// ParseInterpolationSampling parses a InterpolationSampling from a string.
 /// @param str the string to parse
diff --git a/src/tint/lang/core/builtin/interpolation_sampling.h.tmpl b/src/tint/lang/core/builtin/interpolation_sampling.h.tmpl
index eeb51ce..c510fe0 100644
--- a/src/tint/lang/core/builtin/interpolation_sampling.h.tmpl
+++ b/src/tint/lang/core/builtin/interpolation_sampling.h.tmpl
@@ -15,7 +15,7 @@
 
 #include <string>
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
diff --git a/src/tint/lang/core/builtin/interpolation_type.cc b/src/tint/lang/core/builtin/interpolation_type.cc
index e236701..144263c 100644
--- a/src/tint/lang/core/builtin/interpolation_type.cc
+++ b/src/tint/lang/core/builtin/interpolation_type.cc
@@ -42,18 +42,18 @@
     return InterpolationType::kUndefined;
 }
 
-StringStream& operator<<(StringStream& out, InterpolationType value) {
+std::string_view ToString(InterpolationType value) {
     switch (value) {
         case InterpolationType::kUndefined:
-            return out << "undefined";
+            return "undefined";
         case InterpolationType::kFlat:
-            return out << "flat";
+            return "flat";
         case InterpolationType::kLinear:
-            return out << "linear";
+            return "linear";
         case InterpolationType::kPerspective:
-            return out << "perspective";
+            return "perspective";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 }  // namespace tint::builtin
diff --git a/src/tint/lang/core/builtin/interpolation_type.h b/src/tint/lang/core/builtin/interpolation_type.h
index 9957ec1..c742299 100644
--- a/src/tint/lang/core/builtin/interpolation_type.h
+++ b/src/tint/lang/core/builtin/interpolation_type.h
@@ -25,7 +25,7 @@
 
 #include <string>
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
@@ -37,10 +37,17 @@
     kPerspective,
 };
 
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(InterpolationType value);
+
 /// @param out the stream to write to
 /// @param value the InterpolationType
-/// @returns `out` so calls can be chained
-StringStream& operator<<(StringStream& out, InterpolationType value);
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, InterpolationType value) {
+    return out << ToString(value);
+}
 
 /// ParseInterpolationType parses a InterpolationType from a string.
 /// @param str the string to parse
diff --git a/src/tint/lang/core/builtin/interpolation_type.h.tmpl b/src/tint/lang/core/builtin/interpolation_type.h.tmpl
index d537c0d..f8946c3 100644
--- a/src/tint/lang/core/builtin/interpolation_type.h.tmpl
+++ b/src/tint/lang/core/builtin/interpolation_type.h.tmpl
@@ -15,7 +15,7 @@
 
 #include <string>
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
diff --git a/src/tint/lang/core/builtin/number.cc b/src/tint/lang/core/builtin/number.cc
index 5c48960..a1ee952 100644
--- a/src/tint/lang/core/builtin/number.cc
+++ b/src/tint/lang/core/builtin/number.cc
@@ -50,16 +50,6 @@
 
 }  // namespace
 
-StringStream& operator<<(StringStream& out, ConversionFailure failure) {
-    switch (failure) {
-        case ConversionFailure::kExceedsPositiveLimit:
-            return out << "value exceeds positive limit for type";
-        case ConversionFailure::kExceedsNegativeLimit:
-            return out << "value exceeds negative limit for type";
-    }
-    return out << "<unknown>";
-}
-
 f16::type f16::Quantize(f16::type value) {
     if (value > kHighestValue) {
         return std::numeric_limits<f16::type>::infinity();
diff --git a/src/tint/lang/core/builtin/number.h b/src/tint/lang/core/builtin/number.h
index 3bf71b0..8c99b89 100644
--- a/src/tint/lang/core/builtin/number.h
+++ b/src/tint/lang/core/builtin/number.h
@@ -178,8 +178,8 @@
 /// @param out the stream to write to
 /// @param num the Number
 /// @return the stream so calls can be chained
-template <typename T>
-inline StringStream& operator<<(StringStream& out, Number<T> num) {
+template <typename STREAM, typename T, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, Number<T> num) {
     return out << num.value;
 }
 
@@ -324,7 +324,16 @@
 /// @param out the stream to write to
 /// @param failure the ConversionFailure
 /// @return the stream so calls can be chained
-StringStream& operator<<(StringStream& out, ConversionFailure failure);
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, ConversionFailure failure) {
+    switch (failure) {
+        case ConversionFailure::kExceedsPositiveLimit:
+            return out << "value exceeds positive limit for type";
+        case ConversionFailure::kExceedsNegativeLimit:
+            return out << "value exceeds negative limit for type";
+    }
+    return out << "<unknown>";
+}
 
 /// Converts a number from one type to another, checking that the value fits in the target type.
 /// @param num the value to convert
diff --git a/src/tint/lang/core/builtin/texel_format.cc b/src/tint/lang/core/builtin/texel_format.cc
index b3e3665..57dec1f 100644
--- a/src/tint/lang/core/builtin/texel_format.cc
+++ b/src/tint/lang/core/builtin/texel_format.cc
@@ -82,46 +82,46 @@
     return TexelFormat::kUndefined;
 }
 
-StringStream& operator<<(StringStream& out, TexelFormat value) {
+std::string_view ToString(TexelFormat value) {
     switch (value) {
         case TexelFormat::kUndefined:
-            return out << "undefined";
+            return "undefined";
         case TexelFormat::kBgra8Unorm:
-            return out << "bgra8unorm";
+            return "bgra8unorm";
         case TexelFormat::kR32Float:
-            return out << "r32float";
+            return "r32float";
         case TexelFormat::kR32Sint:
-            return out << "r32sint";
+            return "r32sint";
         case TexelFormat::kR32Uint:
-            return out << "r32uint";
+            return "r32uint";
         case TexelFormat::kRg32Float:
-            return out << "rg32float";
+            return "rg32float";
         case TexelFormat::kRg32Sint:
-            return out << "rg32sint";
+            return "rg32sint";
         case TexelFormat::kRg32Uint:
-            return out << "rg32uint";
+            return "rg32uint";
         case TexelFormat::kRgba16Float:
-            return out << "rgba16float";
+            return "rgba16float";
         case TexelFormat::kRgba16Sint:
-            return out << "rgba16sint";
+            return "rgba16sint";
         case TexelFormat::kRgba16Uint:
-            return out << "rgba16uint";
+            return "rgba16uint";
         case TexelFormat::kRgba32Float:
-            return out << "rgba32float";
+            return "rgba32float";
         case TexelFormat::kRgba32Sint:
-            return out << "rgba32sint";
+            return "rgba32sint";
         case TexelFormat::kRgba32Uint:
-            return out << "rgba32uint";
+            return "rgba32uint";
         case TexelFormat::kRgba8Sint:
-            return out << "rgba8sint";
+            return "rgba8sint";
         case TexelFormat::kRgba8Snorm:
-            return out << "rgba8snorm";
+            return "rgba8snorm";
         case TexelFormat::kRgba8Uint:
-            return out << "rgba8uint";
+            return "rgba8uint";
         case TexelFormat::kRgba8Unorm:
-            return out << "rgba8unorm";
+            return "rgba8unorm";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 }  // namespace tint::builtin
diff --git a/src/tint/lang/core/builtin/texel_format.h b/src/tint/lang/core/builtin/texel_format.h
index 63ebeb3..85662c5 100644
--- a/src/tint/lang/core/builtin/texel_format.h
+++ b/src/tint/lang/core/builtin/texel_format.h
@@ -23,7 +23,7 @@
 #ifndef SRC_TINT_LANG_CORE_BUILTIN_TEXEL_FORMAT_H_
 #define SRC_TINT_LANG_CORE_BUILTIN_TEXEL_FORMAT_H_
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
@@ -49,10 +49,17 @@
     kRgba8Unorm,
 };
 
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(TexelFormat value);
+
 /// @param out the stream to write to
 /// @param value the TexelFormat
-/// @returns `out` so calls can be chained
-StringStream& operator<<(StringStream& out, TexelFormat value);
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, TexelFormat value) {
+    return out << ToString(value);
+}
 
 /// ParseTexelFormat parses a TexelFormat from a string.
 /// @param str the string to parse
diff --git a/src/tint/lang/core/builtin/texel_format.h.tmpl b/src/tint/lang/core/builtin/texel_format.h.tmpl
index 3a71306..e4b1125 100644
--- a/src/tint/lang/core/builtin/texel_format.h.tmpl
+++ b/src/tint/lang/core/builtin/texel_format.h.tmpl
@@ -14,7 +14,7 @@
 #ifndef SRC_TINT_LANG_CORE_BUILTIN_TEXEL_FORMAT_H_
 #define SRC_TINT_LANG_CORE_BUILTIN_TEXEL_FORMAT_H_
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::builtin {
 
diff --git a/src/tint/lang/core/ir/function.cc b/src/tint/lang/core/ir/function.cc
index 408daf8..dbee758 100644
--- a/src/tint/lang/core/ir/function.cc
+++ b/src/tint/lang/core/ir/function.cc
@@ -42,30 +42,30 @@
     TINT_ASSERT(!params_.Any(IsNull));
 }
 
-StringStream& operator<<(StringStream& out, Function::PipelineStage value) {
+std::string_view ToString(Function::PipelineStage value) {
     switch (value) {
         case Function::PipelineStage::kVertex:
-            return out << "vertex";
+            return "vertex";
         case Function::PipelineStage::kFragment:
-            return out << "fragment";
+            return "fragment";
         case Function::PipelineStage::kCompute:
-            return out << "compute";
+            return "compute";
         default:
             break;
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
-StringStream& operator<<(StringStream& out, enum Function::ReturnBuiltin value) {
+std::string_view ToString(enum Function::ReturnBuiltin value) {
     switch (value) {
         case Function::ReturnBuiltin::kFragDepth:
-            return out << "frag_depth";
+            return "frag_depth";
         case Function::ReturnBuiltin::kSampleMask:
-            return out << "sample_mask";
+            return "sample_mask";
         case Function::ReturnBuiltin::kPosition:
-            return out << "position";
+            return "position";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 }  // namespace tint::ir
diff --git a/src/tint/lang/core/ir/function.h b/src/tint/lang/core/ir/function.h
index 660440f..061fbab 100644
--- a/src/tint/lang/core/ir/function.h
+++ b/src/tint/lang/core/ir/function.h
@@ -152,8 +152,29 @@
     ir::Block* block_ = nullptr;
 };
 
-StringStream& operator<<(StringStream& out, Function::PipelineStage value);
-StringStream& operator<<(StringStream& out, enum Function::ReturnBuiltin value);
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(Function::PipelineStage value);
+
+/// @param out the stream to write to
+/// @param value the Function::PipelineStage
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, Function::PipelineStage value) {
+    return out << ToString(value);
+}
+
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(enum Function::ReturnBuiltin value);
+
+/// @param out the stream to write to
+/// @param value the Function::ReturnBuiltin
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, enum Function::ReturnBuiltin value) {
+    return out << ToString(value);
+}
 
 }  // namespace tint::ir
 
diff --git a/src/tint/lang/core/ir/function_param.cc b/src/tint/lang/core/ir/function_param.cc
index 1dc8930..5d9dbbe 100644
--- a/src/tint/lang/core/ir/function_param.cc
+++ b/src/tint/lang/core/ir/function_param.cc
@@ -26,43 +26,32 @@
 
 FunctionParam::~FunctionParam() = default;
 
-StringStream& operator<<(StringStream& out, enum FunctionParam::Builtin value) {
+std::string_view ToString(enum FunctionParam::Builtin value) {
     switch (value) {
         case FunctionParam::Builtin::kVertexIndex:
-            out << "vertex_index";
-            break;
+            return "vertex_index";
         case FunctionParam::Builtin::kInstanceIndex:
-            out << "instance_index";
-            break;
+            return "instance_index";
         case FunctionParam::Builtin::kPosition:
-            out << "position";
-            break;
+            return "position";
         case FunctionParam::Builtin::kFrontFacing:
-            out << "front_facing";
-            break;
+            return "front_facing";
         case FunctionParam::Builtin::kLocalInvocationId:
-            out << "local_invocation_id";
-            break;
+            return "local_invocation_id";
         case FunctionParam::Builtin::kLocalInvocationIndex:
-            out << "local_invocation_index";
-            break;
+            return "local_invocation_index";
         case FunctionParam::Builtin::kGlobalInvocationId:
-            out << "global_invocation_id";
-            break;
+            return "global_invocation_id";
         case FunctionParam::Builtin::kWorkgroupId:
-            out << "workgroup_id";
-            break;
+            return "workgroup_id";
         case FunctionParam::Builtin::kNumWorkgroups:
-            out << "num_workgroups";
-            break;
+            return "num_workgroups";
         case FunctionParam::Builtin::kSampleIndex:
-            out << "sample_index";
-            break;
+            return "sample_index";
         case FunctionParam::Builtin::kSampleMask:
-            out << "sample_mask";
-            break;
+            return "sample_mask";
     }
-    return out;
+    return "<unknown>";
 }
 
 }  // namespace tint::ir
diff --git a/src/tint/lang/core/ir/function_param.h b/src/tint/lang/core/ir/function_param.h
index 44ad9a5..6571438 100644
--- a/src/tint/lang/core/ir/function_param.h
+++ b/src/tint/lang/core/ir/function_param.h
@@ -106,7 +106,17 @@
     bool invariant_ = false;
 };
 
-StringStream& operator<<(StringStream& out, enum FunctionParam::Builtin value);
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(enum FunctionParam::Builtin value);
+
+/// @param out the stream to write to
+/// @param value the FunctionParam::Builtin
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, enum FunctionParam::Builtin value) {
+    return out << ToString(value);
+}
 
 }  // namespace tint::ir
 
diff --git a/src/tint/lang/core/ir/intrinsic_call.cc b/src/tint/lang/core/ir/intrinsic_call.cc
index 5ab9663..091076a 100644
--- a/src/tint/lang/core/ir/intrinsic_call.cc
+++ b/src/tint/lang/core/ir/intrinsic_call.cc
@@ -28,106 +28,74 @@
 
 IntrinsicCall::~IntrinsicCall() = default;
 
-StringStream& operator<<(StringStream& out, enum IntrinsicCall::Kind kind) {
+std::string_view ToString(enum IntrinsicCall::Kind kind) {
     switch (kind) {
         case IntrinsicCall::Kind::kSpirvArrayLength:
-            out << "spirv.array_length";
-            break;
+            return "spirv.array_length";
         case IntrinsicCall::Kind::kSpirvAtomicIAdd:
-            out << "spirv.atomic_iadd";
-            break;
+            return "spirv.atomic_iadd";
         case IntrinsicCall::Kind::kSpirvAtomicISub:
-            out << "spirv.atomic_isub";
-            break;
+            return "spirv.atomic_isub";
         case IntrinsicCall::Kind::kSpirvAtomicAnd:
-            out << "spirv.atomic_and";
-            break;
+            return "spirv.atomic_and";
         case IntrinsicCall::Kind::kSpirvAtomicCompareExchange:
-            out << "spirv.atomic_compare_exchange";
-            break;
+            return "spirv.atomic_compare_exchange";
         case IntrinsicCall::Kind::kSpirvAtomicExchange:
-            out << "spirv.atomic_exchange";
-            break;
+            return "spirv.atomic_exchange";
         case IntrinsicCall::Kind::kSpirvAtomicLoad:
-            out << "spirv.atomic_load";
-            break;
+            return "spirv.atomic_load";
         case IntrinsicCall::Kind::kSpirvAtomicOr:
-            out << "spirv.atomic_or";
-            break;
+            return "spirv.atomic_or";
         case IntrinsicCall::Kind::kSpirvAtomicSMax:
-            out << "spirv.atomic_smax";
-            break;
+            return "spirv.atomic_smax";
         case IntrinsicCall::Kind::kSpirvAtomicSMin:
-            out << "spirv.atomic_smin";
-            break;
+            return "spirv.atomic_smin";
         case IntrinsicCall::Kind::kSpirvAtomicStore:
-            out << "spirv.atomic_store";
-            break;
+            return "spirv.atomic_store";
         case IntrinsicCall::Kind::kSpirvAtomicUMax:
-            out << "spirv.atomic_umax";
-            break;
+            return "spirv.atomic_umax";
         case IntrinsicCall::Kind::kSpirvAtomicUMin:
-            out << "spirv.atomic_umin";
-            break;
+            return "spirv.atomic_umin";
         case IntrinsicCall::Kind::kSpirvAtomicXor:
-            out << "spirv.atomic_xor";
-            break;
+            return "spirv.atomic_xor";
         case IntrinsicCall::Kind::kSpirvDot:
-            out << "spirv.dot";
-            break;
+            return "spirv.dot";
         case IntrinsicCall::Kind::kSpirvImageFetch:
-            out << "spirv.image_fetch";
-            break;
+            return "spirv.image_fetch";
         case IntrinsicCall::Kind::kSpirvImageGather:
-            out << "spirv.image_gather";
-            break;
+            return "spirv.image_gather";
         case IntrinsicCall::Kind::kSpirvImageDrefGather:
-            out << "spirv.image_dref_gather";
-            break;
+            return "spirv.image_dref_gather";
         case IntrinsicCall::Kind::kSpirvImageQuerySize:
-            out << "spirv.image_query_size";
-            break;
+            return "spirv.image_query_size";
         case IntrinsicCall::Kind::kSpirvImageQuerySizeLod:
-            out << "spirv.image_query_size_lod";
-            break;
+            return "spirv.image_query_size_lod";
         case IntrinsicCall::Kind::kSpirvImageSampleImplicitLod:
-            out << "spirv.image_sample_implicit_lod";
-            break;
+            return "spirv.image_sample_implicit_lod";
         case IntrinsicCall::Kind::kSpirvImageSampleExplicitLod:
-            out << "spirv.image_sample_explicit_lod";
-            break;
+            return "spirv.image_sample_explicit_lod";
         case IntrinsicCall::Kind::kSpirvImageSampleDrefImplicitLod:
-            out << "spirv.image_sample_dref_implicit_lod";
-            break;
+            return "spirv.image_sample_dref_implicit_lod";
         case IntrinsicCall::Kind::kSpirvImageSampleDrefExplicitLod:
-            out << "spirv.image_sample_dref_implicit_lod";
-            break;
+            return "spirv.image_sample_dref_implicit_lod";
         case IntrinsicCall::Kind::kSpirvImageWrite:
-            out << "spirv.image_write";
-            break;
+            return "spirv.image_write";
         case IntrinsicCall::Kind::kSpirvMatrixTimesMatrix:
-            out << "spirv.matrix_times_matrix";
-            break;
+            return "spirv.matrix_times_matrix";
         case IntrinsicCall::Kind::kSpirvMatrixTimesScalar:
-            out << "spirv.matrix_times_scalar";
-            break;
+            return "spirv.matrix_times_scalar";
         case IntrinsicCall::Kind::kSpirvMatrixTimesVector:
-            out << "spirv.matrix_times_vector";
-            break;
+            return "spirv.matrix_times_vector";
         case IntrinsicCall::Kind::kSpirvSampledImage:
-            out << "spirv.sampled_image";
-            break;
+            return "spirv.sampled_image";
         case IntrinsicCall::Kind::kSpirvSelect:
-            out << "spirv.select";
-            break;
+            return "spirv.select";
         case IntrinsicCall::Kind::kSpirvVectorTimesScalar:
-            out << "spirv.vector_times_scalar";
-            break;
+            return "spirv.vector_times_scalar";
         case IntrinsicCall::Kind::kSpirvVectorTimesMatrix:
-            out << "spirv.vector_times_matrix";
-            break;
+            return "spirv.vector_times_matrix";
     }
-    return out;
+    return "<unknown>";
 }
 
 }  // namespace tint::ir
diff --git a/src/tint/lang/core/ir/intrinsic_call.h b/src/tint/lang/core/ir/intrinsic_call.h
index 9a01acf..a04b3ef 100644
--- a/src/tint/lang/core/ir/intrinsic_call.h
+++ b/src/tint/lang/core/ir/intrinsic_call.h
@@ -80,8 +80,15 @@
     enum Kind kind_;
 };
 
+/// @param kind the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(enum IntrinsicCall::Kind kind);
+
 /// Emits the name of the intrinsic type.
-StringStream& operator<<(StringStream& out, enum IntrinsicCall::Kind kind);
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, enum IntrinsicCall::Kind kind) {
+    return out << ToString(kind);
+}
 
 }  // namespace tint::ir
 
diff --git a/src/tint/lang/core/type/sampler_kind.cc b/src/tint/lang/core/type/sampler_kind.cc
index 0780929..17e950c 100644
--- a/src/tint/lang/core/type/sampler_kind.cc
+++ b/src/tint/lang/core/type/sampler_kind.cc
@@ -16,16 +16,14 @@
 
 namespace tint::type {
 
-StringStream& operator<<(StringStream& out, SamplerKind kind) {
+std::string_view ToString(SamplerKind kind) {
     switch (kind) {
         case SamplerKind::kSampler:
-            out << "sampler";
-            break;
+            return "sampler";
         case SamplerKind::kComparisonSampler:
-            out << "comparison_sampler";
-            break;
+            return "comparison_sampler";
     }
-    return out;
+    return "<unknown>";
 }
 
 }  // namespace tint::type
diff --git a/src/tint/lang/core/type/sampler_kind.h b/src/tint/lang/core/type/sampler_kind.h
index 33d766e..fed81c2 100644
--- a/src/tint/lang/core/type/sampler_kind.h
+++ b/src/tint/lang/core/type/sampler_kind.h
@@ -16,6 +16,7 @@
 #define SRC_TINT_LANG_CORE_TYPE_SAMPLER_KIND_H_
 
 #include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::type {
 
@@ -27,10 +28,17 @@
     kComparisonSampler
 };
 
+/// @param kind the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(enum SamplerKind kind);
+
 /// @param out the stream to write to
 /// @param kind the SamplerKind
 /// @return the stream so calls can be chained
-StringStream& operator<<(StringStream& out, SamplerKind kind);
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, SamplerKind kind) {
+    return out << ToString(kind);
+}
 
 }  // namespace tint::type
 
diff --git a/src/tint/lang/core/type/texture_dimension.cc b/src/tint/lang/core/type/texture_dimension.cc
index ecabdb2..dc2ce79 100644
--- a/src/tint/lang/core/type/texture_dimension.cc
+++ b/src/tint/lang/core/type/texture_dimension.cc
@@ -16,31 +16,24 @@
 
 namespace tint::type {
 
-StringStream& operator<<(StringStream& out, type::TextureDimension dim) {
+std::string_view ToString(type::TextureDimension dim) {
     switch (dim) {
         case type::TextureDimension::kNone:
-            out << "None";
-            break;
+            return "None";
         case type::TextureDimension::k1d:
-            out << "1d";
-            break;
+            return "1d";
         case type::TextureDimension::k2d:
-            out << "2d";
-            break;
+            return "2d";
         case type::TextureDimension::k2dArray:
-            out << "2d_array";
-            break;
+            return "2d_array";
         case type::TextureDimension::k3d:
-            out << "3d";
-            break;
+            return "3d";
         case type::TextureDimension::kCube:
-            out << "cube";
-            break;
+            return "cube";
         case type::TextureDimension::kCubeArray:
-            out << "cube_array";
-            break;
+            return "cube_array";
     }
-    return out;
+    return "<unknown>";
 }
 
 }  // namespace tint::type
diff --git a/src/tint/lang/core/type/texture_dimension.h b/src/tint/lang/core/type/texture_dimension.h
index 9bcd724..1d5fd44 100644
--- a/src/tint/lang/core/type/texture_dimension.h
+++ b/src/tint/lang/core/type/texture_dimension.h
@@ -16,6 +16,7 @@
 #define SRC_TINT_LANG_CORE_TYPE_TEXTURE_DIMENSION_H_
 
 #include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::type {
 
@@ -37,10 +38,17 @@
     kCubeArray,
 };
 
+/// @param dim the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(enum type::TextureDimension dim);
+
 /// @param out the stream to write to
 /// @param dim the type::TextureDimension
 /// @return the stream so calls can be chained
-StringStream& operator<<(StringStream& out, type::TextureDimension dim);
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, type::TextureDimension dim) {
+    return out << ToString(dim);
+}
 
 }  // namespace tint::type
 
diff --git a/src/tint/lang/spirv/reader/ast_parser/construct.h b/src/tint/lang/spirv/reader/ast_parser/construct.h
index bcc35a3..59f6010 100644
--- a/src/tint/lang/spirv/reader/ast_parser/construct.h
+++ b/src/tint/lang/spirv/reader/ast_parser/construct.h
@@ -184,7 +184,8 @@
 /// @param o the stream
 /// @param c the structured construct
 /// @returns the stream
-inline StringStream& operator<<(StringStream& o, const Construct& c) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& o, const Construct& c) {
     o << "Construct{ " << ToString(c.kind) << " [" << c.begin_pos << "," << c.end_pos << ")"
       << " begin_id:" << c.begin_id << " end_id:" << c.end_id << " depth:" << c.depth;
 
@@ -215,7 +216,8 @@
 /// @param o the stream
 /// @param c the structured construct
 /// @returns the stream
-inline StringStream& operator<<(StringStream& o, const std::unique_ptr<Construct>& c) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& o, const std::unique_ptr<Construct>& c) {
     return o << *(c.get());
 }
 
@@ -246,7 +248,8 @@
 /// @param o the stream
 /// @param cl the construct list
 /// @returns the stream
-inline StringStream& operator<<(StringStream& o, const ConstructList& cl) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& o, const ConstructList& cl) {
     o << "ConstructList{\n";
     for (const auto& c : cl) {
         o << "  " << c << "\n";
diff --git a/src/tint/lang/spirv/reader/ast_parser/function.h b/src/tint/lang/spirv/reader/ast_parser/function.h
index 29ad3da..5658690 100644
--- a/src/tint/lang/spirv/reader/ast_parser/function.h
+++ b/src/tint/lang/spirv/reader/ast_parser/function.h
@@ -183,7 +183,8 @@
 /// @param o the stream
 /// @param bi the BlockInfo
 /// @returns the stream so calls can be chained
-inline StringStream& operator<<(StringStream& o, const BlockInfo& bi) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& o, const BlockInfo& bi) {
     o << "BlockInfo{"
       << " id: " << bi.id << " pos: " << bi.pos << " merge_for_header: " << bi.merge_for_header
       << " continue_for_header: " << bi.continue_for_header
@@ -358,7 +359,8 @@
 /// @param o the stream
 /// @param di the DefInfo
 /// @returns the stream so calls can be chained
-inline StringStream& operator<<(StringStream& o, const DefInfo& di) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& o, const DefInfo& di) {
     o << "DefInfo{"
       << " inst.result_id: " << di.inst.result_id();
     if (di.local.has_value()) {
diff --git a/src/tint/lang/spirv/reader/ast_parser/usage.h b/src/tint/lang/spirv/reader/ast_parser/usage.h
index 4d7fbb5..144abe4 100644
--- a/src/tint/lang/spirv/reader/ast_parser/usage.h
+++ b/src/tint/lang/spirv/reader/ast_parser/usage.h
@@ -18,6 +18,7 @@
 #include <string>
 
 #include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::spirv::reader {
 
@@ -128,7 +129,8 @@
 /// @param out the stream
 /// @param u the Usage
 /// @returns the stream so calls can be chained
-inline StringStream& operator<<(StringStream& out, const Usage& u) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, const Usage& u) {
     return u.operator<<(out);
 }
 
diff --git a/src/tint/lang/spirv/writer/test_helper.h b/src/tint/lang/spirv/writer/test_helper.h
index d4d6ae9..e8c1ab6 100644
--- a/src/tint/lang/spirv/writer/test_helper.h
+++ b/src/tint/lang/spirv/writer/test_helper.h
@@ -47,7 +47,8 @@
     kF32,
     kF16,
 };
-inline StringStream& operator<<(StringStream& out, TestElementType type) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, TestElementType type) {
     switch (type) {
         case kBool:
             out << "bool";
diff --git a/src/tint/lang/spirv/writer/texture_builtin_test.cc b/src/tint/lang/spirv/writer/texture_builtin_test.cc
index 3df9f72..b1fbb11 100644
--- a/src/tint/lang/spirv/writer/texture_builtin_test.cc
+++ b/src/tint/lang/spirv/writer/texture_builtin_test.cc
@@ -62,7 +62,8 @@
     Vector<const char*, 2> instructions;
 };
 
-inline StringStream& operator<<(StringStream& out, TextureType type) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, TextureType type) {
     switch (type) {
         case kSampledTexture:
             out << "SampleTexture";
diff --git a/src/tint/lang/wgsl/ast/binary_expression.h b/src/tint/lang/wgsl/ast/binary_expression.h
index 7f90d6f..2b9cd2c 100644
--- a/src/tint/lang/wgsl/ast/binary_expression.h
+++ b/src/tint/lang/wgsl/ast/binary_expression.h
@@ -300,7 +300,8 @@
 /// @param out the stream to write to
 /// @param op the BinaryOp
 /// @return the stream so calls can be chained
-inline StringStream& operator<<(StringStream& out, BinaryOp op) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, BinaryOp op) {
     out << FriendlyName(op);
     return out;
 }
diff --git a/src/tint/lang/wgsl/ast/float_literal_expression.cc b/src/tint/lang/wgsl/ast/float_literal_expression.cc
index 36dd37c..9fb7cbf 100644
--- a/src/tint/lang/wgsl/ast/float_literal_expression.cc
+++ b/src/tint/lang/wgsl/ast/float_literal_expression.cc
@@ -37,14 +37,14 @@
     return ctx->dst->create<FloatLiteralExpression>(src, value, suffix);
 }
 
-StringStream& operator<<(StringStream& out, FloatLiteralExpression::Suffix suffix) {
+std::string_view ToString(FloatLiteralExpression::Suffix suffix) {
     switch (suffix) {
         default:
-            return out;
+            return "";
         case FloatLiteralExpression::Suffix::kF:
-            return out << "f";
+            return "f";
         case FloatLiteralExpression::Suffix::kH:
-            return out << "h";
+            return "h";
     }
 }
 
diff --git a/src/tint/lang/wgsl/ast/float_literal_expression.h b/src/tint/lang/wgsl/ast/float_literal_expression.h
index 964106a..679d3f2 100644
--- a/src/tint/lang/wgsl/ast/float_literal_expression.h
+++ b/src/tint/lang/wgsl/ast/float_literal_expression.h
@@ -56,11 +56,18 @@
     const Suffix suffix;
 };
 
+/// @param suffix the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(FloatLiteralExpression::Suffix suffix);
+
 /// Writes the float literal suffix to the stream.
 /// @param out the stream to write to
 /// @param suffix the suffix to write
 /// @returns out so calls can be chained
-StringStream& operator<<(StringStream& out, FloatLiteralExpression::Suffix suffix);
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, FloatLiteralExpression::Suffix suffix) {
+    return out << ToString(suffix);
+}
 
 }  // namespace tint::ast
 
diff --git a/src/tint/lang/wgsl/ast/int_literal_expression.cc b/src/tint/lang/wgsl/ast/int_literal_expression.cc
index 74e99db..6858bf7 100644
--- a/src/tint/lang/wgsl/ast/int_literal_expression.cc
+++ b/src/tint/lang/wgsl/ast/int_literal_expression.cc
@@ -35,14 +35,14 @@
     return ctx->dst->create<IntLiteralExpression>(src, value, suffix);
 }
 
-StringStream& operator<<(StringStream& out, IntLiteralExpression::Suffix suffix) {
+std::string_view ToString(IntLiteralExpression::Suffix suffix) {
     switch (suffix) {
         default:
-            return out;
+            return "";
         case IntLiteralExpression::Suffix::kI:
-            return out << "i";
+            return "i";
         case IntLiteralExpression::Suffix::kU:
-            return out << "u";
+            return "u";
     }
 }
 
diff --git a/src/tint/lang/wgsl/ast/int_literal_expression.h b/src/tint/lang/wgsl/ast/int_literal_expression.h
index c0ae78d..f6399db 100644
--- a/src/tint/lang/wgsl/ast/int_literal_expression.h
+++ b/src/tint/lang/wgsl/ast/int_literal_expression.h
@@ -55,11 +55,18 @@
     const Suffix suffix;
 };
 
+/// @param suffix the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(IntLiteralExpression::Suffix suffix);
+
 /// Writes the integer literal suffix to the stream.
 /// @param out the stream to write to
 /// @param suffix the suffix to write
 /// @returns out so calls can be chained
-StringStream& operator<<(StringStream& out, IntLiteralExpression::Suffix suffix);
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, IntLiteralExpression::Suffix suffix) {
+    return out << ToString(suffix);
+}
 
 }  // namespace tint::ast
 
diff --git a/src/tint/lang/wgsl/ast/pipeline_stage.cc b/src/tint/lang/wgsl/ast/pipeline_stage.cc
index 4d00143..2b1fa2e 100644
--- a/src/tint/lang/wgsl/ast/pipeline_stage.cc
+++ b/src/tint/lang/wgsl/ast/pipeline_stage.cc
@@ -16,26 +16,18 @@
 
 namespace tint::ast {
 
-StringStream& operator<<(StringStream& out, PipelineStage stage) {
+std::string_view ToString(PipelineStage stage) {
     switch (stage) {
-        case PipelineStage::kNone: {
-            out << "none";
-            break;
-        }
-        case PipelineStage::kVertex: {
-            out << "vertex";
-            break;
-        }
-        case PipelineStage::kFragment: {
-            out << "fragment";
-            break;
-        }
-        case PipelineStage::kCompute: {
-            out << "compute";
-            break;
-        }
+        case PipelineStage::kNone:
+            return "none";
+        case PipelineStage::kVertex:
+            return "vertex";
+        case PipelineStage::kFragment:
+            return "fragment";
+        case PipelineStage::kCompute:
+            return "compute";
     }
-    return out;
+    return "<unknown>";
 }
 
 }  // namespace tint::ast
diff --git a/src/tint/lang/wgsl/ast/pipeline_stage.h b/src/tint/lang/wgsl/ast/pipeline_stage.h
index 456a8b3..8d0c7c3 100644
--- a/src/tint/lang/wgsl/ast/pipeline_stage.h
+++ b/src/tint/lang/wgsl/ast/pipeline_stage.h
@@ -16,16 +16,24 @@
 #define SRC_TINT_LANG_WGSL_AST_PIPELINE_STAGE_H_
 
 #include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::ast {
 
 /// The pipeline stage
 enum class PipelineStage { kNone = -1, kVertex, kFragment, kCompute };
 
+/// @param stage the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(PipelineStage stage);
+
 /// @param out the stream to write to
 /// @param stage the PipelineStage
 /// @return the stream so calls can be chained
-StringStream& operator<<(StringStream& out, PipelineStage stage);
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, PipelineStage stage) {
+    return out << ToString(stage);
+}
 
 }  // namespace tint::ast
 
diff --git a/src/tint/lang/wgsl/ast/unary_op.cc b/src/tint/lang/wgsl/ast/unary_op.cc
index 35f28f7..1c1843d 100644
--- a/src/tint/lang/wgsl/ast/unary_op.cc
+++ b/src/tint/lang/wgsl/ast/unary_op.cc
@@ -16,30 +16,20 @@
 
 namespace tint::ast {
 
-StringStream& operator<<(StringStream& out, UnaryOp mod) {
+std::string_view ToString(UnaryOp mod) {
     switch (mod) {
-        case UnaryOp::kAddressOf: {
-            out << "address-of";
-            break;
-        }
-        case UnaryOp::kComplement: {
-            out << "complement";
-            break;
-        }
-        case UnaryOp::kIndirection: {
-            out << "indirection";
-            break;
-        }
-        case UnaryOp::kNegation: {
-            out << "negation";
-            break;
-        }
-        case UnaryOp::kNot: {
-            out << "not";
-            break;
-        }
+        case UnaryOp::kAddressOf:
+            return "address-of";
+        case UnaryOp::kComplement:
+            return "complement";
+        case UnaryOp::kIndirection:
+            return "indirection";
+        case UnaryOp::kNegation:
+            return "negation";
+        case UnaryOp::kNot:
+            return "not";
     }
-    return out;
+    return "<unknown>";
 }
 
 }  // namespace tint::ast
diff --git a/src/tint/lang/wgsl/ast/unary_op.h b/src/tint/lang/wgsl/ast/unary_op.h
index 2dda746..9d9bee6 100644
--- a/src/tint/lang/wgsl/ast/unary_op.h
+++ b/src/tint/lang/wgsl/ast/unary_op.h
@@ -16,6 +16,7 @@
 #define SRC_TINT_LANG_WGSL_AST_UNARY_OP_H_
 
 #include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::ast {
 
@@ -28,10 +29,17 @@
     kNot,          // !EXPR
 };
 
+/// @param mod the enum value
+/// @returns the string for the given enum value
+std::string_view ToString(UnaryOp mod);
+
 /// @param out the stream to write to
 /// @param mod the UnaryOp
 /// @return the stream so calls can be chained
-StringStream& operator<<(StringStream& out, UnaryOp mod);
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, UnaryOp mod) {
+    return out << ToString(mod);
+}
 
 }  // namespace tint::ast
 
diff --git a/src/tint/lang/wgsl/reader/parser/token.h b/src/tint/lang/wgsl/reader/parser/token.h
index 2523c91..536bb35 100644
--- a/src/tint/lang/wgsl/reader/parser/token.h
+++ b/src/tint/lang/wgsl/reader/parser/token.h
@@ -359,7 +359,8 @@
     std::variant<int64_t, double, std::string, std::string_view> value_;
 };
 
-inline StringStream& operator<<(StringStream& out, Token::Type type) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, Token::Type type) {
     out << Token::TypeToName(type);
     return out;
 }
diff --git a/src/tint/lang/wgsl/resolver/builtin_test.cc b/src/tint/lang/wgsl/resolver/builtin_test.cc
index ddfa96d..0ccd6e9 100644
--- a/src/tint/lang/wgsl/resolver/builtin_test.cc
+++ b/src/tint/lang/wgsl/resolver/builtin_test.cc
@@ -2071,7 +2071,8 @@
 namespace texture_builtin_tests {
 
 enum class Texture { kF32, kI32, kU32 };
-inline StringStream& operator<<(StringStream& out, Texture data) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, Texture data) {
     if (data == Texture::kF32) {
         out << "f32";
     } else if (data == Texture::kI32) {
diff --git a/src/tint/lang/wgsl/resolver/dependency_graph.cc b/src/tint/lang/wgsl/resolver/dependency_graph.cc
index f6399fd..f2077b6 100644
--- a/src/tint/lang/wgsl/resolver/dependency_graph.cc
+++ b/src/tint/lang/wgsl/resolver/dependency_graph.cc
@@ -666,7 +666,7 @@
             [&](const ast::ConstAssert*) { return "const_assert"; },  //
             [&](Default) {
                 UnhandledNode(node);
-                return "<error>";
+                return "<unknown>";
             });
     }
 
diff --git a/src/tint/lang/wgsl/sem/behavior.cc b/src/tint/lang/wgsl/sem/behavior.cc
index f47070c..e1c007c 100644
--- a/src/tint/lang/wgsl/sem/behavior.cc
+++ b/src/tint/lang/wgsl/sem/behavior.cc
@@ -16,18 +16,18 @@
 
 namespace tint::sem {
 
-StringStream& operator<<(StringStream& out, Behavior behavior) {
+std::string_view ToString(Behavior behavior) {
     switch (behavior) {
         case Behavior::kReturn:
-            return out << "Return";
+            return "Return";
         case Behavior::kBreak:
-            return out << "Break";
+            return "Break";
         case Behavior::kContinue:
-            return out << "Continue";
+            return "Continue";
         case Behavior::kNext:
-            return out << "Next";
+            return "Next";
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 
 }  // namespace tint::sem
diff --git a/src/tint/lang/wgsl/sem/behavior.h b/src/tint/lang/wgsl/sem/behavior.h
index dc0df58..b77614a 100644
--- a/src/tint/lang/wgsl/sem/behavior.h
+++ b/src/tint/lang/wgsl/sem/behavior.h
@@ -16,6 +16,7 @@
 #define SRC_TINT_LANG_WGSL_SEM_BEHAVIOR_H_
 
 #include "src/tint/utils/containers/enum_set.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint::sem {
 
@@ -31,11 +32,18 @@
 /// Behaviors is a set of Behavior
 using Behaviors = tint::EnumSet<Behavior>;
 
+/// @param behavior the behavior
+/// @returns the string for the given enumerator
+std::string_view ToString(Behavior behavior);
+
 /// Writes the Behavior to the stream.
 /// @param out the stream to write to
 /// @param behavior the Behavior to write
 /// @returns out so calls can be chained
-StringStream& operator<<(StringStream& out, Behavior behavior);
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, Behavior behavior) {
+    return out << ToString(behavior);
+}
 
 }  // namespace tint::sem
 
diff --git a/src/tint/lang/wgsl/sem/sampler_texture_pair.h b/src/tint/lang/wgsl/sem/sampler_texture_pair.h
index 516a050..51e3dc2 100644
--- a/src/tint/lang/wgsl/sem/sampler_texture_pair.h
+++ b/src/tint/lang/wgsl/sem/sampler_texture_pair.h
@@ -48,7 +48,8 @@
 /// @param o the stream to write to
 /// @param stp the SamplerTexturePair
 /// @return the stream so calls can be chained
-inline StringStream& operator<<(StringStream& o, const SamplerTexturePair& stp) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& o, const SamplerTexturePair& stp) {
     return o << "[sampler: " << stp.sampler_binding_point
              << ", texture: " << stp.sampler_binding_point << "]";
 }
diff --git a/src/tint/utils/containers/enum_set.h b/src/tint/utils/containers/enum_set.h
index 2e3fd15..488a230 100644
--- a/src/tint/utils/containers/enum_set.h
+++ b/src/tint/utils/containers/enum_set.h
@@ -20,7 +20,7 @@
 #include <type_traits>
 #include <utility>
 
-#include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint {
 
@@ -227,8 +227,8 @@
 /// @param out the stream to write to
 /// @param set the EnumSet to write
 /// @returns out so calls can be chained
-template <typename ENUM>
-inline StringStream& operator<<(StringStream& out, EnumSet<ENUM> set) {
+template <typename STREAM, typename ENUM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, EnumSet<ENUM> set) {
     out << "{";
     bool first = true;
     for (auto e : set) {
diff --git a/src/tint/utils/containers/hashmap_base.h b/src/tint/utils/containers/hashmap_base.h
index 753564d..e1ea31e 100644
--- a/src/tint/utils/containers/hashmap_base.h
+++ b/src/tint/utils/containers/hashmap_base.h
@@ -24,6 +24,7 @@
 #include "src/tint/utils/containers/vector.h"
 #include "src/tint/utils/ice/ice.h"
 #include "src/tint/utils/math/hash.h"
+#include "src/tint/utils/traits/traits.h"
 
 #define TINT_ASSERT_ITERATORS_NOT_INVALIDATED
 
@@ -97,8 +98,11 @@
 /// @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>
-StringStream& operator<<(StringStream& out, const KeyValue<KEY, VALUE>& key_value) {
+template <typename STREAM,
+          typename KEY,
+          typename VALUE,
+          typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, const KeyValue<KEY, VALUE>& key_value) {
     return out << "[" << key_value.key << ": " << key_value.value << "]";
 }
 
diff --git a/src/tint/utils/containers/vector.h b/src/tint/utils/containers/vector.h
index dc29721..4760cb5 100644
--- a/src/tint/utils/containers/vector.h
+++ b/src/tint/utils/containers/vector.h
@@ -27,16 +27,11 @@
 #include "src/tint/utils/ice/ice.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 {
 
 /// Forward declarations
+namespace tint {
 template <typename>
 class VectorRef;
-template <typename>
-class VectorRef;
-
 }  // namespace tint
 
 namespace tint {
@@ -834,8 +829,8 @@
 /// @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) {
+template <typename STREAM, typename T, size_t N, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& o, const Vector<T, N>& vec) {
     o << "[";
     bool first = true;
     for (auto& el : vec) {
@@ -853,8 +848,8 @@
 /// @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) {
+template <typename STREAM, typename T, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& o, VectorRef<T> vec) {
     o << "[";
     bool first = true;
     for (auto& el : vec) {
diff --git a/src/tint/utils/containers/vector_test.cc b/src/tint/utils/containers/vector_test.cc
index ab8a3e2..602bc25 100644
--- a/src/tint/utils/containers/vector_test.cc
+++ b/src/tint/utils/containers/vector_test.cc
@@ -21,7 +21,6 @@
 
 #include "src/tint/utils/containers/predicates.h"
 #include "src/tint/utils/memory/bitcast.h"
-#include "src/tint/utils/text/string_stream.h"
 
 namespace tint {
 namespace {
diff --git a/src/tint/utils/diagnostic/source.h b/src/tint/utils/diagnostic/source.h
index a377e61..d1ac0b3 100644
--- a/src/tint/utils/diagnostic/source.h
+++ b/src/tint/utils/diagnostic/source.h
@@ -22,6 +22,7 @@
 #include <vector>
 
 #include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint {
 
@@ -196,7 +197,8 @@
 /// @param out the stream to write to
 /// @param loc the location to write
 /// @returns out so calls can be chained
-inline StringStream& operator<<(StringStream& out, const Source::Location& loc) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, const Source::Location& loc) {
     out << loc.line << ":" << loc.column;
     return out;
 }
@@ -205,7 +207,8 @@
 /// @param out the stream to write to
 /// @param range the range to write
 /// @returns out so calls can be chained
-inline StringStream& operator<<(StringStream& out, const Source::Range& range) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, const Source::Range& range) {
     out << "[" << range.begin << ", " << range.end << "]";
     return out;
 }
@@ -220,7 +223,8 @@
 /// @param out the stream to write to
 /// @param content the file content to write
 /// @returns out so calls can be chained
-inline StringStream& operator<<(StringStream& out, const Source::FileContent& content) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, const Source::FileContent& content) {
     out << content.data;
     return out;
 }
diff --git a/src/tint/utils/generation_id.h b/src/tint/utils/generation_id.h
index 6a9249b..16ee56d 100644
--- a/src/tint/utils/generation_id.h
+++ b/src/tint/utils/generation_id.h
@@ -20,6 +20,7 @@
 
 #include "src/tint/utils/ice/ice.h"
 #include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint {
 
@@ -75,7 +76,8 @@
 /// @param out the stream to write to
 /// @param id the generation identifier to write
 /// @returns out so calls can be chained
-inline StringStream& operator<<(StringStream& out, GenerationID id) {
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, GenerationID id) {
     out << "Generation<" << id.Value() << ">";
     return out;
 }
diff --git a/src/tint/utils/result/result.h b/src/tint/utils/result/result.h
index aab5048..f79468b 100644
--- a/src/tint/utils/result/result.h
+++ b/src/tint/utils/result/result.h
@@ -20,6 +20,7 @@
 
 #include "src/tint/utils/ice/ice.h"
 #include "src/tint/utils/text/string_stream.h"
+#include "src/tint/utils/traits/traits.h"
 
 namespace tint {
 
@@ -163,8 +164,11 @@
 /// @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 StringStream& operator<<(StringStream& out, Result<SUCCESS, FAILURE> res) {
+template <typename STREAM,
+          typename SUCCESS,
+          typename FAILURE,
+          typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, Result<SUCCESS, FAILURE> res) {
     return res ? (out << "success: " << res.Get()) : (out << "failure: " << res.Failure());
 }
 
diff --git a/src/tint/utils/templates/enums.tmpl.inc b/src/tint/utils/templates/enums.tmpl.inc
index 73db9f7..47a3435 100644
--- a/src/tint/utils/templates/enums.tmpl.inc
+++ b/src/tint/utils/templates/enums.tmpl.inc
@@ -47,10 +47,17 @@
 {{-   end }}
 };
 
+/// @param value the enum value
+/// @returns the string for the given enum value
+std::string_view ToString({{$enum}} value);
+
 /// @param out the stream to write to
 /// @param value the {{$enum}}
-/// @returns `out` so calls can be chained
-StringStream& operator<<(StringStream& out, {{$enum}} value);
+/// @returns @p out so calls can be chained
+template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+auto& operator<<(STREAM& out, {{$enum}} value) {
+    return out << ToString(value);
+}
 
 /// Parse{{$enum}} parses a {{$enum}} from a string.
 /// @param str the string to parse
@@ -94,16 +101,16 @@
 {{- /* provided sem.Enum.                                                 */ -}}
 {{- /* ------------------------------------------------------------------ */ -}}
 {{- $enum := Eval "EnumName" $ -}}
-    StringStream& operator<<(StringStream& out, {{$enum}} value) {
+std::string_view ToString({{$enum}} value) {
     switch (value) {
         case {{$enum}}::kUndefined:
-            return out << "undefined";
+            return "undefined";
 {{-   range $entry := $.Entries }}
         case {{template "EnumCase" $entry}}:
-            return out << "{{$entry.Name}}";
+            return "{{$entry.Name}}";
 {{-   end }}
     }
-    return out << "<unknown>";
+    return "<unknown>";
 }
 {{- end -}}
 
diff --git a/src/tint/utils/text/string_stream.h b/src/tint/utils/text/string_stream.h
index 360f4b1..4e88f8f 100644
--- a/src/tint/utils/text/string_stream.h
+++ b/src/tint/utils/text/string_stream.h
@@ -41,6 +41,9 @@
                                       std::is_same_v<SetFillRetTy, std::decay_t<T>>;
 
   public:
+    /// @see tint::traits::IsOStream
+    static constexpr bool IsStreamWriter = true;
+
     /// Constructor
     StringStream();
     /// Destructor
diff --git a/src/tint/utils/traits/traits.h b/src/tint/utils/traits/traits.h
index 6516c06..62071c7 100644
--- a/src/tint/utils/traits/traits.h
+++ b/src/tint/utils/traits/traits.h
@@ -15,6 +15,7 @@
 #ifndef SRC_TINT_UTILS_TRAITS_TRAITS_H_
 #define SRC_TINT_UTILS_TRAITS_TRAITS_H_
 
+#include <ostream>
 #include <string>
 #include <tuple>
 #include <type_traits>
@@ -100,10 +101,9 @@
 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.
+/// 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;
+using EnableIf = std::enable_if_t<CONDITION, T>;
 
 /// If `T` is of type `BASE`, or derives from `BASE`, then EnableIfIsType
 /// resolves to type `T`, otherwise an invalid type.
@@ -209,6 +209,36 @@
 template <typename T>
 using CharArrayToCharPtr = typename traits::detail::CharArrayToCharPtrImpl<T>::type;
 
+namespace detail {
+/// Helper for determining whether the type T can be used as a stream writer
+template <typename T, typename ENABLE = void>
+struct IsOStream : std::false_type {};
+
+/// Specialization for types that declare a `static constexpr bool IsStreamWriter` member
+template <typename T>
+struct IsOStream<T, std::void_t<decltype(T::IsStreamWriter)>> {
+    /// Equal to T::IsStreamWriter
+    static constexpr bool value = T::IsStreamWriter;
+};
+
+/// Specialization for std::ostream
+template <typename T>
+struct IsOStream<T, std::enable_if_t<std::is_same_v<T, std::ostream>>> : std::true_type {};
+
+/// Specialization for std::stringstream
+template <typename T>
+struct IsOStream<T, std::enable_if_t<std::is_same_v<T, std::stringstream>>> : std::true_type {};
+
+}  // namespace detail
+
+/// Is true if the class T can be treated as an output stream
+template <typename T>
+static constexpr bool IsOStream = detail::IsOStream<T>::value;
+
+/// If `CONDITION` is true then EnableIfIsOStream resolves to type T, otherwise an invalid type.
+template <typename T = void>
+using EnableIfIsOStream = EnableIf<IsOStream<T>, T>;
+
 }  // namespace tint::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
index 348415f..417b7c2 100644
--- a/src/tint/utils/traits/traits_test.cc
+++ b/src/tint/utils/traits/traits_test.cc
@@ -14,12 +14,17 @@
 
 #include "src/tint/utils/traits/traits.h"
 
+#include <ostream>
+
 #include "gtest/gtest.h"
 
 namespace tint::traits {
 
 namespace {
 
+static_assert(traits::IsOStream<std::ostream>);
+static_assert(traits::IsOStream<std::stringstream>);
+
 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>);