yabase

CIHex.pm

yabase_logo

Yet Another Base -- a unified, type-safe interface for multiple binary-to-text encodings in Gleam.

Requirements

Supported targets

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:

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:

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/base58checkencoding.base58_check(version) Bitcoin-style: version byte + payload + SHA-256 double-hash checksum
Bech32 yabase/bech32encoding.bech32(hrp) BIP 173: byte-payload encoding (HRP + 8-to-5 conversion + checksum), not SegWit address validation
Bech32m yabase/bech32encoding.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:

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_ascii85fn(BitArray) -> String
z85, rfc1924_base85fn(BitArray) -> Result(String, CodecError)
base58checkfn(Int, BitArray) -> Result(String, CodecError)
bech32fn(String, BitArray, Bech32Variant) -> Result(String, CodecError)

The four Result-returning codecs have genuine encode-time preconditions:

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/intidInt <-> 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.encodeResult(String, CodecError)
yabase.decodeResult(BitArray, CodecError)
yabase.encode_multibaseResult(String, CodecError)
yabase.decode_multibaseResult(Decoded, CodecError)
Low-level *.decodeResult(BitArray, CodecError)
Low-level *.encodeString (total; except z85/rfc1924_base85 which return Result)
bech32.encode(variant, hrp, data)Result(String, CodecError)
bech32.decodeResult(Bech32Decoded, CodecError)
base58check.encodeResult(String, CodecError)
base58check.decodeResult(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)
InvalidChecksumbase58check.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.

License

MIT