Skip to content

Let a consumer declare how a type crosses the wire - #2442

Closed
woksin wants to merge 2 commits into
mainfrom
feature/proxy-generator-type-mappings
Closed

Let a consumer declare how a type crosses the wire#2442
woksin wants to merge 2 commits into
mainfrom
feature/proxy-generator-type-mappings

Conversation

@woksin

@woksin woksin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Added

  • --type-to-ts argument and matching TypeToTsType MSBuild item declare how a .NET type crosses the wire to TypeScript, and take the place of the built-in mapping for that type.

Changed

  • @cratis/arc declares @cratis/fundamentals as a peer dependency instead of a versioned dependency. Consumers whose package manager does not install peers automatically must now declare it themselves.

The generator's type map is a static with a collection initializer: no
setter, no add, no fallback. A consumer with a domain type that should
reach TypeScript as something other than what the map decides has nowhere
to say so, and neither does one who needs an existing mapping corrected -
both need a release of the generator.

Add a repeatable --type-to-ts argument, fed from a TypeToTsType MSBuild
item, following the pattern AssemblyToPackageMapping already established.
It is consulted ahead of the built-in map, which is what lets it correct an
existing mapping rather than only add an unknown one; consulted after, it
could never reach a type the generator already knows.

Deliberately not an attribute. A build-time concern belongs in the build
file rather than on the domain type, and an attribute cannot express a
mapping for a type the consumer does not own.

Generated proxies are committed in consumer repositories, so the guarantee
worth protecting is that a build configuring nothing generates exactly what
it generated before: a spec covers configure-then-clear leaving no residue,
and the 1055 existing generator specs still pass untouched.
Copilot AI lite review requested due to automatic review settings August 4, 2026 10:02
@woksin woksin added the major label Aug 4, 2026
@woksin

woksin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer context — verification and design notes, kept out of the release notes.

Design

  • The mapping is consulted ahead of _primitiveTypeMap. Consulted after, it could only ever add a type the generator has never seen; ahead, it can also correct one it already knows, which is the case the seam exists for. A spec covers the ordering, and reversing it turns three specs red.
  • Deliberately an MSBuild item rather than a [TsType]-style attribute: a build-time concern belongs in the build file, and an attribute cannot express a mapping for a type the consumer does not own. This follows the AssemblyToPackageMapping pattern already in the targets.

Verification

  • dotnet build -c Release 0 warnings 0 errors; ProxyGenerator.Specs 1055 specs pass, including all pre-existing ones untouched; Source/JavaScript/Arc yarn ci 749 specs.
  • Generated proxies are committed in consumer repositories, so the guarantee worth protecting is that a build configuring nothing generates exactly what it generated before. A spec covers configure-then-clear leaving no residue, and both mutations (setter stores nothing; lookup moved after the built-in map) were confirmed to turn the intended specs red.

Notes

  • The peer dependency change is unrelated to the generator feature and is a separate commit. Two copies of @cratis/fundamentals in one realm give each its own converter registry and class objects: a converter registered on one reaches only that one, instanceof against the other is false, and a version pinned at the top level never reaches a nested copy. Measured: a sibling declaring it as a dependency pinned to 7.16.7, in an app pinning 7.16.8, installs two copies; declared as a peer, one.
  • Unrelated to this PR but worth knowing: DateOnly and TimeOnly still map onto Date on main. A fix exists on the local ada-upstream-backlog branch and has not been pushed.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds an opt-in mechanism for consumers to override the .NET→TypeScript “cross-the-wire” type mapping used by the ProxyGenerator, and adjusts @cratis/arc’s NPM dependency model to treat @cratis/fundamentals as a peer dependency.

Changes:

  • Added repeatable --type-to-ts / TypeToTsType plumbing (MSBuild → CLI → generator) and applied it ahead of the built-in type map.
  • Introduced specs covering configured/override/cleared type mappings behavior.
  • Changed @cratis/arc to move @cratis/fundamentals from dependencies to peerDependencies (with a pinned devDependency for local development).

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
Source/JavaScript/Arc/package.json Moves @cratis/fundamentals to peerDependencies and pins it as a dev dependency for development.
Source/DotNET/Tools/ProxyGenerator/TypeExtensions.cs Adds configurable per-type TS mappings and consults them before the built-in map.
Source/DotNET/Tools/ProxyGenerator/Program.cs Parses repeatable --type-to-ts args and passes mappings into generation.
Source/DotNET/Tools/ProxyGenerator/Generator.cs Wires typeMappings into TypeExtensions initialization.
Source/DotNET/Tools/ProxyGenerator.Specs/for_TypeExtensions/when_no_type_mappings_are_configured.cs Verifies clearing mappings leaves prior behavior unchanged.
Source/DotNET/Tools/ProxyGenerator.Specs/for_TypeExtensions/when_a_type_mapping_overrides_a_built_in_one.cs Verifies configured mappings override built-in ones.
Source/DotNET/Tools/ProxyGenerator.Specs/for_TypeExtensions/when_a_type_mapping_is_configured.cs Verifies configured mappings produce expected TS type and import metadata.
Source/DotNET/Tools/ProxyGenerator.Specs/for_TypeExtensions/when_a_type_mapping_is_configured_without_a_package.cs Verifies mapping without package produces no import.
Source/DotNET/Tools/ProxyGenerator.Specs/for_TypeExtensions/given/no_type_mappings.cs Adds shared fixture ensuring mappings are cleared between specs.
Source/DotNET/Tools/ProxyGenerator.Build/build/Cratis.Arc.ProxyGenerator.Build.targets Adds MSBuild item transform to emit --type-to-ts args to the generator.

