Skip to content

Encoding

The Encoding module (from the core effect package) does pure, synchronous conversion between bytes and text in three formats: Base64 (RFC 4648), Base64Url (the URL-safe, unpadded variant), and Hex (lowercase hexadecimal).

There is a deliberate asymmetry between the two directions:

  • Encoders are total. encodeBase64, encodeBase64Url, and encodeHex always return a string — any Uint8Array or string can be encoded, so they never fail.
  • Decoders are safe, not throwing. decodeBase64, decodeHex, and friends return Result.Result<…, EncodingError> because the input might be malformed. Instead of throwing, they hand you a Result you branch on.

The mental model for inputs and outputs:

  • A string input is first converted to UTF-8 bytes with TextEncoder, then encoded. A Uint8Array input is encoded directly.
  • Byte decoders (decodeBase64, decodeBase64Url, decodeHex) return a raw Uint8Array.
  • The *String decoders (decodeBase64String, decodeBase64UrlString, decodeHexString) decode those bytes back into UTF-8 text with TextDecoder.

A full encode/decode round trip. Encoding produces a plain string; decoding produces a Result that you resolve with Result.match.

import { Encoding, Result } from "effect"
// Some binary payload
const bytes = new Uint8Array([72, 101, 108, 108, 111]) // "Hello"
// Encode (total — always a string)
const encoded = Encoding.encodeBase64(bytes)
// => "SGVsbG8="
// Decode (safe — a Result, because input could be malformed)
const result = Encoding.decodeBase64(encoded)
const message = Result.match(result, {
onFailure: (error) => `decode failed: ${error.message}`,
onSuccess: (decoded) => `got ${decoded.length} bytes back`
})
// => "got 5 bytes back"

Standard, padded RFC 4648 Base64. This is the default choice for embedding binary data in JSON, data URLs, or anywhere a Base64 string is expected.

import { Encoding, Result } from "effect"
// Encode text
Encoding.encodeBase64("hello")
// => "aGVsbG8="
// Encode bytes
Encoding.encodeBase64(new Uint8Array([72, 101, 108, 108, 111]))
// => "SGVsbG8="
// Decode back to bytes
const bytes = Encoding.decodeBase64("SGVsbG8=")
if (Result.isSuccess(bytes)) {
Array.from(bytes.success)
// => [72, 101, 108, 108, 111]
}
// Decode straight to UTF-8 text
const text = Encoding.decodeBase64String("aGVsbG8=")
if (Result.isSuccess(text)) {
text.success
// => "hello"
}

Use decodeBase64 when you want the raw Uint8Array (binary payloads), and decodeBase64String when the encoded bytes are UTF-8 text and you want the string directly.

The URL-safe variant: it replaces + with - and / with _, and emits unpadded output (no trailing =). This is the form used for JWT segments, URL query parameters, and filenames.

import { Encoding, Result } from "effect"
// Encode text — note the trailing "_" instead of "/" and no padding
Encoding.encodeBase64Url("hello?")
// => "aGVsbG8_"
// Encode bytes
Encoding.encodeBase64Url(new Uint8Array([72, 101, 108, 108, 111, 63]))
// => "SGVsbG8_"
// Decode back to bytes (accepts padded AND unpadded input)
const bytes = Encoding.decodeBase64Url("SGVsbG8_")
if (Result.isSuccess(bytes)) {
Array.from(bytes.success)
// => [72, 101, 108, 108, 111, 63]
}
// Decode straight to UTF-8 text
const text = Encoding.decodeBase64UrlString("aGVsbG8_")
if (Result.isSuccess(text)) {
text.success
// => "hello?"
}

A common use is splitting and decoding a JWT, whose three segments are Base64Url-encoded:

import { Encoding, Result } from "effect"
const decodeJwtPayload = (jwt: string) => {
const segment = jwt.split(".")[1] ?? ""
return Result.map(Encoding.decodeBase64UrlString(segment), JSON.parse)
}

Lowercase hexadecimal. Encoding always emits lowercase letters; decoding requires an even number of hex characters.

import { Encoding, Result } from "effect"
// Encode text
Encoding.encodeHex("hello")
// => "68656c6c6f"
// Encode bytes
Encoding.encodeHex(new Uint8Array([72, 101, 108, 108, 111]))
// => "48656c6c6f"
// Decode back to bytes
const bytes = Encoding.decodeHex("48656c6c6f")
if (Result.isSuccess(bytes)) {
Array.from(bytes.success)
// => [72, 101, 108, 108, 111]
}
// Decode straight to UTF-8 text
const text = Encoding.decodeHexString("68656c6c6f")
if (Result.isSuccess(text)) {
text.success
// => "hello"
}

Decoders never throw. A malformed input becomes Result.fail carrying an EncodingError, which records what went wrong.

import { Encoding, Result } from "effect"
// "!" is not a valid Base64 character
const result = Encoding.decodeBase64("not-base64!")
Result.match(result, {
onFailure: (error) => {
error._tag // => "EncodingError"
error.kind // => "Decode"
error.module // => "Base64"
error.message // human-readable reason
},
onSuccess: (bytes) => bytes
})

