Convert EnableIfIsOStream to a concept.

Change the `EnableIfIsOstream` machinery to use a `concept` and update
usages to `requires` statements.

Change-Id: I211a42adc170ef8b31f2f3908c58810f834aaadd
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/239034
Auto-Submit: dan sinclair <dsinclair@chromium.org>
Reviewed-by: James Price <jrprice@google.com>
Reviewed-by: Corentin Wallez <cwallez@chromium.org>
Commit-Queue: dan sinclair <dsinclair@chromium.org>
diff --git a/src/tint/api/common/binding_point.h b/src/tint/api/common/binding_point.h
index e5040b8..f6110d4 100644
--- a/src/tint/api/common/binding_point.h
+++ b/src/tint/api/common/binding_point.h
@@ -81,7 +81,8 @@
 /// @param o the stream to write to
 /// @param bp the BindingPoint
 /// @return the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, const BindingPoint& bp) {
     return o << "[group: " << bp.group << ", binding: " << bp.binding << "]";
 }
diff --git a/src/tint/cmd/common/helper.cc b/src/tint/cmd/common/helper.cc
index 1767240..8d7a09f 100644
--- a/src/tint/cmd/common/helper.cc
+++ b/src/tint/cmd/common/helper.cc
@@ -65,7 +65,8 @@
 /// @param out the stream to write to
 /// @param value the InputFormat
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, InputFormat value) {
     switch (value) {
         case InputFormat::kUnknown:
diff --git a/src/tint/lang/core/access.h b/src/tint/lang/core/access.h
index ca40a9d..43fd170 100644
--- a/src/tint/lang/core/access.h
+++ b/src/tint/lang/core/access.h
@@ -59,7 +59,8 @@
 /// @param out the stream to write to
 /// @param value the Access
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, Access value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/core/address_space.h b/src/tint/lang/core/address_space.h
index b6fde43..8a79826 100644
--- a/src/tint/lang/core/address_space.h
+++ b/src/tint/lang/core/address_space.h
@@ -65,7 +65,8 @@
 /// @param out the stream to write to
 /// @param value the AddressSpace
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, AddressSpace value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/core/attribute.h b/src/tint/lang/core/attribute.h
index 640f76f..9d5d618 100644
--- a/src/tint/lang/core/attribute.h
+++ b/src/tint/lang/core/attribute.h
@@ -76,7 +76,8 @@
 /// @param out the stream to write to
 /// @param value the Attribute
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, Attribute value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/core/binary_op.h b/src/tint/lang/core/binary_op.h
index d6f558b..c73416d 100644
--- a/src/tint/lang/core/binary_op.h
+++ b/src/tint/lang/core/binary_op.h
@@ -61,7 +61,8 @@
 /// @param out the stream to write to
 /// @param value the BinaryOp
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, BinaryOp value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/core/builtin_fn.h b/src/tint/lang/core/builtin_fn.h
index 4491b5a..76aed36 100644
--- a/src/tint/lang/core/builtin_fn.h
+++ b/src/tint/lang/core/builtin_fn.h
@@ -211,7 +211,8 @@
 
 /// Emits the name of the builtin function type. The spelling, including case,
 /// matches the name in the WGSL spec.
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, BuiltinFn i) {
     return o << str(i);
 }
diff --git a/src/tint/lang/core/builtin_fn.h.tmpl b/src/tint/lang/core/builtin_fn.h.tmpl
index 6f4ee9f..a18e010 100644
--- a/src/tint/lang/core/builtin_fn.h.tmpl
+++ b/src/tint/lang/core/builtin_fn.h.tmpl
@@ -44,7 +44,8 @@
 
 /// Emits the name of the builtin function type. The spelling, including case,
 /// matches the name in the WGSL spec.
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, BuiltinFn i) {
   return o << str(i);
 }
