T
TanStack2mo ago
absent-sapphire

createFileRoute() and createServerFn() loader type error with Vercel AI-SDK.

I get a type error in a createFileRoute() or createServerFn() loader when returning AI SDK UIMessage types. The problem is UIMessage has unknowns deeply embedded in the type structure that can't be controlled from the outside ('dynamic-tool' message parts, in particular). I realize this is because you don't know if "unknown" will be serializable. But in a case like this, I can't control the type, and we know it will be serializable because that's the purpose of AI-SDK's UIMessage. What's the best way to work around this case?
4 Replies
absent-sapphire
absent-sapphireOP2mo ago
I just realized this exact issue exists in Github too: https://github.com/TanStack/router/issues/5229 But @Manuel Schiller 's suggestion to explicitly type the metadata in that issue doesn't solve the problem, as some of the unknowns are in dynamic tool types that the AI-SDK adds, and that can't be specified from the outside. Specifically, here's the problem with AI-SDK's UIMessage: UIMessage contains parts: Array<UIMessagePart<DATA_PARTS, TOOLS>>. And UIMessagePart is
type UIMessagePart<DATA_TYPES extends UIDataTypes, TOOLS extends UITools> = TextUIPart | ReasoningUIPart | ToolUIPart<TOOLS> | DynamicToolUIPart | SourceUrlUIPart | SourceDocumentUIPart | FileUIPart | DataUIPart<DATA_TYPES> | StepStartUIPart
type UIMessagePart<DATA_TYPES extends UIDataTypes, TOOLS extends UITools> = TextUIPart | ReasoningUIPart | ToolUIPart<TOOLS> | DynamicToolUIPart | SourceUrlUIPart | SourceDocumentUIPart | FileUIPart | DataUIPart<DATA_TYPES> | StepStartUIPart
DynamicToolUIPart and ToolUIPart<TOOLS> both have this problem. For example, here is the relevant part of DynamicToolUIPart :
type DynamicToolUIPart = {
type: 'dynamic-tool';
toolName: string;
toolCallId: string;
} & ({
state: 'input-streaming';
...
} | {
state: 'input-available';
...
} | {
state: 'output-available';
input: unknown;
output: unknown;
errorText?: never;
callProviderMetadata?: ProviderMetadata;
preliminary?: boolean;
} | {
state: 'output-error';
input: unknown;
output?: never;
errorText: string;
callProviderMetadata?: ProviderMetadata;
});
type DynamicToolUIPart = {
type: 'dynamic-tool';
toolName: string;
toolCallId: string;
} & ({
state: 'input-streaming';
...
} | {
state: 'input-available';
...
} | {
state: 'output-available';
input: unknown;
output: unknown;
errorText?: never;
callProviderMetadata?: ProviderMetadata;
preliminary?: boolean;
} | {
state: 'output-error';
input: unknown;
output?: never;
errorText: string;
callProviderMetadata?: ProviderMetadata;
});
'output-available' and 'output-error' state have unknowns, and they're not controllable from the outside. I think the only viable options are to recreate the type manually or do typescript surgery on UIMessage type to replace the DynamicToolUIPart with one with more concrete types... Me and GPT-5 did some work and came up with what I think is the minimal "fix" , replacing unknowns with JSONValue:
// Create "Original" UIMessage type with unknowns that need to be replaced.
export type MyUIToolset = InferUITools<MyToolset>
type OriginalMyUIMessage = UIMessage<MyMetadata, MyDataPart, MyUIToolset>

// Create serializable DynamicToolUIPart (replace `unknown` -> JSONValue)
type DynamicToolUIPartBase = Pick<
Extract<OriginalMyUIMessage["parts"][number], { type: "dynamic-tool" }>,
"type" | "toolName" | "toolCallId"
>
type ProviderMetadata = Record<string, Record<string, JSONValue>>

type FixedDynamicToolUIPart = DynamicToolUIPartBase &
(
| {
state: "input-streaming"
input: JSONValue | undefined
}
| {
state: "input-available"
input: JSONValue
callProviderMetadata?: ProviderMetadata
}
| {
state: "output-available"
input: JSONValue
output: JSONValue
callProviderMetadata?: ProviderMetadata
preliminary?: boolean
}
| {
state: "output-error"
input: JSONValue
errorText: string
callProviderMetadata?: ProviderMetadata
}
)

type ReplaceDynamicToolUnknowns<P> = P extends { type: "dynamic-tool" }
? FixedDynamicToolUIPart
: P

