DEV Community

Deva Krishna
Deva Krishna

Posted on

The False Security of tRPC Type Safety: Catching Serialization Bugs at Compile Time

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
// CLIENT
const event = await client.getEvent.query();

// ❌ Error at runtime
console.log(event.date.format('YYYY-MM-DD'));
Enter fullscreen mode Exit fullscreen mode

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:

  1. Server Creates a real Dayjs object with all its methods.
  2. SuperJSON converts it to JSON for transport, in this case it converts Dayjs object into a ISOString.
  3. 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'
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

If AllOutputsSerializable is true, this compiles fine.

If it's false, TypeScript throws:

Type 'false' does not satisfy the constraint 'true'.
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

How to Use This

  1. Create a file called router-type-check.ts (or similar)
  2. Paste the code above
  3. Import your AppRouter type
  4. Run tsc (or let your IDE do it)

If you have an unserializable type, you'll get a compile error. To fix it:

  1. Register the type with SuperJSON using registerCustom()
  2. Add the type to the SafePrimitive union

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:

  1. Register it with SuperJSON (runtime fix)
  2. 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>
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)