Skip to content

tkoehlerlg/t7m

Repository files navigation

t7m

npm version TypeScript 5+ license

APIs shouldn't return raw database models. Sensitive fields leak, related data loads slowly, and the same async call runs over and over.

t7m is a transformer layer that fixes this: one class per model controls what gets exposed, loads includes in parallel, and caches repeated calls.

Works with Hono and Elysia. No overhead — 1,000 objects with includes in under 100ms.

t7m = t(ransfor)m - 7 letters between t and m

AI Agent Skill

t7m ships with a Claude Code skill that teaches AI coding agents how to build transformers correctly — includes, cache, props, nested transformers, and framework integration.

Install it with the Vercel Skills CLI:

npx skills add tkoehlerlg/t7m

This works with Claude Code, Cursor, Codex, and other agents that support the open agent skills standard.

Quick Start

npm install t7m
# or
bun add t7m
import { AbstractTransformer } from 't7m';

type User = { id: number; name: string; email: string; password: string };
type PublicUser = Omit<User, "id" | "password">;

class UserTransformer extends AbstractTransformer<User, PublicUser> {
  data(input: User): PublicUser {
    return { name: input.name, email: input.email };
  }
}

const transformer = new UserTransformer();
const user: User = { id: 1, name: "Alice", email: "alice@example.com", password: "secret" };
const result = await transformer.transform({ input: user });
// { name: "Alice", email: "alice@example.com" } - sensitive fields stripped!

Why t7m?

The Problem

Database models contain sensitive data you shouldn't expose (IDs, passwords, internal flags). Every API endpoint needs to strip fields, optionally include related data, and do this consistently. Without structure, transformation logic scatters across your codebase - easy to forget a field, expose something you shouldn't, or handle includes inconsistently.

The Solution

t7m gives you a single place to define how each model transforms to its public form. Type-safe, consistent, with built-in support for optional includes and caching. Works anywhere - built with serverless in mind.

Basic Usage

Defining a Transformer

Extend AbstractTransformer and implement the data method:

import { AbstractTransformer } from 't7m';

interface User {
  id: number;
  name: string;
  email: string;
}

type PublicUser = Omit<User, "id">;

// AbstractTransformer<Input, Output>
class UserTransformer extends AbstractTransformer<User, PublicUser> {
  data(input: User): PublicUser {
    return {
      name: input.name,
      email: input.email,
    };
  }
}

const transformer = new UserTransformer();

const user: User = { id: 1, name: "John Doe", email: "john.doe@example.com" };
const publicUser = await transformer.transform({ input: user });
// { name: 'John Doe', email: 'john.doe@example.com' }

Includes

Includes let you optionally add related data to your output (like posts for a user, or author for a comment). Define handlers in includesMap - they only run when requested. All include functions run in parallel.

// Third generic = Props type (passed to data and include functions)
interface UserWithPosts extends PublicUser {
  posts?: { title: string }[];
}

class UserTransformer extends AbstractTransformer<User, UserWithPosts, { db: Database }> {
  // data() can be async
  async data(input: User): Promise<UserWithPosts> {
    return { name: input.name, email: input.email };
  }

  includesMap = {
    posts: async (input: User, props) =>
      new PostTransformer().transformMany({ inputs: await props.db.getPostsByUserId(input.id) }),
  };
}

const transformer = new UserTransformer();
const publicUser = await transformer.transform({
  input: user,
  includes: ["posts"],
  props: { db },
});
// { name: "John", email: "...", posts: [{ title: "Hello" }, ...] }

Props

Props are available in both data(input, props) and include functions (input, props, forwardedIncludes). Common uses:

  • Database connections
  • Feature flags (e.g., redactSensitiveData: boolean)
  • Request context

When your transformer defines a Props type, props becomes required in transform() and transformMany(). When no Props type is defined (the default), props cannot be passed.

Unsafe Includes

For dynamic includes from user input (e.g., query strings), use unsafeIncludes. They're not type-checked but handled gracefully at runtime:

await transformer.transform({
  input: user,
  includes: ["posts"],           // Type-safe
  unsafeIncludes: queryIncludes, // Runtime includes
  props: { db },
});

t7m automatically deduplicates includes. Unhandled includes (not in your includesMap) are passed to include functions as forwardedIncludes, so you can forward them to nested transformers:

includesMap = {
  posts: async (input: User, props, forwardedIncludes) =>
    new PostTransformer().transformMany({
      inputs: await props.db.getPostsByUserId(input.id),
      unsafeIncludes: forwardedIncludes, // Forward "author", "comments", etc.
    }),
};

// Request includes: ["posts", "author"]
// → "posts" handled by UserTransformer (this includesMap)
// → "author" not in UserTransformer's includesMap, so forwarded to PostTransformer

Type Utilities

t7m exports utility types for extracting type information from transformer instances. Useful for writing generic functions and framework integrations.

Type Description
AnyAbstractTransformer Base type for typing transformer collections and generic utilities
InputOf<T> Extract the input type from a transformer
OutputOf<T> Extract the output type from a transformer
PropsOf<T> Extract the props type from a transformer
IncludesOf<T> Extract the available include keys from a transformer
import type { InputOf, OutputOf } from 't7m';

type UserInput = InputOf<UserTransformer>;   // User
type UserOutput = OutputOf<UserTransformer>; // PublicUser

Cache

When transforming data, you often need to enrich it with external information. Cache wraps any function and ensures calls with the same input resolve only once. Concurrent calls share the same promise - no duplicate requests, no race conditions.

Keep in mind:

  • Cache lives on the transformer instance — reuse one instance per request, don't create a new one each call
  • There's no TTL. The framework middleware (Hono/Elysia) clears caches after each response; when calling transform()/transformMany() directly, call clearCache() yourself (see Cache Auto-Clear)

Basic Usage

import { AbstractTransformer, Cache } from 't7m';

class CommentTransformer extends AbstractTransformer<Comment, PublicComment> {
  cache = {
    userProfile: new Cache((userId: string) => auth.getUser(userId)),
  };

  data(input: Comment): PublicComment {
    return { id: input.id, content: input.content };
  }

  includesMap = {
    author: async (input: Comment) => {
      // Cached! 20 comments with same userId = 1 auth call
      const user = await this.cache.userProfile.call(input.userId);
      return { name: user.name, avatarUrl: user.picture };
    },
  };
}

// 100 comments, 20 unique users = only 20 auth calls!
const transformer = new CommentTransformer();
await transformer.transformMany({ inputs: comments, includes: ["author"] });

Zero-Argument Functions

Cache supports 0-arg functions - useful for deferring transformer instantiation (e.g., to avoid circular dependencies or reduce startup cost):

class ParentTransformer extends AbstractTransformer<Parent, PublicParent> {
  transformers = {
    child: new Cache(() => new ChildTransformer()),
  };

  includesMap = {
    children: (input) => this.transformers.child.call().transformMany({ inputs: input.children }),
  };
}

Object Arguments and Selective Keys

For object arguments, specify which keys to use for the cache key:

const cached = new Cache(
  (params: { id: number; timestamp: number }) => db.users.findOne({ id: params.id }),
  { on: ["id"] } // Only cache on 'id', ignore 'timestamp'
);

await cached.call({ id: 1, timestamp: 100 });
await cached.call({ id: 1, timestamp: 200 }); // Cache hit!

You can specify multiple keys: new Cache(fn, { on: ["id", "type"] })

Limit cache size with maxSize: new Cache(fn, { maxSize: 100 })

Cache Auto-Clear

By default, caches clear after each transformation. Disable auto-clear with:

class MyTransformer extends AbstractTransformer<Input, Output> {
  constructor() {
    super({ clearCacheOnTransform: false });
  }
}

Nested Transformer Cache Clearing

Register nested transformers in transformers for cache clearing propagation. Parent clears all caches only after transformation completes - handled internally:

class PostTransformer extends AbstractTransformer<Post, PublicPost> {
  authorTransformer = new AuthorTransformer();

  transformers = { author: this.authorTransformer };

  includesMap = {
    author: async (input) => this.authorTransformer.transform({ input: await getAuthor(input.authorId) }),
  };
}

Circular references between transformers are handled safely - cache clearing uses cycle detection to prevent infinite loops.

Framework Integration

Hono

Setup

import { Hono } from 'hono';
import { t7mMiddleware } from 't7m/hono';

const app = new Hono();
app.use(t7mMiddleware);

Basic route usage:

