Keelv0.86.0
Docs / Transformers

A model knows the database; a transformer knows the API. It's the presentation layer between the two — subclass Transformer, define one transform() that maps a value to the exact shape you expose, and get item / collection / document for free. No columns leak by accident, no relation triggers a surprise query, and the same shape renders everywhere. Edge-safe, like everything under it — a transformer leans on nothing but the value you hand it.

Defining a transformer

Subclass Transformer<T> and implement transform. The generic is the type you map from (often a model); the return is a plain, JSON-ready object:

import { Transformer, type Attributes } from "@shaferllc/keel/core";
import { User } from "../app/Models/User.js";

export class UserTransformer extends Transformer<User> {
  transform(user: User): Attributes {
    return {
      id: user.id,
      name: user.name,
      joined: user.created_at,
    };
  }
}

Generate one with keel make:transformer User (→ app/Transformers/UserTransformer.ts). Pass --model Account when the class name doesn't match the value it maps.

Transforming

Three methods cover every case — one, many, or a full response document:

import { json } from "@shaferllc/keel/core";

const users = new UserTransformer();

json(users.item(user));        // one   → { id, name, joined } | null
json(users.collection(list));  // many  → [{ … }, { … }]
json(users.document(list, {    // wrapped, with meta
  meta: { total: list.length },
}));                           // → { data: [{ … }], total }

item returns null for a nullish value, so a not-found lookup passes straight through. collection maps each value through transform. document is what you usually hand back from a controller — it wraps the payload under a key (data by default) and merges any top-level meta (pagination, counts, links) beside it.

Conditional fields

when includes a key only when a condition holds — and removes the key entirely otherwise, so no null leaks into the payload:

transform(user: User): Attributes {
  return {
    id: user.id,
    name: user.name,
    email: this.when(String(user.id) === this.viewerId, user.email), // only your own email
  };
}

For someone else's user, the response is simply { id, name } — the email key is gone, not null. Pass a third argument to substitute a fallback instead of omitting, and pass a thunk to defer an expensive value until the condition is true:

token: this.when(fresh, () => mintToken(user), null),  // null when not fresh

To gate several keys at once, mergeWhen returns an object to spread — {} when the condition is false, so nothing is added:

return {
  id: user.id,
  ...this.mergeWhen(user.admin, { role: user.role, permissions: user.permissions }),
};

Transformers pass the current viewer (or any context) through the constructor — they're plain instances:

export class UserTransformer extends Transformer<User> {
  constructor(private viewerId: string | null) {
    super();
  }
  // …use this.viewerId in transform()
}

json(new UserTransformer(auth().id()).collection(users));

Nesting & relations

Embed one transformer inside another by calling it inline — the seam composes:

transform(post: Post): Attributes {
  return {
    id: post.id,
    title: post.title,
    author: new UserTransformer(this.viewerId).item(post.author),
  };
}

But for a model relation, reach for whenLoaded — it includes the relation only if it was eager-loaded, so a transformer never fires a query behind your back:

transform(user: User): Attributes {
  return {
    id: user.id,
    name: user.name,
    posts: this.whenLoaded(user, "posts", new PostTransformer()),
  };
}

