router icon indicating copy to clipboard operation
router copied to clipboard

Missing support for Tagged types in Server Function response

Open bm-vs opened this issue 9 months ago • 2 comments

Which project does this relate to?

Start

Describe the bug

If a server function response includes a Tagged/Branded type

type AccountId = Tagged<string, 'AccountId'>

declare const tag: unique symbol;
type AccountIdExpanded = string & {readonly [tag]: 'AccountId'}

the TS compiler fails to resolve the response type, and the handler function gets flagged as invalid, even if the underlying type is a primitive.

Your Example Website or App

https://stackblitz.com/edit/tanstack-router-swedhutz?file=src%2Futils%2Faccount.tsx&view=editor

Steps to Reproduce the Bug or Issue

  1. Create a Tagged type
  2. Create a server function
  3. Return a variable of that type from the server function

Expected behavior

When returning data that includes Tagged types, the output of server functions should be correctly inferred, and Typescript shouldn't flag any errors.

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Browser: Firefox
  • Version: 137.0.1

Additional context

I'm using Tagged/Branded types extensively in my project to make the type system more robust.

Example:

export type AccountId = Tagged<string, 'AccountId'>;
export type AccountVersionId = Tagged<string, 'AccountVersionId'>;
export type AccountUrl = Tagged<string, 'AccountUrl'>;

The problem I've run into is that, when the response includes one of these types, the field gets flagged as "unserializable" by the TS compiler:

The types of 'data.id.toString' are incompatible between these types.
    Type '() => string' is not assignable to type '"Function is not serializable"'.

This happens because the id field is no longer just a primitive type, but a union with {[k: symbol]: string}.


I traced this backed to the SerializerStringifyBy type in the router-core package and, by extension, the Serializable type on l.28.

If Serializable became Date | undefined | Error | FormData | bigint | string the check on l.8 would pass and make the serialization types support Tagged types (at least for strings, adding number broke the tests).


I get that this is a quite niche use case.

So, for anyone that runs into this, my temporary workaround was to use pnpm patch – pnpm patch @tanstack/router-core and change the Serializable type to:

export type Serializable = Date | undefined | Error | FormData | bigint | string

bm-vs avatar Apr 16 '25 10:04 bm-vs

I just want to point out on caveat on this that I mentioned in my issue:

If you have the following code, your type would technically be incorrect, as it would see the type as string, and no longer handle the attached object keys, hence why I don't think this suggestion will work in the context of tanstack router:

const id = 123456
const extendedId = Object.assign(id, {
  doSomething: () => ...
})

Personally, I think people would be better off patching the type as follows:

export type SerializerStringifyBy<T, TSerializable> = T extends TSerializable
  ? T
  : T extends Tagged<infer I>
    ? SerializerStringifyBy<I>
    : T extends (...args: Array<any>) => any
      ? 'Function is not serializable'
      : { [K in keyof T]: SerializerStringifyBy<T[K], TSerializable> }

Unfortunately this isn't a fix that can be applies at the router level, unless the router exports a tagged type that they can check against, otherwise they would have to add a condition for every possible tagged type from all libraries.

ViewableGravy avatar May 14 '25 00:05 ViewableGravy

Just wanted to update this in case anyone else comes along and wants an actual solution to using TaggedTypes (opposed to my above pseudo code type).

If you are using type-fest tagged types, here is a yarn patch with the updated type for the ValidateJSON type which will prevent serialization errors when working with structural sharing enabled. Note: The solution will be very similar for pretty much all versions of TaggedTypes, but this one specifically handles type-fest. This is not a generalized solution that can be implemented on the router side, since the implementation is different for each tagged type.

This solves the type error for structural sharing, but you may need to update the SerializerStringifyBy type with the same patch:

diff --git a/dist/esm/utils.d.ts b/dist/esm/utils.d.ts
index 920fcb3ad94818846fd27b4c11da28b383a0e35e..2648f8cf95277de83792a16798bb13510f27f22e 100644
--- a/dist/esm/utils.d.ts
+++ b/dist/esm/utils.d.ts
@@ -1,5 +1,8 @@
+import type { UnwrapTagged } from 'type-fest';
+import type { Tag } from 'type-fest/source/tagged';
 import { RouteIds } from './routeInfo.js';
 import { AnyRouter } from './router.js';
