Transform
Transform response data with full type inference
The Transform plugin enables per-request transformation of response data with full type inference.
Installation
npm install @spoosh/plugin-transformSetup
import { Spoosh } from "@spoosh/core";
import { transformPlugin } from "@spoosh/plugin-transform";
const client = new Spoosh<ApiSchema, Error>("/api").use([transformPlugin()]);Usage
Response Transforms (injectRead)
Response transforms produce a separate transformedData field in meta while preserving the original data:
posts = injectRead((api) => api("posts").GET(), {
transform: (posts) => ({
count: posts.length,
hasMore: posts.length >= 10,
ids: posts.map((p) => p.id),
}),
});
// posts.data() = Post[] (original response, preserved)
// posts.meta().transformedData = { count: number, hasMore: boolean, ids: number[] } | undefinedThe meta().transformedData type is automatically inferred from your transformer's return type.
Response Transforms (injectWrite)
Just like injectRead, response transforms for injectWrite produce a separate transformedData field in meta:
createPost = injectWrite((api) => api("posts").POST);
const response = await createPost.trigger({
body: { title: "New Post" },
transform: (post) => ({
success: true,
postId: post.id,
createdAt: new Date(post.timestamp),
}),
});
// After trigger completes:
// createPost.data() = Post (original response, preserved)
// createPost.meta().transformedData = { success: boolean, postId: number, createdAt: Date } | undefinedTypeScript Limitation: Due to TypeScript's limitations with dynamic trigger options, meta().transformedData is typed as never in the result. You'll need to use type assertion to access it:
createPost = injectWrite((api) => api("posts").POST);
// Type assertion required
const typed = createPost.meta().transformedData as
| { success: boolean; postId: number }
| undefined;Alternatively, extract it from the trigger response:
createPost = injectWrite((api) => api("posts").POST);
const response = await createPost.trigger({
body: { title: "New Post" },
transform: (post) => ({ success: true, postId: post.id }),
});
// Access from response
const result = response.data; // Post
// meta().transformedData is available in the state after trigger completesResponse Transform Examples
Transform and Analyze Data
Transform and analyze response data:
analytics = injectRead((api) => api("analytics").GET(), {
transform: (analytics) => ({
totalViews: analytics.reduce((sum, a) => sum + a.views, 0),
averageEngagement:
analytics.reduce((sum, a) => sum + a.engagement, 0) / analytics.length,
topPerformer: analytics.sort((a, b) => b.views - a.views)[0],
}),
});
// analytics.data() = original analytics array
// analytics.meta().transformedData = computed summary with totals and top performerAsync Transforms
All transform functions support async operations:
posts = injectRead((api) => api("posts").GET(), {
transform: async (posts) => {
const enriched = await enrichPostsWithMetadata(posts);
return {
count: enriched.length,
titles: enriched.map((p) => p.title),
};
},
});Removing Data
Return undefined to remove the data entirely:
posts = injectRead((api) => api("posts").GET(), {
transform: (posts) => {
if (posts.length === 0) return undefined;
return {
count: posts.length,
titles: posts.map((p) => p.title),
};
},
});Type Inference
The transform plugin provides full type inference:
// Response type is inferred from endpoint (injectRead)
users = injectRead((api) => api("users").GET(), {
transform: (users) => {
// 'users' is typed as User[]
return {
activeCount: users.filter((u) => u.active).length,
admins: users.filter((u) => u.role === "admin"),
};
},
});
// users.meta().transformedData is typed as:
// { activeCount: number; admins: User[] } | undefinedTypeScript Limitations with injectWrite
For injectWrite, there is a TypeScript limitation where meta().transformedData is inferred as never in the result due to dynamic trigger options:
createPost = injectWrite((api) => api("posts").POST);
// meta().transformedData is typed as 'never' here because TypeScript cannot infer
// the type from dynamic trigger options passed later
// ❌ Type error: transformedData is never
console.log(createPost.meta().transformedData.success);
// ✅ Use type assertion
const typed = createPost.meta().transformedData as
| { success: boolean; postId: number }
| undefined;
console.log(typed?.success);This is a known TypeScript limitation with higher-order function type inference. For injectRead, the options are passed at function creation time, allowing full type inference. For injectWrite, options are passed to trigger dynamically, which breaks TypeScript's ability to infer the result type.
Workaround: Define your transformed type separately:
type TransformedPost = {
success: boolean;
postId: number;
createdAt: Date;
};
createPost = injectWrite((api) => api("posts").POST);
// Use type assertion
const transformed = createPost.meta().transformedData as
| TransformedPost
| undefined;Use Cases
Compute Derived Data
orders = injectRead((api) => api("orders").GET(), {
transform: (orders) => ({
total: orders.reduce((sum, o) => sum + o.amount, 0),
averageOrder: orders.reduce((sum, o) => sum + o.amount, 0) / orders.length,
pendingCount: orders.filter((o) => o.status === "pending").length,
}),
});Extract Specific Fields
posts = injectRead((api) => api("posts").GET(), {
transform: (posts) => ({
ids: posts.map((p) => p.id),
titles: posts.map((p) => p.title),
publishedCount: posts.filter((p) => p.published).length,
}),
});Enrich Response Data
users = injectRead((api) => api("users").GET(), {
transform: (users) => ({
totalUsers: users.length,
activeUsers: users.filter((u) => u.active),
usersByRole: users.reduce(
(acc, u) => {
acc[u.role] = (acc[u.role] || 0) + 1;
return acc;
},
{} as Record<string, number>
),
}),
});