yabase

Yet Another Base – a unified, type-safe interface for multiple binary-to-text encodings in Gleam.
- Encoding schemes are first-class values.
- Both low-level (direct module) and high-level (unified dispatch) APIs.
- Multibase prefix support for auto-detection.
- Pure Gleam implementation, no external dependencies.
Requirements
- Gleam 1.15 or later
- Erlang/OTP 26 or later (CI tests OTP 26, 27, 28)
- Node.js 18 or later (when targeting JavaScript)
Supported targets
- Erlang (BEAM) — full encoding catalogue.
- JavaScript — the package compiles for the JavaScript target with
no FFI. Encodings whose internals rely on arbitrary-precision
integer arithmetic (base32 Crockford, base58, base36 — used in
multibase too) inherit JavaScript’s
Number.MAX_SAFE_INTEGERceiling and may produce incorrect output for inputs that overflow 53-bit integers. base16, base64, base91, base32 RFC4648 and ascii85 are JavaScript-safe.
CI runs gleam test --target javascript against Node.js to catch
JavaScript-target regressions on the public codec surface. Tests
that exercise the bignum-backed codecs above are isolated with
@target(erlang) so they do not run on JavaScript — they remain
covered on BEAM, but are intentionally skipped on JS rather than
silently passing or failing.
The JavaScript lane runs on two Node versions:
- Node 18 — the documented minimum supported version. This is the support floor: the package is required to work here, and regressions on this lane block release.
- Node 22 — latest-LTS coverage for general confidence.
The release workflow runs the same matrix; both lanes must pass
before gleam publish runs.
Install
gleam add yabase
Quick start
The simplest entry point is the facade module: one function per
encoding. encode_* returns a plain String because the encodings
covered here cannot fail on a valid BitArray; decode_* returns
Result because a malformed input is a real error.
import yabase/facade
pub fn main() {
let encoded = facade.encode_base64(<<"Hello":utf8>>)
// encoded == "SGVsbG8="
let assert Ok(_decoded) = facade.decode_base64(encoded)
// _decoded == <<"Hello":utf8>>
}
Why not
let asserton encode? yabase’s owngleam.tomlenables glinter’sassert_ok_pattern = "error"rule, so the recommended shape in production code is to avoidlet assert Ok(_)for cases that cannot meaningfully fail. The facade returns plainStringfor infallible encodings and only the decode side isResult-shaped — therelet assertis fine in a README snippet but real code should propagate the error. If you need to pick an encoding at runtime, see the unified API in API layers;yabase.encodeisResult-shaped for every variant because theEncodingADT erases per-variant error possibilities.
Integer IDs
Short URL-safe identifiers — DB autoincrement ids, sequence numbers, hash truncations — usually want Int -> compact string rather than BitArray -> String. The yabase/intid module provides this directly so callers do not have to write the Int -> big-endian bytes -> trim-leading-zero shim themselves.
import yabase/intid
pub fn main() {
let token = intid.encode_int_base58(42)
// token == "j"
let assert Ok(_n) = intid.decode_int_base58(token)
// _n == 42
}
Available: encode_int_base32_rfc4648, encode_int_base32_crockford, encode_int_base36, encode_int_base58, encode_int_base58_flickr, encode_int_base62 and their matching decode_int_* (returning Result(Int, CodecError)).
encode_int_* emits canonical form. decode_int_* is tolerant of leading zero characters (decode_int_base58("0042") and decode_int_base58("42") return the same Int). Negative inputs are normalized via int.absolute_value before encoding.
decode_int_* accepts inputs of any length, so the decoded Int is an unbounded Erlang bignum. If the value flows into a fixed-width sink (SQLite INTEGER, Postgres bigserial, MySQL BIGINT, or a JS number), use the matching decode_int_*_bounded(input:, max:) to get Error(Overflow) instead of a downstream crash. Common caps are exported as intid.int64_max (signed 64-bit, 2^63 - 1) and intid.int53_max (Number.MAX_SAFE_INTEGER).
import yabase/intid
pub fn lookup(public_id: String) -> Bool {
case intid.decode_int_base58_bounded(input: public_id, max: intid.int64_max) {
// Bind `_internal_id` to `sqlight.int(_)` etc.; the value fits BIGINT.
Ok(_internal_id) -> True
// Respond 404/400; input was malformed or numerically out of range.
Error(_) -> False
}
}
Supported encodings
Core
| Encoding | Variants |
|---|---|
| Base2 | (binary string) |
| Base8 | (octal) |
| Base10 | (decimal) |
| Base16 | (hex) |
| Base32 | RFC4648, Hex, Crockford (with optional check symbol), Clockwork, z-base-32 |
| Base64 | Standard, URL-safe, No padding, URL-safe no padding, DQ (hiragana) |
| Base58 | Bitcoin, Flickr |
Additional
| Encoding | Description |
|---|---|
| Base36 | 0-9, a-z (case-insensitive decode) |
| Base45 | RFC 9285 (QR-code friendly) |
| Base62 | 0-9, A-Z, a-z |
| Base91 | 91 printable ASCII characters |
| Ascii85 | btoa style |
| Adobe Ascii85 | PDF/PostScript with <~ ~> delimiters |
| Z85 | ZeroMQ variant of Ascii85 |
| RFC 1924 Base85 | RFC 1924 alphabet |
Big-integer encodings (Base8, Base10, Base36, Base58, Base62, Crockford Base32) preserve leading zero bytes: each leading 0x00 byte encodes as the alphabet’s zero character, and decoding reverses this. For example, base10.decode("001") returns Ok(<<0, 0, 1>>).
Checksum-bearing (separate API)
These encodings carry metadata (version bytes, checksums, HRP) and have their own API outside the Encoding ADT.
| Encoding | Module | Description |
|---|---|---|
| Base58Check | yabase/base58check | Bitcoin-style: version byte + payload + SHA-256 double-hash checksum |
| Bech32 | yabase/bech32 | BIP 173: byte-payload encoding (HRP + 8-to-5 conversion + checksum), not SegWit address validation |
| Bech32m | yabase/bech32 | BIP 350: improved checksum constant, same byte-payload API |
API layers
yabase provides three API layers:
- Start with
yabase/facade– one function per encoding, no type parameters. Covers most use cases. - Use the unified API (
yabase) when you need to select an encoding at runtime (e.g. user config, multibase auto-detection). - Use low-level modules (
yabase/base64/standard, etc.) when you need full control over a specific codec.
1. Low-level modules (direct usage)
Each encoding is accessible directly:
import yabase/base64/standard
import yabase/base32/clockwork
let _encoded = standard.encode(<<"Hello":utf8>>)
// "SGVsbG8="
let assert Ok(_data) = clockwork.decode("91JPRV3F41BPYWKCCGGG")
2. Unified API (dispatch by Encoding type)
import yabase
import yabase/core/encoding
let assert Ok(encoded) =
yabase.encode(encoding.base32_clockwork(), <<"Hello":utf8>>)
let assert Ok(_decoded) =
yabase.decode(encoding.base32_clockwork(), encoded)
3. Facade (developer-friendly shortcuts)
import yabase/facade
let encoded = facade.encode_base64(<<"Hello":utf8>>)
let assert Ok(_decoded) = facade.decode_base64(encoded)
Multibase support
Prefix-based encoding and auto-detection:
import yabase
import yabase/core/encoding
// Encode with multibase prefix
let assert Ok(prefixed) =
yabase.encode_multibase(encoding.base16(), <<"Hello":utf8>>)
// "f48656c6c6f"
// Decode with auto-detection. The result is an opaque `Decoded` value
// — use `encoding.decoded_encoding/1` and `encoding.decoded_data/1`
// to inspect it, and `encoding.multibase_name/1` if you need to
// label the detected codec.
let assert Ok(d) = yabase.decode_multibase(prefixed)
let assert True = encoding.decoded_encoding(d) == encoding.base16()
let _data = encoding.decoded_data(d)
Selecting codecs by target
yabase/core/encoding exposes machine-readable target capability
helpers so callers that pick an encoding at runtime — multibase
auto-detection, user-configurable codec choice, or any list-of-options
UI — can branch on JavaScript safety without scraping the README.
import yabase/core/encoding.{type Decoded}
import yabase/core/multibase
pub fn safe_decode_for_javascript(
prefixed: String,
) -> Result(Decoded, Nil) {
let js = encoding.target_javascript()
case multibase.decode(prefixed) {
Ok(decoded) -> {
let enc = encoding.decoded_encoding(decoded)
case encoding.supports_target(enc, js) {
True -> Ok(decoded)
// Auto-detected codec (e.g. base58btc, base36) is bignum-backed
// and may produce wrong output past Number.MAX_SAFE_INTEGER on
// the JavaScript target — reject rather than return a silently
// corrupt payload.
False -> Error(Nil)
}
}
Error(_) -> Error(Nil)
}
}
On the BEAM target every encoding is supported, so
encoding.supports_target(_, encoding.target_erlang()) is always
True. The boolean only narrows on target_javascript(). The
matching encoding.is_javascript_safe/1 is the same check as a
direct Bool if you do not need a Target value.
For intid callers who decode an Int rather than a BitArray,
use intid.decode_int_*_bounded(..., max: intid.int53_max) when the
value is going to flow into a JavaScript number. The unbounded
decoders return Erlang bignums, which silently lose precision once
serialized for a JavaScript consumer.
Multibase prefix coverage
yabase supports the following multibase prefixes.
“encode + decode” means encode_multibase emits this prefix and decode_multibase recognizes it.
“decode only” means decode_multibase recognizes the prefix but encode_multibase uses the canonical form named in parentheses.
The table below is generated from yabase/core/encoding. To regenerate it, run just gen-readme and replace the fenced block. CI fails if the README drifts from the source-of-truth functions (multibase_prefix, from_multibase_prefix, multibase_name).
| Prefix | Encoding | Support |
|---|---|---|
0 | base2 | encode + decode |
7 | base8 | encode + decode |
9 | base10 | encode + decode |
f | base16 | encode + decode |
F | base16 | decode only (encode emits f) |
c | base32pad | encode + decode |
C | base32pad | decode only (encode emits c) |
b | base32pad | decode only (encode emits c) |
B | base32pad | decode only (encode emits c) |
t | base32hexpad | encode + decode |
T | base32hexpad | decode only (encode emits t) |
v | base32hexpad | decode only (encode emits t) |
V | base32hexpad | decode only (encode emits t) |
k | base36 | encode + decode |
K | base36 | decode only (encode emits k) |
R | base45 | encode + decode |
z | base58btc | encode + decode |
Z | base58flickr | encode + decode |
h | base32z | encode + decode |
M | base64pad | encode + decode |
m | base64 | encode + decode |
U | base64urlpad | encode + decode |
u | base64url | encode + decode |
The c and t decoder lanes also accept unpadded input (b / B, v / V); they share the same underlying decoder.
Bech32 / Bech32m (BIP 173, BIP 350)
Byte-payload convenience API. Takes raw bytes, handles 8-to-5-bit conversion internally, and produces the checksummed Bech32 string. Does not validate SegWit address semantics (witness version, program length):
import yabase/bech32
import yabase/core/error.{Bech32}
// Bech32 encode
let assert Ok(encoded) = bech32.encode(Bech32, "bc", <<0, 14, 20, 15>>)
// "bc1..." with 6-char checksum
// Auto-detect Bech32 vs Bech32m on decode
let assert Ok(_decoded) = bech32.decode(encoded)
// _decoded.hrp == "bc", _decoded.variant == Bech32
Base58Check (Bitcoin)
import yabase/base58check
// Encode with version byte 0 (Bitcoin mainnet P2PKH)
let assert Ok(encoded) = base58check.encode(0, <<0xab, 0xcd>>)
// Base58 string with 4-byte SHA-256 checksum
// Decode and verify checksum
let assert Ok(_decoded) = base58check.decode(encoded)
// _decoded.version == 0, _decoded.payload == <<0xab, 0xcd>>
Modules
| Module | Responsibility |
|---|---|
yabase | Top-level unified API: encode, decode, encode_multibase, decode_multibase |
yabase/facade | Developer-friendly shortcut functions for each encoding |
yabase/core/encoding | Type definitions: Encoding, Decoded, CodecError |
yabase/core/multibase | Multibase prefix encoding and auto-detection |
yabase/base2 | Base2 (binary string) |
yabase/base8 | Base8 (octal) |
yabase/base10 | Base10 (decimal) |
yabase/base16 | Base16 (hex) |
yabase/base32/* | Base32 variants: rfc4648, hex, crockford (with encode_check/decode_check), clockwork, zbase32 |
yabase/base64/* | Base64 variants: standard, urlsafe, nopadding, urlsafe_nopadding, dq |
yabase/base36 | Base36 |
yabase/base45 | Base45 (RFC 9285) |
yabase/base58/bitcoin | Base58 (Bitcoin alphabet) |
yabase/base58/flickr | Base58 (Flickr alphabet) |
yabase/base62 | Base62 |
yabase/intid | Int <-> short string helpers for IDs (Base32 / Base36 / Base58 / Base62) |
yabase/base91 | Base91 |
yabase/ascii85 | Ascii85 (btoa) |
yabase/adobe_ascii85 | Adobe Ascii85 (PDF/PostScript, <~ ~> delimiters) |
yabase/rfc1924_base85 | RFC 1924 Base85 |
yabase/z85 | Z85 (ZeroMQ) |
yabase/base58check | Base58Check (version byte + SHA-256 checksum) |
yabase/bech32 | Bech32/Bech32m byte-payload encoding with checksum (not SegWit address validation) |
Error handling
Encode and decode functions that can fail return Result(_, CodecError). The concrete return types vary by API:
| Function | Return type |
|---|---|
yabase.encode | Result(String, CodecError) |
yabase.decode | Result(BitArray, CodecError) |
yabase.encode_multibase | Result(String, CodecError) |
yabase.decode_multibase | Result(Decoded, CodecError) |
Low-level *.decode | Result(BitArray, CodecError) |
Low-level *.encode | String (total; except z85/rfc1924_base85 which return Result) |
bech32.encode(variant, hrp, data) | Result(String, CodecError) |
bech32.decode | Result(Bech32Decoded, CodecError) |
base58check.encode | Result(String, CodecError) |
base58check.decode | Result(Base58CheckDecoded, CodecError) |
The CodecError type provides specific error information:
| Variant | Returned from | Meaning |
|---|---|---|
InvalidCharacter(character, position) | decode | Input contains a character not in the alphabet |
InvalidLength(length) | encode / decode | Input length is not valid for the encoding |
Overflow | encode / decode | Decoded value overflows the expected range (Base45, Ascii85, Adobe Ascii85, Z85, RFC 1924 Base85); base58check.encode returns this when version is outside 0..255 |
UnsupportedPrefix(prefix) | yabase.decode_multibase | Unknown multibase prefix during auto-detection |
UnsupportedMultibaseEncoding(name) | yabase.encode_multibase | Encoding has no assigned multibase prefix (e.g. Base64 DQ) |
InvalidChecksum | base58check.decode, bech32.decode | Checksum verification failed |
InvalidHrp(reason) | bech32.encode, bech32.decode | Invalid human-readable part in Bech32 |
Examples
The examples/ directory contains runnable use-case examples:
| File | Use case |
|---|---|
jwt_urlsafe_base64.gleam | JWT header/payload encoding (URL-safe Base64 without padding) |
qr_base45.gleam | QR-code-friendly encoding (RFC 9285) |
bitcoin_base58check.gleam | Bitcoin address encoding with version byte and checksum |
bitcoin_bech32.gleam | Bech32/Bech32m address framing (BIP 173 / BIP 350) |
multibase_auto_detect.gleam | Prefix-based encoding auto-detection for content-addressed systems |
Development
This project uses mise to manage Gleam and Erlang versions, and just as a task runner.
mise install # install Gleam and Erlang
just ci # download deps and run all checks, including glinter
just lint # run glinter with the repo config
just test # gleam test
just format # gleam format
just check # all checks without deps download
Contributing
Contributions are welcome. See CONTRIBUTING.md for details.