Comment on lines +124 to +136
public static void SetTypeMappings(IReadOnlyCollection<(string TypeName, string TsType, string Package)> mappings)
{
_typeMappings = mappings.ToDictionary(
static mapping => mapping.TypeName,
static mapping => new TargetType(
typeof(object),
mapping.TsType,
mapping.TsType,
mapping.Package,
Final: true,
FromPackage: !string.IsNullOrEmpty(mapping.Package)),
StringComparer.Ordinal);
}
Comment on lines +14 to +17
[Fact] void should_generate_the_declared_type() => _result.Type.ShouldEqual("string");
[Fact] void should_not_import_anything() => _result.Module.ShouldBeEmpty();
[Fact] void should_not_be_from_a_package() => _result.FromPackage.ShouldBeFalse();
}
Copilot AI review requested due to automatic review settings August 4, 2026 10:15
@woksin
woksin force-pushed the feature/proxy-generator-type-mappings branch from 8551674 to b89e9ef Compare August 4, 2026 10:15
@woksin woksin added minor and removed major labels Aug 4, 2026
@woksin

woksin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Relabelled minor and the peer-dependency change moved out to #2445.

Bundling them was wrong: this half is purely additive and opt-in — a build passing no --type-to-ts generates byte-identical output, which is the property that matters since proxies are committed in consumer repositories. The peer-dependency change is the only part that can break an install, and it deserves its own label rather than dragging this one to major.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Suppressed comments (3)

Source/DotNET/Tools/ProxyGenerator/TypeExtensions.cs:136

  • SetTypeMappings() uses ToDictionary(), which throws if the same type name is configured more than once (e.g., duplicated MSBuild items). That would crash proxy generation with a generic exception. Consider making mappings deterministic (e.g., last-one-wins) to avoid fragile builds and allow intentional overrides.
    public static void SetTypeMappings(IReadOnlyCollection<(string TypeName, string TsType, string Package)> mappings)
    {
        _typeMappings = mappings.ToDictionary(
            static mapping => mapping.TypeName,
            static mapping => new TargetType(
                typeof(object),
                mapping.TsType,
                mapping.TsType,
                mapping.Package,
                Final: true,
                FromPackage: !string.IsNullOrEmpty(mapping.Package)),
            StringComparer.Ordinal);
    }

Source/DotNET/Tools/ProxyGenerator/Program.cs:58

  • Parsing --type-to-ts uses entry.Split('='), which will split on every '=' and can mis-handle inputs if the TS type or package ever contains '='. Limiting the split to 3 parts avoids accidental truncation while still supporting the intended <Type>=<TsType>[=<Package>] format.
