skip to content

How do you convert between signed and unsigned types in Kotlin, and what are the bit-level gotchas?

level: middleimportance: must knowfreq 28%

answer

  1. No implicit conversion — call toUInt()/toInt()/toUByte()
  2. Same width = reinterpret bits, e.g. (-1).toUInt()==4294967295
  3. Widening signed sign-extends; unsigned zero-extends
  4. Byte gotcha: use b.toUByte().toUInt() not b.toUInt()
  5. Java interop crosses at the underlying int/long

basics

~10 s

Kotlin never converts numbers implicitly, so you call explicit functions like toUInt(), toInt(), toULong(), or toUByte(). Converting reinterprets the bits, which can flip a negative signed number into a large unsigned one.

solid answer

~40 s

Kotlin has no implicit widening, so all conversions are explicit calls. Each numeric type offers `toUByte()`, `toUShort()`, `toUInt()`, `toULong()` and the reverse `toByte()`, `toInt()`, `toLong()`. Two distinct semantics matter: converting a *signed* value to its same-width unsigned counterpart (e.g. `Int.toUInt()`) **reinterprets the bits** — `(-1).toInt().toUInt()` becomes `4294967295`. Converting across widths follows the usual truncate/sign-extend rules of the source type. There are also `Int.toUByte()` style narrowing conversions that truncate. Be careful with `Byte.toUByte()` vs `Byte.toUInt()`: a negative byte sign-extends when going through Int. For correct unsigned widening of a byte read from a stream, use `byte.toUByte().toUInt()` rather than `byte.toUInt()`. Unsigned types interop with the JDK via helpers like `java.lang.Integer.toUnsignedLong`.

code

kotlin · 7 lines
kotlin
val negByte: Byte = -1
println(negByte.toUInt())            // 4294967295 (sign-extended)
println(negByte.toUByte().toUInt())  // 255 (zero-extended, correct)

val u: UInt = 0xFFFF_FFFFu
println(u.toInt())                   // -1 (same bits)
println(u.toLong())                  // 4294967295 (zero-extended)

go deeper

for a junior

Knows conversions are explicit toX() calls and that unsigned can't be implicit.

for a middle

Distinguishes same-width reinterpretation from cross-width sign/zero extension and truncation.

for a senior

Diagnoses the Byte sign-extension bug and prescribes toUByte().toUInt() for protocol parsing.

for a principal

Establishes conversion conventions at module boundaries (where to convert, what JDK helpers to use) to keep unsigned semantics correct across interop.

## Explicit conversions only Kotlin deliberately has **no implicit widening or narrowing** between numeric types — including signed↔unsigned. You must call an explicit conversion function. The available ones on every numeric type include: - to unsigned: `toUByte()`, `toUShort()`, `toUInt()`, `toULong()` - back to signed: `toByte()`, `toShort()`, `toInt()`, `toLong()` - to floating: `toFloat()`, `toDouble()` ## Two kinds of conversion semantics **1. Same-width reinterpretation.** Converting a signed value to the *same-width* unsigned type keeps the bits and only changes interpretation: ```kotlin val s = -1 // Int, bits 0xFFFFFFFF val u = s.toUInt() // 4294967295u (same bits) println(u.toInt()) // -1 (reinterpret back) ``` **2. Cross-width conversion** follows the **source** type's rules: widening a *signed* source **sign-extends**, widening an *unsigned* source **zero-extends**, and narrowing **truncates**. ## The classic byte gotcha This is the most common real bug. A `Byte` read from a stream can be negative (range −128..127). Watch the difference: ```kotlin val b: Byte = -1 // 0xFF println(b.toUInt()) // 4294967295 (sign-extended through Int!) println(b.toUByte().toUInt()) // 255 (zero-extended, what you usually want) ``` Going `Byte.toUInt()` first sign-extends the byte to a 32-bit Int, *then* reinterprets. Going `Byte.toUByte()` first reinterprets the 8 bits as `0..255`, then `toUInt()` zero-extends. For protocol/byte-buffer code, **`toUByte().toUInt()`** is the safe idiom. ## Floating-point and parsing Unsigned types also support `toFloat()`/`toDouble()`. To parse text you can use `"255".toUInt()` / `toUIntOrNull()` style extensions. The standard library and `java.lang.Integer`/`Long` provide `toUnsignedString`, `divideUnsigned`, `remainderUnsigned`, and `compareUnsigned` used internally. ## Java interop Because Java lacks unsigned types, crossing the boundary you typically pass the underlying `int`/`long` and use JDK unsigned helpers, or convert at the edge with `.toInt()`/`.toUInt()`. ## Summary - No implicit conversions — always `toX()`. - Same-width signed↔unsigned = bit reinterpretation. - Cross-width follows the *source* type (sign-extend signed, zero-extend unsigned, truncate narrowing). - For bytes, prefer `toUByte().toUInt()` to avoid accidental sign extension.

  • Why does (-1).toUInt() print 4294967295?
    Int -1 is the bit pattern 0xFFFFFFFF. toUInt() keeps the exact 32 bits and reinterprets them as unsigned, which is the maximum UInt value.
  • You read a Byte 0x80 and want its unsigned value 128. What do you write?
    byte.toUByte().toUInt() (or .toInt() and(0xFF)). Plain byte.toInt()/toUInt() sign-extends to a negative/huge value because Byte 0x80 is -128.

saying these in an interview costs you the question

  • Expecting implicit signed->unsigned conversion to compile
  • Using byte.toUInt() for protocol parsing and getting sign extension
  • Thinking (-1).toUInt() throws or clamps instead of reinterpreting
  • Believing widening an unsigned value sign-extends

context