Designing a TypeScript helper to prevent accidental database field leaks
A lot of database code starts by selecting too much.
That is usually not intentional. It happens because selecting a full table is convenient, the UI only needs a few fields, and nobody notices the extra data moving through the system.
But the extra fields still matter.
They make API responses larger than they need to be. They blur the contract between the backend and the frontend. More importantly, they can expose data that the client should not receive in the first place: internal notes, permission flags, emails, timestamps, audit fields, or values that become sensitive later.
The safest pattern is to select exactly what each surface needs.
That sounds simple, but in practice it creates friction. The same selected shapes start showing up across admin tables, search results, dashboards, exports, and API responses.
After a while, the important decision is no longer id: Listing.id. The important decision is that this surface is allowed to return id, title, and price.
That is where a small helper can earn its place.
I did not want a new query builder. I wanted a tiny TypeScript utility that made explicit column selection easier to write, easier to review, and harder to accidentally loosen.
The helper started as a DX improvement.
The better reason for it was data discipline.
Selecting everything creates quiet exposure
Too Much By Default
A full-table select is convenient because it removes a decision.
const users = await db.select().from(User);That can be fine inside a trusted backend-only path. The problem starts when the result moves toward an API response, a server component, an admin table, or a frontend data loader.
The frontend might only render id, name, and avatarUrl, but the response may still contain much more.
// The UI only needs these:
user.id;
user.name;
user.avatarUrl;
// But the query may also return these:
user.email;
user.role;
user.internalNotes;
user.createdAt;
user.updatedAt;
user.deletedAt;Even when the UI ignores those fields, they still crossed the boundary.
They are in the response. They are visible to the client. They become part of the accidental contract.
That is the kind of leak that does not look like a dramatic security failure at first. It looks like ordinary convenience.
Hidden Contracts
The more a codebase relies on full-table results, the harder it becomes to know what a surface is actually allowed to expose.
A component may only use three fields today. A future change may reuse the same loader somewhere else. Another developer may assume the data is safe because it is already being returned. Over time, the shape becomes normal just because it is available.
That is the wrong default.
The query should say what the surface needs.
const users = await db
.select({
id: User.id,
name: User.name,
avatarUrl: User.avatarUrl,
})
.from(User);This version is more explicit. It makes the data contract visible at the boundary where the data is selected.
The downside is repetition.
Once exact selection becomes the standard, the same mechanical mappings begin appearing everywhere.
Exact Selection
That was the real problem I wanted to solve.
Not “how do I avoid writing Drizzle selects?”
Drizzle selects are already clear.
The question was: how do I make the safer pattern easy enough that I keep using it?
A helper that encourages exact column selection changes the default habit. Instead of selecting everything and hoping the caller only uses the safe fields, each query starts from the fields it is allowed to return.
const users = await db
.select({
id: User.id,
name: User.name,
avatarUrl: User.avatarUrl,
})
.from(User);The code is not hard.
The repetition is what makes people reach for the looser pattern.
A good helper should remove that repetition without weakening the explicitness.
Exact picks make the safe path cheap
Small Call Site
The helper I wanted was small enough to read as the operation itself.
const fields = pick(User, "id", "name", "avatarUrl");
const users = await db
.select({
...fields,
})
.from(User);That call reads left to right:
from User, pick these columns.
The table comes first because it is the source of truth. Once TypeScript knows the table, it can narrow the valid column names. If I try to pick a column that does not exist, the helper should fail immediately.
pick(User, "id", "nam");
// ^ should failThe goal is not only convenience. The goal is to make exact selection feel like the easiest path.
If the safe version is annoying, people eventually route around it.
Allowed Fields
The helper also makes review easier.
const users = await db
.select({
...pick(User, "id", "name", "avatarUrl"),
})
.from(User);The selected columns are visible in one compact line. A reviewer can quickly answer the most important question: what data leaves this query?
That matters more than saving a few keystrokes.
The helper creates a habit around naming the allowed fields. It nudges each query toward a smaller data contract.
That does not replace authorization, validation, or careful API design. It just removes one common source of accidental exposure: returning more columns than the surface needs.
Repeated Shapes
The pattern becomes especially useful when the same shape appears in multiple places.
const listingPreviewFields = pick(Listing, "id", "title", "price");
const listings = await db
.select({
...listingPreviewFields,
})
.from(Listing);At that point, the important idea is not the mechanical mapping.
The important idea is the shape.
listingPreviewFields says something about the product surface. It describes a safe, reusable subset of the table.
That is different from blindly selecting a whole row and trusting every caller to be careful.
The helper should remove mechanical mapping
Drizzle Selects
A normal Drizzle select is already readable.
const listings = await db
.select({
id: Listing.id,
title: Listing.title,
price: Listing.price,
})
.from(Listing);There is nothing wrong with writing the shape directly when the query has meaningful field names, computed values, or one-off aliases.
The repetition only becomes worth abstracting when the mapping is mechanical.
{
id: Listing.id,
title: Listing.title,
price: Listing.price,
}That object repeats the same idea three times: expose this column under its existing name.
A helper can make that intent clearer.
pick(Listing, "id", "title", "price");Visible Product Language
The helper should not hide important naming decisions.
Mechanical column selection can be abstracted. Product language should usually stay at the call site.
const listings = await db
.select({
...pick(Listing, "id", "title"),
sellerDisplayName: User.name,
})
.from(Listing)
.innerJoin(User, eq(User.id, Listing.sellerId));If the UI calls a value sellerDisplayName, I want to see that decision in the query.
That name is not mechanical. It is product language. It tells the reader how the value is meant to be understood.
The helper should remove repetitive mapping, not hide the words the product depends on.
Calm Queries
A query can contain several select fragments.
const result = await db
.select({
...pick(User, "id", "email"),
...pickWith(Profile, { prefix: "profile" }, "id", "displayName"),
organizationName: Organization.name,
})
.from(User);If each fragment is visually heavy, the reader has to keep decoding the helper instead of reading the query.
The helper should make repeated structure easier to scan.
It should not become the most interesting part of the query.
The common case needs careful guardrails
Direct Selection
Most calls only need to pick columns.
pick(Listing, "id", "title", "price");That shape is doing a lot of work.
It keeps the table and selected keys close together. It gives TypeScript the information it needs to narrow valid column names. It also keeps the call site small enough that exact selection does not feel like ceremony.
That last part matters.
A safety pattern only works if people keep using it.
Options Pressure
The tempting version was one function with every behavior inside it.
pick(table, options, "id", "name");
pick(table, "id", "name", options);Neither shape felt right.
If options come before the columns, the common case gets pulled toward configuration. If options come after a variadic list of keys, the TypeScript gets more clever than the runtime behavior deserves.
Clever types have a maintenance cost.
Not at runtime, but during review, refactoring, and the next type inference bug.
Function Split
The cleaner move was to give the configured path a separate name.
pick(table, "id", "name");
pickWith(table, { prefix: "user" }, "id", "name");pick stays small because it means one thing.
pickWith carries the extra behavior because the name says the operation is being modified.
That split made the argument order feel less arbitrary. The options are the with part. The selected keys remain direct and trailing, where TypeScript can preserve them cleanly.
The common path stays protected.
The configured path stays available.
Prefixes make joined results harder to misuse
Colliding Columns
Joined data is where flat result objects start to get messy.
const posts = await db
.select({
...pickWith(Post, { prefix: "post" }, "id", "title", "createdAt"),
...pickWith(Author, { prefix: "author" }, "id", "name"),
})
.from(Post)
.innerJoin(Author, eq(Author.id, Post.authorId));A Post table and an Author table can both have id, name, status, or createdAt.
For a single table, id is often better than postId.
For a join, postId can be the difference between a readable result and a guessing game.
The prefix is not just decoration. It makes the output harder to misunderstand.
Typed Prefixes
The runtime shape and the TypeScript shape have to agree.
const fields = pickWith(Listing, { prefix: "listing" }, "id", "title");
type Fields = typeof fields;
// {
// listingId: typeof Listing.id;
// listingTitle: typeof Listing.title;
// }If the helper creates listingId, TypeScript should know about listingId.
It should not pretend the result still has id.
Otherwise the abstraction is lying a little bit.
That is especially dangerous in the same kind of code this helper is meant to improve: boundary code where selected fields become API data.
JavaScript Defaults
I defaulted prefixing to camel-style keys because the output is JavaScript data.
pickWith(User, { prefix: "user" }, "id", "name", "createdAt");
// userId
// userName
// userCreatedAtThere are good reasons for snake case in databases. There are good reasons for explicit separators near SQL aliases or external formats.
But once the data is coming back into TypeScript, camel-style object keys are usually the path of least surprise.
A default should encode the most likely answer so the caller does not have to keep restating it.
Separator Escape Hatch
The default still cannot be the only option.
pickWith(User, { prefix: "user", separator: "_" }, "id", "name");
// user_id
// user_nameThe separator option gives the caller a visible boundary when the result needs one.
That keeps the common path opinionated while making the escape hatch obvious.
The important part is where that option lives.
It belongs in pickWith, not pick, because it changes how the selection works.
The API should preserve compiler precision
Exact Keys
The selected columns are the generic input I most want TypeScript to understand precisely.
If I pass "id" and "email", the resulting type should know that I picked exactly those keys.
const userFields = pick(User, "id", "email");
type UserFields = typeof userFields;
// {
// id: typeof User.id;
// email: typeof User.email;
// }That works best when the keys are passed directly.
Wrapping them in an array or object makes the common call heavier without adding much meaning.
pick(User, "id", "email");This keeps the selected keys visible to the compiler and visible to the reader.
Simplified Implementation
A simplified version of the direct helper looks like this:
type PickedFields<TTable, TKeys extends readonly (keyof TTable)[]> = {
[TKey in TKeys[number]]: TTable[TKey];
};
function pick<
TTable extends Record<string, unknown>,
TKeys extends readonly (keyof TTable)[],
>(table: TTable, ...keys: TKeys): PickedFields<TTable, TKeys> {
return Object.fromEntries(
keys.map((key) => [key, table[key]]),
) as PickedFields<TTable, TKeys>;
}The runtime behavior is small.
Take a table. Take keys. Return an object with those keys mapped to the corresponding table columns.
The type behavior is the useful part.
The return type is not “some selected fields.” It is the exact object created by the keys at the call site.
That is what lets the helper stay honest.
Operation Versus Options
I also considered making everything an object.
pick({
table: User,
columns: {
id: true,
email: true,
},
prefix: true,
separator: "_",
});That style can be useful when parameters are hard to distinguish.
For this helper, it made the simple case feel like configuration.
The table and columns are not options. They are the operation. The options object should only appear when there are actual options.
Keeping that distinction clear protects the call site from growing around the edge cases.
The abstraction has to stop early
Plain Object Boundary
pick returns a plain select object that Drizzle already understands.
db.select({
...pick(Listing, "id", "title"),
sellerDisplayName: User.name,
});That boundary keeps the escape hatch open.
If a query needs a custom alias, computed value, conditional expression, or product-specific name, normal Drizzle code sits right next to the helper output.
The helper composes with the query builder instead of becoming a query builder.
That is important because the purpose is not to hide Drizzle.
The purpose is to make exact selection easier to maintain.
No Full Join Helper
The temptation after a helper works is to keep expanding it.
For this utility, I wanted to stop at column selection.
Full join helpers are not just mechanical. Filters often carry product logic. Pagination can involve performance tradeoffs. Authorization rules should not be hidden inside a small convenience function.
Those are different kinds of decisions:
- column selection
- product naming
- joins
- filters
- pagination
- authorization
Only the first one was mechanical enough to abstract here.
The rest should remain visible until there is a stronger reason to extract them.
Safer Defaults
The pattern I would keep is simple: do not make the common case carry the weight of the edge case.
For this utility, that meant pick for direct selection and pickWith for configured selection. It meant returning a plain object instead of inventing another query builder. It meant giving camel-style prefixing a default and putting separators behind an explicit option.
None of those decisions are large on their own.
Together, they make the safer path feel calm.
That is usually what I want from a small TypeScript abstraction: not the most flexible version, and not the most abstract version.
The version I will still want to use after the novelty wears off.
In this case, that meant a helper that makes database field leaks harder by making exact selection easier.