Skip to main content

Values and serialization

Respire separates flexible command inputs from convenient application outputs.

Keys and input values​

RespireKey accepts string, byte[], or ReadOnlyMemory<byte>. RespireValue accepts keys, text, binary data (byte[], Memory<byte>, ReadOnlyMemory<byte>, or ArraySegment<byte>), numeric primitives, booleans, Guid, DateTimeOffset, TimeSpan, and char through implicit conversions.

RespireKey key = "counter";
RespireValue value = 42;

await redis.SetAsync(key, value);

These small readonly structs keep command overloads manageable without forcing a protocol union type on every result.

Keys and values use value equality. RespireValue compares the exact bulk-string payload sent to Redis, so 5, "5", and the UTF-8 bytes for 5 are equal and share a hash code. GUIDs use the invariant D format, DateTimeOffset uses the invariant round-trip O format, TimeSpan uses the invariant constant c format, and char uses its one-character textual representation.

Typed output​

Choose the representation at the call site:

string? text = await redis.GetStringAsync("payload");
byte[]? bytes = await redis.GetBytesAsync("payload");
Order? order = await redis.GetAsync<Order>("order:42");

A missing key returns null from string, byte-array, reference-type, and explicitly nullable reads. GetAsync<T> returns default(T) for a non-nullable value type, so a missing GetAsync<int> result is 0. Numeric and condition commands return long, double, or bool as appropriate.

Missing keys versus stored defaults​

TryGetAsync<T> returns a RespireGet<T> that reports presence alongside the value, so a missing key stays distinguishable from a stored default(T) without a second existence round trip:

var (found, hits) = await redis.TryGetAsync<int>("page:hits");

if (!found) { /* key is absent */ }
else if (hits == 0) { /* key holds 0 */ }

RespireGet<T> also exposes GetValueOrDefault(fallback). The same method exists on redis.Strings and, per field, on redis.Hashes.

Object serialization​

SetAsync<T> and GetAsync<T> use RespireOptions.Serializer for object values. SystemTextJsonSerializer is the default. Supply an IRespireSerializer for another format:

var options = new RespireOptions
{
Endpoints = { new("localhost") },
Serializer = new MyMessagePackSerializer(),
};

Custom serializers must implement all four IRespireSerializer methods: generic Serialize<T> / Deserialize<T> and runtime-type Serialize(..., Type, object?) / Deserialize(Type, ...). Runtime-type methods use the supplied declared type. They have no default throwing implementation, so a missing method is reported at compile time.

Typed string, byte[], char, Boolean, and numeric primitive values bypass object serialization. Numbers use invariant Redis text. Boolean writes use Redis-native 1/0; reads also accept true/false for interoperability with existing data. Nullable forms use the same fast path when they contain a value.

Objects, enums, and other types use the configured serializer. Custom serializers therefore do not control primitive encoding. Pass a RespireValue explicitly when a command input must use raw Redis scalar conventions, as shown in Keys and input values.

Serializing overloads sit next to the RespireValue ones wherever a facet takes a single payload — Hashes.SetAsync<T>, Sets.ContainsAsync<T>, SortedSets.AddAsync<T>, and the Lists.LeftPopAsync<T> / RightPopAsync<T> reads. An argument already typed as RespireValue selects the raw overload; anything else selects the generic one. The new facet overloads preserve raw ReadOnlyMemory<byte>, textual characters, and non-finite floating-point arguments because those previously bound to RespireValue. Boolean values use the same 1/0 encoding on generic, raw, and collection-member paths.

NativeAOT and trimming​

Typed values use reflection-based System.Text.Json metadata by default. For a trimmed or NativeAOT application, generate metadata for every stored type and pass that context to Respire:

using System.Text.Json.Serialization;
using Respire;
using Respire.Serialization;

var options = new RespireOptions
{
Endpoints = { new RespireEndpoint("localhost") },
Serializer = SystemTextJsonSerializer.FromContext(AppJsonContext.Default),
};

await using var redis = await RespireClient.ConnectAsync(options);