diff --git a/src/tint/lang/core/builtin_type.h b/src/tint/lang/core/builtin_type.h
index 5497b5b..3484c4b 100644
--- a/src/tint/lang/core/builtin_type.h
+++ b/src/tint/lang/core/builtin_type.h
@@ -155,7 +155,8 @@
 /// @param out the stream to write to
 /// @param value the BuiltinType
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, BuiltinType value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/core/builtin_value.h b/src/tint/lang/core/builtin_value.h
index 12fa562..95ce822 100644
--- a/src/tint/lang/core/builtin_value.h
+++ b/src/tint/lang/core/builtin_value.h
@@ -72,7 +72,8 @@
 /// @param out the stream to write to
 /// @param value the BuiltinValue
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, BuiltinValue value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/core/interpolation_sampling.h b/src/tint/lang/core/interpolation_sampling.h
index 4dde2ce..f01c85b 100644
--- a/src/tint/lang/core/interpolation_sampling.h
+++ b/src/tint/lang/core/interpolation_sampling.h
@@ -61,7 +61,8 @@
 /// @param out the stream to write to
 /// @param value the InterpolationSampling
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, InterpolationSampling value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/core/interpolation_type.h b/src/tint/lang/core/interpolation_type.h
index c05024d..7b834ff 100644
--- a/src/tint/lang/core/interpolation_type.h
+++ b/src/tint/lang/core/interpolation_type.h
@@ -59,7 +59,8 @@
 /// @param out the stream to write to
 /// @param value the InterpolationType
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, InterpolationType value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/core/intrinsic/ctor_conv.h b/src/tint/lang/core/intrinsic/ctor_conv.h
index 84bbca6..948f12f 100644
--- a/src/tint/lang/core/intrinsic/ctor_conv.h
+++ b/src/tint/lang/core/intrinsic/ctor_conv.h
@@ -77,7 +77,8 @@
 /// @param o the stream to write to
 /// @param c the CtorConv
 /// @return the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, CtorConv c) {
     return o << str(c);
 }
diff --git a/src/tint/lang/core/intrinsic/ctor_conv.h.tmpl b/src/tint/lang/core/intrinsic/ctor_conv.h.tmpl
index a59b6ad..d86fe92 100644
--- a/src/tint/lang/core/intrinsic/ctor_conv.h.tmpl
+++ b/src/tint/lang/core/intrinsic/ctor_conv.h.tmpl
@@ -39,7 +39,8 @@
 /// @param o the stream to write to
 /// @param c the CtorConv
 /// @return the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, CtorConv c) {
   return o << str(c);
 }
diff --git a/src/tint/lang/core/ir/function.h b/src/tint/lang/core/ir/function.h
index 8a91f13..8223599 100644
--- a/src/tint/lang/core/ir/function.h
+++ b/src/tint/lang/core/ir/function.h
@@ -243,7 +243,8 @@
 /// @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>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, Function::PipelineStage value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/core/number.h b/src/tint/lang/core/number.h
index 4080afa..59f700f 100644
--- a/src/tint/lang/core/number.h
+++ b/src/tint/lang/core/number.h
@@ -191,7 +191,8 @@
 /// @param out the stream to write to
 /// @param num the Number
 /// @return the stream so calls can be chained
-template <typename STREAM, typename T, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM, typename T>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, Number<T> num) {
     return out << num.value;
 }
@@ -344,7 +345,8 @@
 /// @param out the stream to write to
 /// @param failure the ConversionFailure
 /// @return the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, ConversionFailure failure) {
     switch (failure) {
         case ConversionFailure::kExceedsPositiveLimit:
diff --git a/src/tint/lang/core/parameter_usage.h b/src/tint/lang/core/parameter_usage.h
index f9f3fbc..1bbb9c2 100644
--- a/src/tint/lang/core/parameter_usage.h
+++ b/src/tint/lang/core/parameter_usage.h
@@ -104,7 +104,8 @@
 /// @param out the stream to write to
 /// @param value the ParameterUsage
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, ParameterUsage value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/core/parameter_usage.h.tmpl b/src/tint/lang/core/parameter_usage.h.tmpl
index 3b74773..79f9890 100644
--- a/src/tint/lang/core/parameter_usage.h.tmpl
+++ b/src/tint/lang/core/parameter_usage.h.tmpl
@@ -39,7 +39,8 @@
 /// @param out the stream to write to
 /// @param value the ParameterUsage
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, ParameterUsage value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/core/subgroup_matrix_kind.h b/src/tint/lang/core/subgroup_matrix_kind.h
index ef67888..aa188c6 100644
--- a/src/tint/lang/core/subgroup_matrix_kind.h
+++ b/src/tint/lang/core/subgroup_matrix_kind.h
@@ -59,7 +59,8 @@
 /// @param out the stream to write to
 /// @param value the SubgroupMatrixKind
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, SubgroupMatrixKind value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/core/texel_format.h b/src/tint/lang/core/texel_format.h
index dd28dc6..b0b1579 100644
--- a/src/tint/lang/core/texel_format.h
+++ b/src/tint/lang/core/texel_format.h
@@ -73,7 +73,8 @@
 /// @param out the stream to write to
 /// @param value the TexelFormat
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, TexelFormat value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/core/type/sampler_kind.h b/src/tint/lang/core/type/sampler_kind.h
index 4caffce..6c517b2 100644
--- a/src/tint/lang/core/type/sampler_kind.h
+++ b/src/tint/lang/core/type/sampler_kind.h
@@ -48,7 +48,8 @@
 /// @param out the stream to write to
 /// @param kind the SamplerKind
 /// @return the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, SamplerKind kind) {
     return out << ToString(kind);
 }
