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:
const 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:
const 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:
const 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:
const 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:
const 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:
const 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:
const 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)
const 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:
const 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;
};
const createPost = injectWrite((api) => api("posts").POST);
// Use type assertion
const transformed = createPost.meta().transformedData as
| TransformedPost
| undefined;Use Cases
Compute Derived Data
const 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
const 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
const 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>
),
}),
});