DEV Community

Elizabeth Sobiya
Elizabeth Sobiya

Posted on

I couldn't find an npm package to sort filenames into buckets, so I built one

Last week I was building a file-management feature. Users could upload
documents, and I needed to sort them into sections. One bucket for
NOCs, one for agreements, one for invoices, one for anything else.

Simple enough. I opened npm expecting to find a package for it.

What I found instead

Every search under "sort files" or "classify filename" returned one of
three things:

  • sort-package-json variants (sorting keys inside package.json)
  • Natural-sort utilities (alphabetical ordering of file lists)
  • File-type detectors (identifying MIME types from extensions)

Nothing that took a list of filenames and a list of rules and gave
back named buckets.

I asked around. A few devs suggested "just use .filter()". Fair, for
one bucket. But once you need:

  • Multiple buckets with priority order
  • A fallback bucket for unmatched files
  • Natural sort inside each bucket
  • Rules from a config file or user input

...you're rewriting the same 40 lines in every project. That's
package-shaped.

So I built classify-filename

import { classify } from 'classify-filename';

const files = [
  'NOC_2024.pdf',
  'noc_letter.pdf',
  'agreement_v1.docx',
  'invoice_2.pdf',
  'invoice_10.pdf',
  'random.txt',
];

const result = classify(files, [
  { name: 'noc',        match: /^noc/i },
  { name: 'agreements', match: 'agreement' },
  { name: 'invoices',   match: /^invoice/i },
]);
Enter fullscreen mode Exit fullscreen mode

The result:

{
  sections: {
    noc:           ['noc_letter.pdf', 'NOC_2024.pdf'],
    agreements:    ['agreement_v1.docx'],
    invoices:      ['invoice_2.pdf', 'invoice_10.pdf'],
    uncategorized: ['random.txt'],
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice invoice_2.pdf comes before invoice_10.pdf. That's natural
sort, the default behavior, because alphabetical sort would put 10
before 2 and that's usually not what you want with filenames.

Design decisions I actually thought about

A few choices that felt small but mattered.

Section order is priority order.
If a file matches two sections, the earlier one wins. No priority
numbers, no most-specific-match algorithm. Array order is the mental
model users already have when they write a list of rules.

Case-insensitive by default.
Files are messy. NOC_2024.pdf and noc_letter.pdf should land in
the same bucket without the user thinking about it. Regex matchers
still respect their own flags. If you write /^NOC/ you probably
meant it.

A fallback bucket, not a silent drop.
Unmatched files go to uncategorized by default. Silent drops cause
the "where did my files go?" bug I've written before. You can opt into
the drop by passing fallback: false.

An explain mode for debugging.
Set explain: true and the result includes a filename to section map,
so when someone asks "why did noc_agreement.pdf end up in NOC and
not agreements?", you can see the answer in one glance.

What's not in v1 (on purpose)

  • No glob support. Regex covers it. Adding globs invites ambiguity with substring matches unless done as an explicit wrapper. Planned for v1.1.
  • No file-metadata rules. Filename only for now. Size, date, and extension helpers coming in v1.2.
  • No CLI. This is a library. If people want to organize a folder from the command line, that's v1.3.

Each of these could have gone into v1. I left them out because the
scope creep would have delayed shipping, and the API stays cleaner if
each feature earns its way in with real user demand.

The stack

  • TypeScript, dual CJS + ESM output via tsup
  • Zero runtime dependencies
  • Tested with Vitest, around 15 tests covering the matcher and classifier
  • ~2 KB minified

Try it

npm install classify-filename
Enter fullscreen mode Exit fullscreen mode

One thing I'd genuinely like feedback on: section-array-order as
priority. Does that feel natural to you, or would you rather see an
explicit priority: number field?

Would love to hear how you'd approach this. If you find it useful, a
star on the repo helps me know it's worth pushing v1.1 out.

Top comments (8)

Collapse
 
alexshev profile image
Alex Shev

This is a healthy reason to build a package: a small recurring workflow, a clear boundary, and a problem narrow enough to test well. The best tiny npm tools usually win because they do one boring thing predictably instead of trying to become a whole platform.

Collapse
 
elizabethsobiya profile image
Elizabeth Sobiya

Thanks, I really appreciate it. That means a lot.

Collapse
 
alexshev profile image
Alex Shev

You are welcome. Small packages like that are often the healthiest kind because the scope is easy to understand and the maintenance story is honest.

Thread Thread
 
elizabethsobiya profile image
Elizabeth Sobiya

That’s exactly the kind of balance I was hoping to keep. Glad it comes across that way.

Thread Thread
 
alexshev profile image
Alex Shev

It does come across. Small focused packages are easiest to trust when the boundary is narrow and the behavior is obvious. That is often more valuable than trying to turn a tiny utility into a framework.

Thread Thread
 
alexshev profile image
Alex Shev

It does. The healthiest small utilities usually know exactly where they stop. That makes them easier to adopt, easier to audit, and much less likely to grow into a kitchen-sink dependency nobody wants to own.

Collapse
 
frank_signorini profile image
Frank

How did you handle edge cases like filenames with multiple dots or special characters in your sorting logic? I'd love to swap ideas on this, following for more content on file-management solutions.

Collapse
 
elizabethsobiya profile image
Elizabeth Sobiya

Good question.

The multi-dot filenames and special/unicode characters aren't an issue, there's no basename/extension parsing under the hood. Every matcher runs against the full filename string, so something like café (final).tar.gz is treated the same as report.pdf. String matchers use .includes(), regex matchers use .test().

The one place this can trip you up is if you write a regex assuming a single dot, e.g. /^[^.]+\.docx$/ breaks on my.file.v2.final.docx. That's a matcher-writing gotcha though, not a library bug, anchoring on $ instead of assuming dot count fixes it.

Digging into this did surface a real bug: regex matchers with g/y flags are stateful (lastIndex persists between .test() calls), and since the same RegExp instance is reused across a batch of filenames, you can get intermittent false negatives depending on input order. Fixing that now by stripping those flags before testing.

Thanks for pushing on this.