app.get("/users", async (c) => {
  const users = await db.users.findMany();
  return c.transformMany(users, new UserTransformer(), {}, 200);
  // c.transform(user, new UserTransformer(), {}, 200) for single objects
});

Automatic Query Parameter Parsing

The middleware automatically reads ?include= from the query string and passes them as includes to the transformer.

GET /users?include=posts,comments
// Automatically applies includes: ["posts", "comments"]

No additional code needed - just use the middleware.

Extras

The third parameter (extras) is always required — pass {} when you don't need any options. It supports these options:

Option Type Description
includes IncludesOf<T>[] Type-safe includes (used instead of query params)
wrapper (data) => O Wrap the response (e.g., { data: result })
debug boolean Enable colored console logging for debugging
props PropsOf<T> Props to pass to the transformer

Example with wrapper and debug:

app.get("/users", async (c) => {
  const users = await db.users.findMany();
  return c.transformMany(users, new UserTransformer(), {
    wrapper: (data) => ({ data, count: data.length }),
    debug: true,
  }, 200);
});

Custom HTTP headers can be passed as the 5th parameter.

Elysia

Setup

import { Elysia } from 'elysia';
import { t7mPlugin } from 't7m/elysia';

const app = new Elysia();
app.use(t7mPlugin());

The plugin injects transform() and transformMany() into every route handler via Elysia's derive.

Basic route usage:

app.get("/users", async ({ transformMany }) => {
  const users = await db.users.findMany();
  return transformMany(users, new UserTransformer());
  // transform(user, new UserTransformer()) for single objects
});

Automatic Query Parameter Parsing

The plugin automatically reads ?include= from the query string and passes them as includes to the transformer.

GET /users?include=posts,comments
// Automatically applies includes: ["posts", "comments"]

No additional code needed - just use the plugin.

Extras

The third parameter (extras) supports these options:

Option Type Description
includes IncludesOf<T>[] Type-safe includes (used instead of query params)
wrapper (data) => O Wrap the response (e.g., { data: result })
debug boolean Enable colored console logging for debugging
props PropsOf<T> Props to pass to the transformer

The extras parameter is optional when your transformer has no Props type — no empty {} needed (unlike Hono).

Example with wrapper and debug:

app.get("/users", async ({ transformMany }) => {
  const users = await db.users.findMany();
  return transformMany(users, new UserTransformer(), {
    wrapper: (data) => ({ data, count: data.length }),
    debug: true,
  });
});

Key Difference from Hono

Elysia handlers return plain data — the plugin's transform() returns the transformed object directly. For status codes and headers, use Elysia's set:

app.get("/users/:id", async ({ transform, set, params }) => {
  const user = await db.users.findOne(params.id);
  if (!user) {
    set.status = 404;
    return { error: "Not found" };
  }
  set.headers["X-Custom-Header"] = "value";
  return transform(user, new UserTransformer());
});

Performance

Parallel Includes

All include functions run concurrently via Promise.all. If you have 3 includes that each take 50ms, the total is ~50ms, not 150ms.

Cache Deduplication

Cache eliminates redundant calls by sharing the same promise across concurrent lookups:

100 comments, 20 unique authors
├─ Without cache:  100 auth.getUser() calls
└─ With cache:      20 auth.getUser() calls (5x reduction)

Concurrent calls with the same input don't even wait — they share a single in-flight promise, so there are no race conditions and no duplicate work.

Benchmarks (from test suite)

Scenario Result
1,000 objects + 2 includes each < 100ms
10,000 cached primitive lookups microseconds per lookup
Cached vs uncached (1ms async op, 1,000 calls) ~1ms cached vs ~1,000ms uncached

Concurrency Control

The Problem

transformMany fires all items and their includes in parallel via Promise.all. With 100 items and 2 includes each, that's up to 200 concurrent external calls in a single request. This overwhelms services that weren't designed for that kind of burst:

  • Cloudflare Workers: Hard limit of 6 concurrent subrequests per request — anything beyond throws
  • Auth providers (Clerk, Auth0): Rate limits on API calls that trigger 429s under burst traffic
  • Third-party APIs: Connection ceilings, per-second rate limiting, or socket exhaustion

When You Don't Need This

