Functional Weave
Code in TypeScript

encoding.base64

Bytes to base64 and base64url text and back (RFC 4648), with strict decoding, identically in every language.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 46 tests, run in TypeScript, Python and Rust.base64Encode 12 · base64Decode 14 · base64UrlEncode 9 · base64UrlDecode 11

What it does

Base64 from RFC 4648: `base64Encode` / `base64Decode` use the standard alphabet (`+`, `/`) with `=` padding, and `base64UrlEncode` / `base64UrlDecode` use the URL- and filename-safe alphabet (`-`, `_`) of section 5, without padding, which is the form JWTs (RFC 7515 section 2) and most URL tokens use. The four ship as one group because the two alphabets share one codec; install only what you call with `only=`.

Bytes are lists of integers 0 to 255, as everywhere in the registry (see `encoding.hex`). Python callers can pass a `bytes` value directly.

The functions

A group: 4 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.

  1. base64Encode (bytes: int[]) -> string
  2. base64Decode (text: string) -> int[]
  3. base64UrlEncode (bytes: int[]) -> string
  4. base64UrlDecode (text: string) -> int[]

Once installed, your code imports each one from the group's module.

base64Encode throws on bad input 12 tests

export function base64Encode(bytes: readonly number[]): string
bytesint[]each an integer from 0 to 255
returnsstringstandard alphabet (+ and /), padded with = to a multiple of 4

For example

  • base64Encode() → RFC 4648 section 10: the empty string
  • base64Encode(102) → Zg== RFC 4648 section 10: "f", two padding characters
  • base64Encode(102, 111) → Zm8= RFC 4648 section 10: "fo", one padding character
import { base64Encode } from "#fune/encoding.base64@^1";
impl/typescript/base64_encode.ts · 34 lines · open · raw
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

/**
 * Bytes as standard base64 (RFC 4648 section 4), padded with "=".
 *
 * Written out rather than using btoa, which takes a "binary string" and
 * throws on anything above 0xff, and Buffer, which is not in the browser.
 */
export function base64Encode(bytes: readonly number[]): string {
  if (!Array.isArray(bytes) && !(bytes instanceof Uint8Array)) {
    throw new TypeError("bytes must be a list of integers from 0 to 255");
  }
  for (let i = 0; i < bytes.length; i++) {
    const b = bytes[i];
    if (typeof b !== "number" || !Number.isInteger(b) || b < 0 || b > 255) {
      throw new RangeError("bytes must be a list of integers from 0 to 255");
    }
  }
  let out = "";
  let i = 0;
  for (; i + 3 <= bytes.length; i += 3) {
    const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
    out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + ALPHABET[n & 63];
  }
  const rest = bytes.length - i;
  if (rest === 1) {
    const n = bytes[i] << 16;
    out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + "==";
  } else if (rest === 2) {
    const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
    out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + "=";
  }
  return out;
}

base64Decode throws on bad input 14 tests

export function base64Decode(text: string): readonly number[]
textstringstandard alphabet, padded; no whitespace or line breaks
returnsint[]

For example

  • base64Decode() → the empty string
  • base64Decode(Zg==) → 102 RFC 4648 section 10: "Zg==" is "f"
  • base64Decode(Zm8=) → 102, 111 RFC 4648 section 10: "Zm8=" is "fo"
import { base64Decode } from "#fune/encoding.base64@^1";
impl/typescript/base64_decode.ts · 55 lines · open · raw
function sextet(code: number): number {
  if (code >= 65 && code <= 90) return code - 65; // A-Z
  if (code >= 97 && code <= 122) return code - 71; // a-z
  if (code >= 48 && code <= 57) return code + 4; // 0-9
  if (code === 43) return 62; // +
  if (code === 47) return 63; // /
  return -1;
}

/**
 * Standard, padded base64 back to bytes, strictly: no whitespace, no
 * characters outside the alphabet, padding to a multiple of four, and zero
 * bits after the last byte, so every byte string has exactly one spelling.
 */
