Spoosh
Plugin DevelopmentAdvanced

Type Safety

Making your plugins type-safe with TypeScript

Advanced Guide

This guide is for advanced plugin authors. If you're building a simple plugin, start with the Plugin Development guide first.

This guide shows you how to make your plugins fully type-safe using TypeScript generics and module augmentation.

Type Structure

Every plugin can define up to 12 type interfaces:

// types.ts
export interface MyPluginConfig {
  // Plugin-level configuration (passed to plugin factory)
  defaultTimeout?: number;
}

export interface MyReadOptions {
  // Hook-level options for read operations (passed to injectRead 2nd argument)
  timeout?: number;
}

export interface MyWriteOptions {
  // Hook-level options for write operations (passed to injectWrite 2nd argument)
  transform?: (data: unknown) => unknown;
}

export interface MyWriteTriggerOptions {
  // Trigger-level options for write operations (passed to trigger())
  invalidate?: string[];
}

export interface MyInfiniteReadOptions {
  // Hook-level options for infinite read operations
  timeout?: number;
}

export interface MyQueueOptions {
  // Hook-level options for queue operations (passed to injectQueue 2nd argument)
  retries?: number;
}

export interface MyQueueTriggerOptions {
  // Trigger-level options for queue operations (passed to trigger())
  priority?: number;
}

export interface MyReadResult {
  // What gets added to injectRead result
  timedOut: boolean;
}

export interface MyWriteResult {
  // What gets added to injectWrite result
  timedOut: boolean;
}

export interface MyQueueResult {
  // What gets added to queue item meta
  retryCount?: number;
}

export interface MyInstanceApi {
  // Methods added to create() return value
  setTimeout: (timeout: number) => void;
}

Hook-Level vs Trigger-Level Options

For write and queue operations, options are split into two categories:

  • Hook-level options (writeOptions, queueOptions): Passed to the inject as the second argument. These options are set once when the inject is created and enable proper TypeScript inference.

  • Trigger-level options (writeTriggerOptions, queueTriggerOptions): Passed to trigger() when executing. These options can vary per-call.

// Hook-level options (set once, enables type inference)
createPost = injectWrite(
  (api) => api("posts").POST(),
  { transform: (post) => ({ id: post.id }) } // writeOptions
);

// Trigger-level options (can vary per call)
await this.createPost.trigger({
  body: { title: "New Post" },
  invalidate: ["posts"], // writeTriggerOptions
});

// Queue hook-level options
queue = injectQueue(
  (api) => api("uploads").POST(),
  { concurrency: 3, retries: 2 } // queueOptions
);

// Queue trigger-level options
await this.queue.trigger({
  body: { file },
  priority: 1, // queueTriggerOptions
});

Automatic Type Inference with createSpooshPlugin

The createSpooshPlugin helper automatically infers plugin option types in middleware and hooks, eliminating the need for manual type casting:

// plugin.ts
import { createSpooshPlugin } from "@spoosh/core";
import type {
  MyPluginConfig,
  MyReadOptions,
  MyWriteOptions,
  MyWriteTriggerOptions,
} from "./types";

export function myPlugin(config: MyPluginConfig = {}) {
  return createSpooshPlugin<{
    readOptions: MyReadOptions;
    writeOptions: MyWriteOptions;
    writeTriggerOptions: MyWriteTriggerOptions;
  }>({
    name: "my-app:timeout",
    operations: ["read", "write"],

    middleware: async (context, next) => {
      // ✅ Direct access - TypeScript knows the type!
      const timeout = context.pluginOptions?.timeout ?? config.defaultTimeout;

      // No more: context.pluginOptions as MyReadOptions
      return next();
    },
  });
}

Benefits:

  • ✅ No manual type casting (as MyReadOptions) needed
  • ✅ Direct property access in middleware with full IntelliSense
  • ✅ Cleaner, more maintainable plugin code
  • ✅ All properties from all operation types are available (uses intersection types)