Concurrency control is opt-in. You can skip it entirely when:

  • Database with connection pooling (e.g., Neon): The pool manages concurrency for you — includes all go through one pooled connection, so there's no flood of parallel connections
  • In-memory lookups: No external calls means no limits to hit
  • Already-cached calls: If you're using t7m's Cache, duplicate calls are deduplicated — 100 items with 20 unique authors = 20 actual calls, not 100

Item-Level Concurrency

The concurrency constructor parameter limits how many items transformMany / _transformMany process in parallel:

class CommentTransformer extends AbstractTransformer<Comment, PublicComment> {
  constructor() {
    super({ concurrency: 5 }) // Process at most 5 items at a time
  }

  // ...
}

// 100 comments — processed in batches of 5 instead of all at once
await transformer.transformMany({ inputs: comments, includes: ['author'] })

This does not apply to transform / _transform (single item — nothing to throttle).

Per-Include Concurrency

The includesConcurrency class property limits how many times a specific include function can run concurrently. Unlike concurrency, per-include limits apply to all transform methods — both transform() and transformMany(). Include keys not listed remain unlimited:

class CommentTransformer extends AbstractTransformer<Comment, PublicComment> {
  includesConcurrency = {
    author: 3, // At most 3 concurrent author lookups across all items
  }

  includesMap = {
    author: async (input: Comment) => {
      const user = await auth.getUser(input.userId)
      return { name: user.name }
    },
    tags: async (input: Comment) => {
      // No limit — runs with full parallelism
      return await db.getTagsForComment(input.id)
    },
  }
}

The semaphore is shared across all calls on the same transformer instance, so even if multiple transformMany calls overlap, the limit holds.

Combined Example

Use both together for fine-grained control:

class CommentTransformer extends AbstractTransformer<Comment, PublicComment, { db: Database }> {
  constructor() {
    super({ concurrency: 10 }) // 10 items in parallel
  }

  cache = {
    userProfile: new Cache((userId: string) => auth.getUser(userId)),
  }

  includesConcurrency = {
    author: 5,  // Max 5 concurrent auth calls
  }

  data(input: Comment): PublicComment {
    return { id: input.id, content: input.content }
  }

  includesMap = {
    author: async (input: Comment) => {
      const user = await this.cache.userProfile.call(input.userId)
      return { name: user.name, avatarUrl: user.picture }
    },
  }
}

Important Notes

  • Opt-in: Without configuration, behavior is identical to before (unlimited parallelism). Existing code is unaffected.
  • Instance-level: Limits are shared across all calls on the same transformer instance. In server environments where one instance handles multiple requests, concurrent requests share the same semaphore.
  • Batch methods only: transform() / _transform() are not affected by concurrency — only transformMany / _transformMany are throttled. Per-include limits still apply to both single and batch transforms.

API Reference

AbstractTransformer

Member Type Description
data(input, props) protected abstract Core transformation logic (must implement). Can return TOutput or Promise<TOutput>.
includesMap protected readonly Map of include handlers. Each handler receives (input, props, forwardedIncludes).
cache public readonly Record of Cache instances for data fetching.
transformers public Register nested transformers for cache clearing propagation.
transform({input, includes?, unsafeIncludes?, props?*}) public Transform a single object.
transformMany({inputs, includes?, unsafeIncludes?, props?*}) public Transform an array of objects.
includesConcurrency protected readonly Limits concurrent executions per include key. Applies to all transform methods. Define as class property.
concurrency (constructor) constructor param Limits how many items transformMany processes in parallel. Pass via super({ concurrency: N }).
clearCache() public Clear all caches (including nested transformers).

*props is required when the transformer defines a Props type, optional otherwise.

Cache

Method Description
new Cache(fn, options?) Create a cache. fn must take 0 or 1 argument. options.on specifies which object properties to use as cache key. options.maxSize limits entries (oldest evicted).
call(...args) Call the cached function. Same-input calls return cached result. Concurrent calls share the same promise.
clear() Clear all cached results.

Semaphore

Method Description
new Semaphore(limit) Create a semaphore with given concurrency limit. limit must be a positive integer.
run(fn) Execute fn (sync or async) when a slot is available. Returns Promise<T>. Queues if at capacity.

Author

Created and maintained by Torben Köhler. Feel free to reach out via GitHub or LinkedIn.

License

MIT-NSR

About

Hono & Elysia Transformer

Resources

License

Security policy

Stars

6 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors