8000 Add PSJsonSerializerV2 experimental feature for ConvertTo-Json using System.Text.Json by yotsuda · Pull Request #26624 · PowerShell/PowerShell · GitHub
[go: up one dir, main page]

Skip to content

Conversation

@yotsuda
Copy link
Contributor
@yotsuda yotsuda commented Dec 17, 2025

PR Summary

Adds a new experimental feature PSJsonSerializerV2 that migrates ConvertTo-Json from Newtonsoft.Json to System.Text.Json, with improved defaults and support for non-string dictionary keys.

Fixes #5749

PR Context

Problem

When converting objects containing dictionaries with non-string keys (such as Exception.Data which uses IDictionary with object keys), ConvertTo-Json throws NonStringKeyInDictionary error, making it impossible to serialize such objects. Additionally, the default depth of 2 is too shallow for most real-world use cases.

Solution

Added a new PSJsonSerializerV2 experimental feature that uses System.Text.Json instead of Newtonsoft.Json. The new implementation converts non-string dictionary keys via ToString(), enabling serialization of previously unsupported types. Default depth is increased to 64 (matching System.Text.Json's default), and there is no upper limit on depth when V2 is enabled.

PR Checklist

Changes Made

Commit 1: Add System.Text.Json serializer for ConvertTo-Json via PSJsonSerializerV2 experimental feature

1. ExperimentalFeature.cs (+5 lines)

  • Added PSJsonSerializerV2 experimental feature constant and registration

2. ConvertToJsonCommand.cs (+50 lines, -4 lines)

  • Added depth-related constants (DefaultDepth, DefaultDepthV2, DepthAllowed)
  • Changed _depth from int to int? to detect user-specified values
  • Modified Depth property to return V2 default (64) or legacy default (2) based on feature state
  • Added BeginProcessing to validate depth parameter

3. JsonObject.cs (+6 lines)

  • Added dispatch to SystemTextJsonSerializer.ConvertToJson when V2 is enabled

4. SystemTextJsonSerializer.cs (+624 lines, new file)

  • Implemented SystemTextJsonSerializer static class with ConvertToJson method
  • Implemented PowerShellJsonWriter class for manual depth tracking
  • Support for all primitive types, enums, collections, dictionaries, and PSObject
  • Non-string dictionary keys converted via ToString()
  • Graceful depth exceeded handling (converts to string instead of throwing)
  • Respects JsonIgnoreAttribute and HiddenAttribute
  • Backward compatibility with Newtonsoft.Json JObject

5. WebCmdletStrings.resx (+3 lines)

  • Added JsonDepthExceedsLimit error message resource

6. ConvertTo-Json.PSJsonSerializerV2.Tests.ps1 (+199 lines, new file)

  • Added comprehensive tests covering V2 functionality

Commit 2: Refactor PowerShellJsonWriter to use iterative approach instead of recursion

SystemTextJsonSerializer.cs (+180 lines, -83 lines)

  • Replaced recursive WriteValue calls with explicit Stack<WriteTask>
  • This eliminates call stack exhaustion risk for deeply nested objects
  • Enables safe handling of any depth without stack overflow concerns

Commit 3: Remove Depth upper limit and allow -1 for unlimited when PSJsonSerializerV2 is enabled

1. ConvertToJsonCommand.cs

  • Removed DepthAllowedV2 constant (no upper limit when V2 enabled)
  • Removed [ValidateRange] attribute from Depth parameter
  • Added support for -Depth -1 as unlimited depth
  • Added validation: -2 or less throws an error

2. WebCmdletStrings.resx

  • Added JsonDepthMustBeNonNegative error message resource

3. ConvertTo-Json.PSJsonSerializerV2.Tests.ps1

  • Updated tests for new depth behavior

Commit 4: Add backward compatibility tests that run in both V2 enabled and disabled states

ConvertTo-Json.PSJsonSerializerV2.Tests.ps1

  • Added 3 new backward compatibility tests (AsArray, multiple pipeline objects)
  • Removed -Skip from backward compatibility tests so they run in both states

Commit 5: Fix description and doc comment to reflect no upper depth limit

1. ExperimentalFeature.cs

  • Updated feature description to reflect no upper limit

2. ConvertToJsonCommand.cs

  • Updated doc comment to reflect no upper limit

Total: 6 files changed, +1032 insertions, -4 deletions

Behavior Examples

Before (throws error)

$ex = [System.Exception]::new("Test")
$ex.Data.Add(1, "one")
$ex | ConvertTo-Json -Depth 1
# Error: NonStringKeyInDictionary

After (with PSJsonSerializerV2 enabled)

Enable-ExperimentalFeature -Name PSJsonSerializerV2
# Restart PowerShell

$ex = [System.Exception]::new("Test")
$ex.Data.Add(1, "one")
$ex | ConvertTo-Json -Depth 1
# Successfully converts, key "1" is converted to string

Unlimited depth with -1

# Serialize deeply nested object without truncation
$deep = @{ level = 0 }
for ($i = 1; $i -lt 500; $i++) {
    $deep = @{ level = $i; child = $deep }
}
$deep | ConvertTo-Json -Depth -1  # No warning, fully serialized

Default depth comparison

# Legacy: Default depth 2, truncates at 3rd level
$deep = @{ a = @{ b = @{ c = 1 } } }
$deep | ConvertTo-Json  # Warning: depth exceeded

# V2: Default depth 64, handles deep objects
$deep | ConvertTo-Json  # No warning, fully serialized

Testing

Test Categories (30 tests total)

V2 Feature Tests (24 tests) - Run when PSJsonSerializerV2 is enabled:

  • Default depth is 64
  • Large depth values allowed (no upper limit)
  • Depth -1 works as unlimited
  • Negative depth other than -1 throws error
  • Depth exceeded warning and string conversion
  • Non-string dictionary keys (Issue Add Parameter to ConvertTo-Json to ignore unsupported properties #5749)
  • JsonIgnoreAttribute and HiddenAttribute
  • Special types (Uri, Guid, BigInteger, enums)
  • Null handling (null, DBNull, NullString, ETS properties on DBNull)
  • Collections (arrays, hashtables, nested objects)
  • EscapeHandling options

Backward Compatibility Tests (5 tests) - Run in both enabled and disabled states:

  • Newtonsoft JObject support
  • Depth parameter
  • AsArray parameter
  • Multiple objects from pipeline
  • Multiple objects from pipeline with AsArray

Legacy Behavior Test (1 test) - Run when PSJsonSerializerV2 is disabled:

  • Depth over 100 throws error

Test Results

Environment Passed Skipped Status
V2 enabled 29 1 ✅ All V2 and backward compatibility tests pass
V2 disabled 6 24 ✅ Legacy and backward compatibility tests pass

Implementation Details

Key Features

Feature Legacy V2
Default depth 2 64
Maximum depth 100 No limit (-1 for unlimited)
Non-string dictionary keys ❌ Throws ✓ Supported
Serializer Newtonsoft.Json System.Text.Json
Implementation Recursive Iterative (stack-based)

Design Decisions

  1. Experimental feature - Allows users to opt-in to new behavior without breaking existing scripts

  2. Utf8JsonWriter with iterative approach - Direct writer usage instead of JsonSerializer for full control over depth tracking. The iterative (non-recursive) implementation uses an explicit stack, eliminating call stack exhaustion risk for deeply nested objects.

  3. Default depth 64 - Matches System.Text.Json's default maximum depth, providing a good balance between usability and safety

  4. No upper limit with -1 for unlimited - Since the implementation is iterative (not recursive), there's no risk of stack overflow. Users can specify -Depth -1 for truly unlimited depth, while negative values other than -1 throw an error to catch typos.

  5. Graceful depth handling - Converts deep objects to strings instead of throwing (matches legacy behavior)

  6. ToString() for non-string keys - Simple, predictable conversion for non-string dictionary keys

Backward Compatibility

  • Feature disabled: Behavior identical to current implementation
  • Feature enabled: All existing parameters and options work as expected
  • JObject support: Newtonsoft.Json JObject instances still serialize correctly

Questions for Reviewers

I would like to verify that the V2 tests run correctly when PSJsonSerializerV2 is enabled. Is there a way to run CI tests with experimental features enabled, or is there a separate CI job for testing experimental features?

I've verified locally that:

  • V2-specific tests pass when the feature is enabled (24 tests)
  • Backward compatibility tests pass in both enabled and disabled states (5 tests)
  • Legacy behavior test passes when the feature is disabled (1 test)

Related Work

Previous PR

PR Title Date Status Description
#11 8000 198 Port ConvertTo-Json to .Net Core Json API Nov 2019 - Apr 2022 Closed Comprehensive STJ migration attempt with 120+ comments discussing breaking changes, depth behavior, and compatibility concerns

Directly Related Issues

Issue Title Date Status Relevance
#8393 Consider removing the default -Depth value from ConvertTo-Json Dec 2018 Closed Default depth change discussion (2 → 64)
#5749 Add Parameter to ConvertTo-Json to ignore unsupported properties Dec 2017 Open Dictionary with non-string keys handling
#9847 Class hidden properties still serialized to JSON Jun 2019 Closed Fixed as side effect of STJ migration
#5797 ConvertTo-Json: unexpected behavior with objects that have ETS properties Jan 2018 Closed Serialization edge cases with NoteProperty/ScriptProperty
#6847 ConvertTo-Json Honors JsonPropertyAttribute May 2018 Closed JsonIgnoreAttribute support
#8381 ConvertTo-Json: terminate cut off branches with explicit marker Dec 2018 Closed Depth truncation visualization

Indirectly Related (ConvertFrom-Json / Depth handling)

Issue/PR Title Date Status Relevance
#3182 In ConvertFrom-Json, the max depth for deserialization Feb 2017 Closed Origin of depth parameter discussion
#8199 Add configurable maximum depth in ConvertFrom-Json with -Depth Nov 2018 Merged Depth handling precedent
#13592 ConvertFrom-JSON incorrectly deserializes dates to DateTime Sep 2020 Closed DateTime handling differences in STJ
#13598 Add a -DateKind parameter to ConvertFrom-Json Sep 2020 Closed References #11198 for STJ migration plans

.NET Runtime Issues (STJ feedback from previous work)

Issue Title Date Status
dotnet/runtime#611 Enhance Json Serialize() with CancellationToken Dec 2019 Open

@iSazonov
Copy link
Collaborator

I would like to verify that the V2 tests run correctly when PSJsonSerializerV2 is enabled. Is there a way to run CI tests with experimental features enabled, or is there a separate CI job for testing experimental features?

Tests can be skipped or enabled based on an experimental feature. Search by $EnabledExperimentalFeatures in our tests.

Please add old tests we skip to the PR description since it is breaking changes. We should think will we accept them as is or try to fix.

@iSazonov
Copy link
Collaborator

@yotsuda Please add a reference to previous PR (there are a lot of useful comments). Also I guess there are some related to the cmdlet issues. These references would be useful too.

@iSazonov iSazonov added the CL-General Indicates that a PR should be marked as a general cmdlet change in the Change Log label Dec 17, 2025
@yotsuda yotsuda force-pushed the feature-convertto-json-system-text-json branch from 2ad540d to 264335e Compare December 17, 2025 08:35
/// Otherwise: default is 2, max is 100.
/// </summary>
[Parameter]
[ValidateRange(0, 100)]
Copy link
Collaborator
@iSazonov iSazonov Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is [ValidateRange(-1, int.MaxValue)] that we need for new cmdlet version? If so I suggest create new ConvertToJsonCommandV2 class and avoid all new validating extra code. In PR description you can point that only the attribute and serializer are changed. Also SystemTextJsonSerializer.cs‎ content will be in the cmdlet file.
This will simplify converting the experimental feature in regular code and removing old code.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you point a discussion where we said about -1 value? I'd expect we need range from 1 to int.MaxValue.
int.MaxValue is unlimit de-facto.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! I created ConvertToJsonCommandV2 class with [Experimental(..., Show)] and kept V1 with [Experimental(..., Hide)].

For ValidateRange:

  • ValidateRange(0, 1000): The maximum is 1000 due to Utf8JsonWriter's hard limit (documented here). Starts from 0 for V1 compatibility.
  • The -1 for unlimited was my own idea, but I removed it since Utf8JsonWriter has a hard limit of 1000.

Also merged SystemTextJsonSerializer.cs into the cmdlet file as suggested.

Copy link
Collaborator
@iSazonov iSazonov Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yotsuda Thanks!

I think it is better to put new cmdlet in new file. This way it will be easier for us to make sure that we are not dependent on NewtonSoft, and later it will be easier for us to delete the old code. But I don't insist on it strictly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the guidance. Done in 505fa77.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1000 due to Utf8JsonWriter's hard limit (documented here).

I think this code says the default max depth is 1000,
rather than the maximum depth being hard limited to 1000 JsonWriterOptions.cs

I think ValidateRange would prevent using the higher limit

Gets or sets the maximum depth allowed when writing JSON, with the default (i.e. 0) indicating a max depth of 1000.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ninmonkey You're right! Thanks for the correction. The 1000 is STJ's default maximum depth, not a hard limit. ValidateRange is what actually enforces the limit on the user side.

This PR has been closed in favor of #26637, where I've aligned the maximum depth with V1 (100) for consistency.

@yotsuda
Copy link
Contributor Author
yotsuda commented Dec 17, 2025

I would like to verify that the V2 tests run correctly when PSJsonSerializerV2 is enabled. Is there a way to run CI tests with experimental features enabled, or is there a separate CI job for testing experimental features?

Tests can be skipped or enabled based on an experimental feature. Search by $EnabledExperimentalFeatures in our tests.

Please add old tests we skip to the PR description since it is breaking changes. We should think will we accept them as is or try to fix.

Thank you. I've updated the tests to use $EnabledExperimentalFeatures (159b763).

I verified that all existing ConvertTo-Json.Tests.ps1 tests pass with both V2 enabled and disabled - no tests need to be skipped, so no breaking changes to report.

@iSazonov
Copy link
Collaborator

Commit 2: Refactor PowerShellJsonWriter to use iterative approach instead of recursion
...
Enables safe handling of any depth without stack overflow concerns

Is it real problem? Have you taken the measurements? Are you aware of the real problems with the standard serializer?
I strongly doubt that common objects and common usage scenarios of this cmdlet can cause stack overflow. I think we would have seen this with NetonSoft too, but it's not there.
In my PR, I even set a reasonable depth limit of 1000. I don't remember if this value was discussed. We should have looked at the discussion history. Now I think we could start with a range from 1 to 1000 in the ValidateRange attribute.

I think we should start with a standard serializer (I guess this halves size of the PR.). Later, if we receive feedback, we can consider adding a custom serializer.

@jborean93
Copy link
Collaborator
jborean93 commented Dec 17, 2025

Default depth is increased to 64 (matching System.Text.Json's default), and there is no upper limit on depth when V2 is enabled.

Changing the default depth beyond 2/3 is unfortunately dangerous, anything with a Type type, or a recursive parameter, is going to essentially hang and hit memory limits. It's also very trivial to see this in existing code as anything from a provider will contain the provider/drive information as an ETS property which contains ImplementingType. You can see this right now with

# Fine
Get-Item env:PSModulePath | ConvertTo-Json

# Also somewhat ok but there is a lot of data
Get-Item env:PSModulePath | ConvertTo-Json -Depth 4

# Will end but you start to see it struggling
Get-Item env:PSModulePath | ConvertTo-Json -Depth 5

# Hits OOM on Linux for me
Get-Item env:PSModulePath | ConvertTo-Json -Depth 6

A more practical example would be something like Get-ChildItem $path | ConvertTo-Json | Out-File results.json. With multiple entries and it's not even more expensive where a depth of 3/4 will probably bring the process to its knees.

6D47

@daxian-dbw daxian-dbw added WG-Cmdlets general cmdlet issues WG-NeedsReview Needs a review by the labeled Working Group labels Dec 17, 2025
@iSazonov
Copy link
Collaborator

@jborean93 Thanks for feedback! The purpose of this PR is to migrate to a modern .Net API but not fixing all problems and controversial situations. This is not possible, it will just block the work.
(There are a lot of issues for ConvertTo-Json. For example, those that relate to the Depth parameter).
PS1: for your examples I guess you will be interesting in #26533
PS2: if we implement extendable model (by mean of ETS, new parameter or ...) you can add custom converters to your PS module and you will determine the desired behavior yourself.

@daxian-dbw What do you want to discuss in WG? The purpose of this PR is to study migration to the modern .Net API. It was approved earlier by PowerShell Committee #8393 (comment) and nearly :-) implemented in #11198.
This is a pure experimental feature, it does not remove the old implementation. Let's just do this and start to collect feedback. Then there will be topics for discussion in the WGs.

@jborean93
Copy link
Collaborator

Yea I’ll definitely be down for migrating away from newtonsoft and exposing more APIs to make it more extensible than it is now. I’ve added a few to my yaml library that fan do things like transform values based on the type and other logic so would love to see that here!

My comment was more just a warning about changing the default depth and how it is easy to cause an OOM by serializing to json with a depth larger than 4/5. Probably should be done as part of a separate issue/PR to talk through that more and figure out some nice ways to avoid the problem I mentioned.

@yotsuda
Copy link
Contributor Author
yotsuda commented Dec 18, 2025

Thank you @iSazonov and @jborean93 for the detailed feedback!

Changes in this commit

  1. Separated V1 and V2 classes

    • ConvertToJsonCommand with [Experimental(..., Hide)] for V1
    • ConvertToJsonCommandV2 with [Experimental(..., Show)] for V2
  2. Depth handling

    • ValidateRange(0, 1000): Maximum is 1000 due to Utf8JsonWriter's hard limit (documented here)
    • Default changed to 2 for V1 compatibility (@jborean93's concern about provider objects)
  3. Refactored to recursive approach

    • @iSazonov was right: stack overflow is not a concern with max depth 1000
    • Added circular reference detection via HashSet<object>
  4. Merged SystemTextJsonSerializer.cs into cmdlet file

Why Utf8JsonWriter instead of JsonSerializer.Serialize()

I tested JsonSerializer.Serialize() + JsonConverter approach (code here), but V1-compatible depth handling is impossible:

  • When MaxDepth is exceeded, System.Text.Json throws an exception with incomplete JSON
  • V1 converts deep objects to strings and continues with valid JSON + warning
  • Complex PowerShell objects also failed with "The converter wrote too much or not enough" error

With direct Utf8JsonWriter control, we can track depth explicitly and produce V1-compatible output.

@iSazonov
Copy link
Collaborator

@yotsuda Thanks for great summarization!

I tested JsonSerializer.Serialize() + JsonConverter approach (code here), but V1-compatible depth handling is impossible:

So we lost support of custom Json converters? Then we lose all the benefits of the new API. Is there a workaround that preserves these benefits? If we have to rewrite the entire standard serializer, then it's probably too expensive. Maybe it's better to ask in .Net Runtime repository.

@yotsuda
Copy link
Contributor Author
yotsuda commented Dec 20, 2025

@iSazonov Thank you for the feedback. You were right to push back on my earlier analysis.

Correction

In my previous comment, I stated that JsonSerializer.Serialize() + JsonConverter approach couldn't achieve V1-compatible depth handling. This was incorrect. After your feedback, I revisited the approach and found that:

  1. V1-compatible depth handling IS possible - by using writer.CurrentDepth inside the JsonConverter
  2. The "converter wrote too much or not enough" error - was due to implementation bugs, not a fundamental limitation

I apologize for the misleading analysis.

Experimental Implementation

I've created an alternative implementation using JsonSerializer.Serialize() + JsonConverter:

Source code:

Note: V1 file is identical between PR and Experimental branches.

Implementation Comparison

Aspect PR V2 Experimental V2
Serialization Direct Utf8JsonWriter JsonSerializer.Serialize()
Depth tracking Explicit argument passing writer.CurrentDepth
Global state static bool (1) (*) None
Thread safety ⚠️ Static variable ✅ Fully safe
Lines of code 611 ~660
Test results 12/13 pass 12/13 pass (same)

(*) maxDepthWarningWritten - ensures depth-exceeded warning is shown only once per serialization.

New Feature: -JsonSerializerOptions Parameter

The experimental branch adds a -JsonSerializerOptions parameter that enables custom JsonConverter:

$options = [System.Text.Json.JsonSerializerOptions]::new()
$options.Converters.Add([MyDateTimeConverter]::new())

$date | ConvertTo-Json -JsonSerializerOptions $options
# Output: "CUSTOM:2021/06/24"

Works for: Primitive types (DateTime, Guid, etc.) and .NET classes
Does not work for: PSCustomObject (returns empty {})

Note: -Depth and -JsonSerializerOptions are mutually exclusive. To control depth in -JsonSerializerOptions mode, set MaxDepth on the options object:

$options = [System.Text.Json.JsonSerializerOptions]::new()
$options.MaxDepth = 10
$obj | ConvertTo-Json -JsonSerializerOptions $options

Behavior Comparison

Aspect V1 PR V2 Exp V2 (Default) Exp V2 (-JsonSerializerOptions)
ETS properties ✅ Serialized ✅ Serialized ✅ Serialized ❌ Ignored (*)
PSCustomObject ✅ Works ✅ Works ✅ Works ❌ Empty {}
Custom JsonConverter N/A ✅ Works
Default Depth 2 2 2 64 (STJ default)
Depth exceeded ⚠️ Warning + truncation ⚠️ Warning + truncation ⚠️ Warning + truncation ❌ Exception
Circular reference Depth truncation Partial (**) Depth truncation Exception (***)
OOM risk (recursive types) ⚠️ High ⚠️ High ⚠️ High ✅ Safe

(*) ETS properties are ignored because -JsonSerializerOptions mode passes the unwrapped BaseObject directly to JsonSerializer.Serialize() to enable custom JsonConverter support. This is a technical constraint, not a design choice.

(**) PR V2 detects circular references for PSCustomObject (→ null) but not for Hashtable/IDictionary (→ Depth truncation). This can be fixed.

(***) Can be avoided by setting ReferenceHandler = ReferenceHandler.IgnoreCycles.

Why -JsonSerializerOptions Can Use Depth 64 Safely

As @jborean93 pointed out, increasing default depth beyond 2-3 is dangerous when ETS properties are serialized:

# Default mode - ETS properties cause exponential growth
Get-Item env:PSModulePath | ConvertTo-Json -Depth 2  # 7 KB
Get-Item env:PSModulePath | ConvertTo-Json -Depth 5  # 33 MB, 6 sec
Get-Item env:PSModulePath | ConvertTo-Json -Depth 6  # OOM

# -JsonSerializerOptions mode - ETS ignored, safe
$opt = [System.Text.Json.JsonSerializerOptions]::new()
Get-Item env:PSModulePath | ConvertTo-Json -JsonSerializerOptions $opt  # 552 bytes

Since -JsonSerializerOptions mode ignores ETS properties (to enable custom JsonConverter), it avoids the Type object explosion and can safely use Depth 64.

My Assessment

The experimental implementation has advantages:

  • Enables custom JsonConverter (which you mentioned as important)
  • No global state (fully thread-safe)
  • Better leverages STJ's native capabilities
  • V1 compatibility maintained in default mode

I'd appreciate your feedback on both approaches. Should we:

  1. Continue with the current PR (simpler, direct Utf8JsonWriter control)?
  2. Switch to the experimental approach by merging it into this PR (custom converter support, better STJ integration)?
  3. Something else?

@iSazonov
Copy link
Collaborator

@yotsuda Thanks for great investigations!

  • V1-compatible depth handling IS possible - by using writer.CurrentDepth inside the JsonConverter
  • The "converter wrote too much or not enough" error - was due to implementation bugs, not a fundamental limitation

Do you say about just PSObject converter? I guess we still haven't control on others? If we take C# type having 5 depth do we still get exception with default 2 depth serialization?

@yotsuda
Copy link
Contributor Author
yotsuda commented Dec 20, 2025

Default mode (without -JsonSerializerOptions)

Do you say about just PSObject converter?

Yes, it's about the PSObject converter - but this single converter handles all types, not just PSObject.

I guess we still haven't control on others?

We do have control. The key is how we invoke JsonSerializer:

// Wrap ANY object in PSObject
var pso = PSObject.AsPSObject(objectToProcess);

// Explicitly pass typeof(PSObject) - NOT pso.GetType()
return JsonSerializer.Serialize(pso, typeof(PSObject), options);

By passing typeof(PSObject) explicitly, JsonConverterPSObject handles ALL objects - including pure C# types with deep nesting. The same pattern is used for recursive serialization of nested properties.

This wrapping is necessary for V1 compatibility - it enables both ETS property serialization and depth control.

If we take C# type having 5 depth do we still get exception with default 2 depth serialization?

No. It will truncate with a warning, exactly like V1. (See "Depth exceeded" row in the Behavior Comparison table in my previous comment.)


With -JsonSerializerOptions parameter

Additionally, the experimental implementation adds a -JsonSerializerOptions parameter that allows user-supplied custom converters for any .NET type (DateTime, Guid, custom classes, etc.). Please see the Behavior Comparison table in my previous comment for details on trade-offs.

Source code (for reference):

@iSazonov
Copy link
Collaborator

@yotsuda Thanks for clarify!

I'm looking at V1 and I see that there's the same problem - the NewtonSoft serializer throws an exception when the depth is exceeded. V1 implementation is forced to circumvent the limitation of the NewtonSoft serializer by performing preprocessing, converting complex objects into a dictionary, limiting the depth, and then calling the serializer for the truncated object.
Since the STJ serializer works similarly, we will have to use the same approach. It is not too bad, and we still can add custom converters (specially for PSObject!).
Or/and we should request an extension of the STJ functionality for our needs.

@iSazonov
Copy link
Collaborator

As for adding -JsonSerializerOptions parameter I suggest to postpone this for next stage.
I see that this object can become read-only, and the metadata cache is also attached to it. We may need to do something similar to our WebSession class for Web cmdlets to ensure performance. And come up with something else. Perhaps there is another way to add extensions that is more user-friendly. This needs to be discussed.
Let's put that aside.
In the meantime, make something as compatible as possible with the old cmdlet and decoupled from NewtonSoft.

@yotsuda
Copy link
Contributor Author
yotsuda commented Dec 21, 2025

@iSazonov Thank you for the detailed feedback!

Since the STJ serializer works similarly, we will have to use the same approach. It is not too bad, and we still can add custom converters (specially for PSObject!).

My experimental branch uses exactly what you suggested - a custom JsonConverter for PSObject that handles depth control internally. Serialization is done entirely by STJ (Newtonsoft is only referenced for JObject compatibility and the existing EscapeHandling parameter type).

Here's how V1 and experimental V2 differ:

V1:

ProcessValue(obj, depth):
    if depth > max: return obj.ToString()
    
    dict = new Dictionary()
    foreach property in obj:
        dict[name] = ProcessValue(value, depth+1)
    return dict

Newtonsoft.Serialize(dict) → string

Experimental V2:

JsonSerializer.Serialize(obj, options)
  → STJ calls JsonConverterPSObject.Write(writer, obj)

JsonConverterPSObject.Write(writer, obj):
    if writer.CurrentDepth > max: writer.WriteString(obj.ToString()); return
    
    writer.WriteStartObject()
    foreach property in obj:
        writer.WritePropertyName(name)
        Write(writer, PSObject.Wrap(value))  // recursive
    writer.WriteEndObject()

The key difference: V1 creates intermediate Dictionary/List objects, while experimental V2 writes directly without intermediate allocation.

As for adding -JsonSerializerOptions parameter I suggest to postpone this for next stage.

Agreed. I'll remove it from the initial implementation.

I'll close this PR and submit a new one from the experimental branch, focused on V1 compatibility.

For reference:

@iSazonov
Copy link
Collaborator

I'll close this PR and submit a new one from the experimental branch, focused on V1 compatibility.

Ok.

And since we keep our serializer and lost benefits of standard serializer I believe we need to ask .Net team to enhance the API so that we can use it.

@yotsuda
Copy link
Contributor Author
yotsuda commented Dec 22, 2025

Closing in favor of #26637, which has been revised based on @iSazonov's feedback to focus on V1 compatibility.

Key changes in the new PR:

  • Default depth remains 2 (same as V1)
  • Maximum depth remains 100 (same as V1)
  • Removed -JsonSerializerOptions parameter for simplicity

@yotsuda yotsuda closed this Dec 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CL-General Indicates that a PR should be marked as a general cmdlet change in the Change Log WG-Cmdlets general cmdlet issues WG-NeedsReview Needs a review by the labeled Working Group

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Parameter to ConvertTo-Json to ignore unsupported properties

5 participants

0