foreach (var entry in args.Where(_ => _.StartsWith("--type-to-ts=")).Select(_ => _["--type-to-ts=".Length..]))
{
    var parts = entry.Split('=');
    if (parts.Length >= 2 && !string.IsNullOrWhiteSpace(parts[0]) && !string.IsNullOrWhiteSpace(parts[1]))
    {
        typeMappings.Add((parts[0], parts[1], parts.Length > 2 ? parts[2] : string.Empty));

Source/DotNET/Tools/ProxyGenerator.Build/build/Cratis.Arc.ProxyGenerator.Build.targets:44

  • TypeToTsType MSBuild items are expanded into the Exec command without quoting. If %(TsType) ever contains spaces (e.g., a union type like Foo | Bar), the command line will split it into multiple args and the mapping will be parsed incorrectly. Quoting each --type-to-ts=... token avoids this class of failures.
            <ExcludeTypes>@(ExcludeType -> '--exclude-type=%(TypeName)', ' ')</ExcludeTypes>
            <ExcludeNamespaces>@(ExcludeNamespace -> '--exclude-namespace=%(Namespace)', ' ')</ExcludeNamespaces>
            <NamespaceRoots>@(NamespaceRoot -> '--namespace-root=%(Namespace)=%(Folder)', ' ')</NamespaceRoots>
            <TypeMappings>@(TypeToTsType -> '--type-to-ts=%(TypeName)=%(TsType)=%(Package)', ' ')</TypeMappings>
        </PropertyGroup>

        <Exec ConsoleToMsBuild="true"
            Command="dotnet $(CratisProxyGeneratorAssembly) &quot;$(MSBuildProjectDirectory)/$(OutputPath)$(AssemblyName).dll&quot; &quot;$(CratisProxiesOutputPath)&quot; $(CratisProxiesSegmentsToSkip) $(LibraryMode) $(SkipOutputDeletion) $(SkipCommandNameInRoute) $(SkipQueryNameInRoute) $(ApiPrefix) --project-directory=&quot;$(MSBuildProjectDirectory)&quot; $(SkipFileIndexTracking) $(SkipIndexGeneration) $(UseSourceFileAsOutputFile) $(AssemblyPackageMappings) $(ExcludeTypes) $(ExcludeNamespaces) $(NamespaceRoots) $(TypeMappings)"

Declared as a range in dependencies, which tells a package manager a copy
each is acceptable. It is not: the two copies get separate converter
registries and separate class objects, so a converter registered on one
reaches only that one, an instanceof against the other is false, and a
version pinned at the top level never reaches the nested copy - an adopted
fix can land nowhere while every build stays green.

A peer dependency is the mechanism for "exactly one of these in the tree",
which is what this actually is. The range is deliberately wide so a patch
or minor release does not force lockstep releases across repositories, and
an exact devDependency keeps this repository's own build and specs pinned.
Copilot AI review requested due to automatic review settings August 4, 2026 10:23
@woksin woksin added major and removed minor labels Aug 4, 2026
@woksin

woksin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Recombined with the peer-dependency change and relabelled major; #2445 is closed as redundant.

publish.yml triggers on every merged PR touching Source/**, so two PRs here would have cut two Arc releases. One release is preferred, and a release takes the highest bump of its contents — the additive generator feature ships under the major that the dependency change requires, which is the correct semantics for the release even though the feature itself is not breaking.

The two changes remain separate commits, so they stay independently reviewable and revertable.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

Source/DotNET/Tools/ProxyGenerator/TypeExtensions.cs:130

  • SetTypeMappings() uses ToDictionary(), which throws if the same TypeName appears more than once. Since mappings come from repeatable MSBuild items/args, duplicates are a realistic user-input case and would currently crash proxy generation instead of resolving deterministically.
        _typeMappings = mappings.ToDictionary(
            static mapping => mapping.TypeName,
            static mapping => new TargetType(
                typeof(object),
                mapping.TsType,

Console.WriteLine(" Cratis.ProxyGenerator <assembly> <output-path> [segments-to-skip] [--library-mode] [--skip-output-deletion] [--skip-command-name-in-route] [--skip-query-name-in-route] [--api-prefix=<prefix>] [--skip-index-generation] [--use-source-file-as-output-file] [--assembly-to-package=<Assembly>=<Package>]... [--exclude-type=<FullyQualifiedTypeName>]... [--exclude-namespace=<Pattern>]... [--namespace-root=<Namespace>=<Folder>]... [--type-to-ts=<FullyQualifiedTypeName>=<TsType>[=<Package>]]...");
return 1;
}
var assemblyFile = Normalize(Path.GetFullPath(args[0]));
Comment on lines +542 to +546
// Ahead of the built-in map, which is what lets a mapping correct an existing type rather than
// only add an unknown one. The built-in map is still what answers when nothing was configured.
if (type.FullName is not null && _typeMappings.TryGetValue(type.FullName, out var mapped))
{
return mapped with { OriginalType = type };
@woksin

woksin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #2446, which carries this unchanged so the whole set lands as one release rather than four. Closing so it cannot be merged twice — the branch is untouched.

@woksin woksin closed this Aug 4, 2026
@woksin

woksin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Noting for whoever picks this work up, since this PR was closed unmerged and the branch still exists.

CI on this PR failed one job — proxy-specs (Cratis.Arc.ProxyGenerator.for_TypeExtensions). The four specs that configure type mappings race each other: SetTypeMappings replaces a static table wholesale, so two specs configuring it concurrently each resolve against the other's table. Distinct subject types do not help, because the replacement takes the whole table with it.

Fixed in b514f329 on this branch (feature/proxy-generator-type-mappings) by putting them in a collection with DisableParallelization. Measured on the filtered job CI runs: 9 failures in 10 runs without it, 0 in 10 with it. Full assembly 1055 pass.

⚠️ Any branch that carries this type-mapping seam without b514f329 will hit the same failure. It reproduces on the filtered for_TypeExtensions job and generally does not on a full-assembly run, which is why it reached CI in the first place.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants