If you're using tRPC, you probably feel invincible. Full end-to-end type safety. Your IDE autocompletes everything, TypeScript validates your queries, and life is good.
Until it isn't.
Yesterday, I returned a dayjs object from a tRPC procedure. TypeScript was happy. My IDE showed all the methods. And then my app crashed at runtime with:
TypeError: format is not a function
Let me show what happened, why it happened, and how I built a TypeScript utility that catches this class of bugs at compile time before they ever reach production.
The Problem: A Tale of Two Types
Here is the scenario. We are using superjson and dayjs.
Here's the code that broke production:
// SERVER
const appRouter = t.router({
getEvent: t.procedure.query(() => ({
name: "Tech Conference 2025",
// Returning a Dayjs instance
date: dayjs('2025-11-28'),
})),
});
export type AppRouter = typeof appRouter;
// CLIENT
const event = await client.getEvent.query();
// ❌ Error at runtime
console.log(event.date.format('YYYY-MM-DD'));
TypeScript sees event.date as type Dayjs. It autocompletes .format(), .add(), .subtract() the full API. Everything looks fine.
But at runtime? event.date is just a plain string. There's no .format() method. The app crashed.
Why Does This Happen?
The culprit is serialization. When data travels from server to client, it goes through JSON serialization. And JSON doesn't know what a Dayjs object is.
Here's what actually happens:
- Server Creates a real Dayjs object with all its methods.
- SuperJSON converts it to JSON for transport, in this case it converts Dayjs object into a ISOString.
- Client Receives ISOString, but not a Dayjs instance
SuperJSON is smarter than regular JSON.stringify(), it handles Date, Map, Set, BigInt, and more. But it doesn't know about dayjs out of the box. So it falls back to string, and doesn't reconstruct it on the other side.
The type system, however, has no idea this happened. It still thinks you have a Dayjs object.
The Quick Fix: Teaching SuperJSON About Dayjs
Before we dive into the compile-time solution, here's how to actually fix the runtime behavior:
import superjson from 'superjson';
import dayjs, { Dayjs } from 'dayjs';
superjson.registerCustom<Dayjs, string>(
{
isApplicable: (v): v is Dayjs => dayjs.isDayjs(v),
serialize: (v) => v.toISOString(),
deserialize: (v) => dayjs(v),
},
'dayjs'
);
Now SuperJSON knows how to serialize dayjs objects and crucially how to reconstruct them on the client side.
Problem solved. But here's the major issue:
What about the next library I use that has the same problem?
What about Decimal.js? What about that custom class my teammate adds next month?
I wanted TypeScript to catch these issues before they become runtime errors.
Building a Compile-Time Validator
The goal: create a type that analyzes all tRPC router outputs and fails compilation if any of them contain non-serializable values.
Let's build this step by step.
Step 1: Define What "Safe" Means
First, we need to define what types are safe to serialize. These are types that either:
- Are primitives (string, number, boolean, null, undefined)
- Are already handled by SuperJSON (Date, BigInt)
- Are types we've explicitly registered (like Dayjs after our fix)
- Are arrays or objects composed entirely of safe types
type SafePrimitive =
| string
| number
| boolean
| null
| undefined
| Date // SuperJSON handles this
| bigint // SuperJSON handles this
| void;
Step 2: Create a Type-Level Validator
Now we need a type that recursively checks whether a type is "safe." If it is, it returns true. If it isn't, it returns the problematic type itself (so we can see what's wrong).
type IsSerializable<T> =
// Is it a primitive we know is safe?
T extends SafePrimitive
? true
// Is it a function? Functions can't be serialized!
: T extends (...args: any[]) => any
? T // Return the function type as the "error"
// Is it an array? Check the element type
: T extends Array<infer U>
? IsSerializable<U>
// Is it an object? Check all properties
: T extends object
? CheckAllProperties<T>
// Unknown type - be conservative and flag it
: T;
For objects, we need to check that every property is serializable:
type CheckAllProperties<T> = {
[K in keyof T]: IsSerializable<T[K]>;
} extends {
[K in keyof T]: true;
}
? true
: T; // Return the object type as the "error"
We map over every key in the object, check if each property is serializable, and then ask: "Does this produce an object where every value is true?"
If yes, the whole object is safe. If not, we return the object type itself so the error message shows what went wrong.
Step 3: Handle tRPC's Streaming (AsyncIterables)
tRPC supports subscriptions and streaming, which use AsyncIterable. We need to handle these too:
type YieldType<T> = T extends AsyncIterable<infer U> ? U : never;
type IsSerializable<T> =
T extends SafePrimitive
? true
: T extends AsyncIterable<unknown>
? IsSerializable<YieldType<T>> // Check what the stream yields
: T extends (...args: any[]) => any
? T
: T extends Array<infer U>
? IsSerializable<U>
: T extends object
? CheckAllProperties<T>
: T;
Step 4: Apply to All Router Outputs
Now we connect this to tRPC's type system:
import { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "./server";
type RouterOutputs = inferRouterOutputs<AppRouter>;
// Check each procedure in each router
type CheckRouter = {
[K in keyof RouterOutputs]: {
[P in keyof RouterOutputs[K]]: IsSerializable<RouterOutputs[K][P]>;
} extends {
[P in keyof RouterOutputs[K]]: true;
}
? true
: false;
};
// Final check: are ALL procedures safe?
type AllOutputsSerializable = CheckRouter extends {
[K in keyof RouterOutputs]: true;
}
? true
: false;
Step 5: The Assertion
Finally, we create a type that causes a compile error if anything is wrong:
type Expect<T extends true> = T;
// This line is the sentinel
type _AssertAllOutputsSerializable = Expect<AllOutputsSerializable>;
If AllOutputsSerializable is true, this compiles fine.
If it's false, TypeScript throws:
Type 'false' does not satisfy the constraint 'true'.
The Complete Solution
Here's everything together, ready to drop into your codebase:
import { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "./server";
import dayjs from "dayjs";
// === Type Utilities ===
type YieldType<T> = T extends AsyncIterable<infer U> ? U : never;
type Expect<T extends true> = T;
// === Serialization Safety ===
// Types that are safe to serialize over the wire
// Add types here as you register them with SuperJSON!
type SafePrimitive =
| string
| number
| boolean
| null
| undefined
| Date
| void
| bigint
| dayjs.Dayjs; // ← Add this AFTER registering with SuperJSON
type IsSerializable<T> = T extends SafePrimitive
? true
: T extends AsyncIterable<unknown>
? IsSerializable<YieldType<T>>
: T extends (...args: any[]) => any
? T // Functions are not serializable - return type for error message
: T extends Array<infer U>
? IsSerializable<U>
: T extends object
? {
[K in keyof T]: IsSerializable<T[K]>;
} extends {
[K in keyof T]: true;
}
? true
: T // Return the problematic object type
: T;
// === Router Validation ===
type RouterOutputs = inferRouterOutputs<AppRouter>;
type ValidateProcedure<K extends keyof RouterOutputs> = {
[P in keyof RouterOutputs[K]]: IsSerializable<RouterOutputs[K][P]>;
} extends {
[P in keyof RouterOutputs[K]]: true;
}
? true
: false;
type ValidateAllProcedures = {
[K in keyof RouterOutputs]: ValidateProcedure<K>;
} extends {
[K in keyof RouterOutputs]: true;
}
? true
: false;
// If this line errors, you have an unregistered type in your router outputs!
type _EnsureSerializableOutputs = Expect<ValidateAllProcedures>;
How to Use This
- Create a file called
router-type-check.ts(or similar) - Paste the code above
- Import your AppRouter type
- Run
tsc(or let your IDE do it)
If you have an unserializable type, you'll get a compile error. To fix it:
- Register the type with SuperJSON using
registerCustom() - Add the type to the
SafePrimitiveunion
The key insight: the SafePrimitive list is your contract. It says "these are the types I've verified are safe." When you add a new custom type, you must:
- Register it with SuperJSON (runtime fix)
- Add it to
SafePrimitive(compile-time acknowledgment)
This two-step process ensures you can't forget either half of the fix.
What Errors Look Like
When something is wrong, TypeScript will show:
Type 'false' does not satisfy the constraint 'true'.
Type 'false' is not assignable to type 'true'.
at type _EnsureSerializableOutputs = Expect<ValidateAllProcedures>
Getting Better Error Messages
The default error only tells you something is wrong, not what. To pinpoint the problematic type, you can manually invoke IsSerializable on specific procedure outputs:
import { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "./server";
type RouterOutputs = inferRouterOutputs<AppRouter>;
// Hover over this type or check the error message
type DebugGetEvent = IsSerializable<RouterOutputs["events"]["getEvent"]>;
// ^? type DebugGetEvent = { name: string; date: Dayjs }
When a type is serializable, you'll see true. When it's not, TypeScript returns the problematic type itself. In this example, hovering over DebugGetEvent reveals the full object type containing the Dayjs field—immediately showing you where the issue is.
You can drill down further into nested properties:
type DebugDate = IsSerializable<RouterOutputs["events"]["getEvent"]["date"]>;
// ^? type DebugDate = Dayjs
Now you know exactly which field is causing the problem. Once you register Dayjs with SuperJSON and add it to SafePrimitive, this will resolve to true.
Wrapping Up
Type safety in tRPC feels complete, but it has a blind spot: serialization. The types flow from server to client, but they describe what's returned, not what survives the journey.
By building a compile time validator, we can catch these issues before they become production incidents. It's not perfect, but it's a significant improvement over discovering the problem when a user reports a crash.
The broader lesson: when two systems meet (in this case, TypeScript's type system and JSON serialization), there's often a gap where assumptions don't hold. Finding those gaps and building bridges, even imperfect ones, is where a lot of real world engineering happens.
Found this useful? I write about TypeScript, type-level programming, and the weird corners of web development. Subscribe to catch the next one.
Follow me on X for more TypeScript tips and dev thoughts.
Top comments (0)