#229 Jun 29, 2026

229. char::to_digit — Turn a Digit Character Into Its Value, Not Its Byte

Reached for c as u8 - b'0' to turn '7' into 7? It works until the input isn’t a digit — then you get silent garbage. char::to_digit does it safely and handles hex too.

The byte-math trap

The classic trick subtracts the ASCII code of '0':

1
2
3
let c = '7';
let value = c as u8 - b'0';
assert_eq!(value, 7); // fine...

It’s fine for '0'..='9'. But there’s no validation — feed it anything else and the arithmetic just keeps going:

1
2
3
4
5
// 'f' is 102, b'0' is 48 → 54, which is nonsense, not 15
assert_eq!('f' as u8 - b'0', 54);

// 'a' is 97 → 97 - 48 = 49, also wrong
assert_eq!('a' as u8 - b'0', 49);

No panic, no None — just a wrong number flowing downstream.

to_digit returns an Option

char::to_digit(radix) converts a digit character to its numeric value and gives you an Option<u32>, so non-digits become None instead of garbage:

1
2
3
assert_eq!('7'.to_digit(10), Some(7));
assert_eq!('0'.to_digit(10), Some(0));
assert_eq!('x'.to_digit(10), None); // not a digit → None

Pass 16 and it parses hex, letters included and case-insensitive:

1
2
3
4
assert_eq!('f'.to_digit(16), Some(15));
assert_eq!('F'.to_digit(16), Some(15));
assert_eq!('a'.to_digit(16), Some(10));
assert_eq!('9'.to_digit(16), Some(9));

Where it shines

Because it returns an Option, it drops straight into filter_map — sum every digit in a string and skip everything else, no manual bounds check:

1
2
let total: u32 = "a1b2c3".chars().filter_map(|c| c.to_digit(10)).sum();
assert_eq!(total, 6);

The reverse direction

char::from_digit goes the other way — a value plus a radix back to a digit character:

1
2
3
assert_eq!(char::from_digit(15, 16), Some('f'));
assert_eq!(char::from_digit(9, 10), Some('9'));
assert_eq!(char::from_digit(42, 16), None); // out of range → None

Both have been stable since Rust 1.0. Whenever you’re tempted to do char-to-number arithmetic by hand, reach for to_digit — it validates, it does hex, and it never silently lies to you.

← Previous 228. Vec::push_mut — Push and Get a &mut Back, Skip the last_mut().unwrap() Next → 230. slice::split_at_mut — Two Mutable Halves, No unsafe, No Borrow Fight