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:

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

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 } | 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:

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 completes

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

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

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

On this page