diff --git a/src/tint/lang/core/type/texture_dimension.h b/src/tint/lang/core/type/texture_dimension.h
index 7b5b486..0fb37b7 100644
--- a/src/tint/lang/core/type/texture_dimension.h
+++ b/src/tint/lang/core/type/texture_dimension.h
@@ -58,7 +58,8 @@
 /// @param out the stream to write to
 /// @param dim the type::TextureDimension
 /// @return the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, core::type::TextureDimension dim) {
     return out << ToString(dim);
 }
diff --git a/src/tint/lang/core/unary_op.h b/src/tint/lang/core/unary_op.h
index 72c72f9..b1bab65 100644
--- a/src/tint/lang/core/unary_op.h
+++ b/src/tint/lang/core/unary_op.h
@@ -49,7 +49,8 @@
 /// @param out the stream to write to
 /// @param value the UnaryOp
 /// @return the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, UnaryOp value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/glsl/builtin_fn.h b/src/tint/lang/glsl/builtin_fn.h
index 95a5c1c..b99ab78 100644
--- a/src/tint/lang/glsl/builtin_fn.h
+++ b/src/tint/lang/glsl/builtin_fn.h
@@ -98,7 +98,8 @@
 const char* str(BuiltinFn i);
 
 /// Emits the name of the builtin function type.
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, BuiltinFn i) {
     return o << str(i);
 }
diff --git a/src/tint/lang/glsl/builtin_fn.h.tmpl b/src/tint/lang/glsl/builtin_fn.h.tmpl
index edbf0ee..91a6786 100644
--- a/src/tint/lang/glsl/builtin_fn.h.tmpl
+++ b/src/tint/lang/glsl/builtin_fn.h.tmpl
@@ -37,7 +37,8 @@
 const char* str(BuiltinFn i);
 
 /// Emits the name of the builtin function type.
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, BuiltinFn i) {
   return o << str(i);
 }
diff --git a/src/tint/lang/hlsl/builtin_fn.h b/src/tint/lang/hlsl/builtin_fn.h
index c89507c..d910303 100644
--- a/src/tint/lang/hlsl/builtin_fn.h
+++ b/src/tint/lang/hlsl/builtin_fn.h
@@ -113,7 +113,8 @@
 const char* str(BuiltinFn i);
 
 /// Emits the name of the builtin function type.
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, BuiltinFn i) {
     return o << str(i);
 }
diff --git a/src/tint/lang/hlsl/builtin_fn.h.tmpl b/src/tint/lang/hlsl/builtin_fn.h.tmpl
index 6160d6e..c2f0dd5 100644
--- a/src/tint/lang/hlsl/builtin_fn.h.tmpl
+++ b/src/tint/lang/hlsl/builtin_fn.h.tmpl
@@ -37,7 +37,8 @@
 const char* str(BuiltinFn i);
 
 /// Emits the name of the builtin function type.
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, BuiltinFn i) {
   return o << str(i);
 }
diff --git a/src/tint/lang/msl/builtin_fn.h b/src/tint/lang/msl/builtin_fn.h
index 99a735e..e847cc8 100644
--- a/src/tint/lang/msl/builtin_fn.h
+++ b/src/tint/lang/msl/builtin_fn.h
@@ -94,7 +94,8 @@
 const char* str(BuiltinFn i);
 
 /// Emits the name of the builtin function type.
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, BuiltinFn i) {
     return o << str(i);
 }