Recommended Pattern

Use createSpooshPlugin for all new plugins. It provides better developer experience and reduces boilerplate.

Basic Type Safety

Use the SpooshPlugin generic to tell TypeScript about your types:

// plugin.ts
import type { SpooshPlugin } from "@spoosh/core";
import type {
  MyPluginConfig,
  MyReadOptions,
  MyWriteOptions,
  MyWriteTriggerOptions,
  MyQueueOptions,
  MyQueueTriggerOptions,
  MyReadResult,
  MyWriteResult,
  MyQueueResult,
  MyInstanceApi,
} from "./types";

export function myPlugin(config: MyPluginConfig = {}): SpooshPlugin<{
  readOptions: MyReadOptions;
  writeOptions: MyWriteOptions;
  writeTriggerOptions: MyWriteTriggerOptions;
  queueOptions: MyQueueOptions;
  queueTriggerOptions: MyQueueTriggerOptions;
  readResult: MyReadResult;
  writeResult: MyWriteResult;
  queueResult: MyQueueResult;
  api: MyInstanceApi;
}> {
  return {
    name: "my-app:timeout",
    operations: ["read", "write", "queue"],

    middleware: async (context, next) => {
      // Manual type casting required
      const opts = context.pluginOptions as MyReadOptions | undefined;
      const timeout = opts?.timeout ?? config.defaultTimeout;

      return next();
    },

    api: ({ stateManager }) => ({
      setTimeout(timeout: number) {
        // Implementation
      },
    }),
  };
}

Use createSpooshPlugin Instead

The approach above works but requires manual type casting. Use createSpooshPlugin (shown in the previous section) for automatic type inference.

Now TypeScript knows:

  • injectRead accepts MyReadOptions
  • injectWrite hook accepts MyWriteOptions
  • injectWrite trigger accepts MyWriteTriggerOptions
  • injectQueue hook accepts MyQueueOptions
  • injectQueue trigger accepts MyQueueTriggerOptions
  • Hook results include MyReadResult / MyWriteResult
  • Queue item meta includes MyQueueResult
  • create() return includes MyInstanceApi

Minimal Example

You only need to specify the types you use:

// Plugin that only has read options
export function simplePlugin(): SpooshPlugin<{
  readOptions: { enabled?: boolean };
}> {
  return {
    name: "simple",
    operations: ["read"],
    middleware: async (context, next) => next(),
  };
}
// Plugin that only has instance API
export function apiOnlyPlugin(): SpooshPlugin<{
  api: { reset: () => void };
}> {
  return {
    name: "api-only",
    operations: [],
    api: () => ({ reset: () => {} }),
  };
}

Real-World Example: Cache Plugin

Pattern from Official Cache Plugin

This shows the complete type structure used in production.

Step 1: Define Types

// types.ts
export interface CachePluginConfig {
  /** Default stale time in milliseconds. */
  staleTime?: number;
}

export interface ClearCacheOptions {
  /** Whether to trigger all queries to refetch after clearing. */
  refetchAll?: boolean;
}

export interface CacheInstanceApi {
  /** Clear all cached data. */
  clearCache: (options?: ClearCacheOptions) => void;
}

export interface CacheReadOptions {
  /** Time in milliseconds before cached data is considered stale. */
  staleTime?: number;
}

// Hook-level write options (empty for cache plugin)
export type CacheWriteOptions = object;

// Trigger-level write options
export interface CacheWriteTriggerOptions {
  /** Clear all cached data after mutation completes successfully. */
  clearCache?: boolean;
}

export interface CacheInfiniteReadOptions {
  /** Time in milliseconds before cached data is considered stale. */
  staleTime?: number;
}

export type CacheReadResult = object;

export type CacheWriteResult = object;

Step 2: Use in Plugin

