diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs
index 173d999b06d..9ac93147651 100644
--- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs
+++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommand.cs
@@ -15,6 +15,10 @@ namespace Microsoft.PowerShell.Commands
/// The ConvertTo-Json command.
/// This command converts an object to a Json string representation.
///
+ ///
+ /// This class is hidden when PSJsonSerializerV2 experimental feature is enabled.
+ ///
+ [Experimental(ExperimentalFeature.PSJsonSerializerV2, ExperimentAction.Hide)]
[Cmdlet(VerbsData.ConvertTo, "Json", HelpUri = "https://go.microsoft.com/fwlink/?LinkID=2096925", RemotingCapability = RemotingCapability.None)]
[OutputType(typeof(string))]
public class ConvertToJsonCommand : PSCmdlet, IDisposable
diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
new file mode 100644
index 00000000000..f7567a4bea1
--- /dev/null
+++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
@@ -0,0 +1,817 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#nullable enable
+
+using System;
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Management.Automation;
+using System.Management.Automation.Internal;
+using System.Numerics;
+using System.Reflection;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using System.Text.Unicode;
+using System.Threading;
+
+namespace Microsoft.PowerShell.Commands
+{
+ ///
+ /// Specifies how strings are escaped when writing JSON text.
+ ///
+ ///
+ /// The numeric values must match Newtonsoft.Json.StringEscapeHandling for backward compatibility.
+ /// Do not change these values.
+ ///
+ public enum JsonStringEscapeHandling
+ {
+ ///
+ /// Only control characters (e.g. newline) are escaped.
+ ///
+ Default = 0,
+
+ ///
+ /// All non-ASCII and control characters are escaped.
+ ///
+ EscapeNonAscii = 1,
+
+ ///
+ /// HTML (<, >, &, ', ") and control characters are escaped.
+ ///
+ EscapeHtml = 2,
+ }
+
+ ///
+ /// Transforms Newtonsoft.Json.StringEscapeHandling to JsonStringEscapeHandling for backward compatibility.
+ ///
+ internal sealed class StringEscapeHandlingTransformationAttribute : ArgumentTransformationAttribute
+ {
+ public override object Transform(EngineIntrinsics engineIntrinsics, object inputData)
+ => inputData is Newtonsoft.Json.StringEscapeHandling newtonsoftValue ? (JsonStringEscapeHandling)(int)newtonsoftValue : inputData;
+ }
+
+ ///
+ /// The ConvertTo-Json command.
+ /// This command converts an object to a JSON string representation.
+ ///
+ ///
+ /// This class is shown when PSJsonSerializerV2 experimental feature is enabled.
+ ///
+ [Experimental(ExperimentalFeature.PSJsonSerializerV2, ExperimentAction.Show)]
+ [Cmdlet(VerbsData.ConvertTo, "Json", HelpUri = "https://go.microsoft.com/fwlink/?LinkID=2096925", RemotingCapability = RemotingCapability.None)]
+ [OutputType(typeof(string))]
+ public class ConvertToJsonCommandV2 : PSCmdlet
+ {
+ ///
+ /// Gets or sets the InputObject property.
+ ///
+ [Parameter(Position = 0, Mandatory = true, ValueFromPipeline = true)]
+ [AllowNull]
+ public object? InputObject { get; set; }
+
+ ///
+ /// Gets or sets the Depth property.
+ /// Default is 2. Maximum allowed is 100.
+ /// Use 0 to serialize only top-level properties.
+ ///
+ [Parameter]
+ [ValidateRange(0, 100)]
+ public int Depth { get; set; } = 2;
+
+ ///
+ /// Gets or sets the Compress property.
+ /// If the Compress property is set to be true, the Json string will
+ /// be output in the compressed way. Otherwise, the Json string will
+ /// be output with indentations.
+ ///
+ [Parameter]
+ public SwitchParameter Compress { get; set; }
+
+ ///
+ /// Gets or sets the EnumsAsStrings property.
+ /// If the EnumsAsStrings property is set to true, enum values will
+ /// be converted to their string equivalent. Otherwise, enum values
+ /// will be converted to their numeric equivalent.
+ ///
+ [Parameter]
+ public SwitchParameter EnumsAsStrings { get; set; }
+
+ ///
+ /// Gets or sets the AsArray property.
+ /// If the AsArray property is set to be true, the result JSON string will
+ /// be returned with surrounding '[', ']' chars. Otherwise,
+ /// the array symbols will occur only if there is more than one input object.
+ ///
+ [Parameter]
+ public SwitchParameter AsArray { get; set; }
+
+ ///
+ /// Gets or sets how strings are escaped when writing JSON text.
+ /// If the EscapeHandling property is set to EscapeHtml, the result JSON string will
+ /// be returned with HTML (<, >, &, ', ") and control characters (e.g. newline) are escaped.
+ ///
+ [Parameter]
+ [StringEscapeHandlingTransformation]
+ public JsonStringEscapeHandling EscapeHandling { get; set; } = JsonStringEscapeHandling.Default;
+
+ private readonly List _inputObjects = new();
+
+ ///
+ /// Caching the input objects for the command.
+ ///
+ protected override void ProcessRecord()
+ {
+ _inputObjects.Add(InputObject);
+ }
+
+ ///
+ /// Do the conversion to json and write output.
+ ///
+ protected override void EndProcessing()
+ {
+ if (_inputObjects.Count > 0)
+ {
+ object? objectToProcess = (_inputObjects.Count > 1 || AsArray) ? _inputObjects : _inputObjects[0];
+
+ string? output = SystemTextJsonSerializer.ConvertToJson(
+ objectToProcess,
+ Depth,
+ EnumsAsStrings.IsPresent,
+ Compress.IsPresent,
+ EscapeHandling,
+ this,
+ PipelineStopToken);
+
+ // null is returned only if the pipeline is stopping (e.g. ctrl+c is signaled).
+ // in that case, we shouldn't write the null to the output pipe.
+ if (output is not null)
+ {
+ WriteObject(output);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Provides JSON serialization using System.Text.Json with PowerShell-specific handling.
+ ///
+ internal static class SystemTextJsonSerializer
+ {
+ ///
+ /// Convert an object to JSON string using System.Text.Json.
+ ///
+ public static string? ConvertToJson(
+ object? objectToProcess,
+ int maxDepth,
+ bool enumsAsStrings,
+ bool compressOutput,
+ JsonStringEscapeHandling stringEscapeHandling,
+ PSCmdlet? cmdlet,
+ CancellationToken cancellationToken)
+ {
+ if (objectToProcess is null)
+ {
+ return "null";
+ }
+
+ try
+ {
+ var options = new JsonSerializerOptions()
+ {
+ WriteIndented = !compressOutput,
+
+ // Set high value to avoid System.Text.Json exceptions
+ // User-specified depth is enforced by JsonConverterPSObject (max 100 via ValidateRange)
+ MaxDepth = 1000,
+ DefaultIgnoreCondition = JsonIgnoreCondition.Never,
+ Encoder = GetEncoder(stringEscapeHandling),
+ };
+
+ if (enumsAsStrings)
+ {
+ options.Converters.Add(new JsonStringEnumConverter());
+ }
+
+ // Add custom converters for PowerShell-specific types
+ if (!ExperimentalFeature.IsEnabled(ExperimentalFeature.PSSerializeJSONLongEnumAsNumber))
+ {
+ options.Converters.Add(new JsonConverterInt64Enum());
+ }
+
+ options.Converters.Add(new JsonConverterBigInteger());
+ options.Converters.Add(new JsonConverterDouble());
+ options.Converters.Add(new JsonConverterFloat());
+ options.Converters.Add(new JsonConverterType());
+ options.Converters.Add(new JsonConverterNullString());
+ options.Converters.Add(new JsonConverterDBNull());
+ options.Converters.Add(new JsonConverterPSObject(cmdlet, maxDepth, basePropertiesOnly: false));
+ options.Converters.Add(new JsonConverterRawObject(cmdlet, maxDepth));
+ options.Converters.Add(new JsonConverterJObject());
+
+ // Distinguish between PSObject (Extended/Adapted properties) and raw object (Base only)
+ if (objectToProcess is PSObject pso)
+ {
+ return System.Text.Json.JsonSerializer.Serialize(pso, typeof(PSObject), options);
+ }
+ else
+ {
+ var wrapper = new RawObjectWrapper(objectToProcess);
+ return System.Text.Json.JsonSerializer.Serialize(wrapper, typeof(RawObjectWrapper), options);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ return null;
+ }
+ }
+
+ private static JavaScriptEncoder GetEncoder(JsonStringEscapeHandling escapeHandling) =>
+ escapeHandling switch
+ {
+ JsonStringEscapeHandling.Default => JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ JsonStringEscapeHandling.EscapeNonAscii => JavaScriptEncoder.Default,
+ JsonStringEscapeHandling.EscapeHtml => JavaScriptEncoder.Create(UnicodeRanges.BasicLatin),
+ _ => JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+ }
+
+ ///
+ /// Custom JsonConverter for PSObject that handles PowerShell-specific serialization.
+ ///
+ internal sealed class JsonConverterPSObject : System.Text.Json.Serialization.JsonConverter
+ {
+ private readonly PSCmdlet? _cmdlet;
+ private readonly int _maxDepth;
+ private readonly bool _basePropertiesOnly;
+
+ private bool _warningWritten;
+
+ public JsonConverterPSObject(PSCmdlet? cmdlet, int maxDepth, bool basePropertiesOnly)
+ {
+ _cmdlet = cmdlet;
+ _maxDepth = maxDepth;
+ _basePropertiesOnly = basePropertiesOnly;
+ }
+
+ public override PSObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void Write(Utf8JsonWriter writer, PSObject? pso, JsonSerializerOptions options)
+ {
+ if (LanguagePrimitives.IsNull(pso))
+ {
+ writer.WriteNullValue();
+ return;
+ }
+
+ object? obj = pso.BaseObject;
+
+ // Handle special types - check for null-like objects (no depth increment needed)
+ if (obj is null || obj is DBNull or System.Management.Automation.Language.NullString)
+ {
+ // Single enumeration: write properties directly as we find them
+ var etsProperties = new PSMemberInfoIntegratingCollection(
+ pso,
+ PSObject.GetPropertyCollection(PSMemberViewTypes.Extended));
+
+ bool isNull = true;
+ foreach (var prop in etsProperties)
+ {
+ if (JsonSerializerHelper.ShouldSkipProperty(prop))
+ {
+ continue;
+ }
+
+ if (isNull)
+ {
+ writer.WriteStartObject();
+ writer.WritePropertyName("value");
+ writer.WriteNullValue();
+ isNull = false;
+ }
+
+ WriteProperty(writer, prop, options);
+ }
+
+ if (isNull)
+ {
+ writer.WriteNullValue();
+ }
+ else
+ {
+ writer.WriteEndObject();
+ }
+
+ return;
+ }
+
+ // Handle Newtonsoft.Json.Linq.JObject by delegating to JsonConverterJObject
+ if (obj is Newtonsoft.Json.Linq.JObject jObject)
+ {
+ System.Text.Json.JsonSerializer.Serialize(writer, jObject, options);
+ return;
+ }
+
+ // If STJ natively serializes this type as scalar, use STJ directly (no depth increment)
+ if (JsonSerializerHelper.IsStjNativeScalarType(obj))
+ {
+ JsonSerializerHelper.SerializePrimitive(writer, obj, options);
+ return;
+ }
+
+ // Check depth limit for complex types only (after scalar type check)
+ if (writer.CurrentDepth > _maxDepth)
+ {
+ WriteDepthExceeded(writer, pso, obj);
+ return;
+ }
+
+ // For dictionaries, collections, and custom objects
+ if (obj is IDictionary dict)
+ {
+ SerializeDictionary(writer, pso, dict, options);
+ }
+ else if (obj is IEnumerable enumerable)
+ {
+ SerializeEnumerable(writer, enumerable, options);
+ }
+ else
+ {
+ // For custom objects, serialize as dictionary with properties
+ SerializeAsObject(writer, pso, options);
+ }
+ }
+
+ private void WriteDepthExceeded(Utf8JsonWriter writer, PSObject pso, object obj)
+ {
+ // Write warning once
+ if (!_warningWritten && _cmdlet is not null)
+ {
+ _warningWritten = true;
+ string warningMessage = string.Format(
+ CultureInfo.CurrentCulture,
+ "Resulting JSON is truncated as serialization has exceeded the set depth of {0}.",
+ _maxDepth);
+ _cmdlet.WriteWarning(warningMessage);
+ }
+
+ // Convert to string when depth exceeded
+ string stringValue = LanguagePrimitives.ConvertTo(pso.ImmediateBaseObjectIsEmpty ? pso : obj);
+ writer.WriteStringValue(stringValue);
+ }
+
+ private void SerializeEnumerable(Utf8JsonWriter writer, IEnumerable enumerable, JsonSerializerOptions options)
+ {
+ writer.WriteStartArray();
+ foreach (var item in enumerable)
+ {
+ WriteValue(writer, item, options);
+ }
+
+ writer.WriteEndArray();
+ }
+
+ private void SerializeDictionary(Utf8JsonWriter writer, PSObject pso, IDictionary dict, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+
+ // Serialize dictionary entries
+ foreach (DictionaryEntry entry in dict)
+ {
+ string key = entry.Key?.ToString() ?? string.Empty;
+ writer.WritePropertyName(key);
+ WriteValue(writer, entry.Value, options);
+ }
+
+ // Add PSObject extended properties (skip for base properties only mode)
+ if (!_basePropertiesOnly)
+ {
+ AppendPSProperties(writer, pso, options, PSMemberViewTypes.Extended);
+ }
+
+ writer.WriteEndObject();
+ }
+
+ private void WriteValue(Utf8JsonWriter writer, object? value, JsonSerializerOptions options)
+ {
+ // Handle null values (including AutomationNull)
+ if (LanguagePrimitives.IsNull(value) || value is DBNull)
+ {
+ writer.WriteNullValue();
+ }
+ else if (value is Newtonsoft.Json.Linq.JObject jObject)
+ {
+ System.Text.Json.JsonSerializer.Serialize(writer, jObject, options);
+ }
+ else if (value is PSObject psoValue)
+ {
+ // Existing PSObject: use PSObject serialization (Extended/Adapted properties)
+ Write(writer, psoValue, options);
+ }
+ else
+ {
+ // Raw object: check if STJ natively handles this type
+ if (JsonSerializerHelper.IsStjNativeScalarType(value))
+ {
+ // STJ handles this type natively as scalar
+ JsonSerializerHelper.SerializePrimitive(writer, value, options);
+ }
+ else
+ {
+ // Not a native scalar type - delegate to JsonConverterRawObject (Base properties only)
+ System.Text.Json.JsonSerializer.Serialize(writer, new RawObjectWrapper(value), typeof(RawObjectWrapper), options);
+ }
+ }
+ }
+
+ private void SerializeAsObject(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ if (_basePropertiesOnly)
+ {
+ // Base properties only: use Adapted view and filter to Property type
+ AppendPSProperties(writer, pso, options, PSMemberViewTypes.Adapted, filterToPropertyOnly: true);
+ }
+ else
+ {
+ AppendPSProperties(writer, pso, options, PSMemberViewTypes.Extended | PSMemberViewTypes.Adapted);
+ }
+
+ writer.WriteEndObject();
+ }
+
+ private void AppendPSProperties(
+ Utf8JsonWriter writer,
+ PSObject pso,
+ JsonSerializerOptions options,
+ PSMemberViewTypes memberTypes,
+ bool filterToPropertyOnly = false)
+ {
+ var properties = new PSMemberInfoIntegratingCollection(
+ pso,
+ PSObject.GetPropertyCollection(memberTypes));
+
+ foreach (var prop in properties)
+ {
+ // Filter to only Property type if requested (excludes CodeProperty, ScriptProperty, etc.)
+ if (filterToPropertyOnly && prop.MemberType != PSMemberTypes.Property)
+ {
+ continue;
+ }
+
+ if (JsonSerializerHelper.ShouldSkipProperty(prop))
+ {
+ continue;
+ }
+
+ WriteProperty(writer, prop, options);
+ }
+ }
+
+ private void WriteProperty(Utf8JsonWriter writer, PSPropertyInfo prop, JsonSerializerOptions options)
+ {
+ try
+ {
+ var value = prop.Value;
+ writer.WritePropertyName(prop.Name);
+
+ // If maxDepth is 0 and value is non-null non-scalar, convert to string
+ if (_maxDepth == 0 && value is not null && !JsonSerializerHelper.IsStjNativeScalarType(value))
+ {
+ writer.WriteStringValue(value.ToString());
+ }
+ else
+ {
+ WriteValue(writer, value, options);
+ }
+ }
+ catch
+ {
+ // Skip properties that throw on access - write nothing for this property
+ }
+ }
+ }
+
+ ///
+ /// JsonConverter for Int64/UInt64 enums to avoid JavaScript precision issues.
+ ///
+ internal sealed class JsonConverterInt64Enum : System.Text.Json.Serialization.JsonConverter
+ {
+ public override bool CanConvert(Type typeToConvert)
+ {
+ if (!typeToConvert.IsEnum)
+ {
+ return false;
+ }
+
+ var underlyingType = Enum.GetUnderlyingType(typeToConvert);
+ return underlyingType == typeof(long) || underlyingType == typeof(ulong);
+ }
+
+ public override Enum? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void Write(Utf8JsonWriter writer, Enum value, JsonSerializerOptions options)
+ {
+ // Convert to string to avoid JavaScript precision issues with large integers
+ writer.WriteStringValue(value.ToString("D"));
+ }
+ }
+
+ ///
+ /// JsonConverter for NullString to serialize as null.
+ ///
+ internal sealed class JsonConverterNullString : System.Text.Json.Serialization.JsonConverter
+ {
+ public override System.Management.Automation.Language.NullString? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void Write(Utf8JsonWriter writer, System.Management.Automation.Language.NullString value, JsonSerializerOptions options)
+ {
+ writer.WriteNullValue();
+ }
+ }
+
+ ///
+ /// JsonConverter for DBNull to serialize as null.
+ ///
+ internal sealed class JsonConverterDBNull : System.Text.Json.Serialization.JsonConverter
+ {
+ public override DBNull? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void Write(Utf8JsonWriter writer, DBNull value, JsonSerializerOptions options)
+ {
+ writer.WriteNullValue();
+ }
+ }
+
+ ///
+ /// JsonConverter for BigInteger to serialize as number string.
+ ///
+ internal sealed class JsonConverterBigInteger : System.Text.Json.Serialization.JsonConverter
+ {
+ public override BigInteger Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void Write(Utf8JsonWriter writer, BigInteger value, JsonSerializerOptions options)
+ {
+ // Write as number string to preserve precision
+ writer.WriteRawValue(value.ToString(CultureInfo.InvariantCulture));
+ }
+ }
+
+ ///
+ /// JsonConverter for double to serialize Infinity and NaN as strings for V1 compatibility.
+ ///
+ internal sealed class JsonConverterDouble : System.Text.Json.Serialization.JsonConverter
+ {
+ public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
+ {
+ if (double.IsPositiveInfinity(value))
+ {
+ writer.WriteStringValue("Infinity");
+ }
+ else if (double.IsNegativeInfinity(value))
+ {
+ writer.WriteStringValue("-Infinity");
+ }
+ else if (double.IsNaN(value))
+ {
+ writer.WriteStringValue("NaN");
+ }
+ else
+ {
+ writer.WriteNumberValue(value);
+ }
+ }
+ }
+
+ ///
+ /// JsonConverter for float to serialize Infinity and NaN as strings for V1 compatibility.
+ ///
+ internal sealed class JsonConverterFloat : System.Text.Json.Serialization.JsonConverter
+ {
+ public override float Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void Write(Utf8JsonWriter writer, float value, JsonSerializerOptions options)
+ {
+ if (float.IsPositiveInfinity(value))
+ {
+ writer.WriteStringValue("Infinity");
+ }
+ else if (float.IsNegativeInfinity(value))
+ {
+ writer.WriteStringValue("-Infinity");
+ }
+ else if (float.IsNaN(value))
+ {
+ writer.WriteStringValue("NaN");
+ }
+ else
+ {
+ writer.WriteNumberValue(value);
+ }
+ }
+ }
+
+ ///
+ /// JsonConverter for System.Type to serialize as AssemblyQualifiedName string for V1 compatibility.
+ ///
+ internal sealed class JsonConverterType : System.Text.Json.Serialization.JsonConverter
+ {
+ public override bool CanConvert(Type typeToConvert)
+ {
+ // Handle Type and all derived types (e.g., RuntimeType)
+ return typeof(Type).IsAssignableFrom(typeToConvert);
+ }
+
+ public override Type Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void Write(Utf8JsonWriter writer, Type value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.AssemblyQualifiedName);
+ }
+ }
+
+ ///
+ /// Custom JsonConverter for Newtonsoft.Json.Linq.JObject to isolate Newtonsoft-related code.
+ ///
+ internal sealed class JsonConverterJObject : System.Text.Json.Serialization.JsonConverter
+ {
+ public override Newtonsoft.Json.Linq.JObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void Write(Utf8JsonWriter writer, Newtonsoft.Json.Linq.JObject jObject, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ foreach (var prop in jObject.Properties())
+ {
+ writer.WritePropertyName(prop.Name);
+ WriteJTokenValue(writer, prop.Value, options);
+ }
+
+ writer.WriteEndObject();
+ }
+
+ private static void WriteJTokenValue(Utf8JsonWriter writer, Newtonsoft.Json.Linq.JToken token, JsonSerializerOptions options)
+ {
+ var value = token.Type switch
+ {
+ Newtonsoft.Json.Linq.JTokenType.String => token.ToObject(),
+ Newtonsoft.Json.Linq.JTokenType.Integer => token.ToObject(),
+ Newtonsoft.Json.Linq.JTokenType.Float => token.ToObject(),
+ Newtonsoft.Json.Linq.JTokenType.Boolean => token.ToObject(),
+ Newtonsoft.Json.Linq.JTokenType.Null => (object?)null,
+ _ => token.ToString(),
+ };
+ System.Text.Json.JsonSerializer.Serialize(writer, value, options);
+ }
+ }
+
+ ///
+ /// Wrapper class for raw .NET objects to distinguish them from PSObjects at the type level.
+ /// Inherits from PSObject to avoid rewrapping when passed to JsonConverterPSObject.
+ ///
+ internal sealed class RawObjectWrapper : PSObject
+ {
+ public RawObjectWrapper(object value) : base(value)
+ {
+ }
+ }
+
+ ///
+ /// Custom JsonConverter for RawObjectWrapper that delegates to JsonConverterPSObject with base properties only.
+ ///
+ ///
+ /// This converter wraps raw objects (non-PSObject) and serializes them with only base .NET properties,
+ /// excluding PowerShell extended/adapted properties. This maintains V1 compatibility for nested raw objects.
+ ///
+ internal sealed class JsonConverterRawObject : System.Text.Json.Serialization.JsonConverter
+ {
+ private readonly JsonConverterPSObject _psoConverter;
+
+ public JsonConverterRawObject(PSCmdlet? cmdlet, int maxDepth)
+ {
+ _psoConverter = new JsonConverterPSObject(cmdlet, maxDepth, basePropertiesOnly: true);
+ }
+
+ public override RawObjectWrapper? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void Write(Utf8JsonWriter writer, RawObjectWrapper wrapper, JsonSerializerOptions options)
+ {
+ // RawObjectWrapper inherits from PSObject, so no rewrapping needed
+ _psoConverter.Write(writer, wrapper, options);
+ }
+ }
+
+ ///
+ /// Shared helper methods for JSON serialization.
+ ///
+ internal static class JsonSerializerHelper
+ {
+ private static readonly ConcurrentDictionary s_stjNativeScalarTypeCache = new();
+
+ ///
+ /// Determines if STJ natively serializes the type as a scalar (string, number, boolean).
+ /// Uses JsonTypeInfoKind to classify types. Results are cached per type for performance.
+ ///
+ public static bool IsStjNativeScalarType(object obj)
+ {
+ var type = obj.GetType();
+
+ // Special cases: types that need custom handling but should be treated as scalars
+ // BigInteger: STJ serializes as object, but V1 serializes as number
+ if (type == typeof(BigInteger))
+ {
+ return true;
+ }
+
+ // System.Type: STJ reports JsonTypeInfoKind.None but cannot serialize it
+ // Use JsonConverterType to serialize as AssemblyQualifiedName (V1 compatibility)
+ if (typeof(Type).IsAssignableFrom(type))
+ {
+ return true;
+ }
+
+ // Infinity/NaN: STJ throws, but V1 serializes as string
+ if (obj is double d && (double.IsInfinity(d) || double.IsNaN(d)))
+ {
+ return true;
+ }
+
+ if (obj is float f && (float.IsInfinity(f) || float.IsNaN(f)))
+ {
+ return true;
+ }
+
+ return s_stjNativeScalarTypeCache.GetOrAdd(type, static t =>
+ {
+ var typeInfo = JsonSerializerOptions.Default.GetTypeInfo(t);
+ return typeInfo.Kind == JsonTypeInfoKind.None;
+ });
+ }
+
+ public static void SerializePrimitive(Utf8JsonWriter writer, object obj, JsonSerializerOptions options)
+ {
+ // Delegate to STJ - custom converters handle special cases:
+ // - JsonConverterBigInteger: BigInteger as number string
+ // - JsonConverterDouble/Float: Infinity/NaN as string
+ // - JsonConverterType: Type as AssemblyQualifiedName
+ System.Text.Json.JsonSerializer.Serialize(writer, obj, obj.GetType(), options);
+ }
+
+ public static bool ShouldSkipProperty(PSPropertyInfo prop)
+ {
+ // Check for Hidden attribute
+ if (prop.IsHidden)
+ {
+ return true;
+ }
+
+ // Check for JsonIgnoreAttribute on the underlying member
+ if (prop is PSProperty psProperty)
+ {
+ if (psProperty.adapterData is MemberInfo memberInfo &&
+ memberInfo.GetCustomAttribute() is not null)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs
index 7e17ec43137..c3098dee703 100644
--- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs
+++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs
@@ -22,6 +22,7 @@ public class ExperimentalFeature
internal const string EngineSource = "PSEngine";
internal const string PSSerializeJSONLongEnumAsNumber = nameof(PSSerializeJSONLongEnumAsNumber);
internal const string PSProfileDSCResource = "PSProfileDSCResource";
+ internal const string PSJsonSerializerV2 = nameof(PSJsonSerializerV2);
#endregion
@@ -111,6 +112,10 @@ static ExperimentalFeature()
name: PSSerializeJSONLongEnumAsNumber,
description: "Serialize enums based on long or ulong as an numeric value rather than the string representation when using ConvertTo-Json."
),
+ new ExperimentalFeature(
+ name: PSJsonSerializerV2,
+ description: "Use System.Text.Json for ConvertTo-Json with V1-compatible behavior and support for non-string dictionary keys."
+ ),
new ExperimentalFeature(
name: PSProfileDSCResource,
description: "DSC v3 resources for managing PowerShell profile."
diff --git a/src/System.Management.Automation/engine/LanguagePrimitives.cs b/src/System.Management.Automation/engine/LanguagePrimitives.cs
index 13d8f66e5e9..4ac2e07a7e9 100644
--- a/src/System.Management.Automation/engine/LanguagePrimitives.cs
+++ b/src/System.Management.Automation/engine/LanguagePrimitives.cs
@@ -1056,10 +1056,12 @@ internal static bool IsTrue(IList objectArray)
///
/// The object to test.
/// True if the object is null.
- internal static bool IsNull(object obj)
+#nullable enable
+ internal static bool IsNull([NotNullWhen(false)] object? obj)
{
- return (obj == null || obj == AutomationNull.Value);
+ return obj is null || obj == AutomationNull.Value;
}
+#nullable restore
///
/// Auxiliary for the cases where we want a new PSObject or null.
diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1
new file mode 100644
index 00000000000..3d54ea083c9
--- /dev/null
+++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1
@@ -0,0 +1,73 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+Describe 'ConvertTo-Json PSJsonSerializerV2 specific behavior' -Tags "CI" {
+
+ BeforeAll {
+ $skipTest = -not $EnabledExperimentalFeatures.Contains('PSJsonSerializerV2')
+
+ if ($skipTest) {
+ Write-Verbose "Test Suite Skipped. The test suite requires the experimental feature 'PSJsonSerializerV2' to be enabled." -Verbose
+ $originalDefaultParameterValues = $PSDefaultParameterValues.Clone()
+ $PSDefaultParameterValues["it:skip"] = $true
+ }
+ }
+
+ AfterAll {
+ if ($skipTest) {
+ $global:PSDefaultParameterValues = $originalDefaultParameterValues
+ }
+ }
+
+ It 'Should serialize dictionary with integer keys' {
+ $dict = @{ 1 = "one"; 2 = "two" }
+ $json = $dict | ConvertTo-Json -Compress
+ $json | Should -BeIn @('{"1":"one","2":"two"}', '{"2":"two","1":"one"}')
+ }
+
+ It 'Should serialize dictionary with non-string keys converting keys to strings' {
+ $dict = [System.Collections.Hashtable]::new()
+ $dict.Add(1, "one")
+ $dict.Add([guid]"12345678-1234-1234-1234-123456789abc", "guid-value")
+ $json = $dict | ConvertTo-Json -Compress
+ $json | Should -Match '"1":\s*"one"'
+ $json | Should -Match '"12345678-1234-1234-1234-123456789abc":\s*"guid-value"'
+ }
+
+ It 'Should not serialize hidden properties in PowerShell class' {
+ class TestHiddenClass {
+ [string] $Visible
+ hidden [string] $Hidden
+ }
+ $obj = [TestHiddenClass]::new()
+ $obj.Visible = "yes"
+ $obj.Hidden = "no"
+ $json = $obj | ConvertTo-Json -Compress
+ $json | Should -Match 'Visible'
+ $json | Should -Not -Match 'Hidden'
+ }
+
+ # V2 improvement: Guid is serialized consistently (V1 had inconsistency between Pipeline and InputObject)
+ It 'Should serialize Guid as string consistently' {
+ $guid = [guid]"12345678-1234-1234-1234-123456789abc"
+ $jsonPipeline = $guid | ConvertTo-Json -Compress
+ $jsonInputObject = ConvertTo-Json -InputObject $guid -Compress
+ $jsonPipeline | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"'
+ $jsonInputObject | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"'
+ }
+
+ # V2 design: Array elements use Base properties only (consistent behavior)
+ # This differs from V1 where array elements sometimes had Extended properties
+ Context 'Array element serialization' {
+ It 'Should serialize array elements with Base properties only' {
+ $file = Get-Item $PSHOME
+ $pso = [PSCustomObject]@{ Items = @($file) }
+
+ $json = $pso | ConvertTo-Json -Depth 2 -Compress
+ # PSPath is an Extended property - V2 uses Base properties for array elements
+ $json | Should -Not -Match 'PSPath'
+ # Name is a Base property - should be present
+ $json | Should -Match '"Name"'
+ }
+ }
+}
diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1
index 1f2abe05c68..b8fcadfadce 100644
--- a/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1
+++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.Tests.ps1
@@ -156,4 +156,204 @@ Describe 'ConvertTo-Json' -tags "CI" {
$actual = ConvertTo-Json -Compress -InputObject $obj
$actual | Should -Be '{"Positive":18446744073709551615,"Negative":-18446744073709551615}'
}
+
+ It 'Should serialize special floating-point values as strings' {
+ [double]::PositiveInfinity | ConvertTo-Json | Should -BeExactly '"Infinity"'
+ [double]::NegativeInfinity | ConvertTo-Json | Should -BeExactly '"-Infinity"'
+ [double]::NaN | ConvertTo-Json | Should -BeExactly '"NaN"'
+ }
+
+ It 'Should return null for empty array' {
+ @() | ConvertTo-Json | Should -BeNull
+ }
+
+ It 'Should serialize SwitchParameter as object with IsPresent property' {
+ @{ flag = [switch]$true } | ConvertTo-Json -Compress | Should -BeExactly '{"flag":{"IsPresent":true}}'
+ @{ flag = [switch]$false } | ConvertTo-Json -Compress | Should -BeExactly '{"flag":{"IsPresent":false}}'
+ }
+
+ It 'Should serialize Uri correctly' {
+ $uri = [uri]"https://example.com/path"
+ $json = $uri | ConvertTo-Json -Compress
+ $json | Should -BeExactly '"https://example.com/path"'
+ }
+
+ It 'Should serialize enums ' -TestCases @(
+ @{ description = 'as numbers by default'; params = @{}; expected = '1' }
+ @{ description = 'as strings with -EnumsAsStrings'; params = @{ EnumsAsStrings = $true }; expected = '"Monday"' }
+ ) {
+ param($description, $params, $expected)
+ [System.DayOfWeek]::Monday | ConvertTo-Json @params | Should -BeExactly $expected
+ }
+
+ It 'Should serialize nested PSCustomObject correctly' {
+ $obj = [pscustomobject]@{
+ name = "test"
+ child = [pscustomobject]@{
+ value = 42
+ }
+ }
+ $json = $obj | ConvertTo-Json -Compress
+ $json | Should -BeExactly '{"name":"test","child":{"value":42}}'
+ }
+
+ It 'Should not escape HTML tag characters by default' {
+ $json = @{ text = '<>&' } | ConvertTo-Json -Compress
+ $json | Should -BeExactly '{"text":"<>&"}'
+ }
+
+ It 'Should escape with -EscapeHandling ' -TestCases @(
+ @{ description = 'HTML tag characters'; inputText = '<>&'; EscapeHandling = 'EscapeHtml'; pattern = '\\u003C.*\\u003E.*\\u0026' }
+ @{ description = 'non-ASCII characters'; inputText = '日本語'; EscapeHandling = 'EscapeNonAscii'; pattern = '\\u' }
+ ) {
+ param($description, $inputText, $EscapeHandling, $pattern)
+ $json = @{ text = $inputText } | ConvertTo-Json -Compress -EscapeHandling $EscapeHandling
+ $json | Should -Match $pattern
+ }
+
+ It 'Depth over 100 should throw' {
+ { ConvertTo-Json -InputObject @{a=1} -Depth 101 } | Should -Throw
+ }
+
+ Context 'Nested raw object serialization' {
+ BeforeAll {
+ class TestClassWithFileInfo {
+ [System.IO.FileInfo]$File
+ }
+
+ $script:testFilePath = Join-Path $PSHOME 'pwsh.dll'
+ if (-not (Test-Path $script:testFilePath)) {
+ $script:testFilePath = Join-Path $PSHOME 'System.Management.Automation.dll'
+ }
+ }
+
+ It 'Typed property with raw FileInfo should serialize Base properties only' {
+ $obj = [TestClassWithFileInfo]::new()
+ $obj.File = [System.IO.FileInfo]::new($script:testFilePath)
+
+ $json = $obj | ConvertTo-Json -Depth 2
+ $parsed = $json | ConvertFrom-Json
+
+ $parsed.File.PSObject.Properties.Name.Count | Should -Be 17
+ }
+
+ It 'Typed property loses PSObject wrapper from Get-Item' {
+ $obj = [TestClassWithFileInfo]::new()
+ $obj.File = Get-Item $script:testFilePath
+
+ $json = $obj | ConvertTo-Json -Depth 2
+ $parsed = $json | ConvertFrom-Json
+
+ $parsed.File.PSObject.Properties.Name.Count | Should -Be 17
+ }
+
+ It 'PSCustomObject with Get-Item preserves Extended properties' {
+ $obj = [PSCustomObject]@{
+ File = Get-Item $script:testFilePath
+ }
+
+ $json = $obj | ConvertTo-Json -Depth 2
+ $parsed = $json | ConvertFrom-Json
+
+ $parsed.File.PSObject.Properties.Name.Count | Should -BeGreaterThan 17
+ $parsed.File.PSObject.Properties.Name | Should -Contain 'PSPath'
+ }
+
+ It 'PSCustomObject with raw FileInfo should serialize Base properties only' {
+ $obj = [PSCustomObject]@{
+ File = [System.IO.FileInfo]::new($script:testFilePath)
+ }
+
+ $json = $obj | ConvertTo-Json -Depth 2
+ $parsed = $json | ConvertFrom-Json
+
+ $parsed.File.PSObject.Properties.Name.Count | Should -Be 17
+ $parsed.File.PSObject.Properties.Name | Should -Not -Contain 'PSPath'
+ }
+
+ It 'Hashtable with raw FileInfo should serialize Base properties only' {
+ $hash = @{
+ File = [System.IO.FileInfo]::new($script:testFilePath)
+ }
+
+ $json = $hash | ConvertTo-Json -Depth 2
+ $parsed = $json | ConvertFrom-Json
+
+ $parsed.File.PSObject.Properties.Name.Count | Should -Be 17
+ }
+
+ It 'Hashtable with Get-Item preserves Extended properties' {
+ $hash = @{
+ File = Get-Item $script:testFilePath
+ }
+
+ $json = $hash | ConvertTo-Json -Depth 2
+ $parsed = $json | ConvertFrom-Json
+
+ $parsed.File.PSObject.Properties.Name.Count | Should -BeGreaterThan 17
+ }
+
+ It 'Array of raw FileInfo should serialize with Adapted properties' {
+ $arr = @([System.IO.FileInfo]::new($script:testFilePath))
+
+ $json = $arr | ConvertTo-Json -Depth 2
+ $parsed = $json | ConvertFrom-Json
+
+ $parsed[0].PSObject.Properties.Name.Count | Should -Be 24
+ }
+
+ It 'Array of Get-Item FileInfo preserves Extended properties' {
+ $arr = @(Get-Item $script:testFilePath)
+
+ $json = $arr | ConvertTo-Json -Depth 2
+ $parsed = $json | ConvertFrom-Json
+
+ $parsed[0].PSObject.Properties.Name.Count | Should -BeGreaterThan 24
+ }
+ }
+
+ Context 'STJ-native scalar types serialization consistency' {
+ # These types are serialized identically via Pipeline and InputObject
+ It 'Should serialize DateTime consistently via Pipeline and InputObject' {
+ $dt = [datetime]"2024-01-15T10:30:00"
+ $jsonPipeline = $dt | ConvertTo-Json -Compress
+ $jsonInputObject = ConvertTo-Json -InputObject $dt -Compress
+ $jsonPipeline | Should -BeExactly $jsonInputObject
+ }
+
+ It 'Should serialize TimeSpan consistently via Pipeline and InputObject' {
+ $ts = [timespan]::FromHours(2.5)
+ $jsonPipeline = $ts | ConvertTo-Json -Compress
+ $jsonInputObject = ConvertTo-Json -InputObject $ts -Compress
+ $jsonPipeline | Should -BeExactly $jsonInputObject
+ }
+
+ It 'Should serialize Version consistently via Pipeline and InputObject' {
+ $ver = [version]"1.2.3.4"
+ $jsonPipeline = $ver | ConvertTo-Json -Compress
+ $jsonInputObject = ConvertTo-Json -InputObject $ver -Compress
+ $jsonPipeline | Should -BeExactly $jsonInputObject
+ }
+ }
+
+ Context 'PSObject vs raw object distinction' {
+ # Non-scalar types may differ between Pipeline (PSObject) and InputObject (raw)
+ # Pipeline wraps objects in PSObject, adding Extended/Adapted properties
+ It 'Should serialize IPAddress with Extended properties via Pipeline' {
+ $ip = [System.Net.IPAddress]::Parse("192.168.1.1")
+ $jsonPipeline = $ip | ConvertTo-Json -Compress
+ $jsonInputObject = ConvertTo-Json -InputObject $ip -Compress
+ # Pipeline includes IPAddressToString (Extended property)
+ $jsonPipeline | Should -Match 'IPAddressToString'
+ # InputObject does not include Extended properties
+ $jsonInputObject | Should -Not -Match 'IPAddressToString'
+ }
+ }
+
+ It 'Should output warning when depth is exceeded' {
+ $a = @{ a = @{ b = @{ c = @{ d = 1 } } } }
+ $json = $a | ConvertTo-Json -Depth 2 -WarningVariable warn -WarningAction SilentlyContinue
+ $json | Should -Not -BeNullOrEmpty
+ $warn | Should -Not -BeNullOrEmpty
+ }
}
diff --git a/test/xUnit/csharp/test_ConvertToJson.cs b/test/xUnit/csharp/test_ConvertToJson.cs
new file mode 100644
index 00000000000..f07903a0d82
--- /dev/null
+++ b/test/xUnit/csharp/test_ConvertToJson.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.PowerShell.Commands;
+using Xunit;
+
+namespace PSTests.Parallel
+{
+ public static class ConvertToJsonTests
+ {
+ ///
+ /// Verifies that JsonStringEscapeHandling enum values match Newtonsoft.Json.StringEscapeHandling
+ /// for backward compatibility. These values must not change.
+ ///
+ [Fact]
+ public static void JsonStringEscapeHandling_Values_MatchNewtonsoftStringEscapeHandling()
+ {
+ Assert.Equal((int)Newtonsoft.Json.StringEscapeHandling.Default, (int)JsonStringEscapeHandling.Default);
+ Assert.Equal((int)Newtonsoft.Json.StringEscapeHandling.EscapeNonAscii, (int)JsonStringEscapeHandling.EscapeNonAscii);
+ Assert.Equal((int)Newtonsoft.Json.StringEscapeHandling.EscapeHtml, (int)JsonStringEscapeHandling.EscapeHtml);
+ }
+ }
+}