Custom types
PaginateTypeSupport is an append-only, process-wide registry. Call it once at startup, before the first query. It is exactly what the NodaTime package uses — nothing there is privileged.
RegisterValueParser — make a type filterable
// Ulid columns are now usable in .Filterable(...) and accept "?filter.id=$eq:01JB…" from the query string.
PaginateTypeSupport.RegisterValueParser(typeof(Ulid), raw =>
Ulid.TryParse(raw, out var ulid)
? ulid
: throw new PaginateQueryException($"Value '{raw}' is not a valid ULID."));Throw PaginateQueryException for bad input — that is the message the caller sees. FormatException, ArgumentException and OverflowException answer the same 400 under the generic Value 'v' is not valid for 'field'., exactly as a malformed value on a built-in type does. Every other exception type is still a 500, so signal bad input with one of those four and nothing else. Returning null is not a way to signal it either: against a field whose type cannot hold null it is that same 400, but on a nullable or reference-typed field it reads as absence and the filter becomes IS NULL — which is what $null is for. Without a parser at all, filtering on a field of that type is 400 Filtering values for 'id' is not supported. — the message names the field, never the CLR type behind it.
You may not need this at all
If your type implements IParsable<TSelf> — the pattern minimal APIs already bind route and query values through — the engine finds its TryParse on its own, in the invariant culture, with no registration:
public readonly record struct Ticket(int Number) : IParsable<Ticket> { … }
// .Filterable("ticket", j => j.Ticket, PaginateFilterOperator.Eq) now works as it stands.There is no switch to turn that off, and it needs none: parsing only ever happens for a field you declared filterable, so whitelisting a field of type T is the opt-in. Register a parser when you want a different format from the one TryParse accepts, or a different error message.
Resolution order
registry → built-ins → IParsable<TSelf> → 400.
The registry runs first, so registering a parser for a type the engine already handles — DateTime, int, Guid — replaces the built-in one. Before 10.0.3 the registry ran last and such a registration was a silent no-op.
Two things are decided before the registry is consulted, and a parser cannot reach either:
stringis returned verbatim. A registeredstringparser is never called — the type is the wire format, so there is nothing to parse, and routing it through the registry would change what an empty value means for every existing string filter.- An empty or whitespace-only value is a
400, per Value formats. That is a grammar rule rather than a parsing one, so your parser is never handed"".
RegisterSimpleType — stop projection recursing into it
PaginateTypeSupport.RegisterSimpleType(typeof(Ulid));Automatic projection treats an unknown non-primitive target as a nested DTO to build. Marking a type as simple says "copy it, do not look inside". Needed for any struct-like value type you project directly.
RegisterProjectionConversion — convert during projection
// Ulid (entity) -> string (DTO), applied by the automatic projection builder.
PaginateTypeSupport.RegisterProjectionConversion((source, targetType) =>
source.Type == typeof(Ulid) && targetType == typeof(string)
? Expression.Call(source, nameof(Ulid.ToString), Type.EmptyTypes)
: null); // null = this conversion does not applyThe delegate receives the source member expression and the target type, and returns either the converted expression or null. Conversions are tried in registration order and the first non-null wins.
Keep the produced expression translatable — or, like Instant.ToDateTimeOffset(), cheap enough that EF evaluating it in the shaper costs nothing. An expression that forces client evaluation of the whole query is the one thing to avoid here.
Putting the three together
The three calls answer three different questions, and a type usually needs more than one:
| You want to | Register |
|---|---|
| filter on it from the query string | RegisterValueParser |
| project it as itself onto a DTO | RegisterSimpleType |
project it as something else — Ulid → string | RegisterProjectionConversion |
Registering only the parser leaves projection trying to build a nested DTO out of your value type; registering only the simple type leaves ?filter.id=$eq:… returning 400 Filtering values for 'id' is not supported. One startup block covers all of it:
public static class UlidPaginationSupport {
public static void Register() {
PaginateTypeSupport.RegisterValueParser(typeof(Ulid), raw =>
Ulid.TryParse(raw, out var ulid)
? ulid
: throw new PaginateQueryException($"Value '{raw}' is not a valid ULID."));
PaginateTypeSupport.RegisterSimpleType(typeof(Ulid));
PaginateTypeSupport.RegisterProjectionConversion((source, targetType) =>
source.Type == typeof(Ulid) && targetType == typeof(string)
? Expression.Call(source, nameof(Ulid.ToString), Type.EmptyTypes)
: null);
}
}
// Program.cs, before the first request.
UlidPaginationSupport.Register();Three properties of the registry worth knowing before you call it:
- Process-wide. There is no per-config or per-request scope; a registration affects every query in the application, and nothing can be unregistered.
- The three behave differently on a repeat call. Parsers and simple types are keyed by type, so registering the same type again replaces the previous parser — including a built-in one, since the registry is consulted first. Projection conversions are appended and tried in registration order, with the first non-
nullresult winning — so with two conversions that could both apply, registration order decides. - Safe to call concurrently, but register at startup anyway. The registry itself is thread-safe; what is not deterministic is a query that runs before the registration and therefore sees the old behaviour. Doing it lazily on first use is how that becomes an intermittent bug.
Because it is the same registry the NodaTime package uses, anything that package does to Instant and LocalDate is something you can do to a type of your own. There is no privileged path.
What it cannot do
PaginateTypeSupport teaches the engine about values, not about operators or SQL. It cannot add a filter operator, change how a pattern match reaches SQL — that is a LIKE strategy — or make an untranslatable expression translate. If EF cannot turn your conversion into SQL, registering it here does not change that; it just moves where the failure appears.