From 5da5384f178d2874854ac0c75d72c2fbd8f56529 Mon Sep 17 00:00:00 2001 From: Alexander Gluschenko Date: Fri, 27 Mar 2026 21:28:42 +0400 Subject: [PATCH] Add nullable 'null' strategy for TypeScript output Introduce a --nullable option to emit nullable C# members and method signatures as explicit null unions (instead of optional/undefined). Add TypeScriptTypeMapper to wrap mapped types with nullable unions and handle generic wrappers (Task/AsyncEnumerable/ChannelReader). Propagate NullableStrategy through the generator (App, TypedSignalRTranspilationOptions) and switch mappings in InterfaceTranspiler and MethodSymbolExtensions to use the new mapper. Update README, CI workflow, launch settings, and tests (server hub/interface and a TypeScript test) to cover the json-null output. Also bump Tapper packages to 1.15.0. --- .github/workflows/build-and-test.yaml | 3 + Directory.Packages.props | 4 +- README.md | 32 +++++++- .../App.cs | 8 +- .../Properties/launchSettings.json | 4 + .../InterfaceTranspiler.cs | 11 ++- .../Templates/MethodSymbolExtensions.cs | 10 +-- .../TypeScriptTypeMapper.cs | 76 +++++++++++++++++++ .../TypedSignalRTranspilationOptions.cs | 4 +- tests/README.md | 4 +- .../src/json-null/nullableStrategy.test.ts | 23 ++++++ .../Hubs/UnaryHub.cs | 7 ++ .../IUnaryHub.cs | 1 + 13 files changed, 167 insertions(+), 20 deletions(-) create mode 100644 src/TypedSignalR.Client.TypeScript/TypeScriptTypeMapper.cs create mode 100644 tests/TypeScriptTests/src/json-null/nullableStrategy.test.ts diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index d86861b..070b222 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -64,6 +64,9 @@ jobs: - name: Generate TypeScript code for test (JSON) run: dotnet run --project ./src/TypedSignalR.Client.TypeScript.Generator/TypedSignalR.Client.TypeScript.Generator.csproj --framework ${{ env.DOTNET_TFM }} --no-build -- --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ./tests/TypeScriptTests/src/generated/json + - name: Generate TypeScript code for test (JSON nullable as null) + run: dotnet run --project ./src/TypedSignalR.Client.TypeScript.Generator/TypedSignalR.Client.TypeScript.Generator.csproj --framework ${{ env.DOTNET_TFM }} --no-build -- --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ./tests/TypeScriptTests/src/generated/json-null --nullable null + - name: Generate TypeScript code for test (MessagePack) run: dotnet run --project ./src/TypedSignalR.Client.TypeScript.Generator/TypedSignalR.Client.TypeScript.Generator.csproj --framework ${{ env.DOTNET_TFM }} --no-build -- --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ./tests/TypeScriptTests/src/generated/msgpack --serializer MessagePack --naming-style none diff --git a/Directory.Packages.props b/Directory.Packages.props index b66012f..6b323ea 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,8 +20,8 @@ - - + + diff --git a/README.md b/README.md index b970893..17ae5a6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ TypedSignalR.Client.TypeScript is a library/CLI tool that analyzes SignalR hub a - [Packages](#packages) - [Install Using .NET Tool](#install-using-net-tool) - [Usage](#usage) +- [Nullable Style](#nullable-style) - [Transpile the Types Contained in Referenced Assemblies](#transpile-the-types-contained-in-referenced-assemblies) - [Supported Types](#supported-types) - [Built-in Supported Types](#built-in-supported-types) @@ -54,6 +55,7 @@ Please see the [Install Using .NET Tool](#install-using-net-tool) and [Usage](#u ```bash $ dotnet tsrts --project path/to/Project.csproj --output generated +$ dotnet tsrts --project path/to/Project.csproj --output generated --nullable null ``` ```ts @@ -187,6 +189,12 @@ This command analyzes C# and generates TypeScript code. $ dotnet tsrts --project path/to/Project.csproj --output generated ``` +If you want nullable members and nullable hub/receiver method types to use `null` instead of `undefined`, pass `--nullable null`. + +```bash +$ dotnet tsrts --project path/to/Project.csproj --output generated --nullable null +``` + The generated code can be used as follows. There are two important APIs that are generated. @@ -228,6 +236,24 @@ const participants = await hubProxy.getParticipants() // ... ``` +## Nullable Style + +TypedSignalR.Client.TypeScript forwards Tapper's nullable strategy and also applies it to generated hub and receiver method signatures. + +Use the default `undefined` style to keep nullable reference type members as optional properties. + +```bash +$ dotnet tsrts --project path/to/Project.csproj --output generated --nullable undefined +``` + +Use `null` when you want explicit `null` unions in generated TypeScript. + +```bash +$ dotnet tsrts --project path/to/Project.csproj --output generated --nullable null +``` + +With `--nullable null`, a nullable C# property such as `string? Text` is emitted as `text: string | null`, and a nullable hub method such as `Task EchoNullableText(string? value)` is emitted with `string | null` in both the parameter and the return type. + ## Transpile the Types Contained in Referenced Assemblies By default, only types defined in the project specified by the --project option are targeted for transpiling. By passing the --asm true option, types contained in project/package reference assemblies will also be targeted for transpiling. @@ -244,7 +270,7 @@ Here is a brief introduction of which types are supported. ### Built-in Supported Types -`bool` `byte` `sbyte` `char` `decimal` `double` `float` `int` `uint` `long` `ulong` `short` `ushort` `object` `string` `Uri` `Guid` `DateTime` `System.Nullable` `byte[]` `T[]` `System.Array` `ArraySegment` `List` `LinkedList` `Queue` `Stack` `HashSet` `IEnumerable` `IReadOnlyCollection` `ICollection` `IList` `ISet` `Dictionary` `IDictionary` `IReadOnlyDictionary` `Tuple` +`bool` `byte` `sbyte` `char` `decimal` `double` `float` `int` `uint` `long` `ulong` `short` `ushort` `object` `string` `Uri` `Guid` `DateTime` `System.Nullable` (`undefined` or `null`) `byte[]` `T[]` `System.Array` `ArraySegment` `List` `LinkedList` `Queue` `Stack` `HashSet` `IEnumerable` `IReadOnlyCollection` `ICollection` `IList` `ISet` `Dictionary` `IDictionary` `IReadOnlyDictionary` `Tuple` ### User Defined Types @@ -465,7 +491,7 @@ jobs: - run: dotnet tool install --global TypedSignalR.Client.TypeScript.Generator - - run: dotnet tsrts --project ./xxx/yyyy/zzz.csproj --output ${{ github.workspace }}/generated + - run: dotnet tsrts --project ./xxx/yyyy/zzz.csproj --output ${{ github.workspace }}/generated --nullable null - uses: actions/upload-artifact@v4 with: @@ -479,7 +505,7 @@ Add it to your build steps in your csproj. ```xml - + ``` diff --git a/src/TypedSignalR.Client.TypeScript.Generator/App.cs b/src/TypedSignalR.Client.TypeScript.Generator/App.cs index a01881c..61bf549 100644 --- a/src/TypedSignalR.Client.TypeScript.Generator/App.cs +++ b/src/TypedSignalR.Client.TypeScript.Generator/App.cs @@ -39,6 +39,8 @@ public async Task Transpile( NamingStyle namingStyle = NamingStyle.CamelCase, [Option(Description ="Enum representation style")] EnumStyle @enum = EnumStyle.Value, + [Option("nullable", Description ="Nullable representation style. 'undefined' keeps optional properties, 'null' emits explicit null unions.")] + NullableStrategy nullable = NullableStrategy.Undefined, [Option('m', Description = "Method naming style. If none is selected, the C# name is used as it is.")] MethodStyle method = MethodStyle.CamelCase, [Option("attr", Description ="The flag whether attributes such as JsonPropertyName should affect transpilation.")] @@ -52,7 +54,7 @@ public async Task Transpile( { var compilation = await this.CreateCompilationAsync(project); - await TranspileCore(compilation, output, newLine, 4, assemblies, serializer, namingStyle, @enum, method, attribute); + await TranspileCore(compilation, output, newLine, 4, assemblies, serializer, namingStyle, @enum, nullable, method, attribute); _logger.Log(LogLevel.Information, "======== Transpilation is completed. ========"); _logger.Log(LogLevel.Information, "Please check the output folder: {output}", output); @@ -91,6 +93,7 @@ private async Task TranspileCore( SerializerOption serializerOption, NamingStyle namingStyle, EnumStyle enumStyle, + NullableStrategy nullableStrategy, MethodStyle methodStyle, bool enableAttributeReference) { @@ -124,7 +127,8 @@ private async Task TranspileCore( newLine, indent, referencedAssembliesTranspilation, - enableAttributeReference + enableAttributeReference, + nullableStrategy ); // Tapper diff --git a/src/TypedSignalR.Client.TypeScript.Generator/Properties/launchSettings.json b/src/TypedSignalR.Client.TypeScript.Generator/Properties/launchSettings.json index 60a5fa3..5c06e96 100644 --- a/src/TypedSignalR.Client.TypeScript.Generator/Properties/launchSettings.json +++ b/src/TypedSignalR.Client.TypeScript.Generator/Properties/launchSettings.json @@ -16,6 +16,10 @@ "commandName": "Project", "commandLineArgs": "--project ..\\..\\..\\..\\..\\tests\\TypedSignalR.Client.TypeScript.Tests.Shared\\TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ..\\..\\..\\..\\..\\tests\\TypeScriptTests\\src\\generated\\json" }, + "Test-json-null": { + "commandName": "Project", + "commandLineArgs": "--project ..\\..\\..\\..\\..\\tests\\TypedSignalR.Client.TypeScript.Tests.Shared\\TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ..\\..\\..\\..\\..\\tests\\TypeScriptTests\\src\\generated\\json-null --nullable null" + }, "Test-msgpack": { "commandName": "Project", "commandLineArgs": "--project ..\\..\\..\\..\\..\\tests\\TypedSignalR.Client.TypeScript.Tests.Shared\\TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ..\\..\\..\\..\\..\\tests\\TypeScriptTests\\src\\generated\\msgpack --serializer MessagePack --naming-style none" diff --git a/src/TypedSignalR.Client.TypeScript/InterfaceTranspiler.cs b/src/TypedSignalR.Client.TypeScript/InterfaceTranspiler.cs index b39bfff..98f2306 100644 --- a/src/TypedSignalR.Client.TypeScript/InterfaceTranspiler.cs +++ b/src/TypedSignalR.Client.TypeScript/InterfaceTranspiler.cs @@ -5,7 +5,6 @@ using Microsoft.CodeAnalysis; using Microsoft.Extensions.Logging; using Tapper; -using Tapper.TypeMappers; namespace TypedSignalR.Client.TypeScript; @@ -263,7 +262,7 @@ private static void WriteParameters(IMethodSymbol methodSymbol, ITranspilationOp return; } - codeWriter.Append($"{parameter.Name}: {TypeMapper.MapTo(parameter.Type, options)}"); + codeWriter.Append($"{parameter.Name}: {TypeScriptTypeMapper.MapTo(parameter.Type, specialSymbols, (ITypedSignalRTranspilationOptions)options)}"); return; } @@ -271,7 +270,7 @@ private static void WriteParameters(IMethodSymbol methodSymbol, ITranspilationOp .Select(x => SymbolEqualityComparer.Default.Equals(x.Type, specialSymbols.CancellationTokenSymbol) ? null - : $"{x.Name}: {TypeMapper.MapTo(x.Type, options)}") + : $"{x.Name}: {TypeScriptTypeMapper.MapTo(x.Type, specialSymbols, (ITypedSignalRTranspilationOptions)options)}") .Where(x => x is not null); codeWriter.Append(string.Join(", ", paramStrings)); @@ -304,7 +303,7 @@ private static void WriteReturnType( if (SymbolEqualityComparer.Default.Equals(returnType.OriginalDefinition, specialSymbols.AsyncEnumerableSymbol)) { var typeArg = ((INamedTypeSymbol)returnType).TypeArguments[0]; - codeWriter.Append($"IStreamResult<{TypeMapper.MapTo(typeArg, options)}>"); + codeWriter.Append($"IStreamResult<{TypeScriptTypeMapper.MapTo(typeArg, specialSymbols, (ITypedSignalRTranspilationOptions)options)}>"); return; } @@ -322,14 +321,14 @@ private static void WriteReturnType( { var typeArg2 = namedTypeArg.TypeArguments[0]; - codeWriter.Append($"IStreamResult<{TypeMapper.MapTo(typeArg2, options)}>"); + codeWriter.Append($"IStreamResult<{TypeScriptTypeMapper.MapTo(typeArg2, specialSymbols, (ITypedSignalRTranspilationOptions)options)}>"); return; } } } } - codeWriter.Append(TypeMapper.MapTo(returnType, options)); + codeWriter.Append(TypeScriptTypeMapper.MapTo(returnType, specialSymbols, (ITypedSignalRTranspilationOptions)options)); } private static XmlDocument? GetDocumentationComment(ISymbol symbol) diff --git a/src/TypedSignalR.Client.TypeScript/Templates/MethodSymbolExtensions.cs b/src/TypedSignalR.Client.TypeScript/Templates/MethodSymbolExtensions.cs index d49e2b2..8161f73 100644 --- a/src/TypedSignalR.Client.TypeScript/Templates/MethodSymbolExtensions.cs +++ b/src/TypedSignalR.Client.TypeScript/Templates/MethodSymbolExtensions.cs @@ -30,7 +30,7 @@ static string ParametersToTypeArray(IMethodSymbol methodSymbol, SpecialSymbols s ? methodSymbol.Parameters.SkipLast(1) // Ignore if the last parameter of a receiver's method is a CancellationToken. : methodSymbol.Parameters; - var parameters = methodParameters.Select(x => TypeMapper.MapTo(x.Type, options)); + var parameters = methodParameters.Select(x => TypeScriptTypeMapper.MapTo(x.Type, specialSymbols, options)); return $"[{string.Join(", ", parameters)}]"; } @@ -52,7 +52,7 @@ private static string ParametersToTypeScriptString(this IMethodSymbol methodSymb { var parameters = methodSymbol.Parameters .Where(x => !SymbolEqualityComparer.Default.Equals(x.Type, specialSymbols.CancellationTokenSymbol)) - .Select(x => $"{x.Name}: {TypeMapper.MapTo(x.Type, options)}"); + .Select(x => $"{x.Name}: {TypeScriptTypeMapper.MapTo(x.Type, specialSymbols, options)}"); return string.Join(", ", parameters); } @@ -77,7 +77,7 @@ private static string ReturnTypeToTypeScriptString(this IMethodSymbol methodSymb { var typeArg = (returnType as INamedTypeSymbol)!.TypeArguments[0]; - return $"IStreamResult<{TypeMapper.MapTo(typeArg, options)}>"; + return $"IStreamResult<{TypeScriptTypeMapper.MapTo(typeArg, specialSymbols, options)}>"; } // Task>, Task> @@ -90,11 +90,11 @@ private static string ReturnTypeToTypeScriptString(this IMethodSymbol methodSymb { var typeArg2 = (typeArg as INamedTypeSymbol)!.TypeArguments[0]; - return $"IStreamResult<{TypeMapper.MapTo(typeArg2, options)}>"; + return $"IStreamResult<{TypeScriptTypeMapper.MapTo(typeArg2, specialSymbols, options)}>"; } } - return TypeMapper.MapTo(methodSymbol.ReturnType, options); + return TypeScriptTypeMapper.MapTo(methodSymbol.ReturnType, specialSymbols, options); } private static string CreateUnaryMethodString(IMethodSymbol methodSymbol, SpecialSymbols specialSymbols, ITypedSignalRTranspilationOptions options) diff --git a/src/TypedSignalR.Client.TypeScript/TypeScriptTypeMapper.cs b/src/TypedSignalR.Client.TypeScript/TypeScriptTypeMapper.cs new file mode 100644 index 0000000..04c8838 --- /dev/null +++ b/src/TypedSignalR.Client.TypeScript/TypeScriptTypeMapper.cs @@ -0,0 +1,76 @@ +using Microsoft.CodeAnalysis; +using Tapper; +using Tapper.TypeMappers; + +namespace TypedSignalR.Client.TypeScript; + +internal static class TypeScriptTypeMapper +{ + public static string MapTo(ITypeSymbol typeSymbol, SpecialSymbols specialSymbols, ITypedSignalRTranspilationOptions options) + { + if (TryMapNullableValueType(typeSymbol, specialSymbols, options, out var nullableValueType)) + { + return nullableValueType; + } + + if (TryMapGenericWrapper(typeSymbol, specialSymbols, options, out var genericWrapper)) + { + return genericWrapper; + } + + var mappedType = TypeMapper.MapTo(typeSymbol, options); + + if (!typeSymbol.IsValueType && typeSymbol.NullableAnnotation is NullableAnnotation.Annotated) + { + return $"({mappedType} | {options.GetNullableUnionLiteral()})"; + } + + return mappedType; + } + + private static bool TryMapNullableValueType(ITypeSymbol typeSymbol, SpecialSymbols specialSymbols, ITypedSignalRTranspilationOptions options, out string mappedType) + { + if (typeSymbol is INamedTypeSymbol namedTypeSymbol + && namedTypeSymbol.IsGenericType + && namedTypeSymbol.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + mappedType = $"({MapTo(namedTypeSymbol.TypeArguments[0], specialSymbols, options)} | {options.GetNullableUnionLiteral()})"; + return true; + } + + mappedType = string.Empty; + return false; + } + + private static bool TryMapGenericWrapper(ITypeSymbol typeSymbol, SpecialSymbols specialSymbols, ITypedSignalRTranspilationOptions options, out string mappedType) + { + if (typeSymbol is not INamedTypeSymbol namedTypeSymbol || !namedTypeSymbol.IsGenericType) + { + mappedType = string.Empty; + return false; + } + + if (SymbolEqualityComparer.Default.Equals(namedTypeSymbol.OriginalDefinition, specialSymbols.GenericTaskSymbol)) + { + mappedType = $"Promise<{MapTo(namedTypeSymbol.TypeArguments[0], specialSymbols, options)}>"; + return true; + } + + if (specialSymbols.AsyncEnumerableSymbol is not null + && SymbolEqualityComparer.Default.Equals(namedTypeSymbol.OriginalDefinition, specialSymbols.AsyncEnumerableSymbol)) + { + mappedType = $"Subject<{MapTo(namedTypeSymbol.TypeArguments[0], specialSymbols, options)}>"; + return true; + } + + if (specialSymbols.ChannelReaderSymbol is not null + && SymbolEqualityComparer.Default.Equals(namedTypeSymbol.OriginalDefinition, specialSymbols.ChannelReaderSymbol)) + { + mappedType = $"Subject<{MapTo(namedTypeSymbol.TypeArguments[0], specialSymbols, options)}>"; + return true; + } + + mappedType = string.Empty; + return false; + } +} diff --git a/src/TypedSignalR.Client.TypeScript/TypedSignalRTranspilationOptions.cs b/src/TypedSignalR.Client.TypeScript/TypedSignalRTranspilationOptions.cs index 74cacfd..d2ad6d7 100644 --- a/src/TypedSignalR.Client.TypeScript/TypedSignalRTranspilationOptions.cs +++ b/src/TypedSignalR.Client.TypeScript/TypedSignalRTranspilationOptions.cs @@ -12,6 +12,7 @@ public TypedSignalRTranspilationOptions(Compilation compilation, SerializerOption serializerOption, NamingStyle namingStyle, EnumStyle enumStyle, + NullableStrategy nullableStrategy, MethodStyle methodStyle, NewLineOption newLineOption, int indent, @@ -25,7 +26,8 @@ public TypedSignalRTranspilationOptions(Compilation compilation, newLineOption, indent, referencedAssembliesTranspilation, - enableAttributeReference) + enableAttributeReference, + nullableStrategy) { MethodStyle = methodStyle; } diff --git a/tests/README.md b/tests/README.md index 873df57..2d55810 100644 --- a/tests/README.md +++ b/tests/README.md @@ -9,7 +9,9 @@ $ dotnet run --project ./tests/TypedSignalR.Client.TypeScript.Tests.Server/Typed Generate TypeScript code. ``` -$ dotnet tsrts --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --out ./tests/TypeScriptTests/src/generated +$ dotnet tsrts --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ./tests/TypeScriptTests/src/generated/json +$ dotnet tsrts --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ./tests/TypeScriptTests/src/generated/json-null --nullable null +$ dotnet tsrts --project ./tests/TypedSignalR.Client.TypeScript.Tests.Shared/TypedSignalR.Client.TypeScript.Tests.Shared.csproj --output ./tests/TypeScriptTests/src/generated/msgpack --serializer MessagePack --naming-style none ``` Launch tests. diff --git a/tests/TypeScriptTests/src/json-null/nullableStrategy.test.ts b/tests/TypeScriptTests/src/json-null/nullableStrategy.test.ts new file mode 100644 index 0000000..64279bf --- /dev/null +++ b/tests/TypeScriptTests/src/json-null/nullableStrategy.test.ts @@ -0,0 +1,23 @@ +import { getHubProxyFactory } from '../generated/json-null/TypedSignalR.Client' +import { IUnaryHub } from '../generated/json-null/TypedSignalR.Client/TypedSignalR.Client.TypeScript.Tests.Shared'; +import { MyRequestItem } from '../generated/json-null/TypedSignalR.Client.TypeScript.Tests.Shared'; + +type Expect = T; +type IsEqual = + (() => T extends A ? 1 : 2) extends + (() => T extends B ? 1 : 2) ? true : false; + +type IsOptional = {} extends Pick ? true : false; + +type TextPropertyIsRequired = Expect, false>>; +type TextPropertyUsesNull = Expect>; +type NullableMethodParameter = Expect[0], string | null>>; +type NullableMethodReturn = Expect>, string | null>>; + +test('nullableStrategy.test', () => { + const item: MyRequestItem = { text: null }; + const factory = getHubProxyFactory("IUnaryHub"); + + expect(item.text).toBeNull(); + expect(factory).toBeDefined(); +}); diff --git a/tests/TypedSignalR.Client.TypeScript.Tests.Server/Hubs/UnaryHub.cs b/tests/TypedSignalR.Client.TypeScript.Tests.Server/Hubs/UnaryHub.cs index 160ab9b..e04be26 100644 --- a/tests/TypedSignalR.Client.TypeScript.Tests.Server/Hubs/UnaryHub.cs +++ b/tests/TypedSignalR.Client.TypeScript.Tests.Server/Hubs/UnaryHub.cs @@ -40,6 +40,13 @@ public Task EchoMyEnum(MyEnum myEnum) return Task.FromResult(myEnum); } + public Task EchoNullableText(string? value) + { + _logger.Log(LogLevel.Information, "UnaryHub.EchoNullableText"); + + return Task.FromResult(value); + } + public Task Get() { _logger.Log(LogLevel.Information, "UnaryHub.Get"); diff --git a/tests/TypedSignalR.Client.TypeScript.Tests.Shared/IUnaryHub.cs b/tests/TypedSignalR.Client.TypeScript.Tests.Shared/IUnaryHub.cs index bb8baea..dcf1217 100644 --- a/tests/TypedSignalR.Client.TypeScript.Tests.Shared/IUnaryHub.cs +++ b/tests/TypedSignalR.Client.TypeScript.Tests.Shared/IUnaryHub.cs @@ -54,6 +54,7 @@ public partial interface IUnaryHub { Task Echo(UserDefinedType instance); Task EchoMyEnum(MyEnum myEnum); + Task EchoNullableText(string? value); Task RequestArray(MyRequestItem[] array); Task> RequestList(List list); }