diff --git a/src/arch/aarch64.rs b/src/arch/aarch64.rs
index 02c49b0..60daa50 100644
--- a/src/arch/aarch64.rs
+++ b/src/arch/aarch64.rs
@@ -3,6 +3,7 @@
 use super::generic;
 use crate::{get_chars_table, Output};
 use core::arch::aarch64::*;
+use core::mem::MaybeUninit;
 
 pub(crate) const USE_CHECK_FN: bool = false;
 
@@ -85,7 +86,7 @@ pub(crate) unsafe fn check_neon(input: &[u8]) -> bool {
 ///
 /// Based on: <http://0x80.pl/notesen/2022-01-17-validating-hex-parse.html>
 #[inline]
-pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [u8]) -> bool {
+pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [MaybeUninit<u8>]) -> bool {
     if cfg!(miri) || !has_neon() {
         return generic::decode_checked(input, output);
     }
@@ -93,7 +94,7 @@ pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [u8]) -> bool {
 }
 
 #[target_feature(enable = "neon")]
-unsafe fn decode_checked_neon(input: &[u8], output: &mut [u8]) -> bool {
+unsafe fn decode_checked_neon(input: &[u8], output: &mut [MaybeUninit<u8>]) -> bool {
     debug_assert_eq!(output.len(), input.len() / 2);
 
     let add_c6 = vdupq_n_u8(0xC6); // 0xFF - b'9'
@@ -130,7 +131,7 @@ unsafe fn decode_checked_neon(input: &[u8], output: &mut [u8]) -> bool {
 }
 
 #[inline]
-pub(crate) unsafe fn decode_unchecked(input: &[u8], output: &mut [u8]) {
+pub(crate) unsafe fn decode_unchecked(input: &[u8], output: &mut [MaybeUninit<u8>]) {
     if cfg!(miri) || !has_neon() {
         return generic::decode_unchecked(input, output);
     }
@@ -138,7 +139,7 @@ pub(crate) unsafe fn decode_unchecked(input: &[u8], output: &mut [u8]) {
 }
 
 #[target_feature(enable = "neon")]
-unsafe fn decode_unchecked_neon(input: &[u8], output: &mut [u8]) {
+unsafe fn decode_unchecked_neon(input: &[u8], output: &mut [MaybeUninit<u8>]) {
     generic::decode_unchecked_unaligned_chunks(input, output, |[v0, v1]: [uint8x16_t; 2]| {
         let n0 = unhex_neon(v0);
         let n1 = unhex_neon(v1);
diff --git a/src/arch/generic.rs b/src/arch/generic.rs
index a219fa6..5317b4e 100644
--- a/src/arch/generic.rs
+++ b/src/arch/generic.rs
@@ -1,5 +1,5 @@
 use crate::{byte2hex, Output, HEX_DECODE_LUT, NIL};
-use core::mem::size_of;
+use core::mem::{size_of, MaybeUninit};
 
 /// Set to `true` to use `check` + `decode_unchecked` for decoding. Otherwise uses `decode_checked`.
 ///
@@ -143,7 +143,7 @@ pub(crate) fn check_one_unaligned_chunk<T: Copy>(
 ///
 /// Assumes `output.len() == input.len() / 2`.
 #[allow(dead_code)]
-pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [u8]) -> bool {
+pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [MaybeUninit<u8>]) -> bool {
     unsafe { decode_maybe_check::<true>(input, output) }
 }
 
diff --git a/src/arch/portable_simd.rs b/src/arch/portable_simd.rs
index 6d44401..0b759c4 100644
--- a/src/arch/portable_simd.rs
+++ b/src/arch/portable_simd.rs
@@ -2,6 +2,7 @@
 
 use super::generic;
 use crate::{get_chars_table, Output};
+use core::mem::MaybeUninit;
 use core::simd::prelude::*;
 
 type Simd = u8x16;
@@ -45,7 +46,7 @@ pub(crate) fn check(input: &[u8]) -> bool {
 /// - Nibble pairs are merged with `deinterleave` + `(hi << 4) | lo`.
 ///
 /// Based on: <http://0x80.pl/notesen/2022-01-17-validating-hex-parse.html>
-pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [u8]) -> bool {
+pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [MaybeUninit<u8>]) -> bool {
     debug_assert_eq!(output.len(), input.len() / 2);
 
     let add_c6 = Simd::splat(0xC6); // 0xFF - b'9'
@@ -81,7 +82,7 @@ pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [u8]) -> bool {
     })
 }
 
-pub(crate) unsafe fn decode_unchecked(input: &[u8], output: &mut [u8]) {
+pub(crate) unsafe fn decode_unchecked(input: &[u8], output: &mut [MaybeUninit<u8>]) {
     generic::decode_unchecked_unaligned_chunks(input, output, |[v0, v1]: [Simd; 2]| {
         let n0 = unhex(v0);
         let n1 = unhex(v1);
diff --git a/src/arch/wasm32.rs b/src/arch/wasm32.rs
index 4876bc3..ccb2d7f 100644
--- a/src/arch/wasm32.rs
+++ b/src/arch/wasm32.rs
@@ -3,6 +3,7 @@
 use super::generic;
 use crate::{get_chars_table, Output};
 use core::arch::wasm32::*;
+use core::mem::MaybeUninit;
 
 pub(crate) const USE_CHECK_FN: bool = false;
 
@@ -80,7 +81,7 @@ pub(crate) fn check(input: &[u8]) -> bool {
 /// Based on: <http://0x80.pl/notesen/2022-01-17-validating-hex-parse.html>
 #[inline]
 #[target_feature(enable = "simd128")]
-pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [u8]) -> bool {
+pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [MaybeUninit<u8>]) -> bool {
     debug_assert_eq!(output.len(), input.len() / 2);
 
     let add_c6 = u8x16_splat(0xC6); // 0xFF - b'9'
@@ -121,7 +122,7 @@ pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [u8]) -> bool {
 
 #[inline]
 #[target_feature(enable = "simd128")]
-pub(crate) unsafe fn decode_unchecked(input: &[u8], output: &mut [u8]) {
+pub(crate) unsafe fn decode_unchecked(input: &[u8], output: &mut [MaybeUninit<u8>]) {
     generic::decode_unchecked_unaligned_chunks(input, output, |[v0, v1]: [v128; 2]| {
         let n0 = unhex(v0);
         let n1 = unhex(v1);
diff --git a/src/arch/x86.rs b/src/arch/x86.rs
index 1e1cab5..10acf30 100644
--- a/src/arch/x86.rs
+++ b/src/arch/x86.rs
@@ -3,6 +3,7 @@
 
 use super::generic;
 use crate::{get_chars_table, Output};
+use core::mem::MaybeUninit;
 
 #[cfg(target_arch = "x86")]
 use core::arch::x86::*;
@@ -171,7 +172,7 @@ unsafe fn check_chunk_sse2(chunk: __m128i) -> bool {
 }
 
 #[inline]
-pub(crate) unsafe fn decode_unchecked(input: &[u8], output: &mut [u8]) {
+pub(crate) unsafe fn decode_unchecked(input: &[u8], output: &mut [MaybeUninit<u8>]) {
     if !has_avx2() {
         return generic::decode_unchecked(input, output);
     }
@@ -179,7 +180,7 @@ pub(crate) unsafe fn decode_unchecked(input: &[u8], output: &mut [u8]) {
 }
 
 #[target_feature(enable = "avx2")]
-unsafe fn decode_avx2(input: &[u8], output: &mut [u8]) {
+unsafe fn decode_avx2(input: &[u8], output: &mut [MaybeUninit<u8>]) {
     #[rustfmt::skip]
     let mask_a = _mm256_setr_epi8(
         0, -1, 2, -1, 4, -1, 6, -1, 8, -1, 10, -1, 12, -1, 14, -1,
@@ -237,7 +238,7 @@ unsafe fn nib2byte(a1: __m256i, b1: __m256i, a2: __m256i, b2: __m256i) -> __m256
 ///
 /// Based on: <http://0x80.pl/notesen/2022-01-17-validating-hex-parse.html>
 #[inline]
-pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [u8]) -> bool {
+pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [MaybeUninit<u8>]) -> bool {
     if has_avx2() {
         return decode_checked_avx2(input, output);
     }
@@ -245,7 +246,7 @@ pub(crate) unsafe fn decode_checked(input: &[u8], output: &mut [u8]) -> bool {
 }
 
 #[target_feature(enable = "avx2")]
-unsafe fn decode_checked_avx2(input: &[u8], output: &mut [u8]) -> bool {
+unsafe fn decode_checked_avx2(input: &[u8], output: &mut [MaybeUninit<u8>]) -> bool {
     debug_assert_eq!(output.len(), input.len() / 2);
 
     let add_c6 = _mm256_set1_epi8(0xC6u8 as i8); // 0xFF - b'9'
diff --git a/src/impl_core.rs b/src/impl_core.rs
index 455b4ef..44a88ef 100644
--- a/src/impl_core.rs
+++ b/src/impl_core.rs
@@ -4,12 +4,15 @@
 
 use core::mem::{self, MaybeUninit};
 
-/// `MaybeUninit::slice_assume_init_mut`
+/// Reinterprets `&mut [T]` as `&mut [MaybeUninit<T>]`.
+///
+/// This is safe because `MaybeUninit<T>` is guaranteed to have the same layout as `T`,
+/// and an initialized `T` is always a valid `MaybeUninit<T>`.
 #[inline(always)]
-pub(crate) unsafe fn slice_assume_init_mut<T>(slice: &mut [MaybeUninit<T>]) -> &mut [T] {
-    // SAFETY: similar to safety notes for `slice_get_ref`, but we have a
-    // mutable reference which is also guaranteed to be valid for writes.
-    unsafe { &mut *(slice as *mut [MaybeUninit<T>] as *mut [T]) }
+pub(crate) fn slice_as_uninit_mut<T>(slice: &mut [T]) -> &mut [MaybeUninit<T>] {
+    // SAFETY: `MaybeUninit<T>` has the same layout as `T`, and initialized
+    // memory is valid `MaybeUninit`.
+    unsafe { &mut *(slice as *mut [T] as *mut [MaybeUninit<T>]) }
 }
 
 /// `MaybeUninit::uninit_array`
diff --git a/src/lib.rs b/src/lib.rs
index cca1aee..c89b35a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -53,6 +53,7 @@
 extern crate alloc;
 
 use cfg_if::cfg_if;
+use core::mem::MaybeUninit;
 
 #[cfg(feature = "alloc")]
 #[allow(unused_imports)]
@@ -520,14 +521,14 @@ pub fn decode<T: AsRef<[u8]>>(input: T) -> Result<Vec<u8>, FromHexError> {
         // Do not initialize memory since it will be entirely overwritten.
         let len = input.len() / 2;
         let mut output = Vec::with_capacity(len);
-        // SAFETY: The entire vec is never read from, and gets dropped if decoding fails.
-        #[allow(clippy::uninit_vec)]
+
+        // SAFETY: `decode_checked` fully writes `len` bytes on success,
+        // then `set_len` is called only after successful decode.
         unsafe {
+            decode_checked(input, &mut output.spare_capacity_mut()[..len])?;
             output.set_len(len);
         }
-
-        // SAFETY: Lengths are checked above.
-        unsafe { decode_checked(input, &mut output) }.map(|()| output)
+        Ok(output)
     }
 
     decode_inner(input.as_ref())
@@ -558,7 +559,7 @@ pub fn decode<T: AsRef<[u8]>>(input: T) -> Result<Vec<u8>, FromHexError> {
 /// ```
 #[inline]
 pub fn decode_to_slice<T: AsRef<[u8]>>(input: T, output: &mut [u8]) -> Result<(), FromHexError> {
-    decode_to_slice_inner(input.as_ref(), output)
+    decode_to_slice_inner(input.as_ref(), impl_core::slice_as_uninit_mut(output))
 }
 
 /// Decode a hex string into a fixed-length byte-array.
@@ -587,11 +588,9 @@ pub fn decode_to_slice<T: AsRef<[u8]>>(input: T, output: &mut [u8]) -> Result<()
 pub fn decode_to_array<T: AsRef<[u8]>, const N: usize>(input: T) -> Result<[u8; N], FromHexError> {
     fn decode_to_array_inner<const N: usize>(input: &[u8]) -> Result<[u8; N], FromHexError> {
         let mut output = impl_core::uninit_array();
-        // SAFETY: The entire array is never read from.
-        let output_slice = unsafe { impl_core::slice_assume_init_mut(&mut output) };
-        // SAFETY: All elements are initialized.
-        decode_to_slice_inner(input, output_slice)
-            .map(|()| unsafe { impl_core::array_assume_init(output) })
+        decode_to_slice_inner(input, &mut output)?;
+        // SAFETY: All elements are initialized by successful decode.
+        Ok(unsafe { impl_core::array_assume_init(output) })
     }
 
     decode_to_array_inner(input.as_ref())
@@ -601,24 +600,26 @@ pub fn decode_to_array<T: AsRef<[u8]>, const N: usize>(input: T) -> Result<[u8;
 fn encode_inner<const UPPER: bool, const PREFIX: bool>(data: &[u8]) -> String {
     let capacity = PREFIX as usize * 2 + data.len() * 2;
     let mut buf = Vec::<u8>::with_capacity(capacity);
-    // SAFETY: The entire vec is never read from, and gets dropped if decoding fails.
-    #[allow(clippy::uninit_vec)]
-    unsafe {
-        buf.set_len(capacity)
-    };
-    let mut output = buf.as_mut_slice();
+
+    // SAFETY: `spare_capacity_mut` returns uninitialized memory which is fully
+    // written to by the prefix write and `imp::encode`, then `set_len` commits.
+    let mut output = &mut buf.spare_capacity_mut()[..capacity];
     if PREFIX {
         // SAFETY: `output` is long enough.
         unsafe {
-            *output.get_unchecked_mut(0) = b'0';
-            *output.get_unchecked_mut(1) = b'x';
+            output.get_unchecked_mut(0).write(b'0');
+            output.get_unchecked_mut(1).write(b'x');
             output = output.get_unchecked_mut(2..);
         }
     }
-    // SAFETY: `output` is long enough (input.len() * 2).
-    unsafe { imp::encode::<UPPER>(data, output) };
-    // SAFETY: We only write only ASCII bytes.
-    unsafe { String::from_utf8_unchecked(buf) }
+    // SAFETY: `output` is long enough (data.len() * 2), `encode` fully writes all bytes,
+    // then `set_len` is called only after successful encode.
+    // We only write ASCII bytes, which are valid UTF-8.
+    unsafe {
+        imp::encode::<UPPER>(data, output);
+        buf.set_len(capacity);
+        String::from_utf8_unchecked(buf)
+    }
 }
 
 fn encode_to_slice_inner<const UPPER: bool>(
@@ -645,7 +646,7 @@ fn encode_to_str_inner<'o, const UPPER: bool>(
     Ok(s)
 }
 
-fn decode_to_slice_inner(input: &[u8], output: &mut [u8]) -> Result<(), FromHexError> {
+fn decode_to_slice_inner(input: &[u8], output: &mut [MaybeUninit<u8>]) -> Result<(), FromHexError> {
     if unlikely(input.len() % 2 != 0) {
         return Err(FromHexError::OddLength);
     }
@@ -661,7 +662,7 @@ fn decode_to_slice_inner(input: &[u8], output: &mut [u8]) -> Result<(), FromHexE
 ///
 /// Assumes `output.len() == input.len() / 2`.
 #[inline]
-unsafe fn decode_checked(input: &[u8], output: &mut [u8]) -> Result<(), FromHexError> {
+unsafe fn decode_checked(input: &[u8], output: &mut [MaybeUninit<u8>]) -> Result<(), FromHexError> {
     debug_assert_eq!(output.len(), input.len() / 2);
 
     if imp::USE_CHECK_FN {
diff --git a/src/output.rs b/src/output.rs
index 60139ab..46e92a5 100644
--- a/src/output.rs
+++ b/src/output.rs
@@ -1,4 +1,5 @@
 use core::fmt::{self, Write};
+use core::mem::MaybeUninit;
 
 /// Internal trait for abstracting over output buffer types.
 pub(crate) trait Output {
@@ -18,13 +19,25 @@ pub(crate) trait Output {
 impl Output for &mut [u8] {
     #[inline]
     fn write(&mut self, bytes: &[u8]) {
-        let src = bytes.as_ptr();
-        let dst = self.as_mut_ptr();
-        let count = bytes.len();
-        debug_assert!(self.len() >= count);
+        let this = crate::impl_core::slice_as_uninit_mut(self);
         unsafe {
-            dst.copy_from_nonoverlapping(src, count);
-            *self = core::slice::from_raw_parts_mut(dst.add(count), self.len() - count);
+            let count = write_bytes_output_slice(this, bytes);
+            advance_slice(self, count);
+        }
+    }
+
+    #[inline]
+    fn remaining(&self) -> Option<usize> {
+        Some(self.len())
+    }
+}
+
+impl Output for &mut [MaybeUninit<u8>] {
+    #[inline]
+    fn write(&mut self, bytes: &[u8]) {
+        unsafe {
+            let count = write_bytes_output_slice(self, bytes);
+            advance_slice(self, count);
         }
     }
 
@@ -48,3 +61,27 @@ impl Output for &mut fmt::Formatter<'_> {
         let _ = self.write_char(byte as char);
     }
 }
+
+/// # Safety
+///
+/// Caller must guarantee `output.len() >= bytes.len()`.
+#[inline(always)]
+unsafe fn write_bytes_output_slice(output: &mut [MaybeUninit<u8>], bytes: &[u8]) -> usize {
+    let src = bytes.as_ptr().cast::<MaybeUninit<u8>>();
+    let dst = output.as_mut_ptr();
+    let count = bytes.len();
+    debug_assert!(output.len() >= count);
+    // SAFETY: Caller guarantees `output` is at least `count` bytes long.
+    unsafe { dst.copy_from_nonoverlapping(src, count) };
+    count
+}
+
+/// Safety: Caller must guarantee `slice` is long enough, and that `slice` is not concurrently accessed.
+#[inline(always)]
+unsafe fn advance_slice<T>(slice: &mut &mut [T], count: usize) {
+    debug_assert!(slice.len() >= count);
+    let len = slice.len();
+    let ptr = slice.as_mut_ptr();
+    // SAFETY: Caller must guarantee `slice` is long enough, and that `slice` is not concurrently accessed.
+    *slice = core::slice::from_raw_parts_mut(ptr.add(count), len - count);
+}