When a value’s type is wider than EncodingError (e.g. caught from a mixed error channel), use the isEncodingError guard to narrow it.

import { Encoding } from "effect"
const handle = (u: unknown) => {
if (Encoding.isEncodingError(u)) {
// u is now typed as EncodingError
return `${u.module} ${u.kind} failed: ${u.message}`
}
return "unknown error"
}

Inside Effect.gen, lift the decode Result into the Effect error channel with Effect.fromResult. A Result.fail becomes a typed EncodingError failure you can handle with Effect.catchTag.

import { Effect, Encoding } from "effect"
const program = Effect.gen(function* () {
// Fails with EncodingError if the input is not valid Base64
const bytes = yield* Effect.fromResult(Encoding.decodeBase64("SGVsbG8="))
return bytes.length
}).pipe(
// EncodingError is a tagged error, so catchTag narrows it precisely
Effect.catchTag("EncodingError", (error) =>
Effect.succeed(`bad input: ${error.message}`)
)
)

Every public export of the Encoding module. Encoders return string; decoders return Result.Result<…, EncodingError>.

Encodes a string (as UTF-8 bytes) or a Uint8Array into a standard padded RFC 4648 Base64 string.

import { Encoding } from "effect"
Encoding.encodeBase64("hello")
// => "aGVsbG8="
Encoding.encodeBase64(new Uint8Array([255, 0, 255]))
// => "/wD/"

Decodes a standard Base64 string into a Uint8Array, returning a Result. Fails if the length is not a multiple of 4 or padding is misplaced.

import { Encoding, Result } from "effect"
Encoding.decodeBase64("aGVsbG8=")
// => Result.succeed(Uint8Array [104, 101, 108, 108, 111])
Result.isFailure(Encoding.decodeBase64("abc"))
// => true (length is not a multiple of 4)

Decodes a standard Base64 string into UTF-8 text, returning a Result. Equivalent to decodeBase64 followed by TextDecoder.decode.

import { Encoding } from "effect"
Encoding.decodeBase64String("aGVsbG8=")
// => Result.succeed("hello")

Encodes a string or Uint8Array into URL-safe, unpadded Base64 (-/_ instead of +//, no trailing =).

import { Encoding } from "effect"
Encoding.encodeBase64Url("hello?")
// => "aGVsbG8_"
Encoding.encodeBase64Url(new Uint8Array([255, 255, 255]))
// => "____"

Decodes URL-safe Base64 (padded or unpadded) into a Uint8Array, returning a Result.

import { Encoding, Result } from "effect"
Encoding.decodeBase64Url("aGVsbG8_")
// => Result.succeed(Uint8Array [104, 101, 108, 108, 111, 63])
// Padded input is accepted too:
Result.isSuccess(Encoding.decodeBase64Url("aGVsbG8="))
// => true

Decodes URL-safe Base64 into UTF-8 text, returning a Result.

import { Encoding } from "effect"
Encoding.decodeBase64UrlString("aGVsbG8_")
// => Result.succeed("hello?")

Encodes a string or Uint8Array into a lowercase hexadecimal string.

import { Encoding } from "effect"
Encoding.encodeHex("hello")
// => "68656c6c6f"
Encoding.encodeHex(new Uint8Array([0, 15, 255]))
// => "000fff"

Decodes a hexadecimal string into a Uint8Array, returning a Result. Fails on an odd number of characters or non-hex characters.

import { Encoding, Result } from "effect"
Encoding.decodeHex("000fff")
// => Result.succeed(Uint8Array [0, 15, 255])
Result.isFailure(Encoding.decodeHex("abc"))
// => true (odd length)

Decodes a hexadecimal string into UTF-8 text, returning a Result.

import { Encoding } from "effect"
Encoding.decodeHexString("68656c6c6f")
// => Result.succeed("hello")

The structured error returned by decode failures. It is a Data.TaggedError("EncodingError") carrying kind ("Decode" | "Encode"), module (e.g. "Base64", "Base64Url", "Hex"), the original input, and a human-readable message. Because it is a tagged error, it works with Effect.catchTag("EncodingError", …).

import { Encoding, Result } from "effect"
const result = Encoding.decodeHex("zz")
if (Result.isFailure(result)) {
result.failure._tag // => "EncodingError"
result.failure.kind // => "Decode"
result.failure.module // => "Hex"
result.failure.message // human-readable reason
}

Type guard that narrows an unknown value to EncodingError by checking its runtime marker.

import { Encoding } from "effect"
Encoding.isEncodingError(new Error("boom"))
// => false
const err = Encoding.decodeHex("zz")
// inside a Result.failure branch, err.failure is an EncodingError:
// Encoding.isEncodingError(err.failure) // => true

The string marker stored on EncodingError values ("~effect/encoding/EncodingError") that isEncodingError checks. You rarely reference it directly — prefer the guard — but it is exported for advanced narrowing, along with its literal type EncodingErrorTypeId.

import { Encoding } from "effect"
Encoding.EncodingErrorTypeId
// => "~effect/encoding/EncodingError"