export function base64Decode(text: string): readonly number[] {
  if (typeof text !== "string") {
    throw new TypeError("base64 text must be a string");
  }
  const values: number[] = [];
  let padding = 0;
  for (let i = 0; i < text.length; i++) {
    const code = text.charCodeAt(i);
    if (code === 61) {
      padding++;
      continue;
    }
    const v = sextet(code);
    if (v < 0 || padding > 0) {
      throw new RangeError("base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end");
    }
    values.push(v);
  }
  if (text.length % 4 !== 0) {
    throw new RangeError(`base64 text must be a multiple of 4 characters long, received ${text.length}`);
  }
  if (padding > 2) {
    throw new RangeError("base64 text has too much = padding");
  }
  const out: number[] = [];
  let i = 0;
  for (; i + 4 <= values.length; i += 4) {
    const n = (values[i] << 18) | (values[i + 1] << 12) | (values[i + 2] << 6) | values[i + 3];
    out.push((n >> 16) & 255, (n >> 8) & 255, n & 255);
  }
  const rest = values.length - i;
  if (rest === 2) {
    if ((values[i + 1] & 15) !== 0) throw new RangeError("base64 text has non-zero bits after its last byte");
    out.push(((values[i] << 2) | (values[i + 1] >> 4)) & 255);
  } else if (rest === 3) {
    if ((values[i + 2] & 3) !== 0) throw new RangeError("base64 text has non-zero bits after its last byte");
    const n = (values[i] << 18) | (values[i + 1] << 12) | (values[i + 2] << 6);
    out.push((n >> 16) & 255, (n >> 8) & 255);
  }
  return out;
}

base64UrlEncode throws on bad input 9 tests

export function base64UrlEncode(bytes: readonly number[]): string
bytesint[]each an integer from 0 to 255
returnsstringURL-safe alphabet (- and _), no padding, as JWTs use

For example

  • base64UrlEncode() → the empty string
  • base64UrlEncode(102) → Zg "f" without its padding
  • base64UrlEncode(102, 111) → Zm8 "fo" without its padding
import { base64UrlEncode } from "#fune/encoding.base64@^1";
impl/typescript/base64_url_encode.ts · 16 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { base64Encode } from "./encoding_base64_base64_encode.ts";  ← base64Encode, another function of this group · built into the same file, even by a slim install

/**
 * Bytes as base64url (RFC 4648 section 5) without padding: the alphabet with
 * "-" and "_" in place of "+" and "/", as JWTs and URL tokens use it.
 */
export function base64UrlEncode(bytes: readonly number[]): string {
  const standard = base64Encode(bytes);
  let out = "";
  for (let i = 0; i < standard.length; i++) {
    const ch = standard[i];
    if (ch === "=") break;
    out += ch === "+" ? "-" : ch === "/" ? "_" : ch;
  }
  return out;
}

base64UrlDecode throws on bad input 11 tests

export function base64UrlDecode(text: string): readonly number[]
textstringURL-safe alphabet, with or without correct = padding
returnsint[]

For example

  • base64UrlDecode() → the empty string
  • base64UrlDecode(Zg) → 102 unpadded, as a JWT writes it
  • base64UrlDecode(Zg==) → 102 correct padding is accepted too
import { base64UrlDecode } from "#fune/encoding.base64@^1";
impl/typescript/base64_url_decode.ts · 34 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { base64Decode } from "./encoding_base64_base64_decode.ts";  ← base64Decode, another function of this group · built into the same file, even by a slim install

/**
 * base64url text back to bytes. Padding is optional, since JWTs leave it
 * off and some producers keep it, but padding that is present must be right.
 */