// plugin.ts
import { createSpooshPlugin } from "@spoosh/core";
import type {
  CachePluginConfig,
  CacheReadOptions,
  CacheWriteOptions,
  CacheWriteTriggerOptions,
  CacheInfiniteReadOptions,
  CacheReadResult,
  CacheWriteResult,
  CacheInstanceApi,
  ClearCacheOptions,
} from "./types";

export function cachePlugin(config: CachePluginConfig = {}) {
  const { staleTime: defaultStaleTime = 0 } = config;

  return createSpooshPlugin<{
    readOptions: CacheReadOptions;
    writeOptions: CacheWriteOptions;
    writeTriggerOptions: CacheWriteTriggerOptions;
    pagesOptions: CachePagesOptions;
    readResult: CacheReadResult;
    writeResult: CacheWriteResult;
    api: CacheInstanceApi;
  }>({
    name: "spoosh:cache",
    operations: ["read", "pages", "write"],
    priority: -10,

    middleware: async (context, next) => {
      if (!context.forceRefetch) {
        const cached = context.stateManager.getCache(context.queryKey);

        if (cached?.state.data && !cached.stale) {
          // ✅ Direct access with createSpooshPlugin - no casting needed!
          const staleTime =
            context.pluginOptions?.staleTime ?? defaultStaleTime;
          const isTimeStale = Date.now() - cached.state.timestamp > staleTime;

          if (!isTimeStale) {
            return { data: cached.state.data, status: 200 };
          }
        }
      }

      return await next();
    },

    afterResponse(context, response) {
      if (!response.error) {
        // ✅ Direct access for trigger options too!
        if (context.pluginOptions?.clearCache) {
          context.stateManager.clear();
        }
      }
    },

    api(context) {
      const { stateManager, eventEmitter } = context;

      const clearCache = (options?: ClearCacheOptions): void => {
        stateManager.clear();

        if (options?.refetchAll) {
          eventEmitter.emit("refetchAll", undefined);
        }
      };

      return { clearCache };
    },
  });
}

Step 3: Export Types

// index.ts
export * from "./types";
export * from "./plugin";

Step 4: TypeScript Knows Everything

const spoosh = new Spoosh("/api").use([cachePlugin({ staleTime: 5000 })]);
export const { injectRead, injectWrite, clearCache } = create(spoosh);

// In your component - Type-safe read options (hook-level)
posts = injectRead((api) => api("posts").GET(), {
  staleTime: 10000, // ✅ TypeScript knows this option
});

// Type-safe write with trigger options
createPost = injectWrite((api) => api("posts").POST());

await this.createPost.trigger({
  body: { title: "New Post" },
  clearCache: true, // ✅ TypeScript knows this trigger option
});

// Type-safe instance API
clearCache({ refetchAll: true }); // ✅ TypeScript knows this method

Advanced: Module Augmentation

Module Augmentation is Optional

Module augmentation is only needed for schema-aware or inferred typing. Most plugins don't need it — basic SpooshPlugin<{...}> generics are sufficient.

For advanced type inference, extend core interfaces via module augmentation.

If your types don't depend on schema, response data, or option inference, do not use module augmentation.

Use Case: Schema-Aware Options

When your plugin needs to know the API schema, use PluginResolvers:

// types.ts
import type { ResolverContext } from "@spoosh/core";

export type Transformer<TIn = unknown, TOut = unknown> = (
  data: TIn
) => TOut | Promise<TOut>;

export interface TransformReadOptions {
  /** Transform response data. */
  transform?: Transformer<unknown, unknown>;
}

export interface TransformWriteOptions {
  /** Transform response data. */
  transform?: Transformer<unknown, unknown>;
}

export type TransformReadResult = object;

export type TransformWriteResult = {
  transformedData?: unknown;
};

// Module augmentation for type resolution
declare module "@spoosh/core" {
  interface PluginResolvers<TContext extends ResolverContext> {
    // TContext["data"] gives you the actual response type
    transform: Transformer<TContext["data"], unknown> | undefined;
  }

