Every codebase eventually grows a utils.ts file. It starts with three innocent helpers. Six months later it's 1500 lines, half the team has added something to it, nobody knows what's in it, and importing from it slows down builds because everything in the file gets pulled in.

The good news: utility functions don't have to age this way. The bad news: you have to be deliberate from the start.

Utilities Should Have One Job

A utility function is one that does one small, specific thing. The moment a utility has an if/else branch on its purpose ("if this is a date, format it; if it's a number, format it differently"), it's stopped being a utility and started being two functions in disguise.

TypeScript
// 🐛 doing too much
function format(value: unknown): string {
  if (value instanceof Date) return value.toISOString();
  if (typeof value === 'number') return value.toFixed(2);
  if (typeof value === 'string') return value.trim();
  return String(value);
}

// ✅ each one focused
function formatDate(date: Date): string { return date.toISOString(); }
function formatMoney(cents: number): string { return (cents / 100).toFixed(2); }
function trimSafe(s: string): string { return s.trim(); }

The split looks like more code. It's actually less to read — each function has one obvious purpose, and call sites stop carrying the cognitive load of "wait, what does format do for a number again?"

Names Are Documentation

A well-named utility doesn't need a comment to explain what it does. A poorly-named one does, and the comment will rot.

TypeScript
// names that earn their rent
function formatMoneyCents(cents: number, currency: string): string;
function isExpired(token: { expiresAt: number }): boolean;
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T;
function retry<T>(fn: () => Promise<T>, opts: { tries: number; delayMs: number }): Promise<T>;

// names that don't
function process(x: any): any;
function helper(...args: any[]): any;
function utils(thing: unknown): unknown;

The first set tells you what to expect from a glance. The second set requires opening the file to understand. Multiply by 50 utilities and the productivity difference compounds.

A naming pattern that works: verb + Noun for actions (formatMoney, parseSlug, parseEnv), is + Noun for predicates (isExpired, isValid, isAdmin), with + Noun for wrappers (withTimeout, withRetry).

Tests Make Helpers Safe To Reuse

A utility used in three places without tests is fine. A utility used in 30 places without tests is a load-bearing wall in the codebase that you're afraid to change.

TypeScript src/utils/clamp.test.ts
import { clamp } from './clamp';

test('keeps value when in range', () => expect(clamp(5, 0, 10)).toBe(5));
test('clamps to min when below', () => expect(clamp(-5, 0, 10)).toBe(0));
test('clamps to max when above', () => expect(clamp(15, 0, 10)).toBe(10));
test('handles equal min/max', () => expect(clamp(5, 5, 5)).toBe(5));
test('handles inverted bounds gracefully', () => expect(() => clamp(5, 10, 0)).toThrow());

Five tests, twenty seconds to write, five years of confidence to refactor. Utilities are exactly the kind of code where unit tests pay back the most — they're small, pure, and called from many places.

Before and after refactor view. Left panel shows a single 1247-line utils.ts with 47 unrelated exports and four debugging symptoms. Right panel shows the same exports moved into small grouped folders — date, money, async, type, string, array — each file under 80 lines with a single intent.
Same exports, grouped by intent. Tree-shaking and refactors become possible again.

Avoid The Dumping Ground

Folder structure that prevents the utils.ts collapse:

Text
src/utils/
  date/
    formatDate.ts
    parseDate.ts
    isWeekend.ts
  money/
    formatMoney.ts
    parseMoney.ts
  string/
    slugify.ts
    truncate.ts
  async/
    debounce.ts
    retry.ts
    withTimeout.ts

Each file has one function (or one small group of tightly related ones). Each folder groups by concept, not by alphabet or by who happened to add it first.

Three benefits:

  1. Tree-shaking works. Importing formatMoney doesn't pull in date utilities.
  2. Files are small. PR diffs are obvious; merge conflicts are rare.
  3. Ownership is clear. Date utilities have one obvious owner; the kitchen-sink utils file had thirty.

When a new utility doesn't obviously belong in an existing folder, that's a signal to create a new folder, not to dump it in misc/.

Pure And Side-Effect-Free Where Possible

The utilities that age best take inputs and return outputs. No global reads, no Date.now() reads inside the function, no I/O.

TypeScript
// ✅ pure — testable, predictable
function isExpired(token: { expiresAt: number }, now: number): boolean {
  return now > token.expiresAt;
}

// 🐛 reads the clock — harder to test
function isExpiredImpure(token: { expiresAt: number }): boolean {
  return Date.now() > token.expiresAt;
}

The pure version takes now as a parameter. Call sites pass Date.now() themselves. Tests pass any value they want. The function is trivially testable; the impure one needs mocking.

This isn't a religion — sometimes a utility should read the clock or the environment. But when you can avoid the side effect with one extra parameter, do it.

Pro Tips

  1. One file, one function (or one tightly-related group).
  2. Group by concept, not by alphabet — date/, money/, string/.
  3. Test every utility, especially small ones. They're the easiest to test and the most reused.
  4. Pure where possible. Inputs in, outputs out, no hidden reads.
  5. Reject utils.ts PRs. Make people commit to a real folder.

Final Tips

The utility folder is the part of a codebase that ages worst because nobody owns it. The fix is not "be more careful" — it's structure that makes carelessness obvious. One file per function, named folders, tested behavior. The 1500-line utils.ts never gets a chance to form.

Treat utilities like the small load-bearing walls they are. They hold the rest of the code up.

Good luck — and may your utils.ts stay below 100 lines forever 👊