diff --git a/src/tint/lang/msl/builtin_fn.h.tmpl b/src/tint/lang/msl/builtin_fn.h.tmpl
index deb288c..b8e6c6b 100644
--- a/src/tint/lang/msl/builtin_fn.h.tmpl
+++ b/src/tint/lang/msl/builtin_fn.h.tmpl
@@ -37,7 +37,8 @@
 const char* str(BuiltinFn i);
 
 /// Emits the name of the builtin function type.
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, BuiltinFn i) {
   return o << str(i);
 }
diff --git a/src/tint/lang/spirv/builtin_fn.h b/src/tint/lang/spirv/builtin_fn.h
index 9e41fbb..6daede2 100644
--- a/src/tint/lang/spirv/builtin_fn.h
+++ b/src/tint/lang/spirv/builtin_fn.h
@@ -150,7 +150,8 @@
 
 /// Emits the name of the builtin function type. The spelling, including case,
 /// matches the name in the WGSL spec.
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, BuiltinFn i) {
     return o << str(i);
 }
diff --git a/src/tint/lang/spirv/builtin_fn.h.tmpl b/src/tint/lang/spirv/builtin_fn.h.tmpl
index 157542e..0fef8a4 100644
--- a/src/tint/lang/spirv/builtin_fn.h.tmpl
+++ b/src/tint/lang/spirv/builtin_fn.h.tmpl
@@ -39,7 +39,8 @@
 
 /// Emits the name of the builtin function type. The spelling, including case,
 /// matches the name in the WGSL spec.
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, BuiltinFn i) {
   return o << str(i);
 }
diff --git a/src/tint/lang/spirv/reader/ast_parser/construct.h b/src/tint/lang/spirv/reader/ast_parser/construct.h
index 1a17063..f497f9b 100644
--- a/src/tint/lang/spirv/reader/ast_parser/construct.h
+++ b/src/tint/lang/spirv/reader/ast_parser/construct.h
@@ -197,7 +197,8 @@
 /// @param o the stream
 /// @param c the structured construct
 /// @returns the stream
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<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;
@@ -229,7 +230,8 @@
 /// @param o the stream
 /// @param c the structured construct
 /// @returns the stream
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, const std::unique_ptr<Construct>& c) {
     return o << *(c.get());
 }
@@ -261,7 +263,8 @@
 /// @param o the stream
 /// @param cl the construct list
 /// @returns the stream
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, const ConstructList& cl) {
     o << "ConstructList{\n";
     for (const auto& c : cl) {
diff --git a/src/tint/lang/spirv/reader/ast_parser/function.h b/src/tint/lang/spirv/reader/ast_parser/function.h
index 5ac2cf0..d876621 100644
--- a/src/tint/lang/spirv/reader/ast_parser/function.h
+++ b/src/tint/lang/spirv/reader/ast_parser/function.h
@@ -196,7 +196,8 @@
 /// @param o the stream
 /// @param bi the BlockInfo
 /// @returns the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, const BlockInfo& bi) {
     o << "BlockInfo{" << " id: " << bi.id << " pos: " << bi.pos
       << " merge_for_header: " << bi.merge_for_header
@@ -372,7 +373,8 @@
 /// @param o the stream
 /// @param di the DefInfo
 /// @returns the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<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 9cfd116..1105999 100644
--- a/src/tint/lang/spirv/reader/ast_parser/usage.h
+++ b/src/tint/lang/spirv/reader/ast_parser/usage.h
@@ -144,7 +144,8 @@
 /// @param out the stream
 /// @param u the Usage
 /// @returns the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, const Usage& u) {
     return u.operator<<(out);
 }
diff --git a/src/tint/lang/spirv/writer/common/helper_test.h b/src/tint/lang/spirv/writer/common/helper_test.h
index 68949cb..4b419a3 100644
--- a/src/tint/lang/spirv/writer/common/helper_test.h
+++ b/src/tint/lang/spirv/writer/common/helper_test.h
@@ -58,7 +58,8 @@
     kF32,
     kF16,
 };
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, TestElementType type) {
     switch (type) {
         case kBool:
diff --git a/src/tint/lang/spirv/writer/texture_builtin_test.cc b/src/tint/lang/spirv/writer/texture_builtin_test.cc
index fb70af2..37878a4 100644
--- a/src/tint/lang/spirv/writer/texture_builtin_test.cc
+++ b/src/tint/lang/spirv/writer/texture_builtin_test.cc
@@ -79,7 +79,8 @@
     Vector<const char*, 2> instructions;
 };
 
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, TextureType type) {
     switch (type) {
         case kSampledTexture:
diff --git a/src/tint/lang/wgsl/ast/binary_expression.h b/src/tint/lang/wgsl/ast/binary_expression.h
index 2d49aca..5432079 100644
--- a/src/tint/lang/wgsl/ast/binary_expression.h
+++ b/src/tint/lang/wgsl/ast/binary_expression.h
@@ -287,7 +287,8 @@
 /// @param out the stream to write to
 /// @param op the core::BinaryOp
 /// @return the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, core::BinaryOp op) {
     out << FriendlyName(op);
     return out;
diff --git a/src/tint/lang/wgsl/ast/float_literal_expression.h b/src/tint/lang/wgsl/ast/float_literal_expression.h
index db30537..0dca762 100644
--- a/src/tint/lang/wgsl/ast/float_literal_expression.h
+++ b/src/tint/lang/wgsl/ast/float_literal_expression.h
@@ -77,7 +77,8 @@
 /// @param out the stream to write to
 /// @param suffix the suffix to write
 /// @returns out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, FloatLiteralExpression::Suffix suffix) {
     return out << ToString(suffix);
 }
diff --git a/src/tint/lang/wgsl/ast/int_literal_expression.h b/src/tint/lang/wgsl/ast/int_literal_expression.h
index c95d25d..b77c6c4 100644
--- a/src/tint/lang/wgsl/ast/int_literal_expression.h
+++ b/src/tint/lang/wgsl/ast/int_literal_expression.h
@@ -76,7 +76,8 @@
 /// @param out the stream to write to
 /// @param suffix the suffix to write
 /// @returns out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, IntLiteralExpression::Suffix suffix) {
     return out << ToString(suffix);
 }