export function base64UrlDecode(text: string): readonly number[] {
  if (typeof text !== "string") {
    throw new TypeError("base64url text must be a string");
  }
  let body = "";
  let padding = 0;
  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    if (ch === "=") {
      padding++;
      continue;
    }
    const code = text.charCodeAt(i);
    const ok = (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57) || ch === "-" || ch === "_";
    if (!ok || padding > 0) {
      throw new RangeError("base64url text may only contain A-Z, a-z, 0-9, - and _, with optional = padding at the end");
    }
    body += ch === "-" ? "+" : ch === "_" ? "/" : ch;
  }
  if (body.length % 4 === 1) {
    throw new RangeError(`base64url text cannot be ${body.length} characters long; no number of bytes encodes to that`);
  }
  const needed = (4 - (body.length % 4)) % 4;
  if (padding !== 0 && padding !== needed) {
    throw new RangeError("base64url text has the wrong amount of = padding");
  }
  return base64Decode(body + "=".repeat(needed));
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add encoding.base64

That builds the whole group. To build only what you call, and whatever it uses inside the group:

fune add encoding.base64 --only base64Encode
Download for TypeScript encoding.base64-1.0.0-typescript.fune · 18,784 bytes sha256 17db8c2a4caedd1791d62e2f68bef3ced4ff31a7b3bbaa9710ba3fd7d879670b

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./encoding.base64-1.0.0-typescript.fune, or fetch it from a terminal with fune pull encoding.base64@1.0.0:typescript.

The whole function, every language, is one file too: encoding.base64-1.0.0.fune, 31,225 bytes, sha256 9dc42678ee52da6f5471c4c64d22da5ce75f681e734cea0ad5dd0ce6a188c403. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before encoding.base64.base64Encode
// fune: before encoding.base64.base64Decode
// fune: before encoding.base64.base64UrlEncode
// fune: before encoding.base64.base64UrlDecode

after — your function gets the result and the arguments, and returns the final result.

// fune: after encoding.base64.base64Encode
// fune: after encoding.base64.base64Decode
// fune: after encoding.base64.base64UrlEncode
// fune: after encoding.base64.base64UrlDecode

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show encoding.base64 --steps.

// fune: step encoding.base64.<fn> after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

base64Encode 12 tests

CaseArgumentsExpected
RFC 4648 section 10: the empty string →
RFC 4648 section 10: "f", two padding characters 102 → Zg==
RFC 4648 section 10: "fo", one padding character 102, 111 → Zm8=
RFC 4648 section 10: "foo", no padding 102, 111, 111 → Zm9v
RFC 4648 section 10: "foob" 102, 111, 111, 98 → Zm9vYg==
RFC 4648 section 10: "fooba" 102, 111, 111, 98, 97 → Zm9vYmE=
RFC 4648 section 10: "foobar" 102, 111, 111, 98, 97, 114 → Zm9vYmFy
high bytes use the + and / characters of the standard alphabet 251, 255, 191 → +/+/
zero bytes are A, not dropped 0, 0, 0, 0 → AAAAAA==
256 is not a byte 256 → error: bytes must be a list of integers from 0 to 255
Show the other 2 tests
CaseArgumentsExpected
a fraction is not a byte 1.5 → error: bytes must be a list of integers from 0 to 255
a string inside the list is not a byte f → error: bytes must be a list of integers from 0 to 255

base64Decode 14 tests

CaseArgumentsExpected
the empty string →
RFC 4648 section 10: "Zg==" is "f" Zg== → 102
RFC 4648 section 10: "Zm8=" is "fo" Zm8= → 102, 111
RFC 4648 section 10: "Zm9vYg==" is "foob" Zm9vYg== → 102, 111, 111, 98
RFC 4648 section 10: "Zm9vYmFy" is "foobar" Zm9vYmFy → 102, 111, 111, 98, 97, 114
the + and / characters +/+/ → 251, 255, 191
missing padding is refused in standard base64 Zg → error: base64 text must be a multiple of 4 characters long, received 2
non-zero bits after the last byte, which a lenient decoder reads as "f" Zh== → error: base64 text has non-zero bits after its last byte
non-zero bits after the last of two bytes Zm9= → error: base64 text has non-zero bits after its last byte
three padding characters Z=== → error: base64 text has too much = padding
Show the other 4 tests
CaseArgumentsExpected
padding in the middle Zg==Zg== → error: base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end
a trailing newline is not skipped Zm9v → error: base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end
base64url characters in standard text -_-_ → error: base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end
a non-ASCII letter Zm9é → error: base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end

