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);
}