// The generic APIs are conservatively annotated because IRespireSerializer can be
// reflection-based. This configured context makes these two calls AOT-safe.
#pragma warning disable IL2026, IL3050
await redis.SetAsync("user:1", new User("Ada", 36));
User? user = await redis.GetAsync<User>("user:1");
#pragma warning restore IL2026, IL3050

[JsonSerializable(typeof(User))]
internal partial class AppJsonContext : JsonSerializerContext
{
}

Add a [JsonSerializable] entry for each non-primitive type. Strings, byte arrays, Boolean values, and numeric values use Respire's built-in codecs and do not need generated JSON metadata. Custom serializers must implement all four IRespireSerializer members, including both Type-based methods. Forward the supplied declared type instead of substituting object or value.GetType(). For example, a serializer decorator preserves each overload:

using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using Respire.Serialization;

public sealed class ForwardingSerializer(IRespireSerializer inner) : IRespireSerializer
{
[RequiresUnreferencedCode("The wrapped serializer may use reflection.")]
[RequiresDynamicCode("The wrapped serializer may require runtime code generation.")]
public void Serialize<T>(IBufferWriter<byte> destination, T value)
=> inner.Serialize(destination, value);

[RequiresUnreferencedCode("The wrapped serializer may use reflection.")]
[RequiresDynamicCode("The wrapped serializer may require runtime code generation.")]
public T? Deserialize<T>(ReadOnlySpan<byte> payload)
=> inner.Deserialize<T>(payload);

[RequiresUnreferencedCode("The wrapped serializer may use reflection.")]
[RequiresDynamicCode("The wrapped serializer may require runtime code generation.")]
public void Serialize(IBufferWriter<byte> destination, Type type, object? value)
=> inner.Serialize(destination, type, value);

[RequiresUnreferencedCode("The wrapped serializer may use reflection.")]
[RequiresDynamicCode("The wrapped serializer may require runtime code generation.")]
public object? Deserialize(Type type, ReadOnlySpan<byte> payload)
=> inner.Deserialize(type, payload);
}

These annotations preserve the interface's conservative trimming and NativeAOT warnings. Use a source-generated serializer, as shown above, when the application requires AOT safety.

Zero-copy leased reads​

Normal reads prioritize convenient managed values. Large or hot-path payloads can opt into pooled memory:

using RespireLease lease = await redis.Strings.GetLeaseAsync("blob:4mb");

if (!lease.IsNull)
{
Process(lease.Span);
}

The memory remains valid only until Dispose. The Lease name makes that ownership obligation visible.

Streaming large reads​

GetStreamAsync exposes a Redis bulk value as a readable stream without creating a value-sized managed array:

await using Stream? value = await redis.Strings.GetStreamAsync("archive:latest");
if (value is not null)
{
await using var destination = File.Create("archive.bin");
await value.CopyToAsync(destination);
}

Respire uses a bounded 64 KiB pipe between the socket reader and your stream. If you read slowly, the socket reader pauses when that pipe fills. The connection uses one ordered response reader, so later command replies on that connection wait until the streamed value is consumed or disposed. Always dispose the stream, including when you stop early; Respire drains the remaining bulk frame before reading the next reply. A stream that is neither read nor disposed stalls every later reply on its connection. Time spent waiting for you to read does not count toward the connection's idle-read timeout, and graceful connection retirement waits for the streamed frame to finish. Use another client connection for long-running reads that must not delay ordinary commands. Streaming reads bypass the client-side value cache. The cancellation token passed to GetStreamAsync applies while waiting for the reply header; pass a token to stream read/copy calls to cancel payload consumption.

Expiry without sentinels​

Redis represents missing keys and persistent keys with negative TTL values. Respire returns RespireTtl instead:

RespireTtl expiry = await redis.Keys.ExpiryAsync("session:42");

if (!expiry.Exists) { /* missing */ }
else if (!expiry.HasExpiry) { /* persistent */ }
else Console.WriteLine(expiry.TimeToLive);