Functional Weave
Code in TypeScript

encoding.hex

Bytes to lowercase hexadecimal text and back (RFC 4648 base16), strictly, identically in every language.

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

Pinned by 25 tests, run in TypeScript, Python and Rust.hexEncode 12 · hexDecode 13

What it does

`hexEncode([222, 173, 190, 239])` is `"deadbeef"`, and `hexDecode("DEADbeef")` is `[222, 173, 190, 239]`. This is base16 from RFC 4648 section 8, the form digests and keys are usually printed in.

**Bytes are lists of integers.** The registry's type vocabulary has no bytes type, so every encoding and crypto capability (`encoding.*`, `crypto.*`, `auth.*`) takes and returns bytes as `int[]`, each 0 to 255. In Python a `bytes` value is already a sequence of such integers and can be passed directly (`hex_encode(b"\x00\xff")`); the result is a `list`, so wrap it in `bytes(...)` when you need one. In TypeScript pass a plain array (`Array.from(uint8array)`). Anything else in the list (256, -1, 1.5, a string, `true`) is an error, never silently truncated to a byte.

The functions

A group: 2 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. hexEncode (bytes: int[]) -> string
  2. hexDecode (text: string) -> int[]

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

hexEncode throws on bad input 12 tests

export function hexEncode(bytes: readonly number[]): string
bytesint[]each an integer from 0 to 255
returnsstringtwo lowercase digits per byte; empty for no bytes

For example

  • hexEncode() → no bytes is the empty string
  • hexEncode(0) → 00 a zero byte keeps its leading zero
  • hexEncode(255) → ff the largest byte
import { hexEncode } from "#fune/encoding.hex@^1";
impl/typescript/hex_encode.ts · 23 lines · open · raw
const DIGITS = "0123456789abcdef";

/**
 * Bytes as lowercase hexadecimal, two digits per byte.
 *
 * Bytes are a list of integers 0-255 across the registry (there is no bytes
 * type), so a value outside that range is refused rather than masked to a
 * byte: 256 quietly becoming 0 would change key material without a trace.
 */
export function hexEncode(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");
  }
  let out = "";
  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");
    }
    out += DIGITS[b >> 4] + DIGITS[b & 15];
  }
  return out;
}

hexDecode throws on bad input 13 tests

export function hexDecode(text: string): readonly number[]
textstringan even number of hex digits, either case; no prefix, no spaces
returnsint[]one integer from 0 to 255 per pair of digits

For example

  • hexDecode() → the empty string is no bytes
  • hexDecode(666F6F626172) → 102, 111, 111, 98, 97, 114 RFC 4648 section 10: "foobar" as the RFC prints it, in uppercase
  • hexDecode(666f6f626172) → 102, 111, 111, 98, 97, 114 the same bytes in lowercase
import { hexDecode } from "#fune/encoding.hex@^1";
impl/typescript/hex_decode.ts · 33 lines · open · raw
function digitValue(code: number): number {
  if (code >= 48 && code <= 57) return code - 48; // 0-9
  if (code >= 97 && code <= 102) return code - 87; // a-f
  if (code >= 65 && code <= 70) return code - 55; // A-F
  return -1;
}

/**
 * Hexadecimal text back to bytes. Either case is accepted, since RFC 4648
 * prints its vectors in uppercase and most tools print lowercase; anything
 * else (a 0x prefix, whitespace, an odd digit count) is an error, not skipped.
 */
export function hexDecode(text: string): readonly number[] {
  if (typeof text !== "string") {
    throw new TypeError("hex text must be a string");
  }
  // Characters are checked before the length, so the answer for non-ASCII
  // input does not depend on how a language counts its length.
  const digits: number[] = [];
  for (let i = 0; i < text.length; i++) {
    const d = digitValue(text.charCodeAt(i));
    if (d < 0) {
      throw new RangeError("hex text may only contain the digits 0-9, a-f and A-F");
    }
    digits.push(d);
  }
  if (digits.length % 2 !== 0) {
    throw new RangeError(`hex text must have an even number of digits, received ${digits.length}`);
  }
  const out: number[] = [];
  for (let i = 0; i < digits.length; i += 2) out.push(digits[i] * 16 + digits[i + 1]);
  return out;
}

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.hex

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