  interface PluginResultResolvers<TOptions> {
    // Infer transformed data type from transform function return type
    transformedData: TOptions extends {
      transform?: (data: any) => Promise<infer R> | infer R;
    }
      ? Awaited<R> | undefined
      : never;
  }
}

Now TypeScript automatically infers:

posts = injectRead(() => this.api("posts").GET(), {
  transform: (posts) => ({
    // TypeScript knows posts is Post[]
    count: posts.length,
    titles: posts.map((p) => p.title),
  }),
});

// TypeScript knows posts.meta()?.transformedData is { count: number; titles: string[] } | undefined
console.log(posts.meta()?.transformedData.count); // ✅ Fully typed!

Available Augmentation Interfaces

Choose the Right Interface

Each interface solves a different type inference problem.

PluginResolvers<TContext> - Resolve option types based on context

declare module "@spoosh/core" {
  interface PluginResolvers<TContext extends ResolverContext> {
    // Access schema
    myOption: MyFn<TContext["schema"]> | undefined;

    // Access data/error
    myTransform: (data: TContext["data"]) => TContext["data"];

    // Access request input
    myFilter: (query: TContext["input"]["query"]) => boolean;
  }
}

PluginResultResolvers<TOptions> - Infer result types from options

declare module "@spoosh/core" {
  interface PluginResultResolvers<TOptions> {
    // Infer from function return type
    processedData: TOptions extends {
      process?: (data: any) => infer R;
    }
      ? R | undefined
      : never;
  }
}

ApiResolvers<TSchema> - Schema-aware instance APIs

declare module "@spoosh/core" {
  interface ApiResolvers<TSchema> {
    prefetch: PrefetchFn<TSchema>;
    invalidate: InvalidateFn<TSchema>;
  }
}

PluginInternalRegistry - Type-safe plugin internal

declare module "@spoosh/core" {
  interface PluginInternalRegistry {
    "my-plugin": {
      doSomething: () => void;
    };
  }
}

Real Example: Initial Data Plugin

Shows how to use PluginResolvers for schema-aware options:

// types.ts
export interface InitialDataReadOptions<TData = unknown> {
  /** Data to use immediately on first mount */
  initialData?: TData;

  /** Refetch fresh data after showing initial data */
  refetchOnInitialData?: boolean;
}

export type InitialDataInfiniteReadOptions<TData = unknown> =
  InitialDataReadOptions<TData>;

export interface InitialDataReadResult {
  /** True if currently showing initial data */
  isInitialData: boolean;
}

export type InitialDataWriteOptions = object;

export type InitialDataWriteResult = object;

// Module augmentation
declare module "@spoosh/core" {
  interface PluginResolvers<TContext> {
    // TContext["data"] resolves to the actual data type
    initialData: TContext["data"] | undefined;
  }
}

Usage:

posts = injectRead(() => this.api("posts").GET(), {
  initialData: [
    // TypeScript knows this must be Post[]
    { id: 1, title: "Hello" },
  ],
});

Real Example: Optimistic Plugin

Shows complex schema-aware type inference with trigger-level options:

// types.ts (simplified)
export type OptimisticCallbackFn<TSchema = unknown, TResponse = unknown> = (
  api: OptimisticApiHelper<TSchema, TResponse>
) => CompletedOptimisticBuilder | CompletedOptimisticBuilder[];

// Hook-level options (empty for optimistic plugin)
export type OptimisticWriteOptions = object;

// Trigger-level options
export interface OptimisticWriteTriggerOptions<
  TSchema = unknown,
  TResponse = unknown,
> {
  optimistic?: OptimisticCallbackFn<TSchema, TResponse>;
}

// Module augmentation
declare module "@spoosh/core" {
  interface PluginResolvers<TContext> {
    // TContext["schema"] gives you the API schema
    // TContext["data"] gives you the response type
    optimistic:
      | OptimisticCallbackFn<TContext["schema"], TContext["data"]>
      | undefined;
  }
}

