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 totrigger()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:
injectReadacceptsMyReadOptionsinjectWritehook acceptsMyWriteOptionsinjectWritetrigger acceptsMyWriteTriggerOptionsinjectQueuehook acceptsMyQueueOptionsinjectQueuetrigger acceptsMyQueueTriggerOptions- Hook results include
MyReadResult/MyWriteResult - Queue item meta includes
MyQueueResult create()return includesMyInstanceApi
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 methodAdvanced: 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
PluginResolversfor schema-aware options - ✅ Use
PluginResultResolversfor inferred results - ✅ Use
ApiResolversfor schema-aware instance APIs - ✅ Use
PluginInternalRegistryfor 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.ts4. 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:
- Define type interfaces (config, options, trigger options, results, api)
- Pass to
SpooshPlugin<{...}>generic - Export from index.ts
Write Options Split:
writeOptions: Hook-level options passed toinjectWritesecond argumentwriteTriggerOptions: Trigger-level options passed totrigger()
Advanced Type Safety:
- Use
PluginResolversfor schema-aware options - Use
PluginResultResolversfor inferred results - 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.