base64UrlEncode 9 tests

CaseArgumentsExpected
the empty string →
"f" without its padding 102 → Zg
"fo" without its padding 102, 111 → Zm8
"foobar" needs no padding in either alphabet 102, 111, 111, 98, 97, 114 → Zm9vYmFy
- and _ replace + and / 251, 255, 191 → -_-_
- and _ with padding removed 251, 255 → -_8
RFC 7515 appendix A.1: the example JWS header 123, 34, 116, 121, 112, 34, 58, 34, 74, 87, 84, 34, 44, 13, 10, 32, 34, 97, 108, 103, 34, 58, 34, 72, 83, 50, 53, 54, 34, 125 → eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9
300 is not a byte 300 → error: bytes must be a list of integers from 0 to 255
a negative value is not a byte -5 → error: bytes must be a list of integers from 0 to 255

base64UrlDecode 11 tests

CaseArgumentsExpected
the empty string →
unpadded, as a JWT writes it Zg → 102
correct padding is accepted too Zg== → 102
- and _ -_-_ → 251, 255, 191
- and _ in an unpadded tail -_8 → 251, 255
RFC 7515 appendix A.1: the example JWS header eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9 → 123, 34, 116, 121, 112, 34, 58, 34, 74, 87, 84, 34, 44, 13, 10, 32, 34, 97, 108, 103, 34, 58, 34, 72, 83, 50, 53, 54, 34, 125
standard-alphabet characters are refused +/+/ → error: base64url text may only contain A-Z, a-z, 0-9, - and _, with optional = padding at the end
one character past a multiple of four encodes nothing Zm9vY → error: base64url text cannot be 5 characters long; no number of bytes encodes to that
padding that is present must be complete Zg= → error: base64url text has the wrong amount of = padding
non-zero bits after the last byte Zh → error: base64 text has non-zero bits after its last byte
Show the other 1 test
CaseArgumentsExpected
a space inside the text Zm9v YmFy → error: base64url text may only contain A-Z, a-z, 0-9, - and _, with optional = padding at the end

More from the author

**Decoding is strict.** Line breaks, spaces and characters outside the alphabet are errors, not skipped (RFC 4648 section 3.3 says to reject them unless a specification says otherwise). Standard base64 must be padded to a multiple of four characters. The unused bits in the last character must be zero: `"Zh=="` is refused even though a lenient decoder reads it as `"f"`, because accepting it would give one byte string several spellings, which matters when the text is compared or signed (section 3.5). The URL-safe decoder accepts text with or without padding, but padding that is present has to be right, and a length that no byte count produces (one character past a multiple of four) is an error.

Mixing alphabets is an error in both directions: `+` or `/` in base64url text, and `-` or `_` in standard base64 text. That is almost always a token pasted into the wrong field.

Source: RFC 4648, The Base16, Base32, and Base64 Data Encodings, sections 4, 5, 3.3, 3.5, and the test vectors in section 10 (https://www.rfc-editor.org/rfc/rfc4648). The base64url example of a JWS header is from RFC 7515 appendix A.1.

Files

PathBytes
README.md1,692
impl/python/base64_decode.py2,204
impl/python/base64_encode.py1,327
impl/python/base64_url_decode.py1,153
impl/python/base64_url_encode.py303
impl/rust/base64_decode.rs2,441
impl/rust/base64_encode.rs1,603
impl/rust/base64_url_decode.rs1,684
impl/rust/base64_url_encode.rs942
impl/typescript/base64_decode.ts2,059
impl/typescript/base64_encode.ts1,357
impl/typescript/base64_url_decode.ts1,322
impl/typescript/base64_url_encode.ts523
vectors.json6,687