+
 import * as React from 'react';
 export type NoInfer<T> = [T][T extends any ? 0 : never];
 export type IsAny<TValue, TYesResult, TNoResult = TValue> = 1 extends 0 & TValue ? TYesResult : TNoResult;
@@ -45,9 +48,21 @@ export type MergeAllObjects<TUnion, TIntersected = UnionToIntersection<ExtractOb
 };
 export type MergeAll<TUnion> = MergeAllObjects<TUnion> | ExtractPrimitives<TUnion>;
 export type Constrain<T, TConstraint, TDefault = TConstraint> = (T extends TConstraint ? T : never) | TDefault;
-export type ValidateJSON<T> = ((...args: Array<any>) => any) extends T ? unknown extends T ? never : 'Function is not serializable' : {
-    [K in keyof T]: ValidateJSON<T[K]>;
-};
+export type ValidateJSON<T> =
+    ((...args: Array<any>) => any) extends T
+        ? unknown extends T
+            ? never
+            : 'Function is not serializable ey?'
+        : T extends Tag<PropertyKey, any>
+            ? UnwrapTagged<T> extends infer UnwrapTagged
+                ? {
+                    [K in keyof UnwrapTagged]: ValidateJSON<UnwrapTagged[K]>;
+                }
+                : never
+            : {
+                [K in keyof T]: ValidateJSON<T[K]>;
+            };
+
 export declare function last<T>(arr: Array<T>): T | undefined;
 export declare function functionalUpdate<TResult>(updater: Updater<TResult> | NonNullableUpdater<TResult>, previous: TResult): TResult;
 export declare function pick<TValue, TKey extends keyof TValue>(parent: TValue, keys: Array<TKey>): Pick<TValue, TKey>;

ViewableGravy avatar Jun 16 '25 03:06 ViewableGravy

+1 for this

We're using tagged (branded) types for all our primary keys in database, so it would be cool to support it.

akodkod avatar Jul 19 '25 15:07 akodkod

I wanted to share a quick update on this. My previous comment/patch has been working fine, but as I mentioned earlier, it isn’t something that could reliably work for all tagged libraries or implementations in its current form.

That said, I recently had an idea for how this could potentially be handled on TanStack’s side. My only hesitation is whether this approach is viable from a TypeScript performance perspective (since I’m not sure what the best way to benchmark that would be).

For reference, I also forked OP’s StackBlitz so you can try it out: https://stackblitz.com/edit/tanstack-router-tkqxakv3?file=src%2Futils%2Faccount.tsx

The general idea is to solve this with interface merging. The base interface would take a generic, and consumers could optionally define their own unwrapped type on the interface. That way, if someone registers an unwrap function (type) into the library, it gets picked up automatically. Below is a code example, since it’s easier to show than explain:

/***** LIBRARY *****/
// Interface that exists inside tanstack router
interface GeneralisedValueExtractor<T> {}

// Type that could be used in the stringify function to determine if unwrapped has been set in userland, and if not just return T
type ExtractGeneralisedValue<T> =
    GeneralisedValueExtractor<T> extends { unwrapped: infer R }
        ? R
        : T

/**** USER LAND *****/
// Custom unwrap function for tagged values
type ExtractValue<T>  = T extends Tag<PropertyKey, any>
    ? UnwrapTagged<T> extends infer UnwrapTagged
        ? UnwrapTagged
        : never
    : T

// Interface merging to override with tagged type support
interface GeneralisedValueExtractor<T> {
    unwrapped: ExtractValue<T>;
}

/***** EXAMPLE *****/
// example tagged type
type UserId = Tagged<string, 'UserId'>;

type UnwrappedUserId = ExtractGeneralisedValue<UserId>
//       ^? string

As shown in the StackBlitz, removing the user-land interface causes it to return the tagged type, while adding it back unwraps it. This pattern would give developers a way to integrate their own TaggedTypes with the router, while still retaining IntelliSense for cases where values aren’t stringifiable.

Let me know if this is something that would be useful for Tanstack Router generally, and I can look into making a PR if this kind of change is something you'd like to go ahead with.

ViewableGravy avatar Sep 24 '25 07:09 ViewableGravy

closing this as there are no typescript errors anymore. if anyone encounters a similar/related issue, please create a new github issue including a complete minimal example.

schiller-manuel avatar Sep 28 '25 20:09 schiller-manuel