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.
- hex_encode (bytes: int[]) -> string
- hex_decode (text: string) -> int[]
Once installed, your code imports each one from the group's module.
hex_encode throws on bad input 12 tests
def hex_encode(bytes: Sequence[int]) -> str
| bytes | int[] | each an integer from 0 to 255 |
| returns | string | two lowercase digits per byte; empty for no bytes |
For example
hex_encode()→ no bytes is the empty stringhex_encode(0)→ 00 a zero byte keeps its leading zerohex_encode(255)→ ff the largest byte
from fune.encoding.hex import hex_encode # encoding.hex@^1
from typing import Sequence
_DIGITS = "0123456789abcdef"
def hex_encode(bytes: Sequence[int]) -> str:
"""Bytes as lowercase hexadecimal, two digits per byte.
Bytes are a list of integers 0-255 across the registry (there is no bytes
type); a Python ``bytes`` value is such a sequence and is accepted as is.
A value outside the range is refused rather than masked to a byte.
"""
if isinstance(bytes, (str, dict)) or not hasattr(bytes, "__len__"):
raise TypeError("bytes must be a list of integers from 0 to 255")
out = []
for b in bytes:
# bool is an int in Python; true is not a byte in the other two languages.
if type(b) is not int or b < 0 or b > 255:
raise ValueError("bytes must be a list of integers from 0 to 255")
out.append(_DIGITS[b >> 4] + _DIGITS[b & 15])
return "".join(out)hex_decode throws on bad input 13 tests
def hex_decode(text: str) -> List[int]
| text | string | an even number of hex digits, either case; no prefix, no spaces |
| returns | int[] | one integer from 0 to 255 per pair of digits |
For example
hex_decode()→ the empty string is no byteshex_decode(666F6F626172)→ 102, 111, 111, 98, 97, 114 RFC 4648 section 10: "foobar" as the RFC prints it, in uppercasehex_decode(666f6f626172)→ 102, 111, 111, 98, 97, 114 the same bytes in lowercase
from fune.encoding.hex import hex_decode # encoding.hex@^1
from typing import List
def _digit_value(ch: str) -> int:
# Compared by character range rather than int(ch, 16), which would accept
# full-width and other scripts' digits that the other languages refuse.
if "0" <= ch <= "9":
return ord(ch) - 48
if "a" <= ch <= "f":
return ord(ch) - 87
if "A" <= ch <= "F":
return ord(ch) - 55
return -1
def hex_decode(text: str) -> List[int]:
"""Hexadecimal text back to bytes, either case, strictly.
A 0x prefix, whitespace or an odd digit count is an error, not skipped.
"""
if not isinstance(text, str):
raise 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.
digits = []
for ch in text:
d = _digit_value(ch)
if d < 0:
raise ValueError("hex text may only contain the digits 0-9, a-f and A-F")
digits.append(d)
if len(digits) % 2 != 0:
raise ValueError("hex text must have an even number of digits, received %d" % len(digits))
return [digits[i] * 16 + digits[i + 1] for i in range(0, len(digits), 2)]Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python 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
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./encoding.hex-1.0.0-python.fune, or fetch it from a terminal with fune pull encoding.hex@1.0.0:python.
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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,431 |
| impl/python/hex_decode.py | 1,204 |
| impl/python/hex_encode.py | 871 |
| impl/rust/hex_decode.rs | 1,343 |
| impl/rust/hex_encode.rs | 1,504 |
| impl/typescript/hex_decode.ts | 1,282 |
| impl/typescript/hex_encode.ts | 844 |
| vectors.json | 3,477 |