filenamify: turning a string into a filename that will not break a filesystem
Convert a string to a valid safe filename
At a glance
- What is it?
- filenamify replaces the characters that filesystems reserve with a replacement string, on the assumption that a name has to survive being written on any operating system, not just the one you are running. The details in its documentation are the interesting part: it avoids Windows reserved names by substituting the replacement character, it truncates on grapheme boundaries so a Unicode character is never split, and it ships a browser-only entry point so the path handling does not drag a Node polyfill into your bundle.
- Who is it for?
- Adopt filenamify if you generate filenames from user input or from anything that might contain a slash, a colon or a character Windows will not accept, since it encodes the platform rules properly and keeps Unicode intact while truncating. Do not expect it to produce a maximally readable name, because it is deliberately conservative and assumes the name may travel to any filesystem, so a Linux-only application can end up with more mangled names than it needed.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 56 days ago.
- What is it written in?
- Mainly JavaScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 20, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The problem, which is that filenames are not portable
filenamify converts a string to a valid safe filename.
The reason this needs a package is the first line of the README. On Unix-like systems, / is reserved. On Windows, a set of characters is reserved, along with trailing periods and spaces. So a name that is fine on Linux can be unopenable on Windows, and the set of rules differs by platform.
The package takes the conservative line, which is to assume the name has to work everywhere. That is the right default for anything that moves files between systems, which is most software that generates filenames: downloads, uploads, attachments, archives.
On top of the reserved characters, the module also removes non-printable control characters, including Unicode bidirectional marks, and normalizes Unicode whitespace. The bidirectional marks deserve a note, because they are invisible. A right-to-left override character in a filename can make a string render as something other than what it contains, which is a spoofing vector in a list of uploaded files, and stripping them is a small security decision rather than a formatting one.
Normalizing Unicode whitespace is the other half. There are many codepoints that look like a space, and which ones a filesystem treats as a space varies. Normalizing them means one string produces one name everywhere.
The primary language is JavaScript, the licence is MIT, the default branch is main, and the recent releases are v7.0.3 from 2026-08-28, v7.0.2 from 2026-06-16 and v7.0.1 from 2025-11-05. The last push was on 2026-08-28 with the project not archived, and the version at 7.x with three releases across a year suggests a stable, slowly moving package.
The one runtime dependency is filename-reserved-regex at about 4.0.1, and the single dependency is the point. The rules about which characters are reserved on which platform are the fiddly part, and they are in one maintained place rather than in a regular expression somebody wrote from memory in a pull request.
The package layout, and what the exports map says about it
The repository is flat and small, and the file names map directly onto the two entry points.
There is index.js and index.d.ts for the main entry, filenamify.js and filenamify.d.ts for the browser-specific build, and filenamify-path.js and filenamify-path.d.ts for the path helper. Then readme.md, test.js, license, a .editorconfig, .gitattributes, .gitignore, an .npmrc and the .github/ directory.
The package manifest matches that structure and explains the browser entry in machine-readable form. The exports map has two entries. The root entry has a types condition pointing at index.d.ts and a default condition pointing at index.js. And the ./browser entry has a types condition pointing at filenamify.d.ts and a default pointing at filenamify.js.
The types conditions come first in each entry, which is the ordering that lets a TypeScript-aware resolver find the declarations before the runtime file, and it is why the browser entry has its own declaration file rather than reusing the root one.
The rest of the manifest is unremarkable in the way good packaging is. The type is module, so it is ESM only. sideEffects is false, so a bundler can drop the import if the result is unused. The engines field requires Node 20 or newer. The files list publishes exactly the six JavaScript and declaration files, so the test file and the readme assets do not ship.
The keyword list is a good summary of the domain: filename, safe, sanitize, file, name, string, path, filepath, convert, valid, dirname.
The single runtime dependency is filename-reserved-regex:
"filename-reserved-regex": "^4.0.1"And the development tooling is the pair the author's packages use, xo for linting and ava for the tests, with the test script running both in sequence.
Two things follow from the structure. The browser build is a genuinely separate entry point rather than a conditional import inside the main file, so a bundler that resolves the exports map will leave the path module out without any configuration from the user. And the package is CommonJS-free, so a project on Node 20 with ESM gets it without a wrapper, while a project that has not moved to ESM cannot use it at all.
Usage, and the two entry points
The usage section is short and shows both the default behaviour and the custom replacement:
import filenamify from 'filenamify';
filenamify('<foo/bar>');
//=> '!foo!bar!'
filenamify('foo:"bar"', {replacement: '🐴'});
//=> 'foo🐴bar🐴'So the first call replaces the reserved characters with the default replacement character, and the second shows that you can substitute anything, with a horse emoji in the example, giving a name that is valid and still tells you something about what was replaced.
There is a second exported function for the case where you have a whole path. filenamifyPath converts the filename in a path to a valid filename and returns the augmented path:
import {filenamifyPath} from 'filenamify';
filenamifyPath('foo:bar');
//=> 'foo!bar'The distinction matters. filenamify on a whole path would mangle the directory separators, so the two functions exist to make it hard to get that wrong. filenamify takes a name, filenamifyPath takes a path and only touches the last segment.
Then there is the browser-only import, which is the part of the design that is easy to miss and worth reading closely.
You can also import filenamify/browser, which only imports filenamify and not filenamifyPath, which relies on path being available or polyfilled. Importing filenamify this way is therefore useful when it is shipped using webpack or similar tools, and if filenamifyPath is not needed.
So the reason for the split is bundle size and portability. filenamifyPath needs the Node path module, and in a browser bundle that means a polyfill or a stub, and many bundlers will try to include it whether you want it or not. The separate entry point lets you leave it out.
import filenamify from 'filenamify/browser';
filenamify('<foo/bar>');
//=> '!foo!bar!'And the same call produces the same result as the default import, which is the point: the browser build is not a degraded version, it is the same function with the path helper removed.
The options, and the two rules that catch people out
There are two options, and both of them have edge cases the documentation spells out.
The replacement option is a string, defaulting to an exclamation mark, and it is the string used to replace reserved filename characters. It cannot contain the reserved characters themselves, or control characters. And if the replacement would leave the result as a Windows reserved name, an exclamation mark is used instead.
That last clause is the interesting one. Windows has names like CON, PRN, AUX, NUL and COM1 that cannot be used as filenames at all, so if your input is a string like NUL and you replace nothing, the result is still invalid. The library handles it by using the default replacement character instead of yours in that specific case, so the function always returns something usable.
The maxLength option is a number, defaulting to 100, and it truncates the filename to the given length. The rules around it are where the care shows.
Only the base of the filename is truncated, preserving the extension. If the extension itself is longer than maxLength, you will get a string that is longer than maxLength, so you need to check for that if you allow arbitrary extensions. The result may also exceed maxLength when a suffix is needed to avoid a Windows reserved name.
Truncation is grapheme-aware and will not split Unicode characters, whether surrogate pairs or extended grapheme clusters. If the remaining budget, after accounting for the extension, is smaller than a whole grapheme, the base filename may be truncated to an empty string to avoid splitting.
Four separate rules, and each one exists because of a way the naive implementation produces a broken result. Truncating a UTF-16 string at a byte or code unit boundary can produce a lone surrogate, which is not a valid string, and a filename with a lone surrogate is not openable. Truncating on grapheme boundaries means an emoji with a skin-tone modifier or a combining sequence stays intact, which is a different level of correctness from not splitting a code point, and it is the level a person actually perceives.
The empty-string case is the awkward consequence of doing it right. If the extension eats the entire budget, there is no room for a whole grapheme, and rather than emit a half grapheme the library emits nothing. So a very long extension can produce a name that is just the extension, and the caller has to decide whether that is acceptable.
And the final line explains the default of 100: systems generally allow up to 255 characters, but we default to 100 for usability reasons. So the default is not a limit imposed by the filesystem, it is a deliberate choice for names a human will read.
The related packages, and how to evaluate the project
There are four related packages, and together they cover the whole neighbourhood.
filenamify-cli is a command line front end for this module, so you can sanitise a name in a shell script. filenamify-url converts a URL to a valid filename, which is the same problem with the URL parsing already done for you. valid-filename checks if a string is a valid filename, which is the predicate form of what this package computes, and is the one you want when you need to reject rather than repair. And unused-filename gets an unused filename by appending a number if it exists, which is the collision-avoidance step that has to follow sanitisation.
And slugify is listed as well, which is the neighbouring problem: a URL-safe lowercase identifier rather than a filesystem-safe name. The distinction is worth keeping straight, because slugify is much more aggressive, and a slug is a poor filename since it discards the capitals and the spaces that make a name readable.
For an evaluation, four things decide it.
First, whether you want the conservative behaviour. This package makes names safe everywhere, not just on your platform, so a filename it produces may be more mangled than it needs to be on the system you are running. If you only ever write to Linux, that is a small loss of legibility in exchange for a guarantee.
Second, the replacement character. The default is an exclamation mark, which is visible and greppable, and you can change it. But the option cannot contain reserved characters, and if your chosen replacement would produce a Windows reserved name you get an exclamation mark instead, which is a case where the result is not what you asked for and you would not know without looking.
Third, the truncation behaviour. Grapheme-aware truncation is the right default and a good deal of work to have done for you, but the interaction with extensions means the length of the result is not bounded by maxLength, so if you are writing to a system with a hard limit you need to check the output rather than trust the option.
Fourth, the browser entry point. If you are bundling for the browser, use filenamify/browser rather than the main entry, because the main one pulls in the path module through filenamifyPath and your bundle will grow or your build will need a polyfill.
What you get is a small, single-dependency package that gets the platform rules right, keeps Unicode intact while truncating, and tells you about the cases where it cannot do what you asked. The version at 7.x and the release cadence suggest a settled API, and the sibling packages cover the neighbouring tasks well enough that you probably will not need to write the collision-avoidance logic yourself.
Editorial conclusion
Adopt filenamify if you generate filenames from user input or from anything that might contain a slash, a colon or a character Windows will not accept, since it encodes the platform rules properly and keeps Unicode intact while truncating. Do not expect it to produce a maximally readable name, because it is deliberately conservative and assumes the name may travel to any filesystem, so a Linux-only application can end up with more mangled names than it needed. Before you commit, pick a replacement character that is not itself reserved and remember that the library will substitute an exclamation mark when your choice would produce a Windows reserved name, and if you are bundling for the browser import the filenamify/browser entry so the path module stays out of your bundle.
Frequently asked questions
What does the filenamify package do?
It converts a string to a valid safe filename. On Unix-like systems the forward slash is reserved, and on Windows a set of characters along with trailing periods and spaces are reserved, so the package replaces them. It also removes non-printable control characters, including Unicode bidirectional marks, and normalizes Unicode whitespace.
What are the filenamify options?
Two. The replacement string, defaulting to an exclamation mark, is used for reserved characters and cannot itself contain reserved characters or control characters. The maxLength number, defaulting to 100, truncates the filename while preserving the extension, and truncation is grapheme-aware so Unicode characters are never split.
How does filenamify handle Windows reserved names?
If the replacement would leave the result as a Windows reserved name, an exclamation mark is used instead of the configured replacement, and the result may also exceed maxLength when a suffix is needed to avoid one. The default maxLength of 100 is a usability choice rather than a filesystem limit, since systems generally allow up to 255 characters.
What is the difference between filenamify and filenamifyPath?
filenamify converts a string into a valid filename, while filenamifyPath converts the filename in a path to a valid filename and returns the augmented path, so the directory separators are left alone. The two are separate so that a path is never passed to the function that would mangle its separators.
When should I use the filenamify browser import?
When you ship with webpack or similar bundlers and do not need filenamifyPath. The filenamify/browser entry only imports filenamify, so it avoids pulling in the path module that filenamifyPath relies on, which would otherwise need to be available or polyfilled in a browser build.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/sindresorhus-filenamify)Was this useful?