Spoosh
Plugins

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-transform

Setup

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[] } | undefined

The 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 } | undefined

TypeScript 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 completes

Response 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 performer

Async 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[] } | undefined

TypeScript 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>
    ),
  }),
});

On this page