@williamthorsen/toolbelt.strings
v7.1.0
Published
String-handling utilities
Maintainers
Readme
@williamthorsen/toolbelt.strings
String-handling utilities.
Release notes — v7.1.0 (2026-08-30)
Features
Add a ReadyUp adoption kit to
strings(#248)Adds a ReadyUp adoption kit to
@williamthorsen/toolbelt.strings. Where appropriate, the kit recommends the use ofcapitalizeorpluralizeto replace hand-rolled capitalization and pluralization code, respectively.
Installation
pnpm add @williamthorsen/toolbelt.stringsRequires Node.js 24 or later.
pluralize and pluralizeWithCount are release tier, imported from the package root. dedent, hashString, and stripCommonIndent are candidate tier: imported from @williamthorsen/toolbelt.strings/candidate rather than the package root, and subject to change.
pluralize and pluralizeWithCount
pluralize(count: number, singular: string, plural?: string): string;
pluralizeWithCount(count: number, singular: string, plural?: string): string;Selects the singular or plural form of a word for a count. pluralizeWithCount prefixes the count itself.
import { pluralize, pluralizeWithCount } from '@williamthorsen/toolbelt.strings';
pluralize(3, 'match', 'matches');
// 'matches'
pluralizeWithCount(3, 'match', 'matches');
// '3 matches'The plural defaults to the singular with an s appended, so anything else is the caller's to supply: pluralize(2, 'box') returns 'boxs'. A rule set covering the regular endings would fix box and category while still returning 'heros' and 'quizes', and the wrong forms it left would be rarer without being easier to catch. A uniformly naive default is one a caller learns to override.
Selection is English: only a count whose absolute value is exactly 1 takes the singular, so -1 takes the singular and 0, 1.5, NaN, and Infinity take the plural. Neither function throws.
Intl.PluralRules would change none of that, since CLDR's English one rule selects exactly the same counts. What it offers is other locales, and those need a form per plural category rather than a pair: Polish takes three, Arabic six. Reach for Intl.PluralRules or ICU message formatting there.
pluralizeWithCount interpolates the count as given, without grouping separators. Formatted output composes the two:
const total = 1234;
`${total.toLocaleString('en-US')} ${pluralize(total, 'result')}`;
// '1,234 results'dedent
dedent`...`;
dedent.withOptions(options: { valueIndentationStyle?: 'none' | 'line' }): Dedent;Removes the indentation a multi-line template literal inherits from the source it is written in, so the string a reader sees is the string the program gets.
import { dedent } from '@williamthorsen/toolbelt.strings/candidate';
function describeNpc() {
return dedent`
You are assisting the Game Master of a roleplaying game.
Create an ordinary, everyday person in a high-fantasy setting.
`;
}
// 'You are assisting the Game Master of a roleplaying game.\nCreate an ordinary, everyday person in a high-fantasy setting.'The opening line is discarded, and so is the closing line when it holds nothing but whitespace. A closing line carrying text is kept and dedented along with the rest, where String.dedent throws.
Dropping the closing line takes with it the terminator that preceded it, so text on the last line comes back without a trailing newline. Where one is wanted, leave a blank line above the closing backtick: a blank line is emptied rather than discarded, and the terminator above it survives.
dedent`
alpha
`;
// 'alpha\n'Relative depth is preserved. What is removed is the longest indentation every content line shares.
dedent`
outer
inner
outer
`;
// 'outer\n inner\nouter'What counts as indentation
Only tabs and spaces, and they are compared as characters rather than as widths. A tab is never interchangeable with any number of spaces, because no width is knowable from the text alone.
A consequence worth knowing before it surprises you: where every line is indented but the indentation disagrees in kind, the tag throws rather than removing nothing.
dedent`
tab-indented
space-indented
`;
// Error: Every line of the template is indented, but they share no common indentation.Removing nothing would be silent, and a template that silently declines to dedent is the failure this function exists to prevent. A template with a genuine column-zero line is a different case, and strips nothing without complaint.
Blank lines are ignored when measuring and emptied in the output, so an editor's trailing whitespace on an otherwise empty line changes nothing. A line holding an interpolation counts as content even when the rest of it is blank, which means a value's position can affect the measurement even though its content cannot.
Interpolated values
Values are spliced after the indentation is measured. Nothing a value contains can change how much indentation is removed:
const block = 'x\ny';
dedent`
before
${block}
after
`;
// 'before\nx\ny\nafter'By default a value is spliced exactly as given, so a multi-line value's later lines land where its own text puts them. valueIndentationStyle: 'line' indents them to match the line the value opened on:
const items = 'alpha\nbeta';
dedent`
Items:
${items}
`;
// 'Items:\n alpha\nbeta'
dedent.withOptions({ valueIndentationStyle: 'line' })`
Items:
${items}
`;
// 'Items:\n alpha\n beta''none' is the default because indenting a value edits data the caller did not ask to have edited. A patch, a stack trace, or a base64 blob comes back subtly altered, and nothing in the output says so. The other way round, a caller who wanted alignment sees ragged output and can act on it.
withOptions returns a new tag and leaves the receiver untouched, so configuring one call site cannot change behavior at another. Successive calls merge over the receiver, and an explicit undefined inherits rather than resets.
Interpolated values are limited to strings, numbers, bigints, and booleans. Objects are rejected at compile time because they would coerce to [object Object], and null and undefined because they would render as the text "null" and "undefined". Convert deliberately -- ${String(error)}, ${value ?? ''} -- so the reader can see what was intended.
Migrating from unindent: a nullish value used to render as the empty string, so ${maybeMissing} worked as an idiom for optional content. It is now a compile error, which is where to look first if a template stops typechecking. Date, Error, and URL are rejected on the same grounds, even though each has a meaningful toString; interpolate String(value) or the field you actually meant.
Escaped line terminators
The tag reads the cooked strings, so an escaped backtick or \t behaves exactly as it does in an ordinary template literal. An escaped line terminator is different: \n, \r, \u2028, and a backslash-newline line continuation each change how many lines the literal spans, leaving the author's indentation ambiguous. The tag throws rather than guessing.
dedent`
Error: bad input\nDetails: see log
`;
// Error: The template contains an escaped line terminator or a line continuation...Interpolate the text as a value instead. Without this check, the second line would count as content at column zero and silently flatten the whole template -- the same failure as a multi-line value, arriving through the literal.
stripCommonIndent
stripCommonIndent(text: string): string;Removes the indentation shared by every non-blank line of a string that already exists. This is the plain-function counterpart to dedent, for text that arrives at runtime rather than being written in source.
import { stripCommonIndent } from '@williamthorsen/toolbelt.strings/candidate';
stripCommonIndent(await readFile('prompt.txt', 'utf8'));It performs none of the tag's edge handling. A template literal's opening newline is an artifact of the syntax; a runtime string has no such artifact, so no line is discarded and nothing is trimmed. A file's terminating newline survives, which is the point:
stripCommonIndent(' key: value\n other: thing\n');
// 'key: value\nother: thing\n'The indentation rules are the tag's: tabs and spaces only, compared as characters, with blank lines ignored when measuring and emptied in the output. That emptying is the one way this function edits a line beyond de-indenting it.
Line terminators are recognized as \r\n, \n, \r, \u2028, and \u2029, and each is re-emitted unchanged, so CRLF text does not come back with mixed endings.
A leading byte-order mark is held aside while the indentation is measured and restored afterwards. Without that, a file read with a BOM would have a first line starting with no tab or space, the common indentation would be nothing, and the call would silently do nothing at all.
Unlike the tag, this function never throws. It has no author's intent to check against.
Relationship to String.dedent
String.dedent has been a TC39 stage 2 proposal since June 2022, with its stage 3 checklist still open and no champion since PayPal left the committee. No engine ships it. This implementation adopts the parts of it that are settled and diverges where it has reason to:
| | Here | String.dedent |
| ------------------------ | ----------------------------------------- | ----------------------------------------- |
| Common indentation | longest exactly-matching prefix | same |
| Blank lines | ignored when measuring, emptied in output | proposal issue #23, open |
| Opening line | whitespace-only accepted | must be a bare newline, else throws |
| Closing line | dropped only when whitespace-only | throws when it carries text |
| Escaped line terminators | throws | dedents the raw strings and re-cooks them |
| Value indentation | opt-in via valueIndentationStyle | none; declined in proposal issue #88 |
The two lenient edge rules are deliberate. Requiring a bare opening line would reject invisible trailing whitespace after the backtick, which no formatter shows and every editor tolerates; throwing on a closing line that carries text would reject dedent`\n a\n b`, which is a reasonable thing to write.
hashString
hashString(str: string, options?: { max?: number; min?: number; offset?: number }): number;Deterministically derives an integer from a string, for a stable bucket, index, or slot that survives restarts and processes.
import { hashString } from '@williamthorsen/toolbelt.strings/candidate';
hashString('user-4821');
// 2423608544
hashString('user-4821', { max: 999 });
// 544The default range is the full 32-bit width, [0, 4294967295]. Bounding is opt-in through min and max, which are inclusive, because a narrow range imposes a collision floor the caller should choose knowingly: at { max: 999 }, two of roughly forty inputs collide more often than not.
offset rotates the result rather than salting the digest, so every input shifts by the same amount and hashString(str, { offset }) stays derivable from hashString(str). It wraps at both bounds, so a negative offset and one larger than the range are both fine.
hashString('user-4821', { max: 999, offset: 300 });
// 844The returned value is a contract. For a given input and options it is fixed, and changing the algorithm would be a breaking change, so a result may be persisted or compared across releases. The digest is FNV-1a 32-bit finalized through MurmurHash3's fmix32, applied to the low and high byte of each UTF-16 code unit. Encoding the text as UTF-8 first would match published FNV-1a vectors, at the cost of conflating lone surrogates, which TextEncoder replaces with U+FFFD.
A RangeError names the fault when min, max, or offset is not a safe integer, when min exceeds max, or when the range spans more than 2^32 values, which is wider than the digest can fill.
Adoption checks
The package ships a ReadyUp kit, so a project that installs it can ask how far its adoption got:
rdy run --packagesThe kit reads the project's tracked sources and reports every hand-rolled capitalization and pluralization in them, each counted against the calls the project already makes into this package. Both report at recommend: they are correct code that a published utility expresses better, not defects.
A capitalization is claimed where the same subject supplies both halves, as in word.charAt(0).toUpperCase() + word.slice(1). The subscript, substring, and template-substitution variants are claimed too. A tail the source goes on to transform is not: in word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() the chained call reaches the tail alone, which capitalize does not reproduce. A call on the whole expression is claimed, since it applies to what capitalize returns. Taking capitalize from the charAt(0) form is an exact substitution; from the subscript form it is a correction, since indexing an empty string throws where capitalize returns the empty string.
A pluralization is claimed where a ternary tests a value against 1 and its branches are two string literals related as singular and singular plus s, covering 'item' : 'items', '' : 's', and the !== mirror 's' : ''. A pair of identifiers is not claimed, and neither is a pair of unrelated literals such as 'active' : 'inactive': the plural relation is the only evidence available that the compared value counts something. count > 1 ? 's' : '' is not claimed either, since replacing it changes what the code prints at zero. Note that pluralize tests Math.abs(count), so a count of -1 takes the singular where a hand-rolled equality test takes the plural.
Bootstrap wrappers under bin/ are exempt: such a wrapper imports only builtins so its build-first message survives an incomplete install, and importing this package there would replace that message with a module-resolution failure. Tests are exempt too, since they write these forms deliberately.
A reviewed site is silenced by an rdy-ignore pragma on its own line, or rdy-ignore-next-line on the line above. A pragma naming a check's id suppresses that check alone; with no id it covers every check on the line. A failed check prints its id ahead of its fraction, which is the form to write:
// rdy-ignore-next-line toolbelt.strings/no-hand-rolled-capitalize -- the input is never empty here
const label = word.charAt(0).toUpperCase() + word.slice(1);Add the package to .config/readyup.config.ts to include it in a routine sweep:
export default defineRdyConfig({
packages: ['@williamthorsen/toolbelt.strings'],
});