// Create serializable ToolUIPart: replace `rawInput?: unknown` -> `rawInput?: JSONValue`
// Note: this applies only to tool parts (not dynamic tools) and only in the 'output-error' state
type ReplaceToolUIPartUnknowns<P> = P extends { type: `tool-${string}` }
? P extends { state: "output-error" }
? Omit<P, "rawInput"> & { rawInput?: JSONValue }
: P
: P
// Create "Original" UIMessage type with unknowns that need to be replaced.
export type MyUIToolset = InferUITools<MyToolset>
type OriginalMyUIMessage = UIMessage<MyMetadata, MyDataPart, MyUIToolset>

// Create serializable DynamicToolUIPart (replace `unknown` -> JSONValue)
type DynamicToolUIPartBase = Pick<
Extract<OriginalMyUIMessage["parts"][number], { type: "dynamic-tool" }>,
"type" | "toolName" | "toolCallId"
>
type ProviderMetadata = Record<string, Record<string, JSONValue>>

type FixedDynamicToolUIPart = DynamicToolUIPartBase &
(
| {
state: "input-streaming"
input: JSONValue | undefined
}
| {
state: "input-available"
input: JSONValue
callProviderMetadata?: ProviderMetadata
}
| {
state: "output-available"
input: JSONValue
output: JSONValue
callProviderMetadata?: ProviderMetadata
preliminary?: boolean
}
| {
state: "output-error"
input: JSONValue
errorText: string
callProviderMetadata?: ProviderMetadata
}
)

type ReplaceDynamicToolUnknowns<P> = P extends { type: "dynamic-tool" }
? FixedDynamicToolUIPart
: P

// Create serializable ToolUIPart: replace `rawInput?: unknown` -> `rawInput?: JSONValue`
// Note: this applies only to tool parts (not dynamic tools) and only in the 'output-error' state
type ReplaceToolUIPartUnknowns<P> = P extends { type: `tool-${string}` }
? P extends { state: "output-error" }
? Omit<P, "rawInput"> & { rawInput?: JSONValue }
: P
: P
// Compose all surgeries
type ReplaceUnknowns<P> = ReplaceDynamicToolUnknowns<ReplaceToolUIPartUnknowns<P>>

// Sentinel: ensure MyUIMessage is assignable to OriginalMyUIMessage
type _MyUIMessageSentinel = MyUIMessage extends OriginalMyUIMessage ? true : never
export const _myUIMessageSentinel: _MyUIMessageSentinel = true

// Final types with serializable parts:
export type MyUIMessage = Omit<OriginalMyUIMessage, "parts"> & {
parts: Array<ReplaceUnknowns<OriginalMyUIMessage["parts"][number]>>
}

export type MyUIMessagePart = MyUIMessage["parts"][number]
// Compose all surgeries
type ReplaceUnknowns<P> = ReplaceDynamicToolUnknowns<ReplaceToolUIPartUnknowns<P>>

// Sentinel: ensure MyUIMessage is assignable to OriginalMyUIMessage
type _MyUIMessageSentinel = MyUIMessage extends OriginalMyUIMessage ? true : never
export const _myUIMessageSentinel: _MyUIMessageSentinel = true

// Final types with serializable parts:
export type MyUIMessage = Omit<OriginalMyUIMessage, "parts"> & {
parts: Array<ReplaceUnknowns<OriginalMyUIMessage["parts"][number]>>
}

export type MyUIMessagePart = MyUIMessage["parts"][number]
EDIT: updated the above since I found another case of unknowns in normal static tools too. This may be enough to make it work, but it took me a few hours to figure out all the pieces and it's pretty tricky surgery just to be able to return a AI-SDK UIMessage from server to client.
extended-salmon
extended-salmon5w ago
we are thinking about a way to opt out of this check for selected server functions. but you must be aware that any of those unknowns could contains something that is not serializable at runtime and this will break then
absent-sapphire
absent-sapphireOP5w ago
That sounds good. I understand it's a pandora's box, but in this case UIMessage exists specifically to be sent to the client. Oh, and thank you for the reply! Loving Tanstack Start so far. One more note: the error message when returning something with unknown in it is not obvious. I don't know if that's fixable but it took me a while to even realize this was an issue of returning unknown.
extended-salmon
extended-salmon5w ago
@Chris Horobin can we hack the typescript error to be a bit more declarative here?

Did you find this page helpful?