Usage:

deletePost = injectWrite((api) => api("posts/:id").DELETE());

// optimistic is passed to trigger(), not hook options
await this.deletePost.trigger({
  params: { id: deletedId },
  optimistic: (cache) =>
    cache("posts").set((posts) => posts.filter((p) => p.id !== deletedId)),
});

Type Safety Checklist

When building a type-safe plugin:

Basic (Required):

  • ✅ Define type interfaces for options and results
  • ✅ Pass types to SpooshPlugin<{...}> generic
  • ✅ Export all types from index.ts

Advanced (Optional):

  • ✅ Use PluginResolvers for schema-aware options
  • ✅ Use PluginResultResolvers for inferred results
  • ✅ Use ApiResolvers for schema-aware instance APIs
  • ✅ Use PluginInternalRegistry for type-safe internal

Documentation:

  • ✅ Add JSDoc comments to all interfaces
  • ✅ Document each option's purpose and default value
  • ✅ Include usage examples in comments

Common Patterns

Pattern: Empty Results

If your plugin doesn't add to hook results, use empty object:

export type MyReadResult = object;
export type MyWriteResult = object;

Pattern: Shared Options

If read/write have same options, reuse the type:

export interface TimeoutOptions {
  timeout?: number;
}

export type MyReadOptions = TimeoutOptions;
export type MyWriteOptions = TimeoutOptions;

Pattern: Conditional Types

Use conditional types for complex inference:

export type InferTransformedData<TOptions> = TOptions extends {
  transform: (data: never) => Promise<infer R> | infer R;
}
  ? Awaited<R>
  : never;

Pattern: Type Guards

For runtime type checking in plugins:

function isMyOptions(opts: unknown): opts is MyReadOptions {
  return typeof opts === "object" && opts !== null && "timeout" in opts;
}

// In middleware
middleware: async (context, next) => {
  const opts = context.pluginOptions;

  if (isMyOptions(opts) && opts.timeout) {
    // TypeScript knows opts is MyReadOptions
  }

  return next();
};

Best Practices

1. Always Export Types

// ✅ Good - Users can import types
export interface MyReadOptions {
  enabled?: boolean;
}

// ❌ Bad - Types not accessible
interface MyReadOptions {
  enabled?: boolean;
}

2. Use JSDoc Comments

// ✅ Good - Helpful autocomplete
export interface CacheReadOptions {
  /**
   * Time in milliseconds before cached data is considered stale.
   * Overrides plugin default.
   *
   * @default 0
   */
  staleTime?: number;
}

// ❌ Bad - No context
export interface CacheReadOptions {
  staleTime?: number;
}

3. Organize Files

// ✅ Good - Separate concerns
my-plugin/
├── index.ts      // Export everything
├── types.ts      // Type definitions
└── plugin.ts     // Plugin implementation

// ❌ Bad - Everything in one file
my-plugin/
└── index.ts

4. Use Specific Types

// ✅ Good - Precise types
export interface RetryReadOptions {
  retries?: number | false;
  retryDelay?: number;
}

// ❌ Bad - Too loose
export interface RetryReadOptions {
  retries?: any;
  retryDelay?: any;
}

Summary

Basic Type Safety:

  1. Define type interfaces (config, options, trigger options, results, api)
  2. Pass to SpooshPlugin<{...}> generic
  3. Export from index.ts

Write Options Split:

  • writeOptions: Hook-level options passed to injectWrite second argument
  • writeTriggerOptions: Trigger-level options passed to trigger()

Advanced Type Safety:

  1. Use PluginResolvers for schema-aware options
  2. Use PluginResultResolvers for inferred results
  3. Use module augmentation for complex inference

Key Benefits:

  • Autocomplete in user's IDE
  • Compile-time error checking
  • Better developer experience
  • Self-documenting code

For more examples, see the official plugins source code.

On this page