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>>
}
That covers the success path. See Notes for production code at the bottom for the lint-policy and error-propagation guidance.
Strictness
The default decode_base32 and decode_base64 (and their
URL-safe / no-padding cousins) are lenient — they accept
non-canonical pad bits per the spec’s MAY-relax clause. For
interop with strict peers (JWT canonical-form check, signature
verification, RFC-strict gateway), reach for the _strict
siblings. They reject non-canonical pad bits per RFC 4648 §3.5
with a typed error.
import yabase/facade
// Lenient default — accepts non-canonical trailing pad bits:
let _lenient = facade.decode_base64("TR==")
// -> Ok(<<...>>)
// Strict — rejects non-canonical input per RFC 4648 §3.5:
let _strict = facade.decode_base64_strict("TR==")
// -> Error(InvalidPadding)
Available facade pairs:
decode_base32/decode_base32_strict(RFC 4648 §6 + §3.5)decode_base64/decode_base64_strict(RFC 4648 §4 + §3.5)
Reach into yabase/base32/rfc4648 / yabase/base64/standard
for the URL-safe / nopadding variants if you need their strict
forms (yabase/base64/urlsafe.decode_strict,
yabase/base32/hex.decode_strict, etc.). The strictness axis
is independent of the padding axis: _nopadding already
requires pad-free input, but its decoder is also lenient about
non-canonical trailing bits — pair with _strict when both
properties matter.
Use the strict variants by default for any decoder fed with attacker-controlled input (auth tokens, signed payloads, multi-tenant API gateways). The lenient default is the right shape for friendly clients (config files, internal RPC) where the producer is trusted not to ship malformed pad bits.
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
These encodings carry metadata (version bytes, checksums, HRP) and the metadata
is part of the Encoding value:
| Encoding | Module | Encoding constructor | Description |
|---|---|---|---|
| Base58Check | yabase/base58check | encoding.base58_check(version) | Bitcoin-style: version byte + payload + SHA-256 double-hash checksum |
| Bech32 | yabase/bech32 | encoding.bech32(hrp) | BIP 173: byte-payload encoding (HRP + 8-to-5 conversion + checksum), not SegWit address validation |
| Bech32m | yabase/bech32 | encoding.bech32m(hrp) | BIP 350: improved checksum constant, same byte-payload API |
Both fit the unified yabase.encode / yabase.decode shape:
import yabase
import yabase/core/encoding
let assert Ok(_encoded) =
yabase.encode(encoding.bech32("bc"), <<0xDE, 0xAD, 0xBE, 0xEF>>)
yabase.decode rejects any wire whose embedded checksum-bearing
metadata (Base58Check version, Bech32 HRP / variant) does not match
what the caller declared on the Encoding value. The low-level
modules (yabase/base58check.decode, yabase/bech32.decode) remain
available when the caller needs to inspect the embedded metadata
directly.
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)
Codec ergonomics: encode return-type asymmetry
The per-module encode family is not uniform:
| Codec | encode signature |
|---|---|
base2, base8, base10, base16, base32/{rfc4648, hex, crockford, clockwork, zbase32}, base36, base45, base62, base64/{standard, urlsafe, nopadding, urlsafe_nopadding, dq}, base91, base58/{bitcoin, flickr}, ascii85, adobe_ascii85 | fn(BitArray) -> String |
z85, rfc1924_base85 | fn(BitArray) -> Result(String, CodecError) |
base58check | fn(Int, BitArray) -> Result(String, CodecError) |
bech32 | fn(String, BitArray, Bech32Variant) -> Result(String, CodecError) |
The four Result-returning codecs have genuine encode-time
preconditions:
z85/rfc1924_base85require the input length to be a multiple of 4 bytesbase58checkrequires the version byte to be in0..=255bech32validates the human-readable part (HRP) and the variant- All four also reject sub-byte input via the same precondition path used for the constraint above.
Every other codec rejects sub-byte input (bit_array.bit_size % 8 != 0) by panicking via
yabase/core/guard.assert_byte_aligned (see #64) — a
programmer-error path, not a runtime-input path. With that,
sub-byte input is uniformly rejected across the codec family;
only the shape of the rejection differs.
If you need a uniform Result(String, _) shape across every
codec — e.g. for property-test tooling like
metamon’s
forall_round_trip — use the unified API
(yabase.encode) at the top of this README. It always returns
Result(String, CodecError) and absorbs the per-module
asymmetry behind a single Encoding ADT dispatch.
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 |
Notes for production code
The Quick start uses let assert Ok(_decoded) because that keeps the
README snippet readable. Real applications should propagate the error
instead.
Why not let assert on encode? yabase’s own gleam.toml
enables glinter’s assert_ok_pattern = "error" rule, so the
recommended shape in production code is to avoid let assert Ok(_) for cases that cannot meaningfully fail. The facade
returns plain String for infallible encodings and only the
decode side is Result-shaped — there let assert is 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.encode is Result-shaped
for every variant because the Encoding ADT erases per-variant
error possibilities.
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.