diff --git a/src/tint/lang/wgsl/ast/pipeline_stage.h b/src/tint/lang/wgsl/ast/pipeline_stage.h
index 595e4ae..683213c 100644
--- a/src/tint/lang/wgsl/ast/pipeline_stage.h
+++ b/src/tint/lang/wgsl/ast/pipeline_stage.h
@@ -43,7 +43,8 @@
 /// @param out the stream to write to
 /// @param stage the PipelineStage
 /// @return the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, PipelineStage stage) {
     return out << ToString(stage);
 }
diff --git a/src/tint/lang/wgsl/builtin_fn.h b/src/tint/lang/wgsl/builtin_fn.h
index d420455..d752c23 100644
--- a/src/tint/lang/wgsl/builtin_fn.h
+++ b/src/tint/lang/wgsl/builtin_fn.h
@@ -214,7 +214,8 @@
 
 /// Emits the name of the builtin function type. The spelling, including case,
 /// matches the name in the WGSL spec.
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, BuiltinFn i) {
     return o << str(i);
 }
diff --git a/src/tint/lang/wgsl/builtin_fn.h.tmpl b/src/tint/lang/wgsl/builtin_fn.h.tmpl
index 68dc28d..be29cf2 100644
--- a/src/tint/lang/wgsl/builtin_fn.h.tmpl
+++ b/src/tint/lang/wgsl/builtin_fn.h.tmpl
@@ -44,7 +44,8 @@
 
 /// Emits the name of the builtin function type. The spelling, including case,
 /// matches the name in the WGSL spec.
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, BuiltinFn i) {
   return o << str(i);
 }
diff --git a/src/tint/lang/wgsl/diagnostic_rule.h b/src/tint/lang/wgsl/diagnostic_rule.h
index 98e2f55..b6d3efb 100644
--- a/src/tint/lang/wgsl/diagnostic_rule.h
+++ b/src/tint/lang/wgsl/diagnostic_rule.h
@@ -58,7 +58,8 @@
 /// @param out the stream to write to
 /// @param value the CoreDiagnosticRule
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, CoreDiagnosticRule value) {
     return out << ToString(value);
 }
