Codecs
Hex, base64 and base32 as stringify / parse pairs, strict by default, with one contract on every runtime.
Note
ELI5. Bytes are not text. To put them in a URL, a header, a JSON field or a QR code you have to spell them out in characters, and these are the three spellings everyone uses: hex, base64, base32. Each codec has two halves. stringify turns bytes into text; parse turns text back into bytes. parse is strict on purpose: it accepts exactly the canonical spelling and refuses everything else, because two different texts decoding to the same bytes is how signature checks get bypassed. When the text came from a human who may have pasted it with spaces, pass { loose: true }.
import { hexStringify, hexParse } from "unsecure/utils";
hexStringify(bytes); // "deadbeef…"
hexParse("deadbeef", { returnAs: "bytes" }); // Uint8ArrayEverything here lives at unsecure/utils and is re-exported from the barrel.
#Flat functions and grouping objects
Each codec is exported twice: as two flat functions, and grouped JSON-style into an object.
import {
hexStringify,
hexParse,
base64Stringify,
base64Parse,
base32Stringify,
base32Parse,
Hex,
Base64,
Base32,
textEncoder,
textDecoder,
} from "unsecure/utils";
Hex.stringify === hexStringify; // trueThey are the same functions. The difference is what a bundle ends up carrying: importing base64Parse on its own ships one codec, while importing Base64 reaches the grouping object and, through the barrel, can pull the hex and base32 tables in with it. Prefer the flat functions in a browser bundle; use whichever reads better on a server.
For a CDN, name the subpath — https://esm.sh/unsecure/utils ships these helpers and nothing else.
#The shared contract
stringify(data, options?) takes a string, which is UTF-8 encoded first, or any BytesSource: an ArrayBuffer (shared or not), a DataView, or any typed array. It returns the encoded text. null or undefined is INVALID_TYPE.
parse(input, options?) takes the encoded string, or a Uint8Array of that text's bytes. It returns bytes or a UTF-8 string.
returnAs mirrors the input when omitted: a string in gives a string out, a Uint8Array in gives a Uint8Array out. Override with { returnAs: "string" | "uint8array" | "bytes" }, where "bytes" is an alias for "uint8array".
Byte output always owns an ArrayBuffer exactly its own length. It is never a view into a pool or a longer scratch buffer.
#Hex
hexStringify("hello"); // "68656c6c6f"
hexStringify(new Uint8Array([0xde, 0xad])); // "dead"
hexParse("68656c6c6f"); // "hello"
hexParse("68656c6c6f", { returnAs: "bytes" }); // Uint8Array [104, 101, 108, 108, 111]
hexParse("zz");
// UnsecureError MALFORMED: Hex.parse: invalid hexadecimal character "z" at index 0.
hexParse("de ad");
// UnsecureError MALFORMED: Hex.parse: invalid hexadecimal character " " at index 2.
hexParse("de ad", { loose: true, returnAs: "bytes" }); // Uint8Array [222, 173]
hexParse("abc", { loose: true, returnAs: "bytes" }); // Uint8Array [171] — the odd nibble is dropped#Base64
Standard alphabet by default, padded. { alphabet: "base64url" } switches to the URL-safe -_ alphabet, which is unpadded by default. { padding: false } drops = on any alphabet.
base64Stringify(new Uint8Array([1, 2, 3])); // "AQID"
base64Stringify("hello world"); // "aGVsbG8gd29ybGQ="
base64Stringify("hello world", { padding: false }); // "aGVsbG8gd29ybGQ"
base64Stringify("hello?world~", { alphabet: "base64url" }); // "aGVsbG8_d29ybGR-"
base64Parse("aGVsbG8gd29ybGQ="); // "hello world"
base64Parse("Zm9vYg"); // "foob" — unpadded is canonical too
base64Parse(token, { alphabet: "base64url" }); // strict URL-safe decode
base64Parse(untrusted, { loose: true }); // tolerant; accepts either alphabet#Base32
alphabet takes "base32" (RFC 4648, the default), "base32hex", "crockford", or a custom 32-character string. Padded by default, except Crockford; { padding: false } overrides.
base32Stringify("foobar"); // "MZXW6YTBOI======"
base32Stringify("foobar", { padding: false }); // "MZXW6YTBOI"
base32Stringify("foobar", { alphabet: "crockford" }); // "CSQPYRK1E8"
base32Parse("MZXW6YTBOI"); // "foobar"
base32Parse("MZXW6YTBOI", { returnAs: "bytes" }); // raw bytes
base32Parse(id, { alphabet: "crockford" });Strict decoding is uppercase-only for base32 and base32hex; { loose: true } folds case. Crockford is case-insensitive in both modes and maps O to 0, I and L to 1, per that alphabet's own spec. A custom alphabet is taken literally in both modes, because its case may carry meaning.
A custom alphabet must be 32 distinct ASCII characters, none of them = or whitespace. Anything else is OUT_OF_RANGE: the alphabet is configuration you chose, not text the codec was asked to read.
#Strict and loose
parse is strict by default to avoid decode malleability, which is two different texts decoding to the same bytes.
Strict accepts exactly the canonical encoding of some byte string:
- Characters from the selected alphabet only. Whitespace is a character like any other, and is rejected.
=only as a trailing run, and only in the count the body length calls for, or absent entirely. Unpadded is canonical, so anythingstringifyemits round-trips,{ padding: false }and the unpaddedbase64urldefault included.- A length that can encode whole bytes.
"Zm9vY"cannot. - No set bits past the final byte.
"Zg=="decodesf;"Zh=="does not decode at all.
base64Parse("Zm 9v");
// UnsecureError MALFORMED: Base64.parse: invalid base64 character " " at index 2.
base64Parse("Zh==");
// UnsecureError MALFORMED: Base64.parse: the last base64 symbol sets bits past the final byte.
base32Parse("MZXW7===");
// UnsecureError MALFORMED: Base32.parse: the last base32 symbol sets bits past the final byte.
base32Parse("mzxw6ytboi");
// UnsecureError MALFORMED: Base32.parse: invalid base32 character "m" at index 0.Loose normalizes and never throws on shape. Every character outside the alphabet is dropped — whitespace, =, junk, anything non-ASCII. Base64 folds -_ onto +/ and accepts either alphabet. A trailing symbol that cannot start a byte is dropped, and bits past the final byte are ignored. Nullish input still throws INVALID_TYPE.
base64Parse("Zm 9v", { loose: true }); // "foo"
base32Parse("mzxw6ytboi", { loose: true }); // "foobar"Use { loose: true } for values a person may have typed or pasted, such as an OTP secret written in groups of four.
#Text handling
A Uint8Array input is the encoded text's bytes, read one character per byte. A byte at or above 0x80 is therefore a character no alphabet carries: strict rejects it, loose drops it. A leading EF BB BF is three such characters, not a BOM to skip.
On the way out, a decoded U+FEFF is kept, because it is part of the byte string and not a signature. Strict decoding to a string requires the bytes to be valid UTF-8 and throws MALFORMED otherwise; loose substitutes U+FFFD. Ask for { returnAs: "bytes" } when the payload is not text.
#Shared encoder and decoder
import { textEncoder, textDecoder } from "unsecure/utils";
const bytes = textEncoder.encode("hello");
const text = textDecoder.decode(bytes);Two module-level instances, so you do not allocate a TextEncoder per call. textDecoder is created with ignoreBOM: true, so it keeps a leading U+FEFF.
#Errors
| Cause | code |
|---|---|
| A value that is not text or bytes, or is nullish | INVALID_TYPE |
| Text that is not the canonical encoding, or bytes that are not valid UTF-8 | MALFORMED |
A base32 alphabet the codec cannot use | OUT_OF_RANGE |
Malformed errors name the character and where it appeared, so you can put the message straight into a 400 response body.
#Note: which backend runs underneath
Encoding prefers Node's Buffer when it is there, then the TC39 Uint8Array.toBase64 and toHex methods, then a manual fallback.
Decoding settles the contract in JavaScript first and hands a backend only canonical, fully padded, standard-alphabet text to bulk-decode. Native fromBase64's own strict mode is deliberately never used: it enforces a different contract, with padding mandatory and whitespace fatal, and the result must not depend on which runtime is underneath. Same bytes, same error, everywhere.