diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,47 @@
 # Revision history for base16
 
+## 2.0
+
+This major release removes two public functions. See
+[MIGRATION-2.0.md](MIGRATION-2.0.md) for migration examples.
+
+- Replace `isBase16` and `isValidBase16` with `parseBase16` in all six
+  ByteString and Text modules. Parsing checks even length and the mixed-case
+  hexadecimal alphabet, then preserves the original encoded representation.
+  For a Boolean check a la `isBase16`, just use `isRight` . For `parseBase16`, odd-length alphabet-only inputs
+  previously accepted by `isValidBase16` are now rejected.
+- Add an optional manual `simd` flag, which is off by default, using the bundled `cbits`
+  which give runtime-detected x86-64 AVX2/SSSE3 encoding and decoding.
+  Unsupported targets and inputs under the simd threshold use the scalar fallbacks;
+  lenient decoding remains scalar.
+- Accelerate ShortByteString/ShortText encoding and checked/typed decoding with
+  direct byte-array SIMD bindings, without pinning or copying through strict
+  ByteString. Short parsers now validate through the short decoder.
+- Preserve scalar decoding errors and input-relative offsets with SIMD.
+- Fix lazy typed decoding when hex pairs cross chunk boundaries. Lazy checked
+  decoding now materializes the complete input for consistent total-length
+  checks and global error offsets. Lazy parsing retains the original chunks.
+- Fix [#32](https://github.com/emilypi/Base16/issues/32): lenient Text decoding
+  no longer throws on malformed UTF-8. Valid UTF-8 is preserved; invalid bytes
+  fall back to their Latin-1 characters. Apply the same policy to lazy Text
+  and ShortText, avoiding invalid UTF-8 inside the short-text result.
+- Require GHC 9.6 or later, support GHC 9.6 through 9.14, refresh CI, and retire
+  the old i386 32 bit support.
+- Guard encoded output size calculations against integer overflow.
+
+## 1.0
+
+- Revamp api (see: [MIGRATION-1.0.md](MIGRATION-1.0.md) in the source repo)
+  - Add new `Base16` opaque wrapper for encoded strings
+  - Move `decode*` to `decode*Typed` for untyped bytestring values
+  - Make typed decoding the default
+  - Optimize loops in the typed case
+
+- Bump lower bound on `bytestring` to `bytestring-0.11`
+- Bump lower bound on `text` to `text-2.0`
+- Support for all recent GHCs
+- Removed support for SafeHaskell
+
 ## 0.3.2.1
 
 * Add support for GHC 9.4
diff --git a/LICENSE b/LICENSE
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2020, Emily Pillmore
+Copyright (c) 2020-2026, Emily Pillmore
 
 All rights reserved.
 
diff --git a/MIGRATION-1.0.md b/MIGRATION-1.0.md
new file mode 100644
--- /dev/null
+++ b/MIGRATION-1.0.md
@@ -0,0 +1,177 @@
+Migration Guide for 1.0
+----
+
+Between the last major version (0.3.2.1) and the current major epoch (1.0), many API-related constructs have changed, and I'd like to justify them here and now so that users may have an immortalized explanation for what is most likely a disruptive change to their code.
+
+## A faster loop
+
+First, I'd like to say that I don't *like* breaking people's code. As an author and maintainer, I try and make sure that any API breakages are justified either by a significant UX improvement, or by a measurable performance increase large enough to warrant such a breakage. As such, I believe both of these criteria are met by the 0.3.x -> 1.0 upgrade: not only is the API safer to use, but the use of type data to establish the provenance of values encoded by this library also allows the performance-sensitive loops to be much cleaner, eschewing error checking where type data suffices. To prove this point, I've benchmarked the library between these last two epochs. The benchmarks say it all (all benchmarks are done on a Thinkpad P15 Gen 2 Intel i9-11950H, 64GB DDR4, Ubuntu 22.04 with GHC 8.10.7 stock, -O2):
+
+In `base16-0.3.2.1`:
+
+```
+benchmarking decode/25/base16-bytestring
+time                 31.66 ns   (31.64 ns .. 31.69 ns)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 31.62 ns   (31.60 ns .. 31.67 ns)
+std dev              113.1 ps   (71.94 ps .. 207.9 ps)
+
+benchmarking decode/25/base16
+time                 32.31 ns   (32.27 ns .. 32.35 ns)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 32.33 ns   (32.30 ns .. 32.42 ns)
+std dev              178.2 ps   (84.80 ps .. 340.1 ps)
+
+benchmarking decode/100/base16-bytestring
+time                 74.31 ns   (74.27 ns .. 74.35 ns)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 74.37 ns   (74.34 ns .. 74.41 ns)
+std dev              122.0 ps   (102.0 ps .. 147.8 ps)
+
+benchmarking decode/100/base16
+time                 83.74 ns   (83.70 ns .. 83.78 ns)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 83.57 ns   (83.43 ns .. 83.68 ns)
+std dev              380.3 ps   (273.2 ps .. 473.3 ps)
+
+benchmarking decode/1k/base16-bytestring
+time                 582.5 ns   (582.3 ns .. 582.8 ns)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 582.8 ns   (582.6 ns .. 583.1 ns)
+std dev              791.4 ps   (632.0 ps .. 1.101 ns)
+
+benchmarking decode/1k/base16
+time                 686.1 ns   (685.7 ns .. 686.4 ns)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 686.2 ns   (685.9 ns .. 686.6 ns)
+std dev              1.086 ns   (910.3 ps .. 1.357 ns)
+
+benchmarking decode/10k/base16-bytestring
+time                 5.640 μs   (5.633 μs .. 5.649 μs)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 5.663 μs   (5.656 μs .. 5.671 μs)
+std dev              25.71 ns   (21.29 ns .. 29.83 ns)
+
+benchmarking decode/10k/base16
+time                 6.628 μs   (6.609 μs .. 6.649 μs)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 6.614 μs   (6.609 μs .. 6.622 μs)
+std dev              20.78 ns   (12.02 ns .. 37.27 ns)
+
+benchmarking decode/100k/base16-bytestring
+time                 58.41 μs   (58.38 μs .. 58.45 μs)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 58.41 μs   (58.38 μs .. 58.45 μs)
+std dev              111.5 ns   (90.90 ns .. 152.0 ns)
+
+benchmarking decode/100k/base16
+time                 66.40 μs   (66.21 μs .. 66.64 μs)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 66.55 μs   (66.47 μs .. 66.62 μs)
+std dev              264.0 ns   (209.8 ns .. 332.3 ns)
+
+benchmarking decode/1mm/base16-bytestring
+time                 577.4 μs   (576.6 μs .. 578.1 μs)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 577.0 μs   (576.5 μs .. 577.6 μs)
+std dev              1.997 μs   (1.661 μs .. 2.474 μs)
+
+benchmarking decode/1mm/base16
+time                 670.9 μs   (670.3 μs .. 671.5 μs)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 671.1 μs   (670.7 μs .. 671.9 μs)
+std dev              2.003 μs   (1.211 μs .. 3.227 μs)
+```
+
+vs in `base64-1.0.0.0`:
+
+```
+benchmarking decode/25/base16-bytestring
+time                 24.29 ns   (24.27 ns .. 24.32 ns)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 24.27 ns   (24.24 ns .. 24.30 ns)
+std dev              95.03 ps   (76.90 ps .. 125.9 ps)
+
+benchmarking decode/25/base16
+time                 25.64 ns   (25.49 ns .. 25.81 ns)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 25.64 ns   (25.57 ns .. 25.72 ns)
+std dev              262.9 ps   (220.7 ps .. 312.9 ps)
+
+benchmarking decode/100/base16-bytestring
+time                 75.10 ns   (74.95 ns .. 75.31 ns)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 75.33 ns   (75.25 ns .. 75.40 ns)
+std dev              267.5 ps   (202.6 ps .. 340.3 ps)
+
+benchmarking decode/100/base16
+time                 60.99 ns   (60.92 ns .. 61.05 ns)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 60.95 ns   (60.89 ns .. 61.03 ns)
+std dev              238.1 ps   (186.6 ps .. 325.0 ps)
+
+benchmarking decode/1k/base16-bytestring
+time                 606.2 ns   (605.3 ns .. 607.4 ns)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 606.4 ns   (605.5 ns .. 609.2 ns)
+std dev              4.832 ns   (1.865 ns .. 9.636 ns)
+
+benchmarking decode/1k/base16
+time                 472.5 ns   (472.0 ns .. 473.0 ns)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 471.6 ns   (471.3 ns .. 472.0 ns)
+std dev              1.165 ns   (965.8 ps .. 1.434 ns)
+
+benchmarking decode/10k/base16-bytestring
+time                 5.885 μs   (5.881 μs .. 5.890 μs)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 5.891 μs   (5.888 μs .. 5.895 μs)
+std dev              13.03 ns   (10.87 ns .. 16.58 ns)
+
+benchmarking decode/10k/base16
+time                 4.560 μs   (4.551 μs .. 4.567 μs)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 4.549 μs   (4.544 μs .. 4.554 μs)
+std dev              16.61 ns   (14.04 ns .. 19.41 ns)
+
+benchmarking decode/100k/base16-bytestring
+time                 58.71 μs   (58.56 μs .. 58.84 μs)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 58.59 μs   (58.54 μs .. 58.66 μs)
+std dev              201.4 ns   (163.3 ns .. 251.0 ns)
+
+benchmarking decode/100k/base16
+time                 45.74 μs   (45.69 μs .. 45.80 μs)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 45.72 μs   (45.67 μs .. 45.78 μs)
+std dev              172.5 ns   (146.4 ns .. 209.1 ns)
+
+benchmarking decode/1mm/base16-bytestring
+time                 584.6 μs   (583.1 μs .. 586.7 μs)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 587.8 μs   (586.7 μs .. 589.0 μs)
+std dev              3.931 μs   (3.108 μs .. 5.364 μs)
+
+benchmarking decode/1mm/base16
+time                 459.0 μs   (458.5 μs .. 459.7 μs)
+                     1.000 R²   (1.000 R² .. 1.000 R²)
+mean                 458.9 μs   (458.4 μs .. 459.5 μs)
+std dev              1.839 μs   (1.355 μs .. 2.951 μs)
+```
+
+Benchmarks are included in this repo for you to reproduce these results on your own. You can see a parity in the `encode` step between the previous library iterations and the new epoch, with a *marked* improvement in decode speed (up to 25% faster on average between the old and new versions in the optimal case, and up to 40% in the suboptimal case) which justifies the performance aspect to me. Without deferring to pipelining instructions, hex encoding can only get so fast. In the future, this change also opens the library up to an optimal SIMD implementations.
+
+## A sounder api
+
+Second, I do not believe that these changes are unsound or overburdensome to the point that a migration to the new paradigm would be untenable. While it may be inconvenient to unwrap `Base16` types, in the `encode` case (all one must do is call `extractBase16` to extract the value from its wrapper, all caveats implied), and in the case of `decode`, an untyped variant is supplied, and is semantically consistent with the old behavior (the loop is the same). Hence, a migration is fairly easy to sketch out:
+
+```
+"encodeBase16'" -> "extractBase16 . encodeBase16'"
+"encodeBase16" -> "extractBase16 . encodeBase16"
+"decodebase16" -> "decodeBase16Untyped"
+"decodeBase16Unpadded" -> "decodeBase16UnpaddedUntyped"
+"decodeBase16Padded" -> "decodeBase16PaddedUntyped"
+"decodeBase16W*With" -> "decodeBase16*WithUntyped"
+```
+
+And that is all. In order to make use of the new loops, one must only use one of the blessed encode functions to generate a wrapped `Base16` value, or call `assertBase16` and proceed with using `decodeBase16` as usual in order to decode. You'll note that an untyped `encodeBase16` is not supplied, and this is due to the fact that it's trivial to extract a `Base16` encoded value once you have it. However, I want to encourage people to use the new API, so I have only supplied a decode with error checking in the untyped case, because sometimes we deal with other people's data and cannot establish provenance. In the encode case, I would rather keep that provenance a part of the API, and the user may opt to strip that data upon sending to others or around their systems. It's not my problem at that point!
diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md
new file mode 100644
--- /dev/null
+++ b/MIGRATION-2.0.md
@@ -0,0 +1,106 @@
+# Migration guide for 2.0
+
+Version 2.0 replaces `isBase16` and `isValidBase16` with `parseBase16` in all
+six ByteString and Text modules. The existing strict ByteString parser keeps
+its signature; the other modules now provide the corresponding parser:
+
+```haskell
+parseBase16 :: a -> Either Text (Base16 a)
+```
+
+Here `Text` in `Either` is strict `Data.Text.Text`, including in the lazy and
+short modules. The result retains the original encoded value, including its
+case and lazy chunk boundaries.
+
+## Replacing Boolean checks
+
+```haskell
+import Data.Either (isRight)
+import qualified Data.ByteString.Base16 as B16
+
+-- Previously: B16.isBase16 input
+isRight (B16.parseBase16 input)
+```
+
+The parser accepts empty input and even-length ASCII hexadecimal strings in
+lowercase, uppercase, or mixed case. It rejects odd lengths before checking
+characters. An odd-length string such as `"abc"` was accepted by
+`isValidBase16`, which checked only the alphabet; `parseBase16` rejects it with
+`Left "invalid bytestring size"`. For even-length inputs, invalid characters
+produce `Left "invalid character at offset: N"`, using a zero-based byte offset
+from the start of the complete input (UTF-8 bytes for Text inputs).
+
+Both predicates are removed in this major release, without a deprecation
+period. If an application specifically needs an alphabet-only check, define
+that separate policy in the application.
+
+## Validate once, then decode
+
+```haskell
+case B16.parseBase16 input of
+  Left err      -> handleError err
+  Right encoded -> useBytes (B16.decodeBase16 encoded)
+```
+
+Use `decodeBase16Untyped` when you want validation and decoded bytes together.
+`parseBase16` currently validates via the checked decoder and discards its
+output, so parsing followed by typed decoding processes the input twice.
+`assertBase16` remains an unchecked assertion for values with known provenance.
+
+Text parsers validate the encoded hex, not the character encoding of the
+decoded payload. For example, `"ff"` parses successfully even though byte
+`0xff` is not valid UTF-8. Use the ByteString decoding functions or
+`decodeBase16With` with a checked text conversion when payloads may be binary.
+
+Lazy typed decoding now handles hex pairs split across chunks. Lazy checked
+decoding validates the complete input, giving total-length checks and global
+error offsets; it materializes the input as a strict ByteString. Parsing
+preserves the original chunks despite this validation step.
+
+## Lenient text decoding
+
+`decodeBase16Lenient` in all three Text modules now handles invalid UTF-8
+without throwing or constructing invalid ShortText. After skipping non-hex
+characters and dropping a final unpaired hex digit, it decodes valid UTF-8
+sequences normally and maps each invalid byte to its Latin-1 character.
+
+This fixes [#32](https://github.com/emilypi/Base16/issues/32): decoding
+`"0014df1047454e53"` again yields `"\NUL\DC4ß\DLEGENS"`. Both `"df"` (a lone
+Latin-1 byte) and `"c39f"` (valid UTF-8) produce `"ß"`. This is a recovery
+policy, not automatic encoding detection: valid UTF-8 takes precedence even
+inside otherwise malformed input. Lazy decoding handles sequences across
+chunk boundaries and remains streaming.
+
+Typed and checked decoders keep their existing UTF-8 behavior. For explicit
+encoding control, decode to bytes and choose a conversion, or use
+`decodeBase16With` with a checked conversion.
+
+## Optional SIMD acceleration
+
+The manual `simd` flag is disabled by default. To enable it in `cabal.project`:
+
+```cabal
+package base16
+  flags: +simd
+```
+
+For this repository, use `cabal test --flags=simd`. The bundled C kernels use
+ARM64 NEON or runtime-detected x86-64 AVX2/SSSE3. Unsupported targets use the
+Haskell scalar implementation. No external SIMD library or global CPU-specific
+compiler flags are required. Strict ByteString and ShortByteString encoding,
+checked decoding, and typed decoding use SIMD where applicable, as do the
+lazy/Text APIs built on them. ShortByteString and ShortText use direct
+byte-array bindings without pinning or copying through strict ByteString;
+their parsers validate through the short decoder. Inputs below the SIMD
+thresholds and lenient decoding retain scalar paths.
+
+## Compiler and module support
+
+Like base64 2.0, this release requires GHC 9.6 or later (`base >= 4.18`),
+`bytestring >= 0.11`, and `text >= 2.0`. CI covers GHC 9.6 through 9.14 with
+SIMD enabled and disabled; the old i386 CI job has been retired.
+
+The `Base16` definition and instances now live together in `Data.Base16.Types`.
+Its constructor remains hidden. The remaining implementation modules are
+exposed for advanced use, with their `Internal` names marking implementation
+details; `Data.ByteString.Base16.Internal.SIMD` is available only with `simd`.
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,28 +1,75 @@
 # Base16
 
-![Build Status](https://github.com/emilypi/base16/workflows/Haskell-CI/badge.svg)
+![Build Status](https://github.com/emilypi/base16/workflows/ci/badge.svg)
 [![Hackage](https://img.shields.io/hackage/v/base16.svg)](https://hackage.haskell.org/package/base16)
 
-RFC 4648-compliant Base16 encoding and decoding.
+RFC 4648-compliant Base16 encoding and decoding for strict, lazy, and short
+ByteString and Text values. Encoding produces lowercase hex; decoding accepts
+lowercase, uppercase, and mixed case.
 
-For the companion optics and pattern synonyms, see [base16-lens](https://hackage.haskell.org/package/base16-lens).
+The API tracks encoded values with the `Base16` wrapper. Use `parseBase16` to
+validate external input and retain its original representation:
 
-### Summary
+```haskell
+{-# LANGUAGE OverloadedStrings #-}
+import Data.Base16.Types (extractBase16)
+import qualified Data.ByteString.Base16 as B16
 
-The following types have supported codecs:
+extractBase16 (B16.encodeBase16' "Sun") -- "53756e"
+B16.parseBase16 "53756E"               -- Right "53756E"
+B16.decodeBase16Untyped "53756E"       -- Right "Sun"
+B16.parseBase16 "abc"                  -- Left "invalid bytestring size"
+```
 
-- `Data.ByteString`
-- `Data.ByteString.Lazy`
-- `Data.ByteString.Short`
-- `Data.Text`
-- `Data.Text.Lazy`
-- `Data.Text.Short`
+For a Boolean check, use `isRight . parseBase16` from `Data.Either`. Version 2.0
+removes `isBase16` and `isValidBase16`; see [MIGRATION-2.0.md](MIGRATION-2.0.md)
+for replacement examples, lazy decoding changes, and compiler requirements.
+For companion optics and pattern synonyms, see
+[base16-lens](https://hackage.haskell.org/package/base16-lens).
 
-Additionally this library has
+Lenient Text decoding ignores non-hex characters and a final unpaired hex
+digit. It preserves valid UTF-8 sequences and interprets each invalid UTF-8
+byte as Latin-1, so malformed input does not raise Unicode exceptions. This
+also applies to lazy Text and ShortText; see the migration guide for details.
 
-- Much better performance than `base16-bytestring` for encode and decode, with a more conventional api.
-- Support for mixed-case hex decoding (defaults to lower-case encoding by convention)
-- Optics for handling more complex structures with Base16 representations via the `base16-lens` package
-- Checks for both validity and correctness of Base16 encodings.
+## SIMD
 
-There are no dependencies aside from those bundled with GHC.
+The optional, manual `simd` flag enables bundled ARM64 NEON and runtime-detected
+x86-64 AVX2/SSSE3 kernels for strict ByteString and ShortByteString encoding,
+checked decoding, and typed decoding, including the lazy and Text APIs built
+on those operations. ShortByteString and ShortText use direct byte-array
+bindings without pinning or copying through strict ByteString. Their parsers
+validate through the short decoder. Unsupported targets and inputs below the
+SIMD thresholds fall back to Haskell scalar loops. Lenient decoding remains
+scalar.
+
+The flag is off by default. Enable it in your `cabal.project`:
+
+```cabal
+package base16
+  flags: +simd
+```
+
+No external native library is needed. SIMD uses the same alphabet checks and
+error offsets as the scalar implementation.
+
+## Development
+
+GHC 9.6 or later is required. Test both build configurations:
+
+```sh
+cabal test --flags=-simd
+cabal test --flags=simd
+cabal bench --flags=-simd --benchmark-options='--iters 1'
+cabal bench --flags=simd --benchmark-options='--iters 1'
+```
+
+The benchmark suite covers small inputs, vector/call boundaries, and buffers
+up to one million bytes. For timings, omit `--iters 1` and use separate build
+directories (`--builddir=dist-scalar` and `--builddir=dist-simd`). Compare on the
+same machine and compiler; gains depend on input size and CPU.
+
+Native differential tests check exact buffer bounds, alignment, invalid bytes,
+and scalar fallback. CI runs them with address/undefined-behavior sanitizers
+and guard pages, and builds the Haskell tests, benchmarks, and Haddock from the
+source distribution with both flag settings.
diff --git a/Setup.hs b/Setup.hs
deleted file mode 100644
--- a/Setup.hs
+++ /dev/null
@@ -1,2 +0,0 @@
-import Distribution.Simple
-main = defaultMain
diff --git a/base16.cabal b/base16.cabal
--- a/base16.cabal
+++ b/base16.cabal
@@ -1,6 +1,6 @@
 cabal-version:   3.0
 name:            base16
-version:         0.3.2.1
+version:         2.0
 synopsis:        Fast RFC 4648-compliant Base16 encoding
 description:
   RFC 4648-compliant Base16 encodings and decodings.
@@ -11,26 +11,40 @@
 license:         BSD-3-Clause
 license-file:    LICENSE
 author:          Emily Pillmore
-maintainer:      emilypi@cohomolo.gy
-copyright:       (c) 2020-2022 Emily Pillmore
+maintainer:      Emily Pillmore <emilypi@cohomolo.gy>
+copyright:       (c) 2020-2026 Emily Pillmore
 category:        Data
 build-type:      Simple
 extra-doc-files:
   CHANGELOG.md
   README.md
+  MIGRATION-1.0.md
+  MIGRATION-2.0.md
 
 tested-with:
-  GHC ==8.10.7
-   || ==9.0.2
-   || ==9.2.5
-   || ==9.4.3
+  GHC ==9.6.7
+   || ==9.8.4
+   || ==9.10.3
+   || ==9.12.4
+   || ==9.14.1
 
+extra-source-files:
+  cbits/base16_simd.h
+  test/simd-native.c
+  benchmarks/simd-native.c
+
+flag simd
+  description:     Use bundled SIMD kernels where supported, with a scalar fallback
+  default:         False
+  manual:          True
+
 source-repository head
   type:     git
   location: https://github.com/emilypi/base16.git
 
 library
   exposed-modules:
+    Data.Base16.Types
     Data.ByteString.Base16
     Data.ByteString.Lazy.Base16
     Data.ByteString.Short.Base16
@@ -39,54 +53,68 @@
     Data.Text.Lazy.Encoding.Base16
     Data.Text.Short.Encoding.Base16
 
-  other-modules:
     Data.ByteString.Base16.Internal.Head
     Data.ByteString.Base16.Internal.Utils
     Data.ByteString.Base16.Internal.W16.Loop
     Data.ByteString.Base16.Internal.W16.ShortLoop
 
+  if flag(simd)
+    exposed-modules: Data.ByteString.Base16.Internal.SIMD
+    cpp-options:    -DBASE16_SIMD
+    c-sources:      cbits/base16_simd.c
+    include-dirs:   cbits
+    cc-options:     -O2 -std=c99
+
   build-depends:
-      base        >=4.14 && <4.18
-    , bytestring  >=0.10 && <0.12
-    , deepseq     ^>=1.4
-    , primitive   >=0.6  && <0.8
-    , text        ^>=1.2 || ^>= 2.0
+      base        >=4.18 && <4.23
+    , bytestring  >=0.11 && <0.13
+    , deepseq     >=1.4.4.0 && <1.6
+    , primitive   >=0.6  && <0.10
+    , text        >=2.0 && <2.3
     , text-short  ^>=0.1
 
   hs-source-dirs:   src
   default-language: Haskell2010
   ghc-options:      -Wall
 
-test-suite tasty
+test-suite base16-tests
   other-modules:    Internal
+                    Simd
   default-language: Haskell2010
   type:             exitcode-stdio-1.0
   hs-source-dirs:   test
   main-is:          Main.hs
   build-depends:
-      base               >=4.14 && <4.18
+      base               >=4.18 && <4.23
     , base16
     , base16-bytestring  >=1.0
-    , bytestring
+    , bytestring         >=0.11
     , QuickCheck
     , random-bytestring
     , tasty
     , tasty-hunit
     , tasty-quickcheck
-    , text
+    , text               >=2.0
     , text-short
 
+  ghc-options:      -Wall -threaded -with-rtsopts=-N
+
 benchmark bench
   default-language: Haskell2010
   type:             exitcode-stdio-1.0
   hs-source-dirs:   benchmarks
   main-is:          Base16Bench.hs
   build-depends:
-      base               >=4.14 && <4.18
+      base               >=4.18 && <4.23
     , base16
     , base16-bytestring  >=1.0
-    , bytestring
+    , bytestring         >=0.11
     , criterion
     , deepseq
     , random-bytestring
-    , text
+    , text               >=2.0
+
+  if flag(simd)
+    cpp-options:    -DBASE16_SIMD
+
+  ghc-options:      -Wall -rtsopts
diff --git a/benchmarks/Base16Bench.hs b/benchmarks/Base16Bench.hs
--- a/benchmarks/Base16Bench.hs
+++ b/benchmarks/Base16Bench.hs
@@ -1,105 +1,51 @@
+{-# LANGUAGE CPP #-}
 {-# LANGUAGE PackageImports #-}
-module Main
-( main
-)where
-
+-- Copyright (c) 2020-2026 Emily Pillmore. BSD-3-Clause; see LICENSE.
+module Main (main) where
 
-import Criterion
 import Criterion.Main
-
-import Data.ByteString
-import Data.ByteString.Short
-import "base16" Data.ByteString.Base16 as B16
-import "base16" Data.ByteString.Short.Base16 as BS16
-import "base16-bytestring" Data.ByteString.Base16 as Bos
+import Data.Base16.Types (assertBase16, extractBase16)
+import qualified Data.ByteString.Short as SBS
+import qualified "base16" Data.ByteString.Base16 as B16
+import qualified "base16" Data.ByteString.Short.Base16 as S16
+import qualified "base16-bytestring" Data.ByteString.Base16 as Reference
 import Data.ByteString.Random (random)
 
-
 main :: IO ()
-main =
-  defaultMain
-    [ env bs $ \ ~((bs25,bs100,bs1k,bs10k,bs100k,bs1mm),(bs25L,bs100L,bs1kL,bs10kL,bs100kL,bs1mmL)) ->
-      bgroup "encode"
-      [ bgroup "25"
-        [ bench "base16-short" $ whnf BS16.encodeBase16' bs25L
-        , bench "base16-bytestring" $ whnf Bos.encode bs25
-        , bench "base16" $ whnf B16.encodeBase16' bs25
-        ]
-      , bgroup "100"
-        [ bench "base16-short" $ whnf BS16.encodeBase16' bs100L
-        , bench "base16-bytestring" $ whnf Bos.encode bs100
-        , bench "base16" $ whnf B16.encodeBase16' bs100
-        ]
-      , bgroup "1k"
-        [ bench "base16-short" $ whnf BS16.encodeBase16' bs1kL
-        , bench "base16-bytestring" $ whnf Bos.encode bs1k
-        , bench "base16" $ whnf B16.encodeBase16' bs1k
-        ]
-      , bgroup "10k"
-        [ bench "base16-short" $ whnf BS16.encodeBase16' bs10kL
-        , bench "base16-bytestring" $ whnf Bos.encode bs10k
-        , bench "base16" $ whnf B16.encodeBase16' bs10k
-        ]
-      , bgroup "100k"
-        [ bench "base16-short" $ whnf BS16.encodeBase16' bs100kL
-        , bench "base16-bytestring" $ whnf Bos.encode bs100k
-        , bench "base16" $ whnf B16.encodeBase16' bs100k
-        ]
-      , bgroup "1mm"
-        [ bench "base16-short" $ whnf BS16.encodeBase16' bs1mmL
-        , bench "base16-bytestring" $ whnf Bos.encode bs1mm
-        , bench "base16" $ whnf B16.encodeBase16' bs1mm
-        ]
+main = defaultMain [benchSize n | n <- sizes]
+  where
+    sizes = [0, 1, 15, 16, 31, 32, 63, 64, 65, 127, 128, 129,
+             1000, 10000, 100000, 1000000]
+
+benchSize :: Int -> Benchmark
+benchSize size = env inputs $ \ ~(raw, encoded, shortRaw, shortEncoded) ->
+  bgroup (show size)
+    [ bgroup "encode"
+      [ bench variant $ nf (extractBase16 . B16.encodeBase16') raw
+      , bench "base16-short" $ nf (extractBase16 . S16.encodeBase16') shortRaw
+      , bench "base16-bytestring" $ nf Reference.encode raw
       ]
-    , env bs' $ \ ~((bs25,bs100,bs1k,bs10k,bs100k,bs1mm),(bs25L,bs100L,bs1kL,bs10kL,bs100kL,bs1mmL)) ->
-      bgroup "decode"
-      [ bgroup "25"
-        [ bench "base16-short" $ whnf BS16.decodeBase16 bs25L
-        , bench "base16-bytestring" $ whnf Bos.decode bs25
-        , bench "base16" $ whnf B16.decodeBase16 bs25
-        ]
-      , bgroup "100"
-        [ bench "base16-short" $ whnf BS16.decodeBase16 bs100L
-        , bench "base16-bytestring" $ whnf Bos.decode bs100
-        , bench "base16" $ whnf B16.decodeBase16 bs100
-        ]
-      , bgroup "1k"
-        [ bench "base16-short" $ whnf BS16.decodeBase16 bs1kL
-        , bench "base16-bytestring" $ whnf Bos.decode bs1k
-        , bench "base16" $ whnf B16.decodeBase16 bs1k
-        ]
-      , bgroup "10k"
-        [ bench "base16-short" $ whnf BS16.decodeBase16 bs10kL
-        , bench "base16-bytestring" $ whnf Bos.decode bs10k
-        , bench "base16" $ whnf B16.decodeBase16 bs10k
-        ]
-      , bgroup "100k"
-        [ bench "base16-short" $ whnf BS16.decodeBase16 bs100kL
-        , bench "base16-bytestring" $ whnf Bos.decode bs100k
-        , bench "base16" $ whnf B16.decodeBase16 bs100k
-        ]
-      , bgroup "1mm"
-        [ bench "base16-short" $ whnf BS16.decodeBase16 bs1mmL
-        , bench "base16-bytestring" $ whnf Bos.decode bs1mm
-        , bench "base16" $ whnf B16.decodeBase16 bs1mm
-        ]
+    , bgroup "decode"
+      [ bench (variant ++ "-typed") $ nf B16.decodeBase16 (assertBase16 encoded)
+      , bench (variant ++ "-checked") $ nf B16.decodeBase16Untyped encoded
+      , bench "base16-short-typed" $ nf S16.decodeBase16 (assertBase16 shortEncoded)
+      , bench "base16-short-checked" $ nf S16.decodeBase16Untyped shortEncoded
+      , bench "base16-bytestring" $ nf Reference.decode encoded
       ]
+    , bgroup "parse"
+      [ bench variant $ nf B16.parseBase16 encoded
+      , bench "base16-short" $ nf S16.parseBase16 shortEncoded
+      ]
     ]
   where
-    bs = do
-      a <- random 25
-      b <- random 100
-      c <- random 1000
-      d <- random 10000
-      e <- random 100000
-      f <- random 1000000
-      return ((a,b,c,d,e,f),(toShort a,toShort b,toShort c,toShort d,toShort e,toShort f))
+    inputs = do
+      raw <- random (fromIntegral size)
+      let encoded = Reference.encode raw
+      pure (raw, encoded, SBS.toShort raw, SBS.toShort encoded)
 
-    bs' = do
-      a <- B16.encodeBase16' <$> random 25
-      b <- B16.encodeBase16' <$> random 100
-      c <- B16.encodeBase16' <$> random 1000
-      d <- B16.encodeBase16' <$> random 10000
-      e <- B16.encodeBase16' <$> random 100000
-      f <- B16.encodeBase16' <$> random 1000000
-      return ((a,b,c,d,e,f),(toShort a,toShort b,toShort c,toShort d,toShort e,toShort f))
+variant :: String
+#ifdef BASE16_SIMD
+variant = "base16-simd"
+#else
+variant = "base16-scalar"
+#endif
diff --git a/benchmarks/simd-native.c b/benchmarks/simd-native.c
new file mode 100644
--- /dev/null
+++ b/benchmarks/simd-native.c
@@ -0,0 +1,163 @@
+/* Copyright (c) 2026, Emily Pillmore. BSD-3-Clause; see LICENSE. */
+
+/* Native diagnostic benchmark; this does not measure Haskell/FFI overhead. */
+#define _POSIX_C_SOURCE 200809L
+#include <errno.h>
+#include <math.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#include "base16_simd.h"
+
+static volatile size_t sink;
+static const uint8_t alphabet[] = "0123456789abcdef";
+
+static double now(void)
+{
+    struct timespec time;
+    if (clock_gettime(CLOCK_MONOTONIC, &time) != 0) {
+        perror("clock_gettime");
+        exit(EXIT_FAILURE);
+    }
+    return (double)time.tv_sec + (double)time.tv_nsec / 1000000000.0;
+}
+
+static void encode(const uint8_t *src, size_t len, uint8_t *dst, int simd)
+{
+    size_t consumed = simd ? hs_base16_simd_encode(src, len, dst) : 0;
+    for (size_t i = consumed; i < len; i++) {
+        dst[i * 2] = alphabet[src[i] >> 4];
+        dst[i * 2 + 1] = alphabet[src[i] & 15];
+    }
+}
+
+static uint8_t nibble(uint8_t value)
+{
+    return value <= '9' ? (uint8_t)(value - '0') : (uint8_t)(value - 'a' + 10);
+}
+
+/* The benchmark only supplies independently generated valid lowercase input. */
+static void decode(const uint8_t *src, size_t len, uint8_t *dst, int simd)
+{
+    size_t consumed = simd ? hs_base16_simd_decode(src, len, dst) : 0;
+    for (size_t i = consumed; i < len; i += 2) {
+        dst[i / 2] = (uint8_t)(nibble(src[i]) * 16 + nibble(src[i + 1]));
+    }
+}
+
+typedef void (*operation)(const uint8_t *, size_t, uint8_t *, int);
+
+static double measure(operation op, const uint8_t *src, size_t len,
+                      uint8_t *dst, int simd, double seconds)
+{
+    const double start = now();
+    double elapsed;
+    size_t iterations = 0;
+
+    do {
+        for (size_t batch = 0; batch < 16; batch++) {
+            op(src, len, dst, simd);
+            sink += dst[0];
+            iterations++;
+        }
+        elapsed = now() - start;
+    } while (elapsed < seconds);
+    return (double)iterations * (double)len / elapsed / 1048576.0;
+}
+
+static void usage(const char *name)
+{
+    fprintf(stderr, "usage: %s [--seconds 0.001..10] [--rounds 1..20] "
+                    "[--pattern random|repeating]\n", name);
+}
+
+int main(int argc, char **argv)
+{
+    double seconds = 0.05;
+    unsigned long rounds = 3;
+    int random_pattern = 1;
+    const size_t sizes[] = {64, 128, 1024, 16384, 1048576};
+    const int backend = hs_base16_simd_backend();
+
+    for (int i = 1; i < argc; i += 2) {
+        char *end;
+        if (i + 1 >= argc) {
+            usage(argv[0]);
+            return EXIT_FAILURE;
+        }
+        errno = 0;
+        if (strcmp(argv[i], "--seconds") == 0) {
+            seconds = strtod(argv[i + 1], &end);
+            if (errno != 0 || end == argv[i + 1] || *end != '\0' ||
+                !isfinite(seconds) || seconds < 0.001 || seconds > 10) {
+                usage(argv[0]);
+                return EXIT_FAILURE;
+            }
+        } else if (strcmp(argv[i], "--rounds") == 0) {
+            rounds = strtoul(argv[i + 1], &end, 10);
+            if (errno != 0 || end == argv[i + 1] || *end != '\0' ||
+                rounds < 1 || rounds > 20) {
+                usage(argv[0]);
+                return EXIT_FAILURE;
+            }
+        } else if (strcmp(argv[i], "--pattern") == 0 &&
+                   (strcmp(argv[i + 1], "random") == 0 ||
+                    strcmp(argv[i + 1], "repeating") == 0)) {
+            random_pattern = strcmp(argv[i + 1], "random") == 0;
+        } else {
+            usage(argv[0]);
+            return EXIT_FAILURE;
+        }
+    }
+    printf("SIMD backend: %d (%s); input throughput MiB/s; pattern=%s\n", backend,
+           backend == 3 ? "AVX2" : backend == 2 ? "NEON" :
+           backend == 1 ? "SSSE3" : "scalar fallback",
+           random_pattern ? "random" : "repeating");
+    puts("bytes operation scalar simd");
+    for (size_t sample = 0; sample < sizeof(sizes) / sizeof(sizes[0]); sample++) {
+        const size_t len = sizes[sample];
+        uint8_t *src = malloc(len);
+        uint8_t *encoded = malloc(len * 2);
+        uint8_t *output = malloc(len * 2);
+        uint32_t state = UINT32_C(0x6d253fb9);
+
+        if (src == NULL || encoded == NULL || output == NULL) {
+            fputs("allocation failed\n", stderr);
+            free(src);
+            free(encoded);
+            free(output);
+            return EXIT_FAILURE;
+        }
+        for (size_t i = 0; i < len; i++) {
+            state ^= state << 13;
+            state ^= state >> 17;
+            state ^= state << 5;
+            src[i] = random_pattern ? (uint8_t)state : 0xfb;
+        }
+        encode(src, len, encoded, 0);
+        encode(src, len, output, 1);
+        if (memcmp(encoded, output, len * 2) != 0) {
+            fputs("SIMD encoding differs from scalar reference\n", stderr);
+            return EXIT_FAILURE;
+        }
+        decode(encoded, len * 2, output, 1);
+        if (memcmp(src, output, len) != 0) {
+            fputs("SIMD decoding differs from scalar reference\n", stderr);
+            return EXIT_FAILURE;
+        }
+        for (unsigned long round = 0; round < rounds; round++) {
+            const double scalar_encode = measure(encode, src, len, output, 0, seconds);
+            const double simd_encode = measure(encode, src, len, output, 1, seconds);
+            const double scalar_decode = measure(decode, encoded, len * 2, output, 0, seconds);
+            const double simd_decode = measure(decode, encoded, len * 2, output, 1, seconds);
+            printf("%zu encode %.1f %.1f\n", len, scalar_encode, simd_encode);
+            printf("%zu decode %.1f %.1f\n", len * 2, scalar_decode, simd_decode);
+        }
+        free(output);
+        free(encoded);
+        free(src);
+    }
+    return EXIT_SUCCESS;
+}
diff --git a/cbits/base16_simd.c b/cbits/base16_simd.c
new file mode 100644
--- /dev/null
+++ b/cbits/base16_simd.c
@@ -0,0 +1,274 @@
+/* Copyright (c) 2026, Emily Pillmore. BSD-3-Clause; see LICENSE. */
+
+#include "base16_simd.h"
+
+/*
+ * Original bounded prefix kernels. Target attributes keep x86 instructions
+ * out of baseline dispatch, with no global ISA flags or mutable state.
+ * HS_BASE16_SIMD_FORCE_SCALAR exercises the unsupported-target fallback.
+ * HS_BASE16_SIMD_FORCE_SSSE3 caps x86 dispatch for older-processor testing.
+ */
+#if !defined(HS_BASE16_SIMD_FORCE_SCALAR)
+#if defined(__aarch64__) && defined(__ARM_NEON) && \
+    (defined(__GNUC__) || defined(__clang__))
+#define HS_BASE16_NEON 1
+#include <arm_neon.h>
+#elif defined(__x86_64__) && (defined(__GNUC__) || defined(__clang__))
+#define HS_BASE16_SSSE3 1
+#include <immintrin.h>
+#define HS_BASE16_TARGET_SSSE3 __attribute__((target("ssse3")))
+#if !defined(HS_BASE16_SIMD_FORCE_SSSE3)
+#define HS_BASE16_AVX2 1
+#define HS_BASE16_TARGET_AVX2 __attribute__((target("avx2")))
+#endif
+#endif
+#endif
+
+#if defined(HS_BASE16_NEON) || defined(HS_BASE16_SSSE3)
+static const uint8_t hs_base16_alphabet[] = "0123456789abcdef";
+#endif
+
+#if defined(HS_BASE16_NEON)
+
+static size_t hs_base16_neon_encode(const uint8_t *src, size_t len, uint8_t *dst)
+{
+    size_t consumed = 0;
+    const uint8x16_t alphabet = vld1q_u8(hs_base16_alphabet);
+    const uint8x16_t mask = vdupq_n_u8(15);
+
+    while (len - consumed >= 16) {
+        const uint8x16_t input = vld1q_u8(src + consumed);
+        uint8x16x2_t output;
+
+        output.val[0] = vqtbl1q_u8(alphabet, vshrq_n_u8(input, 4));
+        output.val[1] = vqtbl1q_u8(alphabet, vandq_u8(input, mask));
+        vst2q_u8(dst, output);
+        consumed += 16;
+        dst += 32;
+    }
+    return consumed;
+}
+
+static inline uint8x16_t hs_base16_neon_nibbles(uint8x16_t input,
+                                               uint8x16_t *valid)
+{
+    const uint8x16_t digit = vsubq_u8(input, vdupq_n_u8('0'));
+    const uint8x16_t letter = vsubq_u8(vorrq_u8(input, vdupq_n_u8(0x20)),
+                                      vdupq_n_u8('a'));
+    const uint8x16_t is_digit = vcleq_u8(digit, vdupq_n_u8(9));
+    const uint8x16_t is_letter = vcleq_u8(letter, vdupq_n_u8(5));
+
+    *valid = vorrq_u8(is_digit, is_letter);
+    return vbslq_u8(is_letter, vaddq_u8(letter, vdupq_n_u8(10)), digit);
+}
+
+static size_t hs_base16_neon_decode(const uint8_t *src, size_t len, uint8_t *dst)
+{
+    size_t consumed = 0;
+
+    while (len - consumed >= 32) {
+        const uint8x16x2_t input = vld2q_u8(src + consumed);
+        uint8x16_t valid_hi, valid_lo;
+        const uint8x16_t hi = hs_base16_neon_nibbles(input.val[0], &valid_hi);
+        const uint8x16_t lo = hs_base16_neon_nibbles(input.val[1], &valid_lo);
+
+        /* Validate the whole block before committing even its first byte. */
+        if (vminvq_u8(vandq_u8(valid_hi, valid_lo)) == 0) {
+            break;
+        }
+        vst1q_u8(dst, vorrq_u8(vshlq_n_u8(hi, 4), lo));
+        consumed += 32;
+        dst += 16;
+    }
+    return consumed;
+}
+
+#elif defined(HS_BASE16_SSSE3)
+
+HS_BASE16_TARGET_SSSE3
+static size_t hs_base16_ssse3_encode(const uint8_t *src, size_t len, uint8_t *dst)
+{
+    size_t consumed = 0;
+    const __m128i alphabet = _mm_loadu_si128((const __m128i *)hs_base16_alphabet);
+    const __m128i mask = _mm_set1_epi8(15);
+
+    while (len - consumed >= 16) {
+        const __m128i input = _mm_loadu_si128((const __m128i *)(src + consumed));
+        const __m128i hi = _mm_shuffle_epi8(alphabet,
+            _mm_and_si128(_mm_srli_epi16(input, 4), mask));
+        const __m128i lo = _mm_shuffle_epi8(alphabet, _mm_and_si128(input, mask));
+
+        _mm_storeu_si128((__m128i *)dst, _mm_unpacklo_epi8(hi, lo));
+        _mm_storeu_si128((__m128i *)(dst + 16), _mm_unpackhi_epi8(hi, lo));
+        consumed += 16;
+        dst += 32;
+    }
+    return consumed;
+}
+
+HS_BASE16_TARGET_SSSE3
+static inline __m128i hs_base16_ssse3_nibbles(__m128i input, __m128i *valid)
+{
+    const __m128i lower = _mm_or_si128(input, _mm_set1_epi8(0x20));
+    const __m128i is_digit = _mm_and_si128(
+        _mm_cmpgt_epi8(input, _mm_set1_epi8('0' - 1)),
+        _mm_cmpgt_epi8(_mm_set1_epi8('9' + 1), input));
+    const __m128i is_letter = _mm_and_si128(
+        _mm_cmpgt_epi8(lower, _mm_set1_epi8('a' - 1)),
+        _mm_cmpgt_epi8(_mm_set1_epi8('f' + 1), lower));
+
+    /* Bytes >= 128 fail the signed lower-bound comparisons in both ranges. */
+    *valid = _mm_or_si128(is_digit, is_letter);
+    return _mm_add_epi8(_mm_and_si128(input, _mm_set1_epi8(15)),
+                        _mm_and_si128(is_letter, _mm_set1_epi8(9)));
+}
+
+HS_BASE16_TARGET_SSSE3
+static size_t hs_base16_ssse3_decode(const uint8_t *src, size_t len, uint8_t *dst)
+{
+    size_t consumed = 0;
+
+    while (len - consumed >= 16) {
+        const __m128i input = _mm_loadu_si128((const __m128i *)(src + consumed));
+        __m128i valid;
+        const __m128i value = hs_base16_ssse3_nibbles(input, &valid);
+        __m128i pairs;
+        __m128i output;
+
+        if (_mm_movemask_epi8(valid) != 0xffff) {
+            break;
+        }
+        /* Adjacent nibbles form unsigned 0..255 words, without saturation. */
+        pairs = _mm_maddubs_epi16(value, _mm_set1_epi16(0x0110));
+        output = _mm_packus_epi16(pairs, _mm_setzero_si128());
+        _mm_storel_epi64((__m128i *)dst, output);
+        consumed += 16;
+        dst += 8;
+    }
+    return consumed;
+}
+
+#if defined(HS_BASE16_AVX2)
+
+HS_BASE16_TARGET_AVX2
+static size_t hs_base16_avx2_encode(const uint8_t *src, size_t len, uint8_t *dst)
+{
+    size_t consumed = 0;
+    const __m256i alphabet = _mm256_broadcastsi128_si256(
+        _mm_loadu_si128((const __m128i *)hs_base16_alphabet));
+    const __m256i mask = _mm256_set1_epi8(15);
+
+    while (len - consumed >= 32) {
+        const __m256i input = _mm256_loadu_si256((const __m256i *)(src + consumed));
+        const __m256i hi = _mm256_shuffle_epi8(alphabet,
+            _mm256_and_si256(_mm256_srli_epi16(input, 4), mask));
+        const __m256i lo = _mm256_shuffle_epi8(alphabet, _mm256_and_si256(input, mask));
+        const __m256i first = _mm256_unpacklo_epi8(hi, lo);
+        const __m256i second = _mm256_unpackhi_epi8(hi, lo);
+
+        /* Unpacks operate within 128-bit lanes; restore original byte order. */
+        _mm256_storeu_si256((__m256i *)dst,
+            _mm256_permute2x128_si256(first, second, 0x20));
+        _mm256_storeu_si256((__m256i *)(dst + 32),
+            _mm256_permute2x128_si256(first, second, 0x31));
+        consumed += 32;
+        dst += 64;
+    }
+    return consumed;
+}
+
+HS_BASE16_TARGET_AVX2
+static size_t hs_base16_avx2_decode(const uint8_t *src, size_t len, uint8_t *dst)
+{
+    size_t consumed = 0;
+
+    while (len - consumed >= 32) {
+        const __m256i input = _mm256_loadu_si256((const __m256i *)(src + consumed));
+        const __m256i lower = _mm256_or_si256(input, _mm256_set1_epi8(0x20));
+        const __m256i is_digit = _mm256_and_si256(
+            _mm256_cmpgt_epi8(input, _mm256_set1_epi8('0' - 1)),
+            _mm256_cmpgt_epi8(_mm256_set1_epi8('9' + 1), input));
+        const __m256i is_letter = _mm256_and_si256(
+            _mm256_cmpgt_epi8(lower, _mm256_set1_epi8('a' - 1)),
+            _mm256_cmpgt_epi8(_mm256_set1_epi8('f' + 1), lower));
+        __m256i value;
+        __m256i pairs;
+        __m128i output;
+
+        if (_mm256_movemask_epi8(_mm256_or_si256(is_digit, is_letter)) != -1) {
+            break;
+        }
+        value = _mm256_add_epi8(_mm256_and_si256(input, _mm256_set1_epi8(15)),
+            _mm256_and_si256(is_letter, _mm256_set1_epi8(9)));
+        pairs = _mm256_maddubs_epi16(value, _mm256_set1_epi16(0x0110));
+        /* Pack both lanes into exactly 16 output bytes, without lane gaps. */
+        output = _mm_packus_epi16(_mm256_castsi256_si128(pairs),
+                                  _mm256_extracti128_si256(pairs, 1));
+        _mm_storeu_si128((__m128i *)dst, output);
+        consumed += 32;
+        dst += 16;
+    }
+    return consumed;
+}
+
+#endif /* HS_BASE16_AVX2 */
+#endif
+
+int hs_base16_simd_backend(void)
+{
+#if defined(HS_BASE16_NEON)
+    return 2;
+#elif defined(HS_BASE16_SSSE3)
+#if defined(HS_BASE16_AVX2)
+    /* The compiler's query also checks OS support for AVX register state. */
+    if (__builtin_cpu_supports("avx2")) {
+        return 3;
+    }
+#endif
+    return __builtin_cpu_supports("ssse3") ? 1 : 0;
+#else
+    return 0;
+#endif
+}
+
+size_t hs_base16_simd_encode(const uint8_t *src, size_t len, uint8_t *dst)
+{
+#if defined(HS_BASE16_NEON)
+    return hs_base16_neon_encode(src, len, dst);
+#elif defined(HS_BASE16_SSSE3)
+    const int backend = hs_base16_simd_backend();
+#if defined(HS_BASE16_AVX2)
+    if (backend == 3) {
+        return hs_base16_avx2_encode(src, len, dst);
+    }
+#endif
+    if (backend == 1) {
+        return hs_base16_ssse3_encode(src, len, dst);
+    }
+#endif
+    (void)src;
+    (void)len;
+    (void)dst;
+    return 0;
+}
+
+size_t hs_base16_simd_decode(const uint8_t *src, size_t len, uint8_t *dst)
+{
+#if defined(HS_BASE16_NEON)
+    return hs_base16_neon_decode(src, len, dst);
+#elif defined(HS_BASE16_SSSE3)
+    const int backend = hs_base16_simd_backend();
+#if defined(HS_BASE16_AVX2)
+    if (backend == 3) {
+        return hs_base16_avx2_decode(src, len, dst);
+    }
+#endif
+    if (backend == 1) {
+        return hs_base16_ssse3_decode(src, len, dst);
+    }
+#endif
+    (void)src;
+    (void)len;
+    (void)dst;
+    return 0;
+}
diff --git a/cbits/base16_simd.h b/cbits/base16_simd.h
new file mode 100644
--- /dev/null
+++ b/cbits/base16_simd.h
@@ -0,0 +1,39 @@
+/* Copyright (c) 2026, Emily Pillmore. BSD-3-Clause; see LICENSE. */
+
+#ifndef HS_BASE16_SIMD_H
+#define HS_BASE16_SIMD_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/*
+ * Process complete vector blocks at the start of a buffer. The caller handles
+ * the remaining input and error reporting. Source and destination must not
+ * overlap; source has len readable bytes and destination has room for the
+ * produced prefix. Loads never extend past len, and stores cover only the
+ * returned prefix. Buffers must remain alive throughout the call.
+ *
+ * Encode emits lowercase ASCII and writes exactly 2 * result bytes.
+ * Decode accepts ASCII 0-9, a-f and A-F; it returns an even count and writes
+ * exactly result / 2 bytes. An entire vector block containing an invalid byte
+ * is left untouched, including its destination, so the caller can report the
+ * first invalid byte. The final incomplete block is also left to the caller.
+ *
+ * Unsupported targets return zero without reading or writing any buffers.
+ * The caller must reject output-size overflow before allocating buffers.
+ */
+size_t hs_base16_simd_encode(const uint8_t *src, size_t len, uint8_t *dst);
+size_t hs_base16_simd_decode(const uint8_t *src, size_t len, uint8_t *dst);
+
+/* 0: scalar fallback; 1: x86-64 SSSE3; 2: ARM64 NEON; 3: x86-64 AVX2. */
+int hs_base16_simd_backend(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HS_BASE16_SIMD_H */
diff --git a/src/Data/Base16/Types.hs b/src/Data/Base16/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Base16/Types.hs
@@ -0,0 +1,83 @@
+{-# language ExplicitNamespaces #-}
+-- |
+-- Module       : Data.Base16.Types
+-- Copyright    : (c) 2019-2026 Emily Pillmore
+-- License      : BSD-style
+--
+-- Maintainer   : Emily Pillmore <emilypi@cohomolo.gy>,
+--                sofia-m-a <https://github.com/sofia-m-a>
+-- Stability    : stable
+-- Portability  : non-portable
+--
+-- This module contains the opaque 'Base16' type and combinators for
+-- working with Base16-encoded values.
+--
+module Data.Base16.Types
+( type Base16
+, assertBase16
+, extractBase16
+) where
+
+
+import Control.DeepSeq (NFData(..))
+import Data.Functor.Classes (Eq1(..), Ord1(..))
+
+-- | Wrap a value that is or is intended to be Base16-encoded.
+--
+-- Use the @parseBase16@ function in the corresponding ByteString or Text
+-- module to validate external input. Encoding functions also produce this
+-- wrapper. 'assertBase16' and the 'Functor', 'Applicative', and 'Monad'
+-- instances do not validate their results.
+--
+newtype Base16 a = Base16 a
+
+instance Eq a => Eq (Base16 a) where
+  Base16 a == Base16 b = a == b
+
+instance Eq1 Base16 where
+  liftEq f (Base16 a) (Base16 b) = f a b
+
+instance Ord a => Ord (Base16 a) where
+  compare (Base16 a) (Base16 b) = compare a b
+
+instance Ord1 Base16 where
+  liftCompare f (Base16 a) (Base16 b) = f a b
+
+instance Functor Base16 where
+  fmap f (Base16 a) = Base16 (f a)
+
+instance Applicative Base16 where
+  pure = Base16
+  Base16 f <*> Base16 a = Base16 (f a)
+
+instance Monad Base16 where
+  return = pure
+  Base16 a >>= k = k a
+
+instance Show a => Show (Base16 a) where
+  show (Base16 a) = show a
+
+instance NFData a => NFData (Base16 a) where
+  rnf (Base16 a) = rnf a
+
+-- | Assert the provenance of a value.
+--
+-- For untrusted input, use the @parseBase16@ function in the corresponding
+-- ByteString or Text module to validate it first.
+--
+-- /Warning/: This is a blind assertion that a particular
+-- value is base16 encoded in some alphabet. If you are not
+-- sure of the provenance of the value, you may experience
+-- odd behavior when attempting to decode. Use at your own
+-- risk. If I see any issues logged on this project from
+-- negligent use of this, Sofia and I will smite you.
+--
+assertBase16 :: a -> Base16 a
+assertBase16 = Base16
+{-# inline assertBase16 #-}
+
+-- | Forget that a particular value is Base16-encoded
+--
+extractBase16 :: Base16 a -> a
+extractBase16 (Base16 a) = a
+{-# inline extractBase16 #-}
diff --git a/src/Data/ByteString/Base16.hs b/src/Data/ByteString/Base16.hs
--- a/src/Data/ByteString/Base16.hs
+++ b/src/Data/ByteString/Base16.hs
@@ -1,8 +1,6 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE Trustworthy #-}
 -- |
 -- Module       : Data.ByteString.Base16
--- Copyright    : (c) 2020 Emily Pillmore
+-- Copyright    : (c) 2020-2026 Emily Pillmore
 -- License      : BSD-style
 --
 -- Maintainer   : Emily Pillmore <emilypi@cohomolo.gy>
@@ -19,22 +17,25 @@
 , encodeBase16'
 , decodeBase16
 , decodeBase16'
+, decodeBase16Untyped
 , decodeBase16Lenient
-, isBase16
-, isValidBase16
+, parseBase16
 ) where
 
 
-import Prelude hiding (all, elem)
-
-import Data.ByteString (ByteString, all, elem)
+import Data.Base16.Types
+import Data.ByteString (ByteString)
 import Data.ByteString.Base16.Internal.Head
-import Data.Either
 import Data.Text (Text)
 import qualified Data.Text.Encoding as T
 
+-- $setup
+--
+-- >>> import Data.Base16.Types
+-- >>> :set -XOverloadedStrings
+--
 
--- | Encode a 'ByteString' value as Base16 'Text' with padding.
+-- | Encode a 'ByteString' value as Base16 'Text'
 --
 -- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
 --
@@ -43,11 +44,11 @@
 -- >>> encodeBase16 "Sun"
 -- "53756e"
 --
-encodeBase16 :: ByteString -> Text
-encodeBase16 = T.decodeUtf8 . encodeBase16'
+encodeBase16 :: ByteString -> Base16 Text
+encodeBase16 = fmap T.decodeUtf8 . encodeBase16'
 {-# INLINE encodeBase16 #-}
 
--- | Encode a 'ByteString' value as a Base16 'ByteString'  value with padding.
+-- | Encode a 'ByteString' value as a Base16 'ByteString' value
 --
 -- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
 --
@@ -56,8 +57,8 @@
 -- >>> encodeBase16' "Sun"
 -- "53756e"
 --
-encodeBase16' :: ByteString -> ByteString
-encodeBase16' = encodeBase16_
+encodeBase16' :: ByteString -> Base16 ByteString
+encodeBase16' = assertBase16 . encodeBase16_
 {-# INLINE encodeBase16' #-}
 
 -- | Decode a Base16-encoded 'ByteString' value.
@@ -66,14 +67,11 @@
 --
 -- === __Examples__:
 --
--- >>> decodeBase16 "53756e"
--- Right "Sun"
---
--- >>> decodeBase16 "6x"
--- Left "invalid character at offset: 1"
+-- >>> decodeBase16 $ assertBase16 "53756e"
+-- "Sun"
 --
-decodeBase16 :: ByteString -> Either Text ByteString
-decodeBase16 = decodeBase16_
+decodeBase16 :: Base16 ByteString -> ByteString
+decodeBase16 = decodeBase16Typed_
 {-# INLINE decodeBase16 #-}
 
 -- | Decode Base16 'Text'.
@@ -82,15 +80,28 @@
 --
 -- === __Examples__:
 --
--- >>> decodeBase16' "53756e"
+-- >>> decodeBase16' $ assertBase16 "53756e"
+-- "Sun"
+--
+decodeBase16' :: Base16 Text -> ByteString
+decodeBase16' = decodeBase16Typed_ . fmap T.encodeUtf8
+{-# INLINE decodeBase16' #-}
+
+-- | Decode an untyped Base16-encoded 'ByteString' value with error-checking.
+--
+-- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
+--
+-- === __Examples__:
+--
+-- >>> decodeBase16Untyped "53756e"
 -- Right "Sun"
 --
--- >>> decodeBase16' "6x"
+-- >>> decodeBase16Untyped "6x"
 -- Left "invalid character at offset: 1"
 --
-decodeBase16' :: Text -> Either Text ByteString
-decodeBase16' = decodeBase16 . T.encodeUtf8
-{-# INLINE decodeBase16' #-}
+decodeBase16Untyped :: ByteString -> Either Text ByteString
+decodeBase16Untyped = decodeBase16_
+{-# INLINE decodeBase16Untyped #-}
 
 -- | Decode a Base16-encoded 'ByteString' value leniently, using a
 -- strategy that never fails
@@ -109,34 +120,28 @@
 decodeBase16Lenient = decodeBase16Lenient_
 {-# INLINE decodeBase16Lenient #-}
 
--- | Tell whether a 'ByteString' value is base16 encoded.
---
--- === __Examples__:
+-- | Validate an untyped Base16 value and retain its encoded representation.
 --
--- >>> isBase16 "666f6"
--- False
+-- Accepts even-length ASCII hexadecimal input, including upper-case and
+-- mixed-case digits. Empty input is valid. The original input is preserved,
+-- and errors match 'decodeBase16Untyped'.
 --
--- >>> isBase16 "666f"
--- True
+-- === __Examples__:
 --
-isBase16 :: ByteString -> Bool
-isBase16 bs = isValidBase16 bs && isRight (decodeBase16 bs)
-{-# INLINE isBase16 #-}
-
--- | Tell whether a 'ByteString' value is a valid Base16 format.
+-- >>> parseBase16 "666f6"
+-- Left "invalid bytestring size"
 --
--- This will not tell you whether or not this is a correct Base16 representation,
--- only that it conforms to the correct alphabet. To check whether it is a true
--- Base16 encoded 'ByteString' value, use 'isBase16'.
+-- >>> parseBase16 "666F"
+-- Right "666F"
 --
--- === __Examples__:
+-- >>> fmap decodeBase16 (parseBase16 "666F")
+-- Right "fo"
 --
--- >>> isValidBase16 "666f+/6"
--- False
+-- >>> parseBase16 "6x"
+-- Left "invalid character at offset: 1"
 --
--- >>> isValidBase16 "666f6"
--- True
+-- @since 1.0
 --
-isValidBase16 :: ByteString -> Bool
-isValidBase16 = all (flip elem "0123456789abcdefABCDEF")
-{-# INLINE isValidBase16 #-}
+parseBase16 :: ByteString -> Either Text (Base16 ByteString)
+parseBase16 bs = assertBase16 bs <$ decodeBase16Untyped bs
+{-# INLINE parseBase16 #-}
diff --git a/src/Data/ByteString/Base16/Internal/Head.hs b/src/Data/ByteString/Base16/Internal/Head.hs
--- a/src/Data/ByteString/Base16/Internal/Head.hs
+++ b/src/Data/ByteString/Base16/Internal/Head.hs
@@ -1,20 +1,19 @@
 {-# LANGUAGE BangPatterns #-}
-{-# LANGUAGE CPP #-}
 {-# LANGUAGE MagicHash #-}
-{-# LANGUAGE MultiWayIf #-}
 {-# LANGUAGE OverloadedStrings #-}
 module Data.ByteString.Base16.Internal.Head
 ( encodeBase16_
 , decodeBase16_
+, decodeBase16Typed_
 , decodeBase16Lenient_
 , encodeBase16Short_
 , decodeBase16Short_
+, decodeBase16ShortTyped_
 , decodeBase16ShortLenient_
 ) where
 
 
-#include "MachDeps.h"
-
+import Data.Base16.Types (Base16, extractBase16)
 import qualified Data.ByteString as BS (empty)
 import Data.ByteString.Internal
 import qualified Data.ByteString.Short as SBS (empty)
@@ -37,19 +36,19 @@
 -- | Head of the base16 encoding loop - marshal data, assemble loops
 --
 encodeBase16_ :: ByteString -> ByteString
-encodeBase16_ (PS !sfp !soff !slen) =
+encodeBase16_ (BS sfp slen) =
     unsafeCreate dlen $ \dptr ->
-      withForeignPtr sfp $ \sptr ->
+      withForeignPtr sfp $ \sptr -> do
         innerLoop
-          (castPtr dptr)
-          (castPtr (plusPtr sptr soff))
-          (plusPtr sptr (soff + slen))
+          dptr
+          sptr
+          (plusPtr sptr slen)
   where
-    !dlen = 2 * slen
+    !dlen = encodedLength slen
 {-# INLINE encodeBase16_ #-}
 
 decodeBase16_ :: ByteString -> Either Text ByteString
-decodeBase16_ (PS !sfp !soff !slen)
+decodeBase16_ (BS sfp slen)
   | slen == 0 = Right BS.empty
   | r /= 0 = Left "invalid bytestring size"
   | otherwise = unsafeDupablePerformIO $ do
@@ -59,26 +58,38 @@
         decodeLoop
           dfp
           dptr
-          (plusPtr sptr soff)
-          (plusPtr sptr (soff + slen))
-          0
+          sptr
+          (plusPtr sptr slen)
+          q
   where
     !q = slen `quot` 2
     !r = slen `rem` 2
 {-# INLINE decodeBase16_ #-}
 
+decodeBase16Typed_ :: Base16 ByteString -> ByteString
+decodeBase16Typed_ encoded =
+  unsafeCreate q $ \dptr ->
+    withForeignPtr sfp $ \sptr ->
+      decodeLoopTyped
+        dptr
+        sptr
+        (plusPtr sptr slen)
+  where
+    BS sfp slen = extractBase16 encoded
+    !q = slen `quot` 2
+{-# INLINE decodeBase16Typed_ #-}
+
 decodeBase16Lenient_ :: ByteString -> ByteString
-decodeBase16Lenient_ (PS !sfp !soff !slen)
-  | slen == 0 = BS.empty
-  | otherwise = unsafeDupablePerformIO $ do
+decodeBase16Lenient_ (BS sfp slen) =
+  unsafeDupablePerformIO $ do
     dfp <- mallocPlainForeignPtrBytes q
     withForeignPtr dfp $ \dptr ->
       withForeignPtr sfp $ \sptr ->
         lenientLoop
           dfp
           dptr
-          (plusPtr sptr soff)
-          (plusPtr sptr (soff + slen))
+          sptr
+          (plusPtr sptr slen)
           0
   where
     !q = slen `quot` 2
@@ -90,11 +101,11 @@
 encodeBase16Short_ :: ShortByteString -> ShortByteString
 encodeBase16Short_ (SBS !ba#) = runShortST $ do
     dst <- newByteArray l'
-    Short.innerLoop l dst (MutableByteArray (unsafeCoerce# ba#))
+    Short.innerLoop l dst (ByteArray ba#)
     unsafeFreezeByteArray dst
   where
     !l = I# (sizeofByteArray# ba#)
-    !l' = l * 2
+    !l' = encodedLength l
 {-# INLINE encodeBase16Short_ #-}
 
 decodeBase16Short_ :: ShortByteString -> Either Text ShortByteString
@@ -103,22 +114,40 @@
     | r /= 0 = Left "invalid bytestring size"
     | otherwise = runDecodeST $ do
       dst <- newByteArray q
-      Short.decodeLoop l dst (MutableByteArray (unsafeCoerce# ba#))
+      Short.decodeLoop l dst (ByteArray ba#)
   where
     !l = I# (sizeofByteArray# ba#)
     !q = l `quot` 2
     !r = l `rem` 2
 {-# INLINE decodeBase16Short_ #-}
 
+decodeBase16ShortTyped_ :: Base16 ShortByteString -> ShortByteString
+decodeBase16ShortTyped_ encoded = runDecodeST' $ do
+    dst <- newByteArray q
+    Short.decodeLoopTyped l dst (ByteArray ba#)
+  where
+    !(SBS ba#) = extractBase16 encoded
+    !l = I# (sizeofByteArray# ba#)
+    !q = l `quot` 2
+{-# INLINE decodeBase16ShortTyped_ #-}
+
+
 decodeBase16ShortLenient_ :: ShortByteString -> ShortByteString
 decodeBase16ShortLenient_ (SBS !ba#)
     | l == 0 = SBS.empty
     | otherwise = runShortST $ do
       dst <- newByteArray q
-      q' <- Short.lenientLoop l dst (MutableByteArray (unsafeCoerce# ba#))
+      q' <- Short.lenientLoop l dst (ByteArray ba#)
       !_ <- resizeMutableByteArray dst q'
       unsafeFreezeByteArray dst
   where
     !l = I# (sizeofByteArray# ba#)
     !q = l `quot` 2
 {-# INLINE decodeBase16ShortLenient_ #-}
+
+-- Check before allocating or passing the output buffer to native code.
+encodedLength :: Int -> Int
+encodedLength n
+    | n > maxBound `quot` 2 = error "base16: encoded output too large"
+    | otherwise = 2 * n
+{-# INLINE encodedLength #-}
diff --git a/src/Data/ByteString/Base16/Internal/SIMD.hs b/src/Data/ByteString/Base16/Internal/SIMD.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/ByteString/Base16/Internal/SIMD.hs
@@ -0,0 +1,80 @@
+{-# LANGUAGE ForeignFunctionInterface #-}
+{-# LANGUAGE MagicHash #-}
+{-# LANGUAGE UnliftedFFITypes #-}
+-- |
+-- Module       : Data.ByteString.Base16.Internal.SIMD
+-- Copyright    : (c) 2026 Emily Pillmore
+-- License      : BSD-style
+--
+-- Maintainer   : Emily Pillmore <emilypi@cohomolo.gy>
+-- Stability    : Experimental
+-- Portability  : portable
+--
+-- Bounded vector prefixes for foreign pointers and byte arrays. Pointer
+-- callers retain both buffers with @withForeignPtr@. Byte-array callers pass
+-- the arrays directly to unsafe FFI calls, during which GHC cannot run GC;
+-- neither pinning nor copying is needed. The kernels retain no pointers and
+-- make no Haskell callbacks. Invalid decode blocks are left untouched so
+-- scalar tails preserve exact error offsets.
+module Data.ByteString.Base16.Internal.SIMD
+( encodePrefix
+, decodePrefix
+, encodeByteArrayPrefix
+, decodeByteArrayPrefix
+) where
+
+import Control.Monad.ST (ST)
+import Control.Monad.ST.Unsafe (unsafeIOToST)
+import Data.Primitive.ByteArray (ByteArray(..), MutableByteArray(..))
+import Data.Word (Word8)
+import Foreign.C.Types (CSize(..))
+import Foreign.Ptr (Ptr)
+import GHC.Exts (ByteArray#, MutableByteArray#)
+
+foreign import ccall unsafe "hs_base16_simd_encode"
+  c_encodePrefix :: Ptr Word8 -> CSize -> Ptr Word8 -> IO CSize
+
+foreign import ccall unsafe "hs_base16_simd_decode"
+  c_decodePrefix :: Ptr Word8 -> CSize -> Ptr Word8 -> IO CSize
+
+foreign import ccall unsafe "hs_base16_simd_encode"
+  c_encodeByteArrayPrefix :: ByteArray# -> CSize -> MutableByteArray# s -> IO CSize
+
+foreign import ccall unsafe "hs_base16_simd_decode"
+  c_decodeByteArrayPrefix :: ByteArray# -> CSize -> MutableByteArray# s -> IO CSize
+
+-- | Encode complete blocks; return consumed input bytes and write twice as
+-- many bytes. The caller must allocate a sufficiently large output buffer.
+encodePrefix :: Ptr Word8 -> Int -> Ptr Word8 -> IO Int
+encodePrefix src len dst
+  | len < 64 = pure 0
+  | otherwise = fromIntegral <$> c_encodePrefix src (fromIntegral len) dst
+{-# INLINE encodePrefix #-}
+
+-- | Decode complete valid blocks, returning an even input byte count and
+-- writing half as many bytes. Small inputs avoid dispatch/foreign-call cost.
+decodePrefix :: Ptr Word8 -> Int -> Ptr Word8 -> IO Int
+decodePrefix src len dst
+  | len < 128 = pure 0
+  | otherwise = fromIntegral <$> c_decodePrefix src (fromIntegral len) dst
+{-# INLINE decodePrefix #-}
+
+-- | Encode a prefix of an immutable byte array into a distinct mutable
+-- destination with room for twice the input length. Return consumed bytes.
+-- The foreign call only reads the source and writes the destination, so it
+-- can be embedded in ST without letting mutable state escape.
+encodeByteArrayPrefix :: ByteArray -> Int -> MutableByteArray s -> ST s Int
+encodeByteArrayPrefix (ByteArray src) len (MutableByteArray dst)
+  | len < 64 = pure 0
+  | otherwise = unsafeIOToST $
+      fromIntegral <$> c_encodeByteArrayPrefix src (fromIntegral len) dst
+{-# INLINE encodeByteArrayPrefix #-}
+
+-- | Decode complete valid blocks into a distinct mutable destination with
+-- room for half the input length. Return an even count of consumed bytes.
+decodeByteArrayPrefix :: ByteArray -> Int -> MutableByteArray s -> ST s Int
+decodeByteArrayPrefix (ByteArray src) len (MutableByteArray dst)
+  | len < 128 = pure 0
+  | otherwise = unsafeIOToST $
+      fromIntegral <$> c_decodeByteArrayPrefix src (fromIntegral len) dst
+{-# INLINE decodeByteArrayPrefix #-}
diff --git a/src/Data/ByteString/Base16/Internal/Utils.hs b/src/Data/ByteString/Base16/Internal/Utils.hs
--- a/src/Data/ByteString/Base16/Internal/Utils.hs
+++ b/src/Data/ByteString/Base16/Internal/Utils.hs
@@ -7,6 +7,7 @@
 , reChunk
 , runShortST
 , runDecodeST
+, runDecodeST'
 ) where
 
 
@@ -71,3 +72,12 @@
     }
   }
 {-# INLINE runDecodeST #-}
+
+runDecodeST'
+    :: (forall s. ST s ByteArray)
+    -> ShortByteString
+runDecodeST' dec = runRW# $ \s0 -> case dec of
+  { ST g -> case g s0 of
+      (# _, ByteArray r #) -> SBS r
+  }
+{-# inline runDecodeST' #-}
diff --git a/src/Data/ByteString/Base16/Internal/W16/Loop.hs b/src/Data/ByteString/Base16/Internal/W16/Loop.hs
--- a/src/Data/ByteString/Base16/Internal/W16/Loop.hs
+++ b/src/Data/ByteString/Base16/Internal/W16/Loop.hs
@@ -6,7 +6,7 @@
 {-# LANGUAGE TypeApplications #-}
 -- |
 -- Module       : Data.ByteString.Base16.Internal.W16.Loop
--- Copyright 	: (c) 2020 Emily Pillmore
+-- Copyright 	: (c) 2020-2026 Emily Pillmore
 -- License	: BSD-style
 --
 -- Maintainer	: Emily Pillmore <emilypi@cohomolo.gy>
@@ -18,6 +18,7 @@
 module Data.ByteString.Base16.Internal.W16.Loop
 ( innerLoop
 , decodeLoop
+, decodeLoopTyped
 , lenientLoop
 ) where
 
@@ -25,6 +26,9 @@
 import Data.Bits
 import Data.ByteString.Internal
 import Data.ByteString.Base16.Internal.Utils
+#ifdef BASE16_SIMD
+import qualified Data.ByteString.Base16.Internal.SIMD as SIMD
+#endif
 import Data.Text (Text)
 import qualified Data.Text as T
 
@@ -42,7 +46,13 @@
     -> Ptr Word8
     -> Ptr Word8
     -> IO ()
-innerLoop !dptr !sptr !end = go dptr sptr
+innerLoop !dptr !sptr !end = do
+#ifdef BASE16_SIMD
+    consumed <- SIMD.encodePrefix sptr (end `minusPtr` sptr) dptr
+    go (plusPtr dptr (2 * consumed)) (plusPtr sptr consumed)
+#else
+    go dptr sptr
+#endif
   where
     !hex = "0123456789abcdef"#
 
@@ -55,7 +65,6 @@
         poke (plusPtr dst 1) (aix (t .&. 0x0f) hex)
 
         go (plusPtr dst 2) (plusPtr src 1)
-{-# INLINE innerLoop #-}
 
 -- | Hex decoding loop optimized for 16-bit architectures
 --
@@ -66,7 +75,13 @@
   -> Ptr Word8
   -> Int
   -> IO (Either Text ByteString)
-decodeLoop !dfp !dptr !sptr !end !nn = go dptr sptr nn
+decodeLoop !dfp !dptr !sptr !end !l = do
+#ifdef BASE16_SIMD
+    consumed <- SIMD.decodePrefix sptr (end `minusPtr` sptr) dptr
+    go (plusPtr dptr (consumed `quot` 2)) (plusPtr sptr consumed)
+#else
+    go dptr sptr
+#endif
   where
     err !src = return . Left . T.pack
       $ "invalid character at offset: "
@@ -76,8 +91,8 @@
 
     !hi = "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x10\x20\x30\x40\x50\x60\x70\x80\x90\xff\xff\xff\xff\xff\xff\xff\xa0\xb0\xc0\xd0\xe0\xf0\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xa0\xb0\xc0\xd0\xe0\xf0\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff"#
 
-    go !dst !src !n
-      | src == end = return (Right (PS dfp 0 n))
+    go !dst !src
+      | src == end = return $ Right (BS dfp l)
       | otherwise = do
         !x <- peek @Word8 src
         !y <- peek @Word8 (plusPtr src 1)
@@ -89,10 +104,42 @@
           | b == 0xff -> err (plusPtr src 1)
           | otherwise -> do
             poke dst (a .|. b)
-            go (plusPtr dst 1) (plusPtr src 2) (n + 1)
+            go (plusPtr dst 1) (plusPtr src 2)
 {-# INLINE decodeLoop #-}
 
+-- | Hex decoding loop optimized for 16-bit architectures
+--
+decodeLoopTyped
+  :: Ptr Word8
+  -> Ptr Word8
+  -> Ptr Word8
+  -> IO ()
+decodeLoopTyped !dptr !sptr !end = do
+#ifdef BASE16_SIMD
+    consumed <- SIMD.decodePrefix sptr (end `minusPtr` sptr) dptr
+    go (plusPtr dptr (consumed `quot` 2)) (plusPtr sptr consumed)
+#else
+    go dptr sptr
+#endif
+  where
+    !lo = "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\xff\xff\xff\xff\xff\xff\xff\x0a\x0b\x0c\x0d\x0e\x0f\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x0a\x0b\x0c\x0d\x0e\x0f\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff"#
 
+    !hi = "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x10\x20\x30\x40\x50\x60\x70\x80\x90\xff\xff\xff\xff\xff\xff\xff\xa0\xb0\xc0\xd0\xe0\xf0\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xa0\xb0\xc0\xd0\xe0\xf0\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff"#
+
+    go !dst !src
+      | src == end = return ()
+      | otherwise = do
+        !x <- peek @Word8 src
+        !y <- peek @Word8 (plusPtr src 1)
+
+        let !a = aix x hi
+            !b = aix y lo
+
+        poke dst (a .|. b)
+        go (plusPtr dst 1) (plusPtr src 2)
+{-# INLINE decodeLoopTyped #-}
+
+
 -- | Lenient lazy hex decoding loop optimized for 16-bit architectures.
 -- When the 'Right' case is returned, a byte
 --
@@ -110,7 +157,7 @@
     !hi = "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x10\x20\x30\x40\x50\x60\x70\x80\x90\xff\xff\xff\xff\xff\xff\xff\xa0\xb0\xc0\xd0\xe0\xf0\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xa0\xb0\xc0\xd0\xe0\xf0\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff"#
 
     goHi !dst !src !n
-      | src == end = return (PS dfp 0 n)
+      | src == end = return (BS dfp n)
       | otherwise = do
         !x <- peek @Word8 src
 
@@ -121,7 +168,7 @@
         else goLo dst (plusPtr src 1) a n
 
     goLo !dst !src !a !n
-      | src == end = return (PS dfp 0 n)
+      | src == end = return (BS dfp n)
       | otherwise = do
         !y <- peek @Word8 src
 
@@ -132,4 +179,4 @@
         else do
           poke dst (a .|. b)
           goHi (plusPtr dst 1) (plusPtr src 1) (n + 1)
-{-# LANGUAGE lenientLoop #-}
+{-# INLINE lenientLoop #-}
diff --git a/src/Data/ByteString/Base16/Internal/W16/ShortLoop.hs b/src/Data/ByteString/Base16/Internal/W16/ShortLoop.hs
--- a/src/Data/ByteString/Base16/Internal/W16/ShortLoop.hs
+++ b/src/Data/ByteString/Base16/Internal/W16/ShortLoop.hs
@@ -1,10 +1,12 @@
 {-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE CPP #-}
 {-# LANGUAGE MagicHash #-}
 {-# LANGUAGE MultiWayIf #-}
 {-# LANGUAGE OverloadedStrings #-}
 module Data.ByteString.Base16.Internal.W16.ShortLoop
 ( innerLoop
 , decodeLoop
+, decodeLoopTyped
 , lenientLoop
 ) where
 
@@ -17,20 +19,30 @@
 import Data.Text (Text)
 import qualified Data.Text as T
 
+#ifdef BASE16_SIMD
+import qualified Data.ByteString.Base16.Internal.SIMD as SIMD
+#endif
 
 innerLoop
     :: Int
     -> MutableByteArray s
-    -> MutableByteArray s
+    -> ByteArray
     -> ST s ()
-innerLoop !slen !dst !src = go (0 :: Int) (0 :: Int)
+innerLoop !slen !dst !src =
+#ifdef BASE16_SIMD
+  do
+    consumed <- SIMD.encodeByteArrayPrefix src slen dst
+    go (2 * consumed) consumed
+#else
+  go 0 0
+#endif
   where
     !hex = "0123456789abcdef"#
 
     go !doff !soff
       | soff == slen = return ()
       | otherwise = do
-        x <- readByteArray src soff
+        let !x = indexByteArray src soff
         writeByteArray dst doff (aix (unsafeShiftR x 4) hex)
         writeByteArray dst (doff + 1) (aix (x .&. 0x0f) hex)
         go (doff + 2) (soff + 1)
@@ -39,9 +51,16 @@
 decodeLoop
     :: Int
     -> MutableByteArray s
-    -> MutableByteArray s
+    -> ByteArray
     -> ST s (Either Text ByteArray)
-decodeLoop !slen !dst !src = go (0 :: Int) (0 :: Int)
+decodeLoop !slen !dst !src =
+#ifdef BASE16_SIMD
+  do
+    consumed <- SIMD.decodeByteArrayPrefix src slen dst
+    go (consumed `quot` 2) consumed
+#else
+  go 0 0
+#endif
   where
     err i = return . Left . T.pack
       $ "invalid character at offset: "
@@ -54,8 +73,8 @@
     go !doff !soff
       | soff == slen = Right <$> unsafeFreezeByteArray dst
       | otherwise = do
-        x <- readByteArray src soff
-        y <- readByteArray src (soff + 1)
+        let !x = indexByteArray src soff
+            !y = indexByteArray src (soff + 1)
 
         let !a = aix x hi
             !b = aix y lo
@@ -68,10 +87,42 @@
             go (doff + 1) (soff + 2)
 {-# inline decodeLoop #-}
 
-lenientLoop
+decodeLoopTyped
     :: Int
     -> MutableByteArray s
+    -> ByteArray
+    -> ST s ByteArray
+decodeLoopTyped !slen !dst !src =
+#ifdef BASE16_SIMD
+  do
+    consumed <- SIMD.decodeByteArrayPrefix src slen dst
+    go (consumed `quot` 2) consumed
+#else
+  go 0 0
+#endif
+  where
+    !lo = "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\xff\xff\xff\xff\xff\xff\xff\x0a\x0b\x0c\x0d\x0e\x0f\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x0a\x0b\x0c\x0d\x0e\x0f\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff"#
+
+    !hi = "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x10\x20\x30\x40\x50\x60\x70\x80\x90\xff\xff\xff\xff\xff\xff\xff\xa0\xb0\xc0\xd0\xe0\xf0\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xa0\xb0\xc0\xd0\xe0\xf0\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff"#
+
+    go !doff !soff
+      | soff == slen = unsafeFreezeByteArray dst
+      | otherwise = do
+        let !x = indexByteArray src soff
+            !y = indexByteArray src (soff + 1)
+
+        let !a = aix x hi
+            !b = aix y lo
+
+        writeByteArray dst doff (a .|. b)
+        go (doff + 1) (soff + 2)
+{-# inline decodeLoopTyped #-}
+
+
+lenientLoop
+    :: Int
     -> MutableByteArray s
+    -> ByteArray
     -> ST s Int
 lenientLoop !slen !dst !src = goHi (0 :: Int) (0 :: Int)
   where
@@ -82,7 +133,7 @@
     goHi !doff !soff
       | soff == slen = return doff
       | otherwise = do
-        x <- readByteArray src soff
+        let !x = indexByteArray src soff
 
         let !a = aix x hi
 
@@ -93,7 +144,7 @@
     goLo !doff !soff !a
       | soff == slen = return doff
       | otherwise = do
-        y <- readByteArray src soff
+        let !y = indexByteArray src soff
 
         let !b = aix y lo
 
diff --git a/src/Data/ByteString/Lazy/Base16.hs b/src/Data/ByteString/Lazy/Base16.hs
--- a/src/Data/ByteString/Lazy/Base16.hs
+++ b/src/Data/ByteString/Lazy/Base16.hs
@@ -1,8 +1,7 @@
 {-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE Trustworthy #-}
 -- |
 -- Module       : Data.ByteString.Lazy.Base16
--- Copyright    : (c) 2020 Emily Pillmore
+-- Copyright    : (c) 2020-2026 Emily Pillmore
 -- License      : BSD-style
 --
 -- Maintainer   : Emily Pillmore <emilypi@cohomolo.gy>
@@ -19,27 +18,33 @@
 , encodeBase16'
 , decodeBase16
 , decodeBase16'
+, decodeBase16Untyped
 , decodeBase16Lenient
-, isBase16
-, isValidBase16
+, parseBase16
 ) where
 
 
 
-import Prelude hiding (all, elem)
+import Prelude hiding (elem)
 
+import Data.Base16.Types
 import qualified Data.ByteString as B
-import Data.ByteString.Lazy (all, elem, fromChunks, toChunks)
+import Data.ByteString.Lazy (elem, fromChunks, fromStrict, toChunks, toStrict)
 import Data.ByteString.Lazy.Internal (ByteString(..))
 import qualified Data.ByteString.Base16.Internal.Head as B16
+import qualified Data.ByteString.Base16 as B16 (decodeBase16Untyped, parseBase16)
 import Data.ByteString.Base16.Internal.Utils (reChunk)
-import Data.Either
 import Data.Text.Lazy (Text)
 import qualified Data.Text as T
 import qualified Data.Text.Lazy.Encoding as TL
 
+-- $setup
+--
+-- >>> import Data.Base16.Types
+-- >>> :set -XOverloadedStrings
+--
 
--- | Encode a lazy 'ByteString' value as Base16 'Text' with padding.
+-- | Encode a lazy 'ByteString' value as Base16 'Text'
 --
 -- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
 --
@@ -48,11 +53,11 @@
 -- >>> encodeBase16 "Sun"
 -- "53756e"
 --
-encodeBase16 :: ByteString -> Text
-encodeBase16 = TL.decodeUtf8 . encodeBase16'
+encodeBase16 :: ByteString -> Base16 Text
+encodeBase16 = fmap TL.decodeUtf8 . encodeBase16'
 {-# INLINE encodeBase16 #-}
 
--- | Encode a lazy 'ByteString' value as a Base16 'ByteString'  value with padding.
+-- | Encode a lazy 'ByteString' value as a Base16 'ByteString' value
 --
 -- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
 --
@@ -61,45 +66,66 @@
 -- >>> encodeBase16' "Sun"
 -- "53756e"
 --
-encodeBase16' :: ByteString -> ByteString
-encodeBase16' Empty = Empty
-encodeBase16' (Chunk b bs) = Chunk (B16.encodeBase16_ b) (encodeBase16' bs)
+encodeBase16' :: ByteString -> Base16 ByteString
+encodeBase16' bs = assertBase16 $ case bs of
+    Empty -> Empty
+    Chunk b bs' -> Chunk (B16.encodeBase16_ b) (extractBase16 $ encodeBase16' bs')
 {-# INLINE encodeBase16' #-}
 
--- | Decode a padded Base16-encoded lazy 'ByteString' value.
+-- | Decode a Base16-encoded lazy 'ByteString' value.
 --
+-- Hexadecimal byte pairs may cross chunk boundaries. Decoding remains lazy
+-- in the input chunks.
+--
 -- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
 --
 -- === __Examples__:
 --
--- >>> decodeBase16 "53756e"
--- Right "Sun"
---
--- >>> decodeBase16 "6x"
--- Left "invalid character at offset: 1"
+-- >>> decodeBase16 $ assertBase16 "53756e"
+-- "Sun"
 --
-decodeBase16 :: ByteString -> Either T.Text ByteString
-decodeBase16 Empty = Right Empty
-decodeBase16 (Chunk b bs) = Chunk <$> B16.decodeBase16_ b <*> decodeBase16 bs
+decodeBase16 :: Base16 ByteString -> ByteString
+decodeBase16 = fromChunks
+  . fmap (B16.decodeBase16Typed_ . assertBase16)
+  . reChunk
+  . toChunks
+  . extractBase16
 {-# INLINE decodeBase16 #-}
 
--- | Decode Base16 'Text'.
+-- | Decode a Base16-encoded 'Text' value.
 --
 -- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
 --
 -- === __Examples__:
 --
--- >>> decodeBase16' "53756e"
+-- >>> decodeBase16' $ assertBase16 "53756e"
+-- "Sun"
+--
+decodeBase16' :: Base16 Text -> ByteString
+decodeBase16' = decodeBase16 . fmap TL.encodeUtf8
+{-# INLINE decodeBase16' #-}
+
+-- | Decode an untyped Base16-encoded lazy 'ByteString' value.
+--
+-- Checks the whole input, joining chunks before decoding so that lengths
+-- and error offsets refer to the complete encoded value.
+--
+-- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
+--
+-- === __Examples__:
+--
+-- >>> decodeBase16Untyped "53756e"
 -- Right "Sun"
 --
--- >>> decodeBase16' "6x"
+-- >>> decodeBase16Untyped "6x"
 -- Left "invalid character at offset: 1"
 --
-decodeBase16' :: Text -> Either T.Text ByteString
-decodeBase16' = decodeBase16 . TL.encodeUtf8
-{-# INLINE decodeBase16' #-}
+decodeBase16Untyped :: ByteString -> Either T.Text ByteString
+decodeBase16Untyped = fmap fromStrict . B16.decodeBase16Untyped . toStrict
+{-# INLINE decodeBase16Untyped #-}
 
--- | Decode a Base16-encoded 'ByteString' value leniently, using a
+
+-- | Decode an untyped Base16-encoded 'ByteString' value leniently, using a
 -- strategy that never fails
 --
 -- N.B.: this is not RFC 4648-compliant. It may give you garbage if you're not careful!
@@ -120,34 +146,31 @@
   . toChunks
 {-# INLINE decodeBase16Lenient #-}
 
--- | Tell whether a lazy 'ByteString' value is base16 encoded.
+-- | Validate an untyped Base16 value and retain its encoded representation.
 --
--- === __Examples__:
+-- Accepts even-length ASCII hexadecimal input, including upper-case and
+-- mixed-case digits. Empty input is valid. The original input is preserved,
+-- and errors match 'decodeBase16Untyped'.
 --
--- >>> isBase16 "666f6"
--- False
+-- The whole input is validated before returning 'Right', regardless of
+-- chunk boundaries. The returned wrapper preserves those boundaries.
 --
--- >>> isBase16 "666f"
--- True
+-- === __Examples__:
 --
-isBase16 :: ByteString -> Bool
-isBase16 bs = isValidBase16 bs && isRight (decodeBase16 bs)
-{-# INLINE isBase16 #-}
-
--- | Tell whether a lazy 'ByteString' value is a valid Base16 format.
+-- >>> parseBase16 "666f6"
+-- Left "invalid bytestring size"
 --
--- This will not tell you whether or not this is a correct Base16 representation,
--- only that it conforms to the correct alphabet. To check whether it is a true
--- Base16 encoded 'ByteString' value, use 'isBase16'.
+-- >>> parseBase16 "666F"
+-- Right "666F"
 --
--- === __Examples__:
+-- >>> fmap decodeBase16 (parseBase16 "666F")
+-- Right "fo"
 --
--- >>> isValidBase16 "666f+/6"
--- False
+-- >>> parseBase16 "6x"
+-- Left "invalid character at offset: 1"
 --
--- >>> isValidBase16 "666f6"
--- True
+-- @since 2.0
 --
-isValidBase16 :: ByteString -> Bool
-isValidBase16 = all (flip elem "0123456789abcdefABCDEF")
-{-# INLINE isValidBase16 #-}
+parseBase16 :: ByteString -> Either T.Text (Base16 ByteString)
+parseBase16 bs = assertBase16 bs <$ B16.parseBase16 (toStrict bs)
+{-# INLINE parseBase16 #-}
diff --git a/src/Data/ByteString/Short/Base16.hs b/src/Data/ByteString/Short/Base16.hs
--- a/src/Data/ByteString/Short/Base16.hs
+++ b/src/Data/ByteString/Short/Base16.hs
@@ -1,8 +1,6 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE Trustworthy #-}
 -- |
 -- Module       : Data.ByteString.Short.Base16
--- Copyright    : (c) 2020 Emily Pillmore
+-- Copyright    : (c) 2020-2026 Emily Pillmore
 -- License      : BSD-style
 --
 -- Maintainer   : Emily Pillmore <emilypi@cohomolo.gy>
@@ -19,21 +17,26 @@
 , encodeBase16'
 , decodeBase16
 , decodeBase16'
+, decodeBase16Untyped
 , decodeBase16Lenient
-, isBase16
-, isValidBase16
+, parseBase16
 ) where
 
 
+import Data.Base16.Types
 import Data.ByteString.Short
 import Data.ByteString.Base16.Internal.Head
-import qualified Data.ByteString.Base16 as B16
 import Data.Text (Text)
 import Data.Text.Short
 import Data.Text.Short.Unsafe
 
+-- $setup
+--
+-- >>> import Data.Base16.Types
+-- >>> :set -XOverloadedStrings
+--
 
--- | Encode a 'ShortByteString' value as Base16 'ShortText' with padding.
+-- | Encode a 'ShortByteString' value as Base16 'ShortText'
 --
 -- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
 --
@@ -44,11 +47,11 @@
 --
 -- @since 0.3.0.0
 --
-encodeBase16 :: ShortByteString -> ShortText
-encodeBase16 = fromShortByteStringUnsafe . encodeBase16'
+encodeBase16 :: ShortByteString -> Base16 ShortText
+encodeBase16 = fmap fromShortByteStringUnsafe . encodeBase16'
 {-# INLINE encodeBase16 #-}
 
--- | Encode a 'ShortByteString' value as a Base16 'ShortByteString'  value with padding.
+-- | Encode a 'ShortByteString' value as a Base16 'ShortByteString'  value
 --
 -- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
 --
@@ -57,8 +60,8 @@
 -- >>> encodeBase16' "Sun"
 -- "53756e"
 --
-encodeBase16' :: ShortByteString -> ShortByteString
-encodeBase16' = encodeBase16Short_
+encodeBase16' :: ShortByteString -> Base16 ShortByteString
+encodeBase16' = assertBase16 . encodeBase16Short_
 {-# INLINE encodeBase16' #-}
 
 -- | Decode a Base16-encoded 'ShortByteString' value.
@@ -67,33 +70,43 @@
 --
 -- === __Examples__:
 --
--- >>> decodeBase16 "53756e"
--- Right "Sun"
---
--- >>> decodeBase16 "6x"
--- Left "invalid character at offset: 1"
+-- >>> decodeBase16 $ assertBase16 "53756e"
+-- "Sun"
 --
-decodeBase16 :: ShortByteString -> Either Text ShortByteString
-decodeBase16 = decodeBase16Short_
+decodeBase16 :: Base16 ShortByteString -> ShortByteString
+decodeBase16 = decodeBase16ShortTyped_
 {-# INLINE decodeBase16 #-}
 
--- | Decode Base16 'Text'.
+-- | Decode Base16-encoded 'ShortText' value.
 --
 -- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
 --
 -- === __Examples__:
 --
--- >>> decodeBase16' "53756e"
+-- >>> decodeBase16' $ assertBase16 "53756e"
+-- "Sun"
+--
+decodeBase16' :: Base16 ShortText -> ShortByteString
+decodeBase16' = decodeBase16 . fmap toShortByteString
+{-# INLINE decodeBase16' #-}
+
+-- | Decode an untyped Base16-encoded 'ByteString' value with error-checking.
+--
+-- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
+--
+-- === __Examples__:
+--
+-- >>> decodeBase16Untyped "53756e"
 -- Right "Sun"
 --
--- >>> decodeBase16' "6x"
+-- >>> decodeBase16Untyped "6x"
 -- Left "invalid character at offset: 1"
 --
-decodeBase16' :: ShortText -> Either Text ShortByteString
-decodeBase16' = decodeBase16 . toShortByteString
-{-# INLINE decodeBase16' #-}
+decodeBase16Untyped :: ShortByteString -> Either Text ShortByteString
+decodeBase16Untyped = decodeBase16Short_
+{-# INLINE decodeBase16Untyped #-}
 
--- | Decode a Base16-encoded 'ShortByteString' value leniently, using a
+-- | Decode an untyped Base16-encoded 'ShortByteString' value leniently, using a
 -- strategy that never fails
 --
 -- N.B.: this is not RFC 4648-compliant
@@ -111,34 +124,28 @@
 decodeBase16Lenient = decodeBase16ShortLenient_
 {-# INLINE decodeBase16Lenient #-}
 
--- | Tell whether a 'ShortByteString' value is base16 encoded.
---
--- === __Examples__:
+-- | Validate an untyped Base16 value and retain its encoded representation.
 --
--- >>> isBase16 "666f6"
--- False
+-- Accepts even-length ASCII hexadecimal input, including upper-case and
+-- mixed-case digits. Empty input is valid. The original input is preserved,
+-- and errors match 'decodeBase16Untyped'.
 --
--- >>> isBase16 "666f"
--- True
+-- === __Examples__:
 --
-isBase16 :: ShortByteString -> Bool
-isBase16 = B16.isBase16 . fromShort
-{-# INLINE isBase16 #-}
-
--- | Tell whether a 'ShortByteString' value is a valid Base16 format.
+-- >>> parseBase16 "666f6"
+-- Left "invalid bytestring size"
 --
--- This will not tell you whether or not this is a correct Base16 representation,
--- only that it conforms to the correct alphabet. To check whether it is a true
--- Base16 encoded 'ShortByteString' value, use 'isBase16'.
+-- >>> parseBase16 "666F"
+-- Right "666F"
 --
--- === __Examples__:
+-- >>> fmap decodeBase16 (parseBase16 "666F")
+-- Right "fo"
 --
--- >>> isValidBase16 "666f+/6"
--- False
+-- >>> parseBase16 "6x"
+-- Left "invalid character at offset: 1"
 --
--- >>> isValidBase16 "666f6"
--- True
+-- @since 2.0
 --
-isValidBase16 :: ShortByteString -> Bool
-isValidBase16 = B16.isValidBase16 . fromShort
-{-# INLINE isValidBase16 #-}
+parseBase16 :: ShortByteString -> Either Text (Base16 ShortByteString)
+parseBase16 bs = assertBase16 bs <$ decodeBase16Untyped bs
+{-# INLINE parseBase16 #-}
diff --git a/src/Data/Text/Encoding/Base16.hs b/src/Data/Text/Encoding/Base16.hs
--- a/src/Data/Text/Encoding/Base16.hs
+++ b/src/Data/Text/Encoding/Base16.hs
@@ -1,7 +1,6 @@
-{-# LANGUAGE Safe #-}
 -- |
 -- Module       : Data.Text.Encoding.Base16
--- Copyright    : (c) 2020 Emily Pillmore
+-- Copyright    : (c) 2020-2026 Emily Pillmore
 -- License      : BSD-style
 --
 -- Maintainer   : Emily Pillmore <emilypi@cohomolo.gy>
@@ -16,23 +15,29 @@
 module Data.Text.Encoding.Base16
 ( encodeBase16
 , decodeBase16
+, decodeBase16Untyped
 , decodeBase16With
 , decodeBase16Lenient
-, isBase16
-, isValidBase16
+, parseBase16
 ) where
 
 
+import Data.Base16.Types
 import Data.Bifunctor (first)
 import Data.ByteString (ByteString)
 import qualified Data.ByteString.Base16 as B16
+import Data.Char (chr)
 import Data.Text (Text)
-import qualified Data.Text as T
 import Data.Text.Encoding.Base16.Error (Base16Error(..))
 import qualified Data.Text.Encoding as T
 
+-- $setup
+--
+-- >>> import Data.Base16.Types
+-- >>> :set -XOverloadedStrings
+--
 
--- | Encode a 'Text' value in Base16 with padding.
+-- | Encode a 'Text' value in Base16.
 --
 -- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
 --
@@ -41,7 +46,7 @@
 -- >>> encodeBase16 "Sun"
 -- "53756e"
 --
-encodeBase16 :: Text -> Text
+encodeBase16 :: Text -> Base16 Text
 encodeBase16 = B16.encodeBase16 . T.encodeUtf8
 {-# INLINE encodeBase16 #-}
 
@@ -51,17 +56,30 @@
 --
 -- === __Examples__:
 --
--- >>> decodeBase16 "53756e"
+-- >>> decodeBase16 $ assertBase16 "53756e"
+-- "Sun"
+--
+decodeBase16 :: Base16 Text -> Text
+decodeBase16 = T.decodeUtf8 . B16.decodeBase16 . fmap T.encodeUtf8
+{-# INLINE decodeBase16 #-}
+
+-- | Decode an untyped Base16-encoded 'Text' value.
+--
+-- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
+--
+-- === __Examples__:
+--
+-- >>> decodeBase16Untyped "53756e"
 -- Right "Sun"
 --
--- >>> decodeBase16 "6x"
+-- >>> decodeBase16Untyped "6x"
 -- Left "invalid character at offset: 1"
 --
-decodeBase16 :: Text -> Either T.Text Text
-decodeBase16 = fmap T.decodeLatin1 . B16.decodeBase16 . T.encodeUtf8
-{-# INLINE decodeBase16 #-}
+decodeBase16Untyped :: Text -> Either Text Text
+decodeBase16Untyped = fmap T.decodeUtf8 . B16.decodeBase16Untyped . T.encodeUtf8
+{-# INLINE decodeBase16Untyped #-}
 
--- | Attempt to decode a 'Text' value as Base16, converting from
+-- | Attempt to decode an untyped 'Text' value as Base16, converting from
 -- 'ByteString' to 'Text' according to some encoding function. In practice,
 -- This is something like 'decodeUtf8'', which may produce an error.
 --
@@ -82,15 +100,18 @@
     -> ByteString
       -- ^ Input to decode
     -> Either (Base16Error err) Text
-decodeBase16With f t = case B16.decodeBase16 t of
-  Left de -> Left $ DecodeError de
-  Right a -> first ConversionError (f a)
+decodeBase16With f t = case B16.decodeBase16Untyped t of
+    Left de -> Left $ DecodeError de
+    Right a -> first ConversionError (f a)
 {-# INLINE decodeBase16With #-}
 
--- | Decode a Base16-encoded 'Text' value leniently, using a
--- strategy that never fails, catching unicode exceptions raised in the
--- process of converting to text values.
+-- | Decode an untyped Base16-encoded 'Text' value leniently.
 --
+-- Non-hexadecimal characters and a final unpaired hex digit are ignored.
+-- Valid UTF-8 sequences in the decoded bytes are preserved. Each invalid
+-- UTF-8 byte is interpreted as its Latin-1 character (U+0000 through U+00FF),
+-- so malformed input does not raise Unicode exceptions.
+--
 -- N.B.: this is not RFC 4648-compliant.
 --
 -- === __Examples__:
@@ -101,38 +122,43 @@
 -- >>> decodeBase16Lenient "6x6x"
 -- "f"
 --
+-- >>> decodeBase16Lenient "df" == "\223"
+-- True
+--
+-- >>> decodeBase16Lenient "c39f" == "\223"
+-- True
+--
 decodeBase16Lenient :: Text -> Text
-decodeBase16Lenient = T.decodeLatin1 . B16.decodeBase16Lenient . T.encodeUtf8
+decodeBase16Lenient = T.decodeUtf8With (\_ -> fmap (chr . fromIntegral))
+  . B16.decodeBase16Lenient
+  . T.encodeUtf8
 {-# INLINE decodeBase16Lenient #-}
 
--- | Tell whether a 'Text' value is Base16-encoded.
+-- | Validate an untyped Base16 value and retain its encoded representation.
 --
--- === __Examples__:
+-- Accepts even-length ASCII hexadecimal input, including upper-case and
+-- mixed-case digits. Empty input is valid. The original input is preserved,
+-- and errors match 'decodeBase16Untyped'.
 --
--- >>> isBase16 "666f6"
--- False
+-- This validates the Base16 encoding, not the UTF-8 validity of its decoded
+-- payload. Use 'decodeBase16With' with a checked conversion for text decoding.
 --
--- >>> isBase16 "666f"
--- True
+-- === __Examples__:
 --
-isBase16 :: Text -> Bool
-isBase16 = B16.isBase16 . T.encodeUtf8
-{-# INLINE isBase16 #-}
-
--- | Tell whether a 'Text' value is a valid Base16 format.
+-- >>> parseBase16 "666f6"
+-- Left "invalid bytestring size"
 --
--- This will not tell you whether or not this is a correct Base16 representation,
--- only that it conforms to the correct shape. To check whether it is a true
--- Base16 encoded 'Text' value, use 'isBase16'.
+-- >>> parseBase16 "666F"
+-- Right "666F"
 --
--- === __Examples__:
+-- >>> fmap decodeBase16 (parseBase16 "666F")
+-- Right "fo"
 --
--- >>> isValidBase16 "666f+/6"
--- False
+-- >>> parseBase16 "6x"
+-- Left "invalid character at offset: 1"
 --
--- >>> isValidBase16 "666f6"
--- True
+-- @since 2.0
 --
-isValidBase16 :: Text -> Bool
-isValidBase16 = B16.isValidBase16 . T.encodeUtf8
-{-# INLINE isValidBase16 #-}
+parseBase16 :: Text -> Either Text (Base16 Text)
+parseBase16 bs = assertBase16 bs <$ B16.parseBase16 (T.encodeUtf8 bs)
+{-# INLINE parseBase16 #-}
diff --git a/src/Data/Text/Encoding/Base16/Error.hs b/src/Data/Text/Encoding/Base16/Error.hs
--- a/src/Data/Text/Encoding/Base16/Error.hs
+++ b/src/Data/Text/Encoding/Base16/Error.hs
@@ -1,8 +1,7 @@
 {-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE Safe #-}
 -- |
 -- Module       : Data.Text.Encoding.Base16.Error
--- Copyright    : (c) 2019 Emily Pillmore
+-- Copyright    : (c) 2019-2026 Emily Pillmore
 -- License      : BSD-style
 --
 -- Maintainer   : Emily Pillmore <emilypi@cohomolo.gy>
diff --git a/src/Data/Text/Lazy/Encoding/Base16.hs b/src/Data/Text/Lazy/Encoding/Base16.hs
--- a/src/Data/Text/Lazy/Encoding/Base16.hs
+++ b/src/Data/Text/Lazy/Encoding/Base16.hs
@@ -1,7 +1,6 @@
-{-# LANGUAGE Safe #-}
 -- |
--- Module       : Data.Text.Encoding.Base16.Lazy
--- Copyright    : (c) 2020 Emily Pillmore
+-- Module       : Data.Text.Lazy.Encoding.Base16
+-- Copyright    : (c) 2020-2026 Emily Pillmore
 -- License      : BSD-style
 --
 -- Maintainer   : Emily Pillmore <emilypi@cohomolo.gy>
@@ -16,23 +15,30 @@
 module Data.Text.Lazy.Encoding.Base16
 ( encodeBase16
 , decodeBase16
+, decodeBase16Untyped
 , decodeBase16With
 , decodeBase16Lenient
-, isBase16
-, isValidBase16
+, parseBase16
 ) where
 
 
+import Data.Base16.Types
 import Data.Bifunctor (first)
 import Data.ByteString.Lazy (ByteString)
 import qualified Data.ByteString.Lazy.Base16 as B16L
+import Data.Char (chr)
 import qualified Data.Text as T
 import Data.Text.Encoding.Base16.Error (Base16Error(..))
 import Data.Text.Lazy (Text)
 import qualified Data.Text.Lazy.Encoding as TL
 
+-- $setup
+--
+-- >>> import Data.Base16.Types
+-- >>> :set -XOverloadedStrings
+--
 
--- | Encode a lazy 'Text' value in Base16 with padding.
+-- | Encode a lazy 'Text' value in Base16.
 --
 -- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
 --
@@ -41,7 +47,7 @@
 -- >>> encodeBase16 "Sun"
 -- "53756e"
 --
-encodeBase16 :: Text -> Text
+encodeBase16 :: Text -> Base16 Text
 encodeBase16 = B16L.encodeBase16 . TL.encodeUtf8
 {-# INLINE encodeBase16 #-}
 
@@ -51,17 +57,30 @@
 --
 -- === __Examples__:
 --
--- >>> decodeBase16 "53756e"
+-- >>> decodeBase16 $ assertBase16 "53756e"
+-- "Sun"
+--
+decodeBase16 :: Base16 Text -> Text
+decodeBase16 = TL.decodeUtf8 . B16L.decodeBase16 . fmap TL.encodeUtf8
+{-# INLINE decodeBase16 #-}
+
+-- | Decode an untyped Base16-encoded lazy 'Text' value.
+--
+-- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
+--
+-- === __Examples__:
+--
+-- >>> decodeBase16Untyped "53756e"
 -- Right "Sun"
 --
--- >>> decodeBase16 "6x"
+-- >>> decodeBase16Untyped "6x"
 -- Left "invalid character at offset: 1"
 --
-decodeBase16 :: Text -> Either T.Text Text
-decodeBase16 = fmap TL.decodeLatin1 . B16L.decodeBase16 . TL.encodeUtf8
-{-# INLINE decodeBase16 #-}
+decodeBase16Untyped :: Text -> Either T.Text Text
+decodeBase16Untyped = fmap TL.decodeUtf8 . B16L.decodeBase16Untyped . TL.encodeUtf8
+{-# INLINE decodeBase16Untyped #-}
 
--- | Attempt to decode a lazy 'Text' value as Base16, converting from
+-- | Attempt to decode an untyped lazy 'Text' value as Base16, converting from
 -- 'ByteString' to 'Text' according to some encoding function. In practice,
 -- This is something like 'decodeUtf8'', which may produce an error.
 --
@@ -80,19 +99,20 @@
     -> ByteString
       -- ^ Input to decode
     -> Either (Base16Error err) Text
-decodeBase16With f t = case B16L.decodeBase16 t of
-  Left de -> Left $ DecodeError de
-  Right a -> first ConversionError (f a)
+decodeBase16With f t = case B16L.decodeBase16Untyped t of
+    Left de -> Left $ DecodeError de
+    Right a -> first ConversionError $ f a
 {-# INLINE decodeBase16With #-}
 
--- | Decode a Base16-encoded lazy 'Text' value leniently, using a
--- strategy that never fails.
+-- | Decode a Base16-encoded lazy 'Text' value leniently.
 --
--- /Warning/: in the conversion to unicode text, exceptions may be thrown.
--- Please use 'decodeBase16'' if you are unsure if you are working with
--- base16-encoded values, or if you expect garbage.
+-- Non-hexadecimal characters and a final unpaired hex digit are ignored.
+-- Valid UTF-8 sequences are preserved, including across chunk boundaries.
+-- Each invalid UTF-8 byte is interpreted as its Latin-1 character (U+0000
+-- through U+00FF), so malformed input does not raise Unicode exceptions.
+-- Decoding remains lazy in the input chunks.
 --
--- N.B.: this is not RFC 4648-compliant. It may give you garbage if you're not careful!
+-- N.B.: this is not RFC 4648-compliant.
 --
 -- === __Examples__:
 --
@@ -103,38 +123,39 @@
 -- "f"
 --
 decodeBase16Lenient :: Text -> Text
-decodeBase16Lenient = TL.decodeLatin1 . B16L.decodeBase16Lenient . TL.encodeUtf8
+decodeBase16Lenient = TL.decodeUtf8With (\_ -> fmap (chr . fromIntegral))
+  . B16L.decodeBase16Lenient
+  . TL.encodeUtf8
 {-# INLINE decodeBase16Lenient #-}
 
--- | Tell whether a lazy 'Text' value is Base16-encoded.
+-- | Validate an untyped Base16 value and retain its encoded representation.
 --
--- === __Examples__:
+-- Accepts even-length ASCII hexadecimal input, including upper-case and
+-- mixed-case digits. Empty input is valid. The original input is preserved,
+-- and errors match 'decodeBase16Untyped'.
 --
--- >>> isBase16 "666f6"
--- False
+-- The whole input is validated before returning 'Right', regardless of
+-- chunk boundaries. The returned wrapper preserves those boundaries.
 --
--- >>> isBase16 "666f"
--- True
+-- This validates the Base16 encoding, not the UTF-8 validity of its decoded
+-- payload. Use 'decodeBase16With' with a checked conversion for text decoding.
 --
-isBase16 :: Text -> Bool
-isBase16 = B16L.isBase16 . TL.encodeUtf8
-
-{-# INLINE isBase16 #-}
-
--- | Tell whether a lazy 'Text' value is a valid Base16 format.
+-- === __Examples__:
 --
--- This will not tell you whether or not this is a correct Base16 representation,
--- only that it conforms to the correct shape. To check whether it is a true
--- Base16 encoded 'Text' value, use 'isBase16'.
+-- >>> parseBase16 "666f6"
+-- Left "invalid bytestring size"
 --
--- === __Examples__:
+-- >>> parseBase16 "666F"
+-- Right "666F"
 --
--- >>> isValidBase16 "666f+/6"
--- False
+-- >>> fmap decodeBase16 (parseBase16 "666F")
+-- Right "fo"
 --
--- >>> isValidBase16 "666f6"
--- True
+-- >>> parseBase16 "6x"
+-- Left "invalid character at offset: 1"
 --
-isValidBase16 :: Text -> Bool
-isValidBase16 = B16L.isValidBase16 . TL.encodeUtf8
-{-# INLINE isValidBase16 #-}
+-- @since 2.0
+--
+parseBase16 :: Text -> Either T.Text (Base16 Text)
+parseBase16 bs = assertBase16 bs <$ B16L.parseBase16 (TL.encodeUtf8 bs)
+{-# INLINE parseBase16 #-}
diff --git a/src/Data/Text/Short/Encoding/Base16.hs b/src/Data/Text/Short/Encoding/Base16.hs
--- a/src/Data/Text/Short/Encoding/Base16.hs
+++ b/src/Data/Text/Short/Encoding/Base16.hs
@@ -1,7 +1,6 @@
-{-# LANGUAGE Trustworthy #-}
 -- |
 -- Module       : Data.Text.Short.Encoding.Base16
--- Copyright    : (c) 2020 Emily Pillmore
+-- Copyright    : (c) 2020-2026 Emily Pillmore
 -- License      : BSD-style
 --
 -- Maintainer   : Emily Pillmore <emilypi@cohomolo.gy>
@@ -16,24 +15,30 @@
 module Data.Text.Short.Encoding.Base16
 ( encodeBase16
 , decodeBase16
+, decodeBase16Untyped
 , decodeBase16With
 , decodeBase16Lenient
-, isBase16
-, isValidBase16
+, parseBase16
 ) where
 
 
+import Data.Base16.Types
 import Data.Bifunctor (first)
-import qualified Data.ByteString.Base16 as B16
 import Data.ByteString.Short
 import qualified Data.ByteString.Short.Base16 as BS16
 import Data.Text (Text)
+import qualified Data.Text.Encoding.Base16 as T16
 import Data.Text.Encoding.Base16.Error
 import Data.Text.Short
 import Data.Text.Short.Unsafe
 
+-- $setup
+--
+-- >>> import Data.Base16.Types
+-- >>> :set -XOverloadedStrings
+--
 
--- | Encode a 'ShortText' value in Base16 with padding.
+-- | Encode a 'ShortText' value in Base16.
 --
 -- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
 --
@@ -42,29 +47,45 @@
 -- >>> encodeBase16 "Sun"
 -- "53756e"
 --
-encodeBase16 :: ShortText -> ShortText
+encodeBase16 :: ShortText -> Base16 ShortText
 encodeBase16 = BS16.encodeBase16 . toShortByteString
 {-# INLINE encodeBase16 #-}
 
+
 -- | Decode a Base16-encoded 'ShortText' value.
 --
 -- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
 --
 -- === __Examples__:
 --
--- >>> decodeBase16 "53756e"
+-- >>> decodeBase16 $ assertBase16 "53756e"
+-- "Sun"
+--
+decodeBase16 :: Base16 ShortText -> ShortText
+decodeBase16 =  fromShortByteStringUnsafe
+  . BS16.decodeBase16
+  . fmap toShortByteString
+{-# INLINE decodeBase16 #-}
+
+-- | Decode an untyped Base16-encoded 'ShortText' value.
+--
+-- See: <https://tools.ietf.org/html/rfc4648#section-8 RFC-4648 section 8>
+--
+-- === __Examples__:
+--
+-- >>> decodeBase16Untyped "53756e"
 -- Right "Sun"
 --
--- >>> decodeBase16 "6x"
+-- >>> decodeBase16Untyped "6x"
 -- Left "invalid character at offset: 1"
 --
-decodeBase16 :: ShortText -> Either Text ShortText
-decodeBase16 =  fmap fromShortByteStringUnsafe
-  . BS16.decodeBase16
+decodeBase16Untyped :: ShortText -> Either Text ShortText
+decodeBase16Untyped =  fmap fromShortByteStringUnsafe
+  . BS16.decodeBase16Untyped
   . toShortByteString
-{-# INLINE decodeBase16 #-}
+{-# INLINE decodeBase16Untyped #-}
 
--- | Attempt to decode a 'ShortText' value as Base16, converting from
+-- | Attempt to decode an untyped 'ShortText' value as Base16, converting from
 -- 'ByteString' to 'ShortText' according to some encoding function. In practice,
 -- This is something like 'decodeUtf8'', which may produce an error.
 --
@@ -85,15 +106,18 @@
     -> ShortByteString
       -- ^ Input to decode
     -> Either (Base16Error err) ShortText
-decodeBase16With f t = case BS16.decodeBase16 t of
-  Left de -> Left $ DecodeError de
-  Right a -> first ConversionError (f a)
+decodeBase16With f t = case BS16.decodeBase16Untyped t of
+    Left de -> Left $ DecodeError de
+    Right a -> first ConversionError $ f a
 {-# INLINE decodeBase16With #-}
 
--- | Decode a Base16-encoded 'ShortText' value leniently, using a
--- strategy that never fails, catching unicode exceptions raised in the
--- process of converting to text values.
+-- | Decode an untyped Base16-encoded 'ShortText' value leniently.
 --
+-- Non-hexadecimal characters and a final unpaired hex digit are ignored.
+-- Valid UTF-8 sequences are preserved. Each invalid UTF-8 byte is interpreted
+-- as its Latin-1 character (U+0000 through U+00FF), ensuring the result is
+-- valid 'ShortText' without raising Unicode exceptions.
+--
 -- N.B.: this is not RFC 4648-compliant.
 --
 -- === __Examples__:
@@ -105,39 +129,34 @@
 -- "f"
 --
 decodeBase16Lenient :: ShortText -> ShortText
-decodeBase16Lenient = fromShortByteStringUnsafe
-  . BS16.decodeBase16Lenient
-  . toShortByteString
+decodeBase16Lenient = fromText . T16.decodeBase16Lenient . toText
 {-# INLINE decodeBase16Lenient #-}
 
--- | Tell whether a 'ShortText' value is Base16-encoded.
+-- | Validate an untyped Base16 value and retain its encoded representation.
 --
--- === __Examples__:
+-- Accepts even-length ASCII hexadecimal input, including upper-case and
+-- mixed-case digits. Empty input is valid. The original input is preserved,
+-- and errors match 'decodeBase16Untyped'.
 --
--- >>> isBase16 "666f6"
--- False
+-- This validates the Base16 encoding, not the UTF-8 validity of its decoded
+-- payload. Use 'decodeBase16With' with a checked conversion for text decoding.
 --
--- >>> isBase16 "666f"
--- True
+-- === __Examples__:
 --
-isBase16 :: ShortText -> Bool
-isBase16 = B16.isBase16 . toByteString
-{-# INLINE isBase16 #-}
-
--- | Tell whether a 'ShortText' value is a valid Base16 format.
+-- >>> parseBase16 "666f6"
+-- Left "invalid bytestring size"
 --
--- This will not tell you whether or not this is a correct Base16 representation,
--- only that it conforms to the correct shape. To check whether it is a true
--- Base16 encoded 'ShortText' value, use 'isBase16'.
+-- >>> parseBase16 "666F"
+-- Right "666F"
 --
--- === __Examples__:
+-- >>> fmap decodeBase16 (parseBase16 "666F")
+-- Right "fo"
 --
--- >>> isValidBase16 "666f+/6"
--- False
+-- >>> parseBase16 "6x"
+-- Left "invalid character at offset: 1"
 --
--- >>> isValidBase16 "666f6"
--- True
+-- @since 2.0
 --
-isValidBase16 :: ShortText -> Bool
-isValidBase16 = B16.isValidBase16 . toByteString
-{-# INLINE isValidBase16 #-}
+parseBase16 :: ShortText -> Either Text (Base16 ShortText)
+parseBase16 bs = assertBase16 bs <$ BS16.parseBase16 (toShortByteString bs)
+{-# INLINE parseBase16 #-}
diff --git a/test/Internal.hs b/test/Internal.hs
--- a/test/Internal.hs
+++ b/test/Internal.hs
@@ -1,14 +1,12 @@
 {-# LANGUAGE AllowAmbiguousTypes #-}
-{-# LANGUAGE BangPatterns #-}
 {-# LANGUAGE DataKinds #-}
-{-# LANGUAGE EmptyDataDecls #-}
 {-# LANGUAGE FunctionalDependencies #-}
 {-# LANGUAGE KindSignatures #-}
 {-# LANGUAGE PackageImports #-}
 {-# OPTIONS_GHC -fno-warn-orphans #-}
 -- |
 -- Module       : Main
--- Copyright    : (c) 2020 Emily Pillmore
+-- Copyright    : (c) 2020-2026 Emily Pillmore
 -- License      : BSD-style
 --
 -- Maintainer   : Emily Pillmore <emilypi@cohomolo.gy>
@@ -20,6 +18,7 @@
 module Internal where
 
 
+import Data.Base16.Types
 import qualified Data.ByteString as BS
 import qualified Data.ByteString.Char8 as BS8
 import qualified Data.ByteString.Lazy as LBS
@@ -84,73 +83,73 @@
   where
 
   label :: String
-  encode :: bs -> bs
+  encode :: bs -> Base16 bs
+  decodeTyped :: Base16 bs -> bs
   decode :: bs -> Either Text bs
   lenient :: bs -> bs
+  parse :: bs -> Either Text (Base16 bs)
 
   lower :: bs -> bs
-  correct :: bs -> Bool
-  validate :: bs -> Bool
 
 
 instance Harness 'B16 BS.ByteString where
   label = "ByteString"
 
   encode = B16.encodeBase16'
-  decode = B16.decodeBase16
+  decodeTyped = B16.decodeBase16
+  decode = B16.decodeBase16Untyped
   lenient = B16.decodeBase16Lenient
-  correct = B16.isBase16
-  validate = B16.isValidBase16
+  parse = B16.parseBase16
   lower = BS8.map toLower
 
 instance Harness 'LB16 LBS.ByteString where
   label = "Lazy ByteString"
 
   encode = LB16.encodeBase16'
-  decode = LB16.decodeBase16
+  decodeTyped = LB16.decodeBase16
+  decode = LB16.decodeBase16Untyped
   lenient = LB16.decodeBase16Lenient
-  correct = LB16.isBase16
-  validate = LB16.isValidBase16
+  parse = LB16.parseBase16
   lower = LBS8.map toLower
 
 instance Harness 'SB16 SBS.ShortByteString where
   label = "Short ByteString"
 
   encode = SB16.encodeBase16'
-  decode = SB16.decodeBase16
+  decodeTyped = SB16.decodeBase16
+  decode = SB16.decodeBase16Untyped
   lenient = SB16.decodeBase16Lenient
-  correct = SB16.isBase16
-  validate = SB16.isValidBase16
+  parse = SB16.parseBase16
   lower = SBS.toShort . BS8.map toLower . SBS.fromShort
 
 instance Harness 'T16 Text where
   label = "Text"
 
   encode = T16.encodeBase16
-  decode = T16.decodeBase16
+  decodeTyped = T16.decodeBase16
+  decode = T16.decodeBase16Untyped
   lenient = T16.decodeBase16Lenient
-  correct = T16.isBase16
-  validate = T16.isValidBase16
+  parse = T16.parseBase16
   lower = T.map toLower
 
 instance Harness 'TL16 TL.Text where
   label = "Lazy Text"
 
   encode = TL16.encodeBase16
-  decode = TL16.decodeBase16
+  decodeTyped = TL16.decodeBase16
+  decode = TL16.decodeBase16Untyped
   lenient = TL16.decodeBase16Lenient
-  correct = TL16.isBase16
-  validate = TL16.isValidBase16
+  parse = TL16.parseBase16
   lower = TL.map toLower
 
 instance Harness 'TS16 TS.ShortText where
   label = "Short Text"
 
   encode = TS16.encodeBase16
-  decode = TS16.decodeBase16
+  decodeTyped = TS16.decodeBase16
+  decode = TS16.decodeBase16Untyped
   lenient = TS16.decodeBase16Lenient
-  correct = TS16.isBase16
-  validate = TS16.isValidBase16
+  parse = TS16.parseBase16
   lower = TS.fromText . T.map toLower . TS.toText
 
 class Harness a cs
@@ -192,12 +191,12 @@
     coarbitrary = coarbitrary . SBS.unpack
 
 instance Arbitrary T.Text where
-    arbitrary = T.pack <$> arbitrary
-    shrink xs = T.pack <$> shrink (T.unpack xs)
+    arbitrary = T.pack . getUnicodeString <$> arbitrary
+    shrink xs = T.pack . getUnicodeString <$> shrink (UnicodeString $ T.unpack xs)
 
 instance Arbitrary TL.Text where
-    arbitrary = TL.pack <$> arbitrary
-    shrink xs = TL.pack <$> shrink (TL.unpack xs)
+    arbitrary = TL.pack . getUnicodeString <$> arbitrary
+    shrink xs = TL.pack . getUnicodeString <$> shrink (UnicodeString $ TL.unpack xs)
 
 instance CoArbitrary T.Text where
     coarbitrary = coarbitrary . T.unpack
@@ -207,7 +206,7 @@
 
 instance Arbitrary TS.ShortText where
   arbitrary = TS.fromText <$> arbitrary
-  shrink xs = fmap TS.fromText $ shrink (TS.toText xs)
+  shrink xs = TS.fromText <$> shrink (TS.toText xs)
 
 instance CoArbitrary TS.ShortText where
   coarbitrary = coarbitrary . TS.toText
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -1,4 +1,3 @@
-{-# LANGUAGE MultiWayIf #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE PackageImports #-}
 {-# LANGUAGE AllowAmbiguousTypes #-}
@@ -6,7 +5,7 @@
 {-# LANGUAGE TypeApplications #-}
 -- |
 -- Module       : Main
--- Copyright    : (c) 2020 Emily Pillmore
+-- Copyright    : (c) 2020-2026 Emily Pillmore
 -- License      : BSD-style
 --
 -- Maintainer   : Emily Pillmore <emilypi@cohomolo.gy>
@@ -21,26 +20,37 @@
 ) where
 
 
+import Control.Monad (forM_)
+import Data.Base16.Types
 import Data.Bifunctor (second)
 import qualified Data.ByteString as BS
-import Data.ByteString.Internal (c2w)
 import qualified Data.ByteString.Lazy as LBS
 import qualified Data.ByteString.Short as SBS
-import "base16" Data.ByteString.Base16 as B16
+import qualified "base16" Data.ByteString.Base16 as B16
+import qualified "base16" Data.ByteString.Lazy.Base16 as LB16
+import qualified "base16" Data.ByteString.Short.Base16 as SB16
 import qualified "base16-bytestring" Data.ByteString.Base16 as Bos
+import Data.Char (chr)
+import Data.Either (isLeft, isRight)
+import qualified Data.List as L
+import Data.String (fromString)
 import qualified Data.Text as T
 import qualified Data.Text.Encoding as T
+import qualified "base16" Data.Text.Encoding.Base16 as T16
 import Data.Text.Encoding.Base16.Error (Base16Error(..))
 import qualified Data.Text.Lazy as TL
 import qualified Data.Text.Lazy.Encoding as TL
+import qualified "base16" Data.Text.Lazy.Encoding.Base16 as TL16
 import qualified Data.Text.Short as TS
-import Data.Word
+import qualified "base16" Data.Text.Short.Encoding.Base16 as TS16
 
 import Internal
+import qualified Simd
 
 import Test.Tasty
 import Test.Tasty.HUnit
 import Test.Tasty.QuickCheck (testProperty)
+import Test.QuickCheck (Positive(..))
 
 
 main :: IO ()
@@ -77,8 +87,32 @@
     , mkDecodeTree
       (second TS.fromText . T.decodeUtf8' . SBS.fromShort) sb16
     ]
+  , testCase "parseBase16 accepts exactly the ASCII hex alphabet" $
+    assertBool "accepts invalid hex" $
+      isRight (B16.parseBase16 valids) && all (isLeft . B16.parseBase16) invalids
+  , prop_bos_coherence
+  , parserRegressions
+  , lenientTextRegressions
+  , Simd.tests
   ]
 
+valids :: BS.ByteString
+valids = fromString hexChars
+
+hexChars :: String
+hexChars = "0123456789abcdefABCDEF"
+
+-- This is to verify EVERY non-hex char is "not valid"
+-- The bytestrings have the same character twice, so that
+-- the parser does not reject the input
+-- because of the odd number of bytes.
+invalids :: [BS.ByteString]
+invalids = fromString <$> separate8s
+  where
+    allWord8 = chr <$> [0..255]
+    nonHex = L.filter (`L.notElem` hexChars) allWord8
+    separate8s = (\c -> [c,c]) <$> nonHex
+
 -- ---------------------------------------------------------------- --
 -- Test tree generation
 
@@ -109,8 +143,7 @@
 mkPropTree :: forall a b proxy. Harness a b => proxy a -> TestTree
 mkPropTree = mkTests "Property Tests"
   [ prop_roundtrip
-  , prop_correctness
-  , const prop_bos_coherence
+  , prop_untyped_correctness
   ]
 
 -- | Make unit tests for a given harness instance
@@ -122,6 +155,7 @@
   -> TestTree
 mkUnitTree = mkTests "Unit tests"
   [ rfcVectors
+  , parserVectors
   , lenientTests
   ]
 
@@ -146,18 +180,24 @@
 
 prop_roundtrip :: forall a b proxy. Harness a b => proxy a -> TestTree
 prop_roundtrip _ = testGroup "prop_roundtrip"
-  [ testProperty "prop_std_roundtrip" $ \(bs :: b) ->
-      Right (encode bs) == decode (encode (encode bs))
+  [ testProperty "prop_std_roundtrip_typed" $ \(bs :: b) ->
+      bs == decodeTyped (encode bs)
+  , testProperty "prop_std_roundtrip_untyped" $ \(bs :: b) ->
+      Right bs
+        == decode (extractBase16 $ encode bs)
   , testProperty "prop_std_lenient_roundtrip" $ \(bs :: b) ->
-      encode bs == lenient (encode (encode bs))
+      bs == lenient (extractBase16 $ encode bs)
   ]
 
-prop_correctness :: forall a b proxy. Harness a b => proxy a -> TestTree
-prop_correctness _ = testGroup "prop_validity"
-  [ testProperty "prop_std_valid" $ \(bs :: b) ->
-    validate (encode bs)
-  , testProperty "prop_std_correct" $ \(bs :: b) ->
-    correct (encode bs)
+prop_untyped_correctness :: forall a b proxy. Harness a b => proxy a -> TestTree
+prop_untyped_correctness _ = testGroup "prop_validity"
+  [ testProperty "parsing an encoding preserves the encoded representation" $ \(bs :: b) ->
+      let encoded = encode bs
+      in Right encoded == parse (extractBase16 encoded)
+  , testProperty "successful parsing preserves its input" $ \(candidate :: b) ->
+      case parse candidate of
+        Left _ -> True
+        Right encoded -> extractBase16 encoded == candidate
   ]
 
 -- | just a sanity check against `base16-bytestring`
@@ -165,13 +205,160 @@
 prop_bos_coherence :: TestTree
 prop_bos_coherence = testGroup "prop_bos_coherence"
   [ testProperty "prop_std_bos_coherence" $ \bs ->
-      Right bs == B16.decodeBase16 (B16.encodeBase16' bs)
-      && Right bs == Bos.decode (Bos.encode bs)
+      let encoded = Bos.encode bs
+      in encoded == extractBase16 (B16.encodeBase16' bs)
+        && Right bs == B16.decodeBase16Untyped encoded
+        && bs == B16.decodeBase16 (assertBase16 encoded)
   ]
 
 -- ---------------------------------------------------------------- --
 -- Unit tests
 
+parserVectors :: forall a b proxy. Harness a b => proxy a -> TestTree
+parserVectors _ = testGroup "Parser contract"
+  [ testCase "accepts empty, upper-case, lower-case and mixed-case encodings" $
+      forM_ ["", "00", "0123456789aAbBcCdDeEfF", "ff", "FF"] $ \input ->
+        Right input @=? fmap extractBase16 (parse @a input)
+  , testCase "odd length takes precedence over invalid characters" $
+      forM_ ["0", "z", "z00", "0z0"] $ \input ->
+        Left "invalid bytestring size" @=? parse @a input
+  , testCase "reports the first invalid character" $
+      forM_ [("gg", 0 :: Int), ("0z", 1), ("00z0", 2), ("00zz", 2)] $
+        \(input, offset) ->
+          Left (invalidAt offset) @=? parse @a input
+  ]
+
+parserRegressions :: TestTree
+parserRegressions = testGroup "Parser and lazy chunk regressions"
+  [ testProperty "strict byte parsing and untyped decoding agree on arbitrary input" $
+      \candidate ->
+        fmap B16.decodeBase16 (B16.parseBase16 candidate)
+          == B16.decodeBase16Untyped candidate
+  , testProperty "short byte parsing and untyped decoding agree on arbitrary input" $
+      \candidate ->
+        fmap SB16.decodeBase16 (SB16.parseBase16 candidate)
+          == SB16.decodeBase16Untyped candidate
+  , testProperty "lazy errors and decoding are independent of arbitrary chunk boundaries" $
+      \candidate sizes ->
+        let chunked = LBS.fromChunks (chunksBy sizes candidate)
+            parsed = fmap (LBS.toStrict . extractBase16) (LB16.parseBase16 chunked)
+        in parsed == fmap extractBase16 (B16.parseBase16 candidate)
+          && fmap LBS.toStrict (LB16.decodeBase16Untyped chunked)
+            == B16.decodeBase16Untyped candidate
+  , testProperty "parsing preserves lazy byte chunks and typed decoding joins split pairs" $
+      \raw sizes ->
+        let chunked = LBS.fromChunks (chunksBy sizes (Bos.encode raw))
+        in fmap (LBS.toChunks . extractBase16) (LB16.parseBase16 chunked)
+              == Right (LBS.toChunks chunked)
+            && LB16.decodeBase16 (assertBase16 chunked) == LBS.fromStrict raw
+  , testProperty "parsing preserves lazy text chunks and typed decoding joins split pairs" $
+      \raw sizes ->
+        let encoded = Bos.encode (T.encodeUtf8 raw)
+            chunked = TL.fromChunks (map T.decodeUtf8 (chunksBy sizes encoded))
+        in fmap (TL.toChunks . extractBase16) (TL16.parseBase16 chunked)
+              == Right (TL.toChunks chunked)
+            && TL16.decodeBase16 (assertBase16 chunked) == TL.fromStrict raw
+  , testCase "every byte split preserves case and decodes pairs crossing a chunk" $
+      forM_ ["", "fF", "aAbBcCdDeEfF", Bos.encode (BS.pack [0 .. 255])] $ \encoded ->
+        forM_ [0 .. BS.length encoded] $ \split -> do
+          let chunked = LBS.fromChunks [BS.take split encoded, BS.drop split encoded]
+              note = "encoded=" ++ show encoded ++ ", split=" ++ show split
+          assertEqual (note ++ ": parse") (Right (LBS.toChunks chunked))
+            (fmap (LBS.toChunks . extractBase16) (LB16.parseBase16 chunked))
+          expected <- either assertFailure pure (Bos.decode encoded)
+          assertEqual (note ++ ": typed decode") expected
+            (LBS.toStrict (LB16.decodeBase16 (assertBase16 chunked)))
+          assertEqual (note ++ ": untyped decode") (Right expected)
+            (fmap LBS.toStrict (LB16.decodeBase16Untyped chunked))
+  , testCase "every text split preserves chunks and decodes Unicode payloads" $ do
+      let raw = "a\x00e9\x03bb\x1f642"
+          encoded = T.decodeUtf8 (Bos.encode (T.encodeUtf8 raw))
+      forM_ [0 .. T.length encoded] $ \split -> do
+        let chunked = TL.fromChunks [T.take split encoded, T.drop split encoded]
+        Right (TL.toChunks chunked)
+          @=? fmap (TL.toChunks . extractBase16) (TL16.parseBase16 chunked)
+        TL.fromStrict raw @=? TL16.decodeBase16 (assertBase16 chunked)
+        Right (TL.fromStrict raw) @=? TL16.decodeBase16Untyped chunked
+  , testCase "lazy invalid offsets are global and odd total length has priority" $
+      forM_ [("66z0", invalidAt 2), ("666f0zz0", invalidAt 5),
+             ("z00", "invalid bytestring size")] $ \(input, expected) ->
+        forM_ [0 .. BS.length input] $ \split -> do
+          let chunked = LBS.fromChunks [BS.take split input, BS.drop split input]
+              text = TL.fromChunks (map T.decodeUtf8 (LBS.toChunks chunked))
+          Left expected @=? LB16.parseBase16 chunked
+          Left expected @=? LB16.decodeBase16Untyped chunked
+          Left expected @=? TL16.parseBase16 text
+          Left expected @=? TL16.decodeBase16Untyped text
+  , testCase "text parsing checks encoded bytes without decoding invalid UTF-8 payloads" $
+      forM_ ["ff", "c0af", "eda080", "f4908080"] $ \input -> do
+        Right input @=? fmap extractBase16 (T16.parseBase16 input)
+        Right (TS.fromText input) @=? fmap extractBase16 (TS16.parseBase16 (TS.fromText input))
+        let chunked = TL.fromChunks (T.chunksOf 1 input)
+        Right (TL.toChunks chunked)
+          @=? fmap (TL.toChunks . extractBase16) (TL16.parseBase16 chunked)
+  , testCase "text diagnostics use UTF-8 byte lengths and reject non-ASCII hex lookalikes" $
+      forM_ [("\x00e9", invalidAt 0), ("0\x20ac", invalidAt 1),
+             ("00\x1f4a9", invalidAt 2), ("\xff10\xff11", invalidAt 0),
+             ("f\x03bb", "invalid bytestring size"),
+             ("\x00e9\&0", "invalid bytestring size")] $ \(input, expected) -> do
+        Left expected @=? T16.parseBase16 input
+        Left expected @=? TS16.parseBase16 (TS.fromText input)
+        Left expected @=? TL16.parseBase16 (TL.fromChunks (T.chunksOf 1 input))
+  ]
+
+invalidAt :: Int -> T.Text
+invalidAt offset = "invalid character at offset: " <> T.pack (show offset)
+
+lenientTextRegressions :: TestTree
+lenientTextRegressions = testGroup "Lenient text Unicode regressions"
+  [ testCase "issue 32: Latin-1 byte df decodes to sharp s" $
+      checkText "0014df1047454e53" "\NUL\DC4\xdf\DLEGENS"
+  , testCase "each isolated byte has a defined character result" $
+      forM_ [0 .. 255] $ \byte ->
+        checkText (T.decodeUtf8 (Bos.encode (BS.singleton byte)))
+          (T.singleton (chr (fromIntegral byte)))
+  , testCase "valid UTF-8 survives beside invalid and truncated sequences" $
+      forM_ [("c3a9dff09f9982", "\xe9\xdf\x1f642"),
+             ("e282", "\xe2\x82"),
+             ("e228a1", "\xe2(\xa1"),
+             ("c0af", "\xc0\xaf"),
+             ("eda080", "\xed\xa0\x80"),
+             ("f4908080", "\xf4\x90\x80\x80"),
+             ("c3a9#d!f0", "\xe9\xdf")] $ \(input, expected) ->
+        checkText input expected
+  , testCase "lazy decoding keeps UTF-8 sequences across every hex split" $
+      forM_ [("0014df1047454e53", "\NUL\DC4\xdf\DLEGENS"),
+             ("c3a9dff09f9982e282", "\xe9\xdf\x1f642\xe2\x82")] $ \(input, expected) ->
+        forM_ [0 .. T.length input] $ \split -> do
+          let chunked = TL.fromChunks [T.take split input, T.drop split input]
+          expected @=? TL.toStrict (TL16.decodeBase16Lenient chunked)
+  , testProperty "arbitrary decoded bytes yield valid UTF-8 in all text variants" $
+      \raw ->
+        let input = T.decodeUtf8 (Bos.encode raw)
+            strict = T16.decodeBase16Lenient input
+            lazy = TL.toStrict (TL16.decodeBase16Lenient (TL.fromChunks (T.chunksOf 1 input)))
+            short = TS16.decodeBase16Lenient (TS.fromText input)
+        in Right strict == T.decodeUtf8' (T.encodeUtf8 strict)
+          && strict == lazy
+          && Right strict == T.decodeUtf8' (TS.toByteString short)
+  ]
+  where
+    checkText input expected = do
+      expected @=? T16.decodeBase16Lenient input
+      expected @=? TL.toStrict (TL16.decodeBase16Lenient (TL.fromStrict input))
+      let short = TS16.decodeBase16Lenient (TS.fromText input)
+      -- Validate the underlying bytes, not merely an unsafe text wrapper.
+      Right expected @=? T.decodeUtf8' (TS.toByteString short)
+
+-- Vary chunk boundaries independently of the payload, retaining a final chunk
+-- when the generated sizes run out. The bound keeps generated chunks useful.
+chunksBy :: [Positive Int] -> BS.ByteString -> [BS.ByteString]
+chunksBy _ bytes | BS.null bytes = []
+chunksBy [] bytes = [bytes]
+chunksBy (Positive size : sizes) bytes =
+  let (chunk, rest) = BS.splitAt (1 + size `mod` 97) bytes
+  in chunk : chunksBy sizes rest
+
 rfcVectors :: forall a b proxy. Harness a b => proxy a -> TestTree
 rfcVectors _ = testGroup "RFC 4648 Test Vectors"
     [ testGroup "lower-case"
@@ -205,12 +392,19 @@
   where
     testCaseB16 s t =
       testCaseSteps (show $ if s == "" then "empty" else s) $ \step -> do
+
         step "encode is sound"
-        lower t @=? encode @a s
+        lower t @=? extractBase16 (encode @a s)
 
         step "decode is sound"
-        Right s @=? decode (encode s)
+        s @=? decodeTyped (assertBase16 t)
 
+        step "parse preserves the supplied case"
+        Right t @=? fmap extractBase16 (parse @a t)
+
+        step "decodeUntyped is sound"
+        Right s @=? decode (extractBase16 $ encode s)
+
 -- | Unit test trees for the `decode*With` family of text-valued functions
 --
 decodeWithVectors
@@ -233,7 +427,7 @@
         Left (DecodeError _) -> return ()
         _ -> assertFailure "decoding phase"
     , testCase "decodeWith valid utf8 inputs on decodeUtf8" $ do
-      case decodeWith_ @a utf8 (encode @t "\1079743") of
+      case decodeWith_ @a utf8 (extractBase16 $ encode @t "\1079743") of
         Left (ConversionError _) -> return ()
         _ -> assertFailure "conversion phase"
     ]
@@ -258,7 +452,7 @@
   where
     testCaseB16 s t =
       testCaseSteps (show $ if s == "" then "empty" else s) $ \step -> do
-        let t0 = decode (encode @a s)
+        let t0 = decode (extractBase16 $ encode @a s)
             t1 = lenient @a t
 
         step "compare decoding"
diff --git a/test/Simd.hs b/test/Simd.hs
new file mode 100644
--- /dev/null
+++ b/test/Simd.hs
@@ -0,0 +1,215 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE PackageImports #-}
+{-# LANGUAGE MagicHash #-}
+-- |
+-- Module       : Simd
+-- Copyright    : (c) 2026 Emily Pillmore
+-- License      : BSD-style
+--
+-- Maintainer   : Emily Pillmore <emilypi@cohomolo.gy>
+-- Stability    : Experimental
+-- Portability  : portable
+--
+-- The same public API tests exercise scalar and SIMD builds. Expected bytes
+-- come from an independent codec, so matching encoder and decoder mistakes
+-- cannot cancel out as they could in roundtrip-only tests.
+module Simd (tests) where
+
+import Control.Concurrent (forkIO, killThread, threadDelay)
+import Control.Exception (bracket)
+import Control.Monad (forM_, forever)
+import Data.Base16.Types (assertBase16, extractBase16)
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Char8 as BS8
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.ByteString.Short as SBS
+import qualified Data.ByteString.Short.Internal as SBSI
+import qualified "base16" Data.ByteString.Base16 as B16
+import qualified "base16" Data.ByteString.Short.Base16 as S16
+import qualified "base16-bytestring" Data.ByteString.Base16 as Bos
+import Data.Char (toUpper)
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as T
+import qualified Data.Text.Lazy as TL
+import qualified Data.Text.Short as TS
+import Data.Word (Word8)
+import GHC.Exts (isByteArrayPinned#, isTrue#)
+import Internal (Harness(..), b16, lb16, sb16, t16, tl16, ts16)
+import System.Mem (performGC)
+import Test.QuickCheck (arbitrary, choose, conjoin, forAll, vectorOf, (===))
+import Test.Tasty (TestTree, localOption, testGroup)
+import Test.Tasty.HUnit (Assertion, assertEqual, testCase)
+import Test.Tasty.QuickCheck (QuickCheckTests(..), testProperty)
+
+tests :: TestTree
+tests = testGroup "SIMD compatibility"
+  [ testCase "vector and dispatch boundaries at every slice alignment" $
+      forM_ sizes $ \n ->
+        forM_ [0 .. 31] $ \offset ->
+          checkHarness (context n offset) b16 (sliceAt offset) (payload n)
+  , testCase "every input byte across multiple vector blocks" $
+      forM_ [0 .. 255] $ \byte ->
+        checkHarness ("repeated byte=" ++ show byte) b16
+          (sliceAt (fromIntegral byte `mod` 32)) (BS.replicate 257 byte)
+  , testCase "every non-hex byte is rejected before and after vector prefixes" $
+      forM_ invalidBytes $ \byte ->
+        forM_ [0, 1, 15, 16, 31, 32, 63, 64, 127, 128, 4096] $ \position ->
+          checkInvalid (fromIntegral byte `mod` 32) position byte
+  , testCase "every invalid lane reports its original offset after long prefixes" $
+      forM_ [4096 .. 4159] $ \position ->
+        forM_ [35, 128, 255] $ \byte ->
+          forM_ [0, 1, 15, 31] $ \offset ->
+            checkInvalid offset position byte
+  , testCase "odd length takes precedence even after SIMD-sized input" $
+      forM_ [0, 16, 32, 64, 128, 256, 4096] $ \prefixLength -> do
+        let input = BS.replicate prefixLength 48 <> "#00"
+        assertEqual "odd parser input" (Left "invalid bytestring size")
+          (B16.parseBase16 input)
+        assertEqual "odd decoder input" (Left "invalid bytestring size")
+          (B16.decodeBase16Untyped input)
+        assertEqual "odd short parser input" (Left "invalid bytestring size")
+          (S16.parseBase16 (SBS.toShort input))
+        assertEqual "odd short decoder input" (Left "invalid bytestring size")
+          (S16.decodeBase16Untyped (SBS.toShort input))
+  , testCase "lazy chunks split pairs across vector blocks" $
+      forM_ [15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 128, 129, 4096] $ \n ->
+        forM_ [1, 2, 3, 7, 15, 16, 17, 31, 32, 63, 64, 127] $ \chunkSize ->
+          checkHarness ("lazy length=" ++ show n ++ ", chunk=" ++ show chunkSize)
+            lb16 (LBS.fromChunks . chunksOf chunkSize) (payload n)
+  , testCase "short byte arrays remain compatible across boundaries and large inputs" $
+      forM_ sizes $ \n ->
+        checkHarness ("short length=" ++ show n) sb16 SBS.toShort (payload n)
+  , testCase "short decoding reports errors in the final block and scalar tail" $
+      forM_ [126, 128, 130, 254, 256, 258, 4096, 4098] $ \n ->
+        forM_ [n - 33 .. n - 1] $ \position ->
+          forM_ [35, 128, 255] $ \byte -> do
+            let input = SBS.toShort $
+                  BS.replicate position 48 <> BS.singleton byte
+                    <> BS.replicate (n - position - 1) 70
+                expected = Left ("invalid character at offset: " <> T.pack (show position))
+                note = "short length=" ++ show n ++ ", position=" ++ show position
+            assertEqual (note ++ ": decoder") expected (S16.decodeBase16Untyped input)
+            assertEqual (note ++ ": parser") expected (S16.parseBase16 input)
+  , testCase "short codecs retain unpinned arrays under GC pressure" $
+      bracket (forkIO (forever (performGC >> threadDelay 1000))) killThread $ \_ ->
+        forM_ [0 .. 31] $ \byte ->
+          forM_ [64, 65, 127, 128, 129, 511, 1023] $ \n -> do
+            let raw = BS.map (+ byte) (payload n)
+                input = SBS.pack (BS.unpack raw)
+                encoded = extractBase16 (S16.encodeBase16' input)
+                decoded = S16.decodeBase16 (assertBase16 encoded)
+                note = "unpinned length=" ++ show n ++ ", byte=" ++ show byte
+            assertEqual (note ++ ": source is unpinned") False (isPinned input)
+            assertEqual (note ++ ": encoded output is unpinned") False (isPinned encoded)
+            assertEqual (note ++ ": decoded output is unpinned") False (isPinned decoded)
+            checkHarness note sb16 (SBS.pack . BS.unpack) raw
+            assertEqual (note ++ ": source unchanged") raw (SBS.fromShort input)
+  , testCase "text wrappers preserve long Unicode payloads" textWrappers
+  , localOption (QuickCheckTests 40) $
+      testProperty "large random payloads agree with the independent codec" $
+        forAll largePayload $ \raw ->
+          let encoded = Bos.encode raw
+              shortRaw = SBS.toShort raw
+              shortEncoded = SBS.toShort encoded
+          in conjoin
+            [ extractBase16 (B16.encodeBase16' raw) === encoded
+            , B16.decodeBase16 (assertBase16 encoded) === raw
+            , B16.decodeBase16Untyped encoded === Right raw
+            , fmap extractBase16 (B16.parseBase16 encoded) === Right encoded
+            , extractBase16 (S16.encodeBase16' shortRaw) === shortEncoded
+            , S16.decodeBase16 (assertBase16 shortEncoded) === shortRaw
+            , S16.decodeBase16Untyped shortEncoded === Right shortRaw
+            , fmap extractBase16 (S16.parseBase16 shortEncoded) === Right shortEncoded
+            ]
+  ]
+  where
+    sizes = [0 .. 5]
+      ++ concatMap (\n -> [n - 2 .. n + 2]) [8, 16, 32, 64, 128, 256]
+      ++ [4095, 4096, 4097, 65535, 65536, 65537]
+    largePayload = do
+      n <- choose (4096, 65536)
+      BS.pack <$> vectorOf n arbitrary
+
+isPinned :: SBS.ShortByteString -> Bool
+isPinned (SBSI.SBS bytes) = isTrue# (isByteArrayPinned# bytes)
+
+-- Invalid surrounding bytes ensure the codec respects the slice's bounds.
+sliceAt :: Int -> BS.ByteString -> BS.ByteString
+sliceAt offset bytes = BS.take (BS.length bytes) . BS.drop offset $
+  BS.replicate offset 0xe5 <> bytes <> BS.replicate 32 0xff
+
+payload :: Int -> BS.ByteString
+payload n = BS.pack (take n (cycle [0 .. 255]))
+
+context :: Int -> Int -> String
+context n offset = "length=" ++ show n ++ ", slice offset=" ++ show offset
+
+checkHarness
+  :: forall a b proxy. Harness a b
+  => String
+  -> proxy a
+  -> (BS.ByteString -> b)
+  -> BS.ByteString
+  -> Assertion
+checkHarness note _ convert raw = do
+  assertEqual (note ++ ": encode") (convert encoded)
+    (extractBase16 (encode @a input))
+  forM_ [("lower", encoded), ("upper", upper), ("mixed", mixed)] $
+    \(name, representation) -> do
+      let expected = convert representation
+      assertEqual (note ++ ": " ++ name ++ " typed decode") input
+        (decodeTyped @a (assertBase16 expected))
+      assertEqual (note ++ ": " ++ name ++ " untyped decode") (Right input)
+        (decode @a expected)
+      assertEqual (note ++ ": " ++ name ++ " parser preserves case") (Right expected)
+        (fmap extractBase16 (parse @a expected))
+  where
+    input = convert raw
+    encoded = Bos.encode raw
+    upper = BS8.map toUpper encoded
+    mixed = BS.pack (zipWith chooseCase [0 :: Int ..] (BS.unpack encoded))
+    chooseCase i byte
+      | even i && byte >= 97 && byte <= 102 = byte - 32
+      | otherwise = byte
+
+invalidBytes :: [Word8]
+invalidBytes = filter (`BS.notElem` "0123456789abcdefABCDEF") [0 .. 255]
+
+checkInvalid :: Int -> Int -> Word8 -> Assertion
+checkInvalid offset position byte = do
+  assertEqual (note ++ ": parser") (Left expected) (B16.parseBase16 input)
+  assertEqual (note ++ ": decoder") (Left expected) (B16.decodeBase16Untyped input)
+  assertEqual (note ++ ": short parser") (Left expected)
+    (S16.parseBase16 (SBS.toShort input))
+  assertEqual (note ++ ": short decoder") (Left expected)
+    (S16.decodeBase16Untyped (SBS.toShort input))
+  where
+    -- A second invalid byte ensures that the first bad lane wins. The valid
+    -- suffix keeps the first error out of the scalar-only tail.
+    input = sliceAt offset $
+      BS.replicate position 48 <> BS.singleton byte
+        <> BS.replicate 65 70 <> "#" <> BS.replicate suffixLength 97
+    suffixLength = if even position then 257 else 256
+    expected = "invalid character at offset: " <> T.pack (show position)
+    note = context (BS.length input) offset
+      ++ ", invalid byte=" ++ show byte ++ " at=" ++ show position
+
+chunksOf :: Int -> BS.ByteString -> [BS.ByteString]
+chunksOf n bytes
+  | BS.null bytes = []
+  | otherwise = let (chunk, rest) = BS.splitAt n bytes
+                in sliceAt 1 chunk : chunksOf n rest
+
+textWrappers :: Assertion
+textWrappers =
+  forM_ [0, 1, 15, 16, 31, 32, 127, 1024] $ \repetitions -> do
+    let value = T.replicate repetitions "a\x00e9\x03bb\x1f642"
+        raw = T.encodeUtf8 value
+        note = "text repetitions=" ++ show repetitions
+    checkHarness note t16 T.decodeUtf8 raw
+    checkHarness note ts16 (TS.fromText . T.decodeUtf8) raw
+    forM_ [1, 3, 15, 16, 63, 64] $ \chunkSize ->
+      checkHarness (note ++ ", text chunk=" ++ show chunkSize) tl16
+        (TL.fromChunks . T.chunksOf chunkSize . T.decodeUtf8) raw
diff --git a/test/simd-native.c b/test/simd-native.c
new file mode 100644
--- /dev/null
+++ b/test/simd-native.c
@@ -0,0 +1,357 @@
+/* Copyright (c) 2026, Emily Pillmore. BSD-3-Clause; see LICENSE. */
+
+/*
+ * Standalone differential/bounds checks; link with cbits/base16_simd.c.
+ * Recommended: -std=c99 -O2 -Wall -Wextra -Werror
+ *              -fsanitize=address,undefined -fno-omit-frame-pointer
+ * Repeat with HS_BASE16_SIMD_FORCE_SCALAR and HS_BASE16_SIMD_FORCE_SSSE3.
+ */
+#ifndef _DEFAULT_SOURCE
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#if defined(__unix__) || defined(__APPLE__)
+#include <sys/mman.h>
+#include <unistd.h>
+#define HS_BASE16_GUARD_PAGES 1
+#endif
+
+#include "base16_simd.h"
+
+#define CHECK(condition) do { \
+    if (!(condition)) { \
+        fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #condition); \
+        exit(EXIT_FAILURE); \
+    } \
+} while (0)
+
+static const uint8_t alphabet[] = "0123456789abcdef";
+static size_t encode_block;
+static size_t decode_block;
+static size_t checks;
+
+/* Deliberately scalar and independent of the kernels' vector classification. */
+static int hex_value(uint8_t value)
+{
+    if (value >= '0' && value <= '9') {
+        return value - '0';
+    }
+    if (value >= 'a' && value <= 'f') {
+        return value - 'a' + 10;
+    }
+    if (value >= 'A' && value <= 'F') {
+        return value - 'A' + 10;
+    }
+    return -1;
+}
+
+static size_t encode_prefix(size_t len)
+{
+    return encode_block == 0 ? 0 : len / encode_block * encode_block;
+}
+
+static size_t decode_prefix(const uint8_t *src, size_t len)
+{
+    size_t consumed = 0;
+
+    if (decode_block == 0) {
+        return 0;
+    }
+    while (len - consumed >= decode_block) {
+        for (size_t i = 0; i < decode_block; i++) {
+            if (hex_value(src[consumed + i]) < 0) {
+                return consumed;
+            }
+        }
+        consumed += decode_block;
+    }
+    return consumed;
+}
+
+static void scalar_encode(const uint8_t *src, size_t len, uint8_t *dst)
+{
+    for (size_t i = 0; i < len; i++) {
+        *dst++ = alphabet[src[i] / 16];
+        *dst++ = alphabet[src[i] % 16];
+    }
+}
+
+static void scalar_decode(const uint8_t *src, size_t len, uint8_t *dst)
+{
+    for (size_t i = 0; i < len; i += 2) {
+        *dst++ = (uint8_t)(hex_value(src[i]) * 16 + hex_value(src[i + 1]));
+    }
+}
+
+static void check_canary(const uint8_t *data, size_t len)
+{
+    for (size_t i = 0; i < len; i++) {
+        CHECK(data[i] == 0xa5);
+    }
+}
+
+static void check_encode(const uint8_t *src, size_t len, size_t offset)
+{
+    const size_t expected = encode_prefix(len);
+    const size_t output_len = expected * 2;
+    uint8_t *output = malloc(offset + output_len + 32);
+    uint8_t *reference = malloc(output_len + 1);
+    size_t consumed;
+
+    CHECK(output != NULL && reference != NULL);
+    memset(output, 0xa5, offset + output_len + 32);
+    scalar_encode(src, expected, reference);
+    consumed = hs_base16_simd_encode(src, len, output + offset);
+    CHECK(consumed == expected && consumed <= len);
+    CHECK(memcmp(output + offset, reference, output_len) == 0);
+    check_canary(output, offset);
+    check_canary(output + offset + output_len, 32);
+    free(reference);
+    free(output);
+    checks++;
+}
+
+static void check_decode(const uint8_t *src, size_t len, size_t offset)
+{
+    const size_t expected = decode_prefix(src, len);
+    const size_t output_len = expected / 2;
+    uint8_t *output = malloc(offset + output_len + 32);
+    uint8_t *reference = malloc(output_len + 1);
+    size_t consumed;
+
+    CHECK(output != NULL && reference != NULL);
+    memset(output, 0xa5, offset + output_len + 32);
+    scalar_decode(src, expected, reference);
+    consumed = hs_base16_simd_decode(src, len, output + offset);
+    CHECK(consumed == expected);
+    CHECK(consumed % 2 == 0 && consumed <= len);
+    CHECK(memcmp(output + offset, reference, output_len) == 0);
+    check_canary(output, offset);
+    check_canary(output + offset + output_len, 32);
+    free(reference);
+    free(output);
+    checks++;
+}
+
+static void test_lengths_and_alignment(void)
+{
+    uint8_t source[1056];
+    uint8_t encoded[2112];
+
+    for (size_t offset = 0; offset < 32; offset++) {
+        for (size_t i = 0; i < sizeof(source); i++) {
+            source[i] = (uint8_t)(i * 73 + offset * 19);
+        }
+        scalar_encode(source, sizeof(source), encoded);
+        for (size_t len = 0; len <= 1024; len++) {
+            check_encode(source + offset, len, 31 - offset);
+            check_decode(encoded + offset, len, 31 - offset);
+        }
+    }
+    /* Vary both alignments independently at every relevant vector boundary. */
+    const size_t sizes[] = {0, 1, 15, 16, 17, 31, 32, 33, 63, 64, 65,
+                            127, 128, 129, 255, 256, 257};
+    for (size_t source_offset = 0; source_offset < 32; source_offset++) {
+        for (size_t output_offset = 0; output_offset < 32; output_offset++) {
+            for (size_t i = 0; i < sizeof(sizes) / sizeof(sizes[0]); i++) {
+                check_encode(source + source_offset, sizes[i], output_offset);
+                check_decode(encoded + source_offset, sizes[i], output_offset);
+            }
+        }
+    }
+}
+
+static void test_all_byte_values(void)
+{
+    uint8_t source[257];
+    uint8_t encoded[514];
+
+    for (unsigned value = 0; value <= 255; value++) {
+        memset(source, (int)value, sizeof(source));
+        check_encode(source, sizeof(source), value % 32);
+        scalar_encode(source, sizeof(source), encoded);
+        check_decode(encoded, sizeof(encoded), value % 32);
+        for (size_t i = 0; i < sizeof(encoded); i++) {
+            if (encoded[i] >= 'a' && encoded[i] <= 'f' && i % 2 == 0) {
+                encoded[i] = (uint8_t)(encoded[i] - 'a' + 'A');
+            }
+        }
+        check_decode(encoded, sizeof(encoded), value % 32);
+    }
+}
+
+static void test_invalid_block_stops(void)
+{
+    uint8_t source[580];
+
+    for (size_t position = 0; position < sizeof(source); position++) {
+        for (unsigned value = 0; value <= 255; value++) {
+            memset(source, 'a', sizeof(source));
+            source[position] = (uint8_t)value;
+            check_decode(source, sizeof(source), position % 32);
+        }
+    }
+}
+
+static uint32_t next_random(uint32_t *state)
+{
+    uint32_t value = *state;
+    value ^= value << 13;
+    value ^= value >> 17;
+    value ^= value << 5;
+    *state = value;
+    return value;
+}
+
+static void test_random_mixed_inputs(void)
+{
+    uint8_t source[4096];
+    uint8_t encoded[8192];
+    uint32_t state = UINT32_C(0x6d253fb9);
+
+    for (size_t sample = 0; sample < 256; sample++) {
+        const size_t len = next_random(&state) % (sizeof(source) + 1);
+        const size_t encoded_len = len * 2;
+        const size_t offset = next_random(&state) % 32;
+
+        for (size_t i = 0; i < len; i++) {
+            source[i] = (uint8_t)next_random(&state);
+        }
+        check_encode(source, len, offset);
+        scalar_encode(source, len, encoded);
+        for (size_t i = 0; i < encoded_len; i++) {
+            if (encoded[i] >= 'a' && encoded[i] <= 'f' && next_random(&state) % 2) {
+                encoded[i] = (uint8_t)(encoded[i] - 'a' + 'A');
+            }
+        }
+        check_decode(encoded, encoded_len, offset);
+        if (encoded_len > 0) {
+            const size_t position = next_random(&state) % encoded_len;
+            encoded[position] = (uint8_t)next_random(&state);
+            check_decode(encoded, encoded_len, offset);
+        }
+    }
+}
+
+static void test_tight_allocations(void)
+{
+    for (size_t len = 1; len <= 1024; len++) {
+        uint8_t *source = malloc(len);
+        const size_t encode_len = encode_prefix(len) * 2;
+        uint8_t *encoded = malloc(encode_len == 0 ? 1 : encode_len);
+        size_t decode_len;
+        uint8_t *decoded;
+
+        CHECK(source != NULL && encoded != NULL);
+        memset(source, 0xfb, len);
+        CHECK(hs_base16_simd_encode(source, len, encoded) == encode_prefix(len));
+        memset(source, 'F', len);
+        decode_len = decode_prefix(source, len) / 2;
+        decoded = malloc(decode_len == 0 ? 1 : decode_len);
+        CHECK(decoded != NULL);
+        CHECK(hs_base16_simd_decode(source, len, decoded) == decode_prefix(source, len));
+        free(decoded);
+        free(encoded);
+        free(source);
+        checks += 2;
+    }
+}
+
+#if defined(HS_BASE16_GUARD_PAGES)
+static uint8_t *guarded_buffer(size_t page_size)
+{
+    uint8_t *mapping = mmap(NULL, page_size * 3, PROT_NONE,
+                            MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+    CHECK(mapping != MAP_FAILED);
+    CHECK(mprotect(mapping + page_size, page_size, PROT_READ | PROT_WRITE) == 0);
+    return mapping;
+}
+
+static void test_guard_pages(void)
+{
+    const long raw_page_size = sysconf(_SC_PAGESIZE);
+    size_t page_size;
+    uint8_t *source_mapping;
+    uint8_t *output_mapping;
+
+    CHECK(raw_page_size >= 2048);
+    page_size = (size_t)raw_page_size;
+    source_mapping = guarded_buffer(page_size);
+    output_mapping = guarded_buffer(page_size);
+
+    for (size_t len = 0; len <= 1024; len++) {
+        for (size_t at_end = 0; at_end < 2; at_end++) {
+            uint8_t *source = source_mapping + page_size +
+                              (at_end ? page_size - len : 0);
+            const size_t encode_len = encode_prefix(len) * 2;
+            uint8_t *output = output_mapping + page_size +
+                              (at_end ? page_size - encode_len : 0);
+            size_t expected;
+            size_t decode_len;
+
+            memset(source, 0xfb, len);
+            CHECK(hs_base16_simd_encode(source, len, output) == encode_prefix(len));
+            memset(source, 'F', len);
+            expected = decode_prefix(source, len);
+            decode_len = expected / 2;
+            output = output_mapping + page_size + (at_end ? page_size - decode_len : 0);
+            CHECK(hs_base16_simd_decode(source, len, output) == expected);
+            checks += 2;
+        }
+    }
+    CHECK(munmap(source_mapping, page_size * 3) == 0);
+    CHECK(munmap(output_mapping, page_size * 3) == 0);
+}
+#endif
+
+int main(int argc, char **argv)
+{
+    const int backend = hs_base16_simd_backend();
+
+    if (argc != 1) {
+        if (argc != 3 || strcmp(argv[1], "--require-backend") != 0 ||
+            strlen(argv[2]) != 1 || argv[2][0] < '0' || argv[2][0] > '3') {
+            fprintf(stderr, "usage: %s [--require-backend 0|1|2|3]\n", argv[0]);
+            return EXIT_FAILURE;
+        }
+        if (backend != argv[2][0] - '0') {
+            fprintf(stderr, "required SIMD backend %c, detected %d\n", argv[2][0], backend);
+            return EXIT_FAILURE;
+        }
+    }
+    CHECK(backend >= 0 && backend <= 3);
+#if defined(HS_BASE16_SIMD_FORCE_SCALAR)
+    CHECK(backend == 0);
+#elif defined(HS_BASE16_SIMD_FORCE_SSSE3) && defined(__x86_64__)
+    CHECK(backend == 0 || backend == 1);
+#elif defined(__aarch64__) && defined(__ARM_NEON) && \
+      (defined(__GNUC__) || defined(__clang__))
+    CHECK(backend == 2);
+#endif
+    encode_block = backend == 3 ? 32 : backend == 0 ? 0 : 16;
+    decode_block = backend == 1 ? 16 : backend == 0 ? 0 : 32;
+    printf("SIMD backend: %d (%s)\n", backend,
+           backend == 3 ? "AVX2" : backend == 2 ? "NEON" :
+           backend == 1 ? "SSSE3" : "scalar fallback");
+    /* Zero-length and unsupported calls must not dereference their pointers. */
+    CHECK(hs_base16_simd_encode(NULL, 0, NULL) == 0);
+    CHECK(hs_base16_simd_decode(NULL, 0, NULL) == 0);
+    if (backend == 0) {
+        CHECK(hs_base16_simd_encode(NULL, SIZE_MAX, NULL) == 0);
+        CHECK(hs_base16_simd_decode(NULL, SIZE_MAX, NULL) == 0);
+    }
+    test_lengths_and_alignment();
+    test_all_byte_values();
+    test_invalid_block_stops();
+    test_random_mixed_inputs();
+    test_tight_allocations();
+#if defined(HS_BASE16_GUARD_PAGES)
+    test_guard_pages();
+#endif
+    printf("Passed %zu differential/bounds checks.\n", checks);
+    return EXIT_SUCCESS;
+}