@@ -87,7 +88,8 @@
 /// @param out the stream to write to
 /// @param value the ChromiumDiagnosticRule
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, ChromiumDiagnosticRule value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/wgsl/diagnostic_severity.h b/src/tint/lang/wgsl/diagnostic_severity.h
index df2a49f..75d6bd6 100644
--- a/src/tint/lang/wgsl/diagnostic_severity.h
+++ b/src/tint/lang/wgsl/diagnostic_severity.h
@@ -62,7 +62,8 @@
 /// @param out the stream to write to
 /// @param value the DiagnosticSeverity
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, DiagnosticSeverity value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/wgsl/extension.h b/src/tint/lang/wgsl/extension.h
index 46f6d3c..ae4192b 100644
--- a/src/tint/lang/wgsl/extension.h
+++ b/src/tint/lang/wgsl/extension.h
@@ -66,7 +66,8 @@
 /// @param out the stream to write to
 /// @param value the Extension
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, Extension value) {
     return out << ToString(value);
 }
diff --git a/src/tint/lang/wgsl/intrinsic/ctor_conv.h b/src/tint/lang/wgsl/intrinsic/ctor_conv.h
index 253c1b4..463859d 100644
--- a/src/tint/lang/wgsl/intrinsic/ctor_conv.h
+++ b/src/tint/lang/wgsl/intrinsic/ctor_conv.h
@@ -73,7 +73,8 @@
 /// @param o the stream to write to
 /// @param c the CtorConv
 /// @return the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, CtorConv c) {
     return o << str(c);
 }
diff --git a/src/tint/lang/wgsl/intrinsic/ctor_conv.h.tmpl b/src/tint/lang/wgsl/intrinsic/ctor_conv.h.tmpl
index 9ec7728..d00e6ed 100644
--- a/src/tint/lang/wgsl/intrinsic/ctor_conv.h.tmpl
+++ b/src/tint/lang/wgsl/intrinsic/ctor_conv.h.tmpl
@@ -38,7 +38,8 @@
 /// @param o the stream to write to
 /// @param c the CtorConv
 /// @return the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, CtorConv c) {
   return o << str(c);
 }
