Partial responses and field selection
The over-fetching problem
Imagine a mobile app that shows a list of book titles. Nothing fancy, just a scrollable list with titles. The app calls GET /books and gets back:
[
{
"id": "book-1",
"title": "The Old Man and the Sea",
"isbn": "978-0684801223",
"genre": "fiction",
"publishedAt": "1952-09-01",
"authorId": "author-1",
"createdAt": "2024-01-01T00:00:00Z",
"author": { "id": "author-1", "name": "Ernest Hemingway", "bio": "..." }
}
] The app only needs id, title, and maybe author.name. Everything else, the ISBN, genre, publication date, timestamps, the full author bio, is wasted bandwidth. For one book, that’s not a big deal. For a list of 50 books on a slow mobile connection, it adds up fast.
This is called over-fetching, and it’s one of the most common performance issues in REST APIs.
Field selection
The fix is to let the client tell the server which fields it wants:
GET /books?fields=id,title,author.name Instead of returning everything, the server returns only what was requested:
[{ "id": "book-1", "title": "The Old Man and the Sea", "author": { "name": "Ernest Hemingway" } }] The response is smaller, faster to transmit, and faster to parse. The client gets exactly what it needs and nothing more.
Implementation
Let’s build this. First, we need two utility functions: one to parse the fields query parameter and one to filter an object down to only the requested fields.
// src/fields.ts
export function selectFields(obj: Record<string, any>, fields: string[]): Record<string, any> {
const result: Record<string, any> = {};
for (const field of fields) {
if (field.includes(".")) {
// Nested field: "author.name"
const [parent, child] = field.split(".", 2);
if (obj[parent] && typeof obj[parent] === "object") {
if (!result[parent]) result[parent] = {};
result[parent][child] = obj[parent][child];
}
} else {
if (field in obj) {
result[field] = obj[field];
}
}
}
return result;
}
export function parseFields(fieldsParam: string | null): string[] | null {
if (!fieldsParam) return null;
return fieldsParam
.split(",")
.map((f) => f.trim())
.filter(Boolean);
} Let’s walk through this.
parseFields takes the raw query parameter string like "id,title,author.name" and splits it into an array: ["id", "title", "author.name"]. If no fields parameter was provided, it returns null, which means “return everything.”
selectFields is where the real work happens. It takes an object (like a book) and an array of field names, and returns a new object containing only those fields. For simple fields like "id" and "title", it just copies the value. For nested fields like "author.name", it splits on the dot, creates the parent object if needed, and copies just the nested property.
Now wire it into the route handler:
route.get("/books", {
request: {
query: z.object({
fields: z.string().optional(),
}),
},
resolve: (c) => {
if (!c.input.ok) return fromZodIssues(c.input.issues);
const { fields: fieldsParam } = c.input.query;
const fields = parseFields(fieldsParam ?? null);
let allBooks = books.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
// Embed author summary
const booksWithAuthors = allBooks.map((book) => {
const author = authors.find((a) => a.id === book.authorId);
return { ...book, author };
});
if (fields) {
return Response.json(booksWithAuthors.map((book) => selectFields(book, fields)));
}
return Response.json(booksWithAuthors);
},
}); We define the fields query parameter with Zod, the same way we validate params and body. Since it’s optional, clients who don’t send it get the full response. We destructure c.input.query to get the value, then pass it to parseFields for the comma-separated parsing.
Try it out:
# Full response (no fields parameter)
curl http://localhost:3000/books
# Only id and title
curl "http://localhost:3000/books?fields=id,title"
# Nested field: just the author's name
curl "http://localhost:3000/books?fields=id,title,author.name"
# Unknown fields are silently ignored
curl "http://localhost:3000/books?fields=id,fake" The first request returns every field on every book. The second returns just { "id": "...", "title": "..." } for each one. The third includes a nested author object containing only the name. Compare the response sizes, that’s the bandwidth you save on every request.
What happens if the client requests a field that doesn’t exist, like ?fields=id,fake? We ignore it. The id gets included, fake is silently skipped. We don’t return an error, because the client might be built against a newer version of the API that has a field the current server doesn’t know about yet. Ignoring unknown fields keeps the API forward-compatible.
When to use field selection
Field selection adds complexity. You need the parsing logic, you need to handle nested fields, and you need to think about edge cases. So when is it worth it?
Use it when: responses are large, you have clients with different needs (web apps want everything, mobile apps want a summary), or bandwidth is a real concern.
Skip it when: responses are small, you have few clients, or the complexity isn’t justified. Many successful APIs, like GitHub and Stripe, don’t support field selection at all. They return fixed response shapes and it works fine.
An alternative: response profiles
If full field selection feels like overkill, there’s a simpler option. Define a few fixed “views” that clients can pick from:
GET /books?view=summary returns { id, title, author.name }
GET /books?view=full returns everything
GET /books default (full) This is easier to implement, easier to document, and covers most real-world needs. The tradeoff is less flexibility: the client can’t ask for an arbitrary combination of fields.
What’s next
Field selection helps clients get less data. But there’s a bigger problem: our GET /books endpoint returns all books at once. Right now that’s only 6 books, but in a real bookstore with thousands of books, returning everything in one response would be slow and wasteful. We need pagination.
Exercises
Exercise 1: Implement the fields query parameter on GET /books. Test with ?fields=id,title and verify only those fields are returned.
Exercise 2: Add nested field support: ?fields=id,title,author.name. Verify the author object only contains the name.
Exercise 3: What should happen if the client requests a field that doesn’t exist (like ?fields=id,fake)? Answer: ignore it. The client might be using a newer version of the schema that has fields the server doesn’t know about yet.
Why should unknown fields in the fields parameter be ignored instead of causing an error?