Grapheme-safe string operations: length, slice, truncate, split, pad and search that never break a user-visible character.
Every operation names its unit β graphemes, codePoints, codeUnits, utf8
or columns β so the question "which one did you mean?" is answered at the call
site instead of being inherited from whatever .length happens to count.
Whichever unit you measure in, the cut lands between whole characters.
import { codeUnits, graphemes } from '@sjpnz/graphemic';
'hello! π'.slice(0, 8); // 'hello! \uD83D' β half a waving hand, rendered as οΏ½
codeUnits.truncate('hello! π', 8); // 'hello! ' β still eight units, no broken character
'hi ππ½'.length; // 7
graphemes.length('hi ππ½'); // 4 β what a person countsZero dependencies, ESM-only, fully typed. Intl.Segmenter does the
segmentation; this package decides where the cut goes.
npm install @sjpnz/graphemicRequires Node β₯ 22, or any browser with Intl.Segmenter. The package is
ESM-only: import works, require does not.
Namespaces read well in application code:
import { graphemes, utf8 } from '@sjpnz/graphemic';
graphemes.truncate('hello! π nice to meet you', 8, { ellipsis: 'β¦' }); // 'hello! β¦'
utf8.truncate('hello! π nice to meet you', 11); // 'hello! π' β the wave is four of those bytesSubpaths give you plain functions, one entry point per unit:
import { truncate } from '@sjpnz/graphemic/graphemes';
import { truncate as truncateBytes } from '@sjpnz/graphemic/utf8';
truncate('hello! π nice to meet you', 8); // 'hello! π'
truncateBytes('hello! π nice to meet you', 11); // 'hello! π'Both are backed by the same functions β there is one implementation of each β
and the subpath style tree-shakes under every bundler: a caller who only
measures strings ships neither the slicing machinery nor the search matcher,
which is around 0.6 KB gzipped against the 2.6 KB the whole library costs. The
entry points are @sjpnz/graphemic/graphemes, /code-points, /code-units,
/utf8 and /columns. The last of those is a subpath and nothing else β
see columns for why.
The root entry point shakes too under Rollup, rolldown and webpack, which
rewrite graphemes.length into a reference to the function itself. esbuild is
the exception: a namespace that reaches it through a re-export becomes an
object of getters its analysis cannot see through, so
import { graphemes } from '@sjpnz/graphemic' keeps the whole namespace β about
2.0 KB gzipped rather than 0.6 KB. Vite is unaffected, because its production
builds go through Rollup. If esbuild or tsup bundles your application and those
bytes matter, take the namespace from the unit's own entry point instead: it
shakes everywhere and reads the same at the call site.
import * as graphemes from '@sjpnz/graphemic/graphemes';
graphemes.length('hi ππ½'); // 4One caveat applies whichever entry point you use, and to every library that
ships namespaces: bundlers can only drop what they can see is unused.
graphemes.truncate(s, 8) is a static member access and shakes;
graphemes[name](s, 8), { ...graphemes } and passing graphemes around as a
value all retain the whole namespace. Use the named-function style if you need
to do any of that.
| You are limited by⦠| Use | Example |
|---|---|---|
| What a person sees β UI, usernames, previews, anything with a design that says "40 characters" | graphemes |
graphemes.truncate(bio, 160, { ellipsis: 'β¦' }) |
UTF-16 code units β nvarchar(N) in SQL Server, a Java or C# String across an interface, most JS APIs |
codeUnits |
codeUnits.truncate(title, 100) |
UTF-8 bytes β varchar(N) in MySQL/Postgres/SQLite, HTTP headers, filenames, byte-capped payloads |
utf8 |
utf8.truncate(name, 255) |
Unicode code points β character counts on social platforms, char_length() in Postgres |
codePoints |
codePoints.length(post) <= 280 |
| Terminal display columns β tables, boxes, progress bars, anything drawn in a monospace grid | columns |
columns.padEnd(name, 20) |
If nobody imposed a limit on you, the unit you want is graphemes. If you are
drawing into a terminal, it is columns, which lives behind its own entry
point.
One character, four answers β the skin-toned waving hand ππ½:
grapheme ππ½ graphemes.length β 1
code points U+1F44B U+1F3FD codePoints.length β 2
code units D83D DC4B D83C DFFD codeUnits.length β 4
UTF-8 bytes F0 9F 91 8B F0 9F 8F BD utf8.length β 8
Nothing about that is exotic. It is the ordinary shape of text once it leaves ASCII:
| String | .length says |
graphemes | code points | code units | UTF-8 bytes | columns |
|---|---|---|---|---|---|---|
'u\u0308' β a u with a combining diaeresis, ΓΌ |
2 | 1 | 2 | 2 | 3 | 1 |
'π¨\u200DπΌ' β a man feeding a baby |
5 | 1 | 3 | 5 | 11 | 2 |
'π¦πΊ' β the flag of Australia |
4 | 1 | 2 | 4 | 8 | 2 |
'ζ±δΊ¬' β Tokyo |
2 | 2 | 2 | 2 | 6 | 4 |
graphemes.length('u\u0308'); // 1
codePoints.length('π¨\u200DπΌ'); // 3 β man, joiner, baby bottle
codeUnits.length('π¦πΊ'); // 4
utf8.length('π¨\u200DπΌ'); // 11Measuring in code units does not mean cutting between them. Every namespace counts in its own unit and, by default, only ever cuts on a grapheme boundary β the range snaps inward, so the result is every whole character that fits entirely inside the budget:
'hello! π nice'.slice(0, 8); // 'hello! \uD83D' β the native cut lands inside the wave
codeUnits.truncate('hello! π nice', 8); // 'hello! ' β seven units; the wave needs two
codeUnits.truncate('hello! π nice', 9); // 'hello! π' β now it fitsThe result may therefore be a unit or two under the budget. It is never over it, and never half a character.
Pass { boundary: 'codePoint' } to slice or truncate in the codePoints,
codeUnits and utf8 namespaces to snap to code points instead. That still
never splits a surrogate pair, but it will cut a character apart at its joints β
dropping a skin-tone modifier, a combining mark, or half a flag:
codePoints.truncate('hi ππ½', 4); // 'hi ' β the wave keeps its skin tone or goes
codePoints.truncate('hi ππ½', 4, { boundary: 'codePoint' }); // 'hi π' β a differently coloured handReach for it when a downstream system counts code points and will reject
anything over the line, and you would rather lose a modifier than a character.
Otherwise: almost never. The graphemes namespace has no boundary option at
all, because measuring in graphemes and cutting anywhere else is a
contradiction.
Every function takes the string first and never mutates anything. Functions
that need to segment throw SegmenterUnavailableError on a runtime without
Intl.Segmenter; the ones that only count code points or bytes never throw.
The full set β for anything a person reads, this is the namespace you want.
| Signature | |
|---|---|
length(s) |
How many characters, as a person would count them |
iterate(s) |
Lazily yields each character; segments only as far as you ask |
toArray(s) |
Every character, as an array; toArray(s).join('') is s |
at(s, index) |
The character at a grapheme index, or undefined; negatives count back |
slice(s, start?, end?) |
String#slice with grapheme indices |
truncate(s, max, options?) |
At most max characters, optionally with an ellipsis |
split(s, separator, limit?) |
String#split, but the separator must line up with whole characters |
chunk(s, size) |
Consecutive pieces of size characters; rejoining reproduces s |
reverse(s) |
Reversed character by character, leaving emoji and marks intact |
padStart(s, targetLength, fill?) |
Pad to a width counted in characters, with a fill that is never cut |
padEnd(s, targetLength, fill?) |
The same, on the other end |
indexOf(s, search, fromIndex?) |
The grapheme index of the first whole-character match, or -1 |
includes(s, search, fromIndex?) |
Whether such a match exists |
graphemes.length('hi ππ½'); // 4
graphemes.iterate('hi ππ½').next().value; // 'h' β the rest is not segmented yet
graphemes.toArray('hi ππ½'); // ['h', 'i', ' ', 'ππ½']
graphemes.at('hi ππ½', -1); // 'ππ½'
graphemes.slice('hello! π nice to meet you', 0, 8); // 'hello! π'
graphemes.truncate('hello! π nice to meet you', 8, { ellipsis: 'β¦' }); // 'hello! β¦'
graphemes.split('a,b,ππ½', ','); // ['a', 'b', 'ππ½']
graphemes.chunk('hi ππ½!', 2); // ['hi', ' ππ½', '!']
graphemes.reverse('abπ¦πΊ'); // 'π¦πΊba'
graphemes.padStart('ππ½', 3, '.'); // '..ππ½' β native padding adds nothing here
graphemes.padEnd('ab', 5, 'ππ½'); // 'abππ½ππ½ππ½' β native padding cuts the fill in half
graphemes.indexOf('hi ππ½!', '!'); // 4 β native says 6
graphemes.includes('hi ππ½', 'π'); // false β half a character is not thereCounting in code points, cutting on characters.
| Signature | |
|---|---|
length(s) |
How many code points; never throws |
iterate(s) |
What forβ¦of over a string already yields, named |
toArray(s) |
The equivalent of Array.from(s) |
at(s, index) |
The code point at an index, as a string, or undefined |
slice(s, start?, end?, options?) |
String#slice with code-point offsets |
truncate(s, max, options?) |
At most max code points |
codePoints.length('hi ππ½'); // 5
codePoints.iterate('aπ').next().value; // 'a'
codePoints.toArray('hi ππ½'); // ['h', 'i', ' ', 'π', 'π½']
codePoints.at('hi ππ½', -1); // 'π½' β the modifier on its own
codePoints.slice('hi ππ½', 0, 4); // 'hi '
codePoints.truncate('hi ππ½', 4, { ellipsis: 'β¦' }); // 'hi β¦'For budgets denominated in UTF-16 code units, whether or not anyone chose them.
| Signature | |
|---|---|
length(s) |
Identical to s.length, with the unit said out loud |
slice(s, start?, end?, options?) |
String#slice with the cut snapped to a boundary |
truncate(s, max, options?) |
At most max code units, guaranteed |
codeUnits.length('hi ππ½'); // 7
codeUnits.slice('hello! π nice', 0, 8); // 'hello! '
codeUnits.truncate('hello! π nice to meet you', 9); // 'hello! π'There is deliberately no iterate, toArray or at here, and none in utf8
either. Walking a string one code unit at a time is the habit this package
exists to break; use graphemes to visit characters.
For byte budgets β the unit most storage and transport limits are really in.
| Signature | |
|---|---|
length(s) |
Bytes when encoded as UTF-8; no TextEncoder, no allocation, never throws |
slice(s, start?, end?, options?) |
String#slice with byte offsets |
truncate(s, max, options?) |
At most max bytes, guaranteed |
utf8.length('aΒ£β¬π'); // 10 β 1 + 2 + 3 + 4 bytes
utf8.slice('aπb', 0, 5); // 'aπ'
utf8.truncate('hi ππ½', 7); // 'hi ' β the wave is four bytes and its modifier four moreFor anything drawn into a terminal. Imported from @sjpnz/graphemic/columns
and from nowhere else β it is not re-exported from the root entry point.
import * as columns from '@sjpnz/graphemic/columns';
columns.length('ζ±δΊ¬'); // 4 β two characters, four columns| Signature | |
|---|---|
length(s, options?) |
How many terminal columns the string occupies |
slice(s, start?, end?, options?) |
String#slice with column offsets |
truncate(s, max, options?) |
At most max columns, optionally with an ellipsis |
padStart(s, targetLength, fill?, options?) |
Pad to a width counted in columns, never overshooting it |
padEnd(s, targetLength, fill?, options?) |
The same, on the other end |
columns.length('hi ππ½'); // 5 β three narrow characters and one wide one
columns.slice('ζ±δΊ¬ι½', 0, 4); // 'ζ±δΊ¬'
columns.truncate('ζ±δΊ¬ι½', 4, { ellipsis: 'β¦' }); // 'ζ±β¦'
columns.padStart('42', 6); // ' 42'
columns.padEnd('ab', 7, 'ζ±'); // 'abζ±ζ±' β six columns; a third ζ± would be eightpadEnd is the reason the namespace exists. Every other unit lines a column of
ASCII names up and wrecks it the moment one of them is ζ±δΊ¬:
graphemes.length(graphemes.padEnd('ζ±δΊ¬', 8)); // 8 β eight characters
columns.length(graphemes.padEnd('ζ±δΊ¬', 8)); // 10 β and ten columns on screen
columns.length(columns.padEnd('ζ±δΊ¬', 8)); // 8A grapheme is two columns if it is drawn as an emoji or its base is East Asian
wide, zero if it draws nothing, and one otherwise. Width is a property of the
whole character, which is why this belongs on a grapheme-segmenting library
rather than in another wcwidth package: a per-code-point implementation sums
the four-person family emoji to eight columns, and every terminal that can draw
it draws two.
columns.length('π¨\u200Dπ©\u200Dπ§\u200Dπ¦'); // 2 β one glyph, however many code points
columns.length('e\u0301'); // 1 β the acute is drawn on the e, not beside it{ ambiguous: 2 } counts East_Asian_Width A characters β box drawing,
accented Latin, Greek, Cyrillic, much punctuation β as two columns instead of
one. One is the default of essentially every terminal; two is what a CJK locale
setup draws them in. Pass the same value to every call that measures the same
table, or the columns drift.
columns.length('βββ'); // 3
columns.length('βββ', { ambiguous: 2 }); // 6There is no boundary option, unlike every other namespace. Width is a property
of a rendered character, and a code-point cut changes the width of what it
leaves behind β drop a trailing U+FE0F and a two-column glyph becomes a
one-column one β so a code-point cut cannot honour a column budget even in
principle. There is no iterate, toArray or at either.
This is an estimate, not a guarantee. It is the common convention β
wcwidth plus emoji presentation β and no more than that. Terminals disagree
with each other about ambiguous-width characters, about ZWJ sequences (plenty
draw the parts), and about any emoji their font lacks. The only exact answer is
to write the text and ask the terminal where the cursor ended up, which is out
of scope for a string library.
Two further limits worth knowing:
- A tab is zero columns, as are the C0 and C1 controls. There is no width a tab could honestly be given β it depends on the cursor position, which this library cannot see. Expand tabs before measuring.
- Unicode versions drift.
Intl.Segmenterfollows the runtime's; the width tables follow a pinned one (currently 17.0.0). A code point the tables have never heard of measures 1, which is also what a terminal whose font has never heard of it will draw.
Why the subpath and not the root entry point: the width tables are the only bulk
data in the package, around 1.6 KB gzipped, and esbuild retains a whole
namespace that reaches it through a re-export. export * as columns from the
root would hand those tables to every esbuild user who imported graphemes.
Keeping it behind its own door is also honest about the namespace being a
different kind of thing β the other four are exact, where a byte count is a fact
about the encoding; a column count is an estimate about a font in a terminal.
SegmenterUnavailableError, thrown on first use when the runtime has no
Intl.Segmenter; VERSION; and the types Boundary, BoundaryOptions and
TruncateOptions. @sjpnz/graphemic/columns additionally exports
AmbiguousWidth, ColumnsOptions and ColumnsTruncateOptions.
import { SegmenterUnavailableError, VERSION } from '@sjpnz/graphemic';
try {
graphemes.length('hi ππ½');
} catch (error) {
// `error instanceof SegmenterUnavailableError` on a runtime without Intl.Segmenter
}
VERSION.split('.').length; // 3Truncation returns the same string when nothing was cut. truncate(s, max) === s
is how you ask "did this fit?", and an ellipsis is only ever added when
something was actually removed.
const bio = 'short enough';
graphemes.truncate(bio, 100) === bio; // true
graphemes.truncate(bio, 100, { ellipsis: 'β¦' }); // 'short enough' β no marker, nothing was cutThe ellipsis is charged against the budget, in the namespace's own unit. In
graphemes a 'β¦' costs one and '...' costs three; in utf8 that same 'β¦'
costs three bytes. If the marker cannot fit max at all, the bare truncation
comes back without it β the budget is never exceeded and nothing throws.
graphemes.truncate('hello there', 2, { ellipsis: '...' }); // 'he' β no room for the dots
utf8.truncate('hello there', 4, { ellipsis: 'β¦' }); // 'hβ¦' β three of those four bytes are the markerSearch matches whole characters, and does not normalise. Both ends of a match must line up with the segmentation of the string, which rules out the whole family of "found a character inside another character" bugs. Matching is otherwise exact: a precomposed character does not match its decomposed spelling, because normalising would change the string β and with it the byte and code-unit counts the other namespaces report. Normalise both sides first if that is what you want.
graphemes.includes('re\u0301sume\u0301', 'e'); // false β native says true
graphemes.includes('re\u0301sume\u0301'.normalize('NFC'), '\u00E9'); // true
graphemes.indexOf('e\u0301x', 'e'); // -1 β the accented e is not an eIll-formed input passes through unchanged. A lone surrogate β the thing
naive slicing leaves behind β is treated as its own one-unit character, exactly
as Intl.Segmenter treats it. Nothing here repairs it or throws on it. Note
that utf8.length counts it as 3 bytes: that is what an encoder writes for it,
having substituted U+FFFD, and a byte budget has to be told the truth about what
will land on disk.
graphemes.length('a\uD83Dz'); // 3 β the lone surrogate is a character of its own
graphemes.slice('a\uD83Dz', 0, 2); // 'a\uD83D' β passed through, never repaired
utf8.length('\uD83D'); // 3 β the size of the replacement an encoder writesNumeric arguments follow String.prototype where there is a native method to
follow, and are strict where there is not. slice, at, padStart,
padEnd, indexOf and split's limit coerce exactly as their native
counterparts do β fractions truncate, NaN becomes 0, negatives count back
from the end. The APIs with no native counterpart, truncate and chunk,
reject anything that is not a non-negative (or, for chunk, positive) safe
integer, because a silently coerced budget is a budget nobody checked.
graphemes.slice('hi ππ½', 1.9); // 'i ππ½' β a fraction truncates
graphemes.at('hi ππ½', NaN); // 'h' β NaN becomes 0
try {
graphemes.chunk('abc', 0);
} catch (error) {
error.name; // 'RangeError'
}Padding counts the fill in characters too, so a multi-character fill is
never cut through the middle and a character that will not fit is left out
entirely. One caveat is Unicode's rather than this library's: graphemes join
across a seam, so a fill beginning with a combining mark attaches to the
character before it, and two pieces of n and m characters can total
n + m - 1. Pad with things that stand alone β spaces, digits, box drawing,
most punctuation β and the arithmetic holds.
graphemes.padEnd('ab', 4, 'xπ¨\u200DπΌ'); // 'abxπ¨\u200DπΌ'
graphemes.padEnd('ab', 3, 'xπ¨\u200DπΌ'); // 'abx' β the emoji did not fit, so it is not thereReversing is a display operation. It reverses characters rather than rendering text backwards: bidirectional text will not read right, and reversing prose is rarely meaningful in any script. It is for labels and puzzles.
| Runtime | Requirement |
|---|---|
| Node.js | β₯ 22. CI runs 22, 24 and 26 |
| Deno, Bun | Any version with Intl.Segmenter; not covered by CI |
| Chrome, Edge | β₯ 87 |
| Safari | β₯ 14.1 |
| Firefox | β₯ 125 |
| Anything older | SegmenterUnavailableError on first use |
There is no polyfill and no silent fallback to code points, because a code-point fallback would split emoji and combining marks β the exact bug this package exists to prevent. Failing loudly is the only honest option.
Segmentation comes from the runtime's own ICU data, so the Unicode version is the runtime's, not this package's: a character introduced after your runtime's Unicode version may segment as several. Every CI run prints the versions it used; the Node 26.7 that produced the benchmark numbers below ships ICU 78.3 and Unicode 17.0. A version of this package is therefore not a promise about how any particular string segments: upgrading Node can change that, and no release here will announce it. The repository also carries the Unicode Consortium's own grapheme break test, run against whatever runtime you point it at:
UNICODE_CONFORMANCE=1 npm testFull numbers, with the machine that produced them, are in
bench/RESULTS.md. Two things carry most of the weight:
Strings that need no segmenting do not get any. ASCII without a carriage
return has one character per code unit, so every native offset is already a
boundary and the native method is used directly after a single scan. Measuring
a megabyte of ASCII in graphemes is 540 Β΅s; segmenting the same megabyte is
79 ms. columns uses a narrower class for the same trick β printable ASCII,
\x20β\x7E β because the C0 controls are one code unit each and no columns at
all.
Segmentation stops at the budget. Truncating that megabyte to ten characters is 132 ns, because the walk ends one code unit past the tenth and never reads the other 999,989 β the cost is the budget, not the string.
Where it still costs: indexOf has to account for everything before a match to
report a grapheme index, so it scans the whole string even when the match is
early. And the cheapest operations sit a small multiple away from their native
forms rather than within the 2Γ that would be nice β nothing can prove a cut is
safe without looking at the text.
β¦a SafeString class? A fluent wrapper doubles the API surface and invites
the .length ambiguity all over again β is SafeString#length graphemes? β and
every string-taking API on the far side needs a .toString(). Naming the unit
at the call site was the point.
β¦a locale option? Grapheme segmentation is locale-independent under UAX #29; the tailorings that exist are for word and sentence boundaries. One segmenter is created lazily and cached, and there is no locale parameter to get wrong.
β¦a polyfill? See above: the failure mode of a fallback is silent corruption, which is worse than an error naming the runtimes that work.
β¦a wcwidth package instead of columns? Those measure one code point at a
time, which is the wrong granularity: a four-person family emoji is seven code
points and one glyph, and summing its parts gives eight columns where a terminal
draws two. Getting that right needs the segmentation this package already does,
so the width table belongs next to it rather than in a package that cannot see
the boundaries.
β¦codePoints.reverse, or search in utf8? Reversing or separating code
points reorders combining marks and tears ZWJ sequences apart by construction,
and a byte offset into text is not a position anyone means. There is no safe
version of those to offer, so they are not offered β graphemes has them.
- Most string operations aren't safe β the argument this package implements
- UAX #29: Unicode Text Segmentation β where grapheme cluster boundaries are defined
Intl.Segmenteron MDN β the platform API underneathStringInfoβ the same idea in .NET, for anyone arriving from C#
MIT Β© Simon Potter