diff --git a/src/tint/lang/wgsl/reader/parser/token.h b/src/tint/lang/wgsl/reader/parser/token.h
index 1a8a4f8..e6b1d21 100644
--- a/src/tint/lang/wgsl/reader/parser/token.h
+++ b/src/tint/lang/wgsl/reader/parser/token.h
@@ -370,7 +370,8 @@
     std::variant<int64_t, double, std::string, std::string_view> value_;
 };
 
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<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 4aa6ed6..1992be6 100644
--- a/src/tint/lang/wgsl/resolver/builtin_test.cc
+++ b/src/tint/lang/wgsl/resolver/builtin_test.cc
@@ -2161,7 +2161,8 @@
 namespace texture_builtin_tests {
 
 enum class Texture { kF32, kI32, kU32 };
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, Texture data) {
     if (data == Texture::kF32) {
         out << "f32";
diff --git a/src/tint/lang/wgsl/resolver/resolver_helper_test.h b/src/tint/lang/wgsl/resolver/resolver_helper_test.h
index afe3b37..e2b4282 100644
--- a/src/tint/lang/wgsl/resolver/resolver_helper_test.h
+++ b/src/tint/lang/wgsl/resolver/resolver_helper_test.h
@@ -187,7 +187,8 @@
 /// @param out the stream to write to
 /// @param s the Scalar
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 STREAM& operator<<(STREAM& out, const Scalar& s) {
     std::visit([&](auto&& v) { out << v; }, s.value);
     return out;
diff --git a/src/tint/lang/wgsl/sem/behavior.h b/src/tint/lang/wgsl/sem/behavior.h
index ffb0b79..987c851 100644
--- a/src/tint/lang/wgsl/sem/behavior.h
+++ b/src/tint/lang/wgsl/sem/behavior.h
@@ -53,7 +53,8 @@
 /// @param out the stream to write to
 /// @param behavior the Behavior to write
 /// @returns out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, Behavior behavior) {
     return out << ToString(behavior);
 }
diff --git a/src/tint/lang/wgsl/sem/sampler_texture_pair.h b/src/tint/lang/wgsl/sem/sampler_texture_pair.h
index 5388604..5ab5433 100644
--- a/src/tint/lang/wgsl/sem/sampler_texture_pair.h
+++ b/src/tint/lang/wgsl/sem/sampler_texture_pair.h
@@ -75,7 +75,8 @@
 /// @param o the stream to write to
 /// @param stp the SamplerTexturePair
 /// @return the stream so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<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 dec7156..54443b8 100644
--- a/src/tint/utils/containers/enum_set.h
+++ b/src/tint/utils/containers/enum_set.h
@@ -256,7 +256,8 @@
 /// @param out the stream to write to
 /// @param set the EnumSet to write
 /// @returns out so calls can be chained
-template <typename STREAM, typename ENUM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM, typename ENUM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, EnumSet<ENUM> set) {
     out << "{";
     bool first = true;
diff --git a/src/tint/utils/containers/hashmap.h b/src/tint/utils/containers/hashmap.h
index df1ed08..14c4492 100644
--- a/src/tint/utils/containers/hashmap.h
+++ b/src/tint/utils/containers/hashmap.h
@@ -71,10 +71,8 @@
 /// @param out the stream to write to
 /// @param key_value the HashmapEntry to write
 /// @returns out so calls can be chained
-template <typename STREAM,
-          typename KEY,
-          typename VALUE,
-          typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM, typename KEY, typename VALUE>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, const HashmapEntry<KEY, VALUE>& key_value) {
     return out << "[" << key_value.key << ": " << key_value.value << "]";
 }
@@ -346,13 +344,8 @@
 /// @param out the stream to write to
 /// @param map the Hashmap to write
 /// @returns out so calls can be chained
-template <typename STREAM,
-          typename KEY,
-          typename VALUE,
-          size_t N,
-          typename HASH,
-          typename EQUAL,
-          typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM, typename KEY, typename VALUE, size_t N, typename HASH, typename EQUAL>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, const Hashmap<KEY, VALUE, N, HASH, EQUAL>& map) {
     out << "Hashmap{";
     bool first = true;
diff --git a/src/tint/utils/containers/hashmap_base.h b/src/tint/utils/containers/hashmap_base.h
index 5a4a3b6..979fda2 100644
--- a/src/tint/utils/containers/hashmap_base.h
+++ b/src/tint/utils/containers/hashmap_base.h
@@ -161,7 +161,8 @@
 /// @param out the stream to write to
 /// @param key the HashmapKey to write
 /// @returns out so calls can be chained
-template <typename STREAM, typename T, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM, typename T>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, const HashmapKey<T>& key) {
     if constexpr (traits::HasOperatorShiftLeft<STREAM, T>) {
         return out << key.Value();
diff --git a/src/tint/utils/containers/vector.h b/src/tint/utils/containers/vector.h
index dc5a4a0..18588d3 100644
--- a/src/tint/utils/containers/vector.h
+++ b/src/tint/utils/containers/vector.h
@@ -291,7 +291,8 @@
 /// @param out the stream to write to
 /// @param it the VectorIterator
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename T, bool FORWARD, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM, typename T, bool FORWARD>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, const VectorIterator<T, FORWARD>& it) {
     return out << *it;
 }
@@ -1245,7 +1246,8 @@
 /// @param o the stream to write to
 /// @param vec the vector
 /// @return the stream so calls can be chained
-template <typename STREAM, typename T, size_t N, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM, typename T, size_t N>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, const Vector<T, N>& vec) {
     o << "[";
     bool first = true;
@@ -1264,7 +1266,8 @@
 /// @param o the stream to write to
 /// @param vec the vector reference
 /// @return the stream so calls can be chained
-template <typename STREAM, typename T, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM, typename T>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& o, VectorRef<T> vec) {
     o << "[";
     bool first = true;
diff --git a/src/tint/utils/diagnostic/diagnostic.h b/src/tint/utils/diagnostic/diagnostic.h
index 8c333d6..ede8c38 100644
--- a/src/tint/utils/diagnostic/diagnostic.h
+++ b/src/tint/utils/diagnostic/diagnostic.h
@@ -249,7 +249,8 @@
 /// @param out the output stream
 /// @param failure the Failure
 /// @returns the output stream
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, const Failure& failure) {
     return out << failure.reason.Str();
 }
@@ -258,7 +259,8 @@
 /// @param out the output stream
 /// @param list the list to emit
 /// @returns the output stream
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, const List& list) {
     return out << list.Str();
 }
diff --git a/src/tint/utils/diagnostic/source.h b/src/tint/utils/diagnostic/source.h
index 3336f3a..4747ddd 100644
--- a/src/tint/utils/diagnostic/source.h
+++ b/src/tint/utils/diagnostic/source.h
@@ -242,7 +242,8 @@
 /// @param out the stream to write to
 /// @param loc the location to write
 /// @returns out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, const Source::Location& loc) {
     return out << loc.line << ":" << loc.column;
 }