fune add encoding.hex --only hexEncode
Download for TypeScript encoding.hex-1.0.0-typescript.fune · 9,992 bytes sha256 a31275a85aae0f3c57417e0016f8cf488c971be887d5927419423953e4cd50f4

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

The whole function, every language, is one file too: encoding.hex-1.0.0.fune, 15,224 bytes, sha256 8b494ecd32a0b545be0e436a0b8a598e80291fbf890387eaa70883258de64d2a. 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.hex.hexEncode
// fune: before encoding.hex.hexDecode

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

// fune: after encoding.hex.hexEncode
// fune: after encoding.hex.hexDecode

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.hex --steps.

// fune: step encoding.hex.<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.

hexEncode 12 tests

CaseArgumentsExpected
no bytes is the empty string →
a zero byte keeps its leading zero 0 → 00
the largest byte 255 → ff
RFC 4648 section 10: "f" 102 → 66
RFC 4648 section 10: "foobar", written in lowercase 102, 111, 111, 98, 97, 114 → 666f6f626172
the classic deadbeef 222, 173, 190, 239 → deadbeef
every small byte keeps two digits, which toString(16) alone gets wrong 0, 1, 15, 16, 127, 128, 254 → 00010f107f80fe
256 is not a byte and is not masked to 00 1, 256 → error: bytes must be a list of integers from 0 to 255
a negative value is not a byte -1 → error: bytes must be a list of integers from 0 to 255
a fraction is not a byte 1.5 → error: bytes must be a list of integers from 0 to 255
Show the other 2 tests
CaseArgumentsExpected
a string inside the list is not a byte a → error: bytes must be a list of integers from 0 to 255
a boolean is not a byte, even where the language treats it as 1 true → error: bytes must be a list of integers from 0 to 255

hexDecode 13 tests

CaseArgumentsExpected
the empty string is no bytes →
RFC 4648 section 10: "foobar" as the RFC prints it, in uppercase 666F6F626172 → 102, 111, 111, 98, 97, 114
the same bytes in lowercase 666f6f626172 → 102, 111, 111, 98, 97, 114
mixed case DEADbeef → 222, 173, 190, 239
the smallest and largest bytes 00ff → 0, 255
a single digit is half a byte 0 → error: hex text must have an even number of digits, received 1
an odd number of digits abc → error: hex text must have an even number of digits, received 3
a 0x prefix is not accepted 0x00 → error: hex text may only contain the digits 0-9, a-f and A-F
spaces between bytes are not skipped 00 ff → error: hex text may only contain the digits 0-9, a-f and A-F
a trailing newline is an error, not trimmed 00 → error: hex text may only contain the digits 0-9, a-f and A-F
Show the other 3 tests
CaseArgumentsExpected
letters past f gg → error: hex text may only contain the digits 0-9, a-f and A-F
Arabic-Indic digits are not hex digits ٠٠ → error: hex text may only contain the digits 0-9, a-f and A-F
full-width digits are not hex digits 01 → error: hex text may only contain the digits 0-9, a-f and A-F

More from the author

Encoding writes lowercase, which is what `sha256sum`, Python's `bytes.hex()` and most JSON APIs print. RFC 4648 writes its test vectors in uppercase; decoding accepts either case, and mixed case, so both round-trip.

Decoding is strict on purpose: an odd number of digits, a `0x` prefix, spaces, a trailing newline or a non-ASCII digit such as `"٠٠"` are errors rather than being skipped, because a permissive decoder turns a pasted typo into different key material without anyone noticing.

Source: RFC 4648, The Base16, Base32, and Base64 Data Encodings, section 8 and the test vectors in section 10 (https://www.rfc-editor.org/rfc/rfc4648).

Files

PathBytes
README.md1,431
impl/python/hex_decode.py1,204
impl/python/hex_encode.py871
impl/rust/hex_decode.rs1,343
impl/rust/hex_encode.rs1,504
impl/typescript/hex_decode.ts1,282
impl/typescript/hex_encode.ts844
vectors.json3,477