Ian Wold

Create Typed SignalR Clients with ... TypedSignalR.Client

12 March 2026 7 Minutes History Learning Dotnet

Much better to avoid magic strings

hero

SignalR is, I think, a good bidirectional networking library. I like its interface; it offers fully-typed server implementations without onerous reflection overhead - other libraries like MediatR are no-gos for me due to this. However, the typing experience in SignalR doesn't extend to its C# clients. Microsoft's own documentation recommends magic strings, disappointingly.

Curiously, Microsoft does have a Nuget package to support source-generated typed clients, and I have made good use of it. Disappointingly again though they do not reference this library in their documentation. More disappointingly yet, this is surely as they have left this in a pre-release version for years. Most disappointingly, when the release of .NET 9 brought updates to SignalR, this library was left behind, now lacking support for some SignalR uses.

Nonetheless, I have continued using Microsoft's solution; I refuse to use magic strings. A couple months ago I ran into a need to support this feature on clients, leaving me worried that this would spiral into a side project of writing my own source generator, spinning off new side projects to support yet-unfinished projects. Lucky me though, someone else has beaten me to it.

TypedSignalR.Client, aptly named, is a production-ready and up-to-date source generator for typed SignalR clients. I've switched myself over to this library, and I would recommend it. Here's how client implementations work -


As a refresher, servers can be implemented in a typed fashion using interfaces to define the clients:

public interface IClient
{
Task OnSomeEventAsync();
}
 
public class MyServer : IHub<IClient>
{
public async Task DoSomething()
{
await Clients.Group(someId).OnSomeEventAsync();
}
}

This can be called from clients using magic strings:

public class MyClient
{
private readonly HubConnection _connection;
 
public MySignalRClient(string hubUrl)
{
_connection = new HubConnectionBuilder()
.WithUrl(hubUrl)
.WithAutomaticReconnect()
.Build();
 
_connection.On("OnSomeEventAsync", OnSomeEventAsync);
}
 
public async Task DoSomethingAsync() =>
await _connection.InvokeAsync("DoSomethingAsync");
 
public async Task OnSomeEventAsync() { ... }
}

And that's all yucky. To use the new TypedSignalR.Client, we'll first need a server interface:

public interface IServer
{
Task DoSomethingAsync();
}

The server class should implement this now. We'll also want to update the client to implement IClient, but while we're at it we'll also implement a new IHubConnectionObserver interface that comes from our new Nuget package, letting us observe SignalR events.

+public class MyClient : IClient, IHubConnectionObserver
{
private readonly HubConnection _connection;
 
public MySignalRClient(string hubUrl)
{
_connection = new HubConnectionBuilder()
.WithUrl(hubUrl)
.WithAutomaticReconnect()
.Build();
 
_connection.On("OnSomeEventAsync", OnSomeEventAsync);
}
 
+ public async Task OnClosed(Exception? exception) { ... }
+ public async Task OnReconnected(string? connectionId) { ... }
+ public async Task OnReconnecting(Exception? exception) { ... }
 
public async Task DoSomethingAsync() =>
await _connection.InvokeAsync("DoSomethingAsync");
 
public async Task OnSomeEventAsync() { ... }
}

In order to call the typed server, we'll use the new _connection.CreateHubProxy extension:

public class MyClient : IClient, IHubConnectionObserver
{
private readonly HubConnection _connection;
+ private readonly IServer _serverProxy; //
public MySignalRClient(string hubUrl)
{
_connection = new HubConnectionBuilder()
.WithUrl(hubUrl)
.WithAutomaticReconnect()
.Build();
+ _serverProxy = connection.CreateHubProxy<IHub>(); //
_connection.On("OnSomeEventAsync", OnSomeEventAsync);
}
public async Task OnClosed(Exception? exception) { ... }
public async Task OnReconnected(string? connectionId) { ... }
public async Task OnReconnecting(Exception? exception) { ... }
public async Task DoSomethingAsync() =>
+ await _serverProxy.DoSomethingAsync(); //
public async Task OnSomeEventAsync() { ... }
}

Then to be rid of the magic string in the event listener we'll use the Register extension.

public class MyClient : IClient, IHubConnectionObserver
{
private readonly HubConnection _connection;
private readonly IServer _serverProxy;
+ private readonly IDisposable _subscription;
 
public MySignalRClient(string hubUrl)
{
_connection = new HubConnectionBuilder()
.WithUrl(hubUrl)
.WithAutomaticReconnect()
.Build();
 
_serverProxy = connection.CreateHubProxy<IHub>();
- _connection.On("OnSomeEventAsync", OnSomeEventAsync);
+ _subscription = connection.Register<IReceiver>(this);
}
 
public async Task OnClosed(Exception? exception) { ... }
public async Task OnReconnected(string? connectionId) { ... }
public async Task OnReconnecting(Exception? exception) { ... }
 
public async Task DoSomethingAsync() =>
await _serverProxy.DoSomethingAsync();
 
public async Task OnSomeEventAsync() { ... }
}

To wrap it up, it's good practice to implement IDisposable. Further, the library author recommends using a custom cancellation token source when creating the proxy:

+public class MyClient : IClient, IHubConnectionObserver, IDisposable
{
private readonly HubConnection _connection;
private readonly IServer _serverProxy;
private readonly IDisposable _subscription;
private readonly CancellationTokenSource _cancellationTokenSource = new();
 
public MySignalRClient(string hubUrl)
{
_connection = new HubConnectionBuilder()
.WithUrl(hubUrl)
.WithAutomaticReconnect()
.Build();
 
- _serverProxy = connection.CreateHubProxy<IHub>();
+ _serverProxy = connection.CreateHubProxy<IHub>(_cancellationTokenSource.Token);
_subscription = connection.Register<IReceiver>(this);
}
 
public async Task OnClosed(Exception? exception) { ... }
public async Task OnReconnected(string? connectionId) { ... }
public async Task OnReconnecting(Exception? exception) { ... }
 
public async Task DoSomethingAsync() =>
await _serverProxy.DoSomethingAsync();
 
public async Task OnSomeEventAsync() { ... }
 
+ public void Dispose()
+ {
+ _subscription.Dispose();
+ // ... //
+ }
}

Bit of extra work but not enough to keep the juice from being worth the squeeze!