@@ -251,7 +252,8 @@
 /// @param out the stream to write to
 /// @param range the range to write
 /// @returns out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, const Source::Range& range) {
     return out << "[" << range.begin << ", " << range.end << "]";
 }
@@ -260,7 +262,8 @@
 /// @param out the stream to write to
 /// @param source the source to write
 /// @returns out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, const Source& source) {
     return out << ToString(source);
 }
@@ -269,7 +272,8 @@
 /// @param out the stream to write to
 /// @param content the file content to write
 /// @returns out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, const Source::FileContent& content) {
     return out << content.data;
 }
diff --git a/src/tint/utils/generation_id.h b/src/tint/utils/generation_id.h
index 982d915..238360f 100644
--- a/src/tint/utils/generation_id.h
+++ b/src/tint/utils/generation_id.h
@@ -84,7 +84,8 @@
 /// @param out the stream to write to
 /// @param id the generation identifier to write
 /// @returns out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, GenerationID id) {
     out << "Generation<" << id.Value() << ">";
     return out;
diff --git a/src/tint/utils/result.h b/src/tint/utils/result.h
index 5aa594b..1f07a6c 100644
--- a/src/tint/utils/result.h
+++ b/src/tint/utils/result.h
@@ -60,7 +60,8 @@
 /// @param out the output stream
 /// @param failure the Failure
 /// @returns the output stream
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, const Failure& failure) {
     return out << failure.reason;
 }
@@ -217,10 +218,8 @@
 /// @param out the stream to write to
 /// @param res the result
 /// @return the stream so calls can be chained
-template <typename STREAM,
-          typename SUCCESS,
-          typename FAILURE,
-          typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM, typename SUCCESS, typename FAILURE>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, const Result<SUCCESS, FAILURE>& res) {
     if (res == Success) {
         if constexpr (traits::HasOperatorShiftLeft<STREAM&, SUCCESS>) {
diff --git a/src/tint/utils/rtti/traits.h b/src/tint/utils/rtti/traits.h
index 383a64d..00d3473 100644
--- a/src/tint/utils/rtti/traits.h
+++ b/src/tint/utils/rtti/traits.h
@@ -34,6 +34,11 @@
 #include <type_traits>
 #include <utility>
 
+// Predeclarations
+namespace tint {
+class StringStream;
+}
+
 namespace tint::traits {
 
 /// Convience type definition for std::decay<T>::type
@@ -222,35 +227,9 @@
 ////////////////////////////////////////////////////////////////////////////////
 // IsOStream
 ////////////////////////////////////////////////////////////////////////////////
-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 = std::enable_if_t<IsOStream<T>, T>;
+concept IsOStream = std::is_same_v<T, std::ostream> || std::is_same_v<T, std::stringstream> ||
+                    std::is_same_v<T, tint::StringStream>;
 
 ////////////////////////////////////////////////////////////////////////////////
 // HasOperatorShiftLeft
diff --git a/src/tint/utils/templates/enums.tmpl.inc b/src/tint/utils/templates/enums.tmpl.inc
index 45f3945..f0079a3 100644
--- a/src/tint/utils/templates/enums.tmpl.inc
+++ b/src/tint/utils/templates/enums.tmpl.inc
@@ -84,7 +84,8 @@
 /// @param out the stream to write to
 /// @param value the {{$name}}
 /// @returns @p out so calls can be chained
-template <typename STREAM, typename = traits::EnableIfIsOStream<STREAM>>
+template <typename STREAM>
+    requires(traits::IsOStream<STREAM>)
 auto& operator<<(STREAM& out, {{$name}} value) {
     return out << ToString(value);
 }
diff --git a/src/tint/utils/text/string_stream.h b/src/tint/utils/text/string_stream.h
index 9b3e20c..49b3d2f 100644
--- a/src/tint/utils/text/string_stream.h
+++ b/src/tint/utils/text/string_stream.h
@@ -56,9 +56,6 @@
                                       std::is_same_v<SetFillRetTy, std::decay_t<T>>;
 
   public:
-    /// @see tint::traits::IsOStream
-    static constexpr bool IsStreamWriter = true;
-
     /// Constructor
     StringStream();
     /// Copy constructor