whenLoaded reads the relation off the model (via the model's getRelation, set by Model.load), and, if present, runs it through the transformer you pass — a collection for an array relation, an item for a single one. If the relation wasn't loaded, the key is omitted. Pass a plain function instead of a transformer to map it yourself:

roles: this.whenLoaded(user, "roles", (roles) => roles.map((r) => r.name)),

So the caller controls depth by choosing what to load:

const users = await User.all();
await User.load(users, "posts");                 // eager-load first
json(new UserTransformer().collection(users));   // …then posts appear

Without the load, the same transformer simply omits posts — no N+1, no surprise. See Models for eager loading.

Response documents

document builds the envelope most JSON APIs return — a wrapped payload plus top-level metadata:

const page = await User.all();
return json(
  new UserTransformer().document(page, {
    meta: { total: page.length, page: 1 },
  }),
);
// { "data": [ … ], "total": 42, "page": 1 }

Change the wrapper per class by setting wrapKey, or per call with the key option; set key: null to merge a single object's fields to the top level (meta included):

class UserTransformer extends Transformer<User> {
  wrapKey = "user"; // → { user: { … } }
}

new UserTransformer().document(user, { key: null, meta: { fetchedAt } });
// { id, name, …, fetchedAt }

item and collection return the bare shape (no wrapper) so they compose cleanly when nested; document is the one that wraps. Reach for document at the edge of a response and item/collection everywhere inside.

In a controller

The whole point is a controller that reads clean:

export class UserController {
  async show(c: Ctx) {
    const user = await User.findOrFail(c.req.param("id"));
    return c.json(new UserTransformer(auth().id()).item(user));
  }

  async index(c: Ctx) {
    const users = await User.all();
    await User.load(users, "posts");
    return c.json(new UserTransformer(auth().id()).document(users));
  }
}

Transformers sit downstream of Models — they shape what a model exposes without the model knowing about the API. They pair with the request/response helpers (json) at the edge, and with authentication when a field depends on the viewer.


API reference

Transformer<T>

The abstract base. Subclass it, set the generic to the value you map from, and implement transform. Instances are plain — pass request context (a viewer id, a locale) through the constructor.

class UserTransformer extends Transformer<User> {
  transform(user: User): Attributes {
    return { id: user.id, name: user.name };
  }
}

transform(item)

abstract transform(item: T): Attributes

Maps one value to its API shape — the only method a subclass must implement. Returns a plain object; use the helpers below to add fields conditionally.

transform(user: User): Attributes {
  return { id: user.id, name: user.name };
}

Notes: called once per value by item/collection. Its result is pruned (any when/whenLoaded-omitted keys are stripped, recursively) before you see it, so an omitted key is truly absent — not undefined.

item(value)

item(value: T | null | undefined): Attributes | null

Transforms a single value. A nullish value passes straight through as null.

new UserTransformer().item(user);   // { id, name }
new UserTransformer().item(null);   // null

Notes: returns the bare shape (no wrapKey wrapper) — wrap with document when returning a response. null in, null out, so a findOrNull result needs no guard.

collection(values)

collection(values: T[]): Attributes[]

Transforms an array, each value through transform.

new UserTransformer().collection(await User.all());

Notes: returns a bare array (no wrapper). Empty in, empty out. Combine with Model.load beforehand so any whenLoaded relations are present.

document(value, options?)

document(value: T | T[] | null | undefined, options?: DocumentOptions): Attributes

Builds a response document: the transformed payload wrapped under a key, with optional top-level meta. An array becomes a list; anything else a single object.

new UserTransformer().document(users, { meta: { total: users.length } });
// { data: [ … ], total }

Notes: the wrapper key is options.key if given, else the instance wrapKey (default "data"). With key: null a single object's fields merge to the top level alongside meta; an array with no key still gets a data home (meta can't share a level with a bare array).

wrapKey

wrapKey: string | null

The key document wraps under by default. Override per subclass; null disables wrapping.

class UserTransformer extends Transformer<User> {
  wrapKey = "user";
}

Notes: defaults to "data". Only consulted by documentitem and collection never wrap.

when(condition, value, fallback?)

protected when<V>(condition: unknown, value: V | (() => V), fallback?: V): V

Include value when condition is truthy; otherwise omit the key — or use fallback if you pass one. value may be a thunk, evaluated only when the condition holds.

email: this.when(isSelf, user.email),          // key vanishes for others
token: this.when(fresh, () => mint(), null),   // null fallback, lazy value

Notes: a helper for use inside transform. With no fallback, a false condition removes the key entirely (via a sentinel that pruning strips) rather than emitting null. The thunk form defers work you don't want to pay for when the field is hidden.

mergeWhen(condition, values)

protected mergeWhen(condition: unknown, values: Attributes | (() => Attributes)): Attributes

The merge counterpart to when — returns values (spread several keys in) when condition holds, or {} when it doesn't.

return { id: u.id, ...this.mergeWhen(u.admin, { role: u.role, flags: u.flags }) };

Notes: meant to be spread (...). values may be a thunk, deferred until the condition is true. Use it when a group of fields appears together.

whenLoaded(model, name, map?)

protected whenLoaded<V>(model: unknown, name: string, map?: Transformer | ((value) => unknown)): V

Include a relation only if it was already loaded — never fires a query. Reads the relation off the model and, if present, runs it through map (a transformer or a function). Omits the key when it isn't loaded.

posts: this.whenLoaded(user, "posts", new PostTransformer()),
roles: this.whenLoaded(user, "roles", (rs) => rs.map((r) => r.name)),

Notes: resolves the relation via the model's getRelation (set by Model.load) or a plain loaded property — a relation method is never mistaken for a value. With a Transformer, an array relation goes through collection and a single one through item. With no map, the raw loaded value is used.

Attributes

type Attributes = Record<string, unknown>;

The shape a transformer produces — a plain, JSON-ready object. transform returns one; so do item and document.

DocumentOptions

interface DocumentOptions {
  key?: string | null;   // wrap under this key; null disables. Defaults to wrapKey.
  meta?: Attributes;     // top-level fields merged beside the payload.
}

Controls document's envelope. key overrides the instance wrapKey for one call; meta supplies pagination, counts, or links at the top level.

new UserTransformer().document(users, { key: "records", meta: { total: 42 } });
// { records: [ … ], total: 42 }