yabase

CI Hex.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.

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

EncodingVariants
Base2(binary string)
Base8(octal)
Base10(decimal)
Base16(hex)
Base32RFC4648, Hex, Crockford (with optional check symbol), Clockwork, z-base-32
Base64Standard, URL-safe, No padding, URL-safe no padding, DQ (hiragana)
Base58Bitcoin, Flickr

Additional

EncodingDescription
Base360-9, a-z (case-insensitive decode)
Base45RFC 9285 (QR-code friendly)
Base620-9, A-Z, a-z
Base9191 printable ASCII characters
Ascii85btoa style
Adobe Ascii85PDF/PostScript with <~ ~> delimiters
Z85ZeroMQ variant of Ascii85
RFC 1924 Base85RFC 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:

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

Codecencode 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).

PrefixEncodingSupport
0base2encode + decode
7base8encode + decode
9base10encode + decode
fbase16encode + decode
Fbase16decode only (encode emits f)
cbase32padencode + decode
Cbase32paddecode only (encode emits c)
bbase32paddecode only (encode emits c)
Bbase32paddecode only (encode emits c)
tbase32hexpadencode + decode
Tbase32hexpaddecode only (encode emits t)
vbase32hexpaddecode only (encode emits t)
Vbase32hexpaddecode only (encode emits t)
kbase36encode + decode
Kbase36decode only (encode emits k)
Rbase45encode + decode
zbase58btcencode + decode
Zbase58flickrencode + decode
hbase32zencode + decode
Mbase64padencode + decode
mbase64encode + decode
Ubase64urlpadencode + decode
ubase64urlencode + 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

ModuleResponsibility
yabaseTop-level unified API: encode, decode, encode_multibase, decode_multibase
yabase/facadeDeveloper-friendly shortcut functions for each encoding
yabase/core/encodingType definitions: Encoding, Decoded, CodecError
yabase/core/multibaseMultibase prefix encoding and auto-detection
yabase/base2Base2 (binary string)
yabase/base8Base8 (octal)
yabase/base10Base10 (decimal)
yabase/base16Base16 (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/base36Base36
yabase/base45Base45 (RFC 9285)
yabase/base58/bitcoinBase58 (Bitcoin alphabet)
yabase/base58/flickrBase58 (Flickr alphabet)
yabase/base62Base62
yabase/intidInt <-> short string helpers for IDs (Base32 / Base36 / Base58 / Base62)
yabase/base91Base91
yabase/ascii85Ascii85 (btoa)
yabase/adobe_ascii85Adobe Ascii85 (PDF/PostScript, <~ ~> delimiters)
yabase/rfc1924_base85RFC 1924 Base85
yabase/z85Z85 (ZeroMQ)
yabase/base58checkBase58Check (version byte + SHA-256 checksum)
yabase/bech32Bech32/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:

FunctionReturn 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:

VariantReturned fromMeaning
InvalidCharacter(character, position)decodeInput contains a character not in the alphabet
InvalidLength(length)encode / decodeInput length is not valid for the encoding
Overflowencode / decodeDecoded 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_multibaseUnknown multibase prefix during auto-detection
UnsupportedMultibaseEncoding(name)yabase.encode_multibaseEncoding has no assigned multibase prefix (e.g. Base64 DQ)
InvalidChecksumbase58check.decode, bech32.decodeChecksum verification failed
InvalidHrp(reason)bech32.encode, bech32.decodeInvalid human-readable part in Bech32

Examples

The examples/ directory contains runnable use-case examples:

FileUse case
jwt_urlsafe_base64.gleamJWT header/payload encoding (URL-safe Base64 without padding)
qr_base45.gleamQR-code-friendly encoding (RFC 9285)
bitcoin_base58check.gleamBitcoin address encoding with version byte and checksum
bitcoin_bech32.gleamBech32/Bech32m address framing (BIP 173 / BIP 350)
multibase_auto_detect.gleamPrefix-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

Search Document