skip to content

As a library designer, how do you prevent silent precision loss and overflow from primitive conversions, and which JLS conversion contexts are involved?

level: principalimportance: nice to knowfreq 22%

answer

  1. Two loss sources: widen-to-float precision, narrow/pre-widen overflow
  2. 4 contexts: assignment, method invocation, cast, numeric promotion
  3. long for counters/IDs; BigDecimal for money
  4. Math.toIntExact/addExact/multiplyExact throw on overflow
  5. Widen operand before multiply; route callers to safe edges

basics

~20 s

Avoid letting numbers silently shrink or lose digits. Use long instead of int where overflow is possible, BigDecimal for exact money math, and helpers like Math.toIntExact that throw on overflow. Pick API parameter types that don't force lossy conversions on callers.

solid answer

~40 s

Silent loss comes from two language facts: widening int/long to float/double loses precision, and narrowing (and pre-widening int arithmetic) loses or wraps data. As a designer I minimize lossy conversion sites. I choose the widest sensible parameter and return types so callers aren't forced to narrow, use long for counters/IDs that could overflow int, and never use float/double for money -- BigDecimal instead. For unavoidable narrowing I use checked helpers (Math.toIntExact, Math.multiplyExact, Math.addExact) that throw on overflow rather than wrap. I'm aware of the JLS conversion contexts -- assignment, method invocation (overload resolution prefers widening over boxing over varargs), casting, and numeric promotion -- because they govern when conversions happen implicitly. I also enable static analysis to flag implicit narrowing and lossy widening, and document numeric contracts in the API.

code

java · 12 lines
java
// fail-fast narrowing instead of silent wrap
int count = Math.toIntExact(longCount);

// widen an operand before arithmetic to avoid int overflow
long millis = (long) hours * 3600 * 1000;

// exact money, not double
BigDecimal price = new BigDecimal("19.99");
BigDecimal total = price.multiply(BigDecimal.valueOf(3));

// double cannot do this exactly:
// System.out.println(0.1 + 0.2); // 0.30000000000000004

go deeper

for a junior

Knows to use BigDecimal for money and that big numbers can overflow int.

for a middle

Picks long over int for large counters and knows Math.toIntExact exists for safe narrowing.

for a senior

Designs APIs to avoid forcing lossy conversions, uses the Exact math helpers, and explains long->double precision limits.

for a principal

Reasons across all four JLS conversion contexts and overload resolution, mandates fail-fast/explicit conversions and domain types, and enforces them with static analysis and team conventions.

## Why a designer must care Primitive conversions are convenient but lossy in two directions, and the loss is usually **silent**: - **Widening to floating types loses precision**: `int`/`long` -> `float`/`double` can drop low-order digits because floating types have fewer mantissa bits (float ~24, double ~53) than the integer types (32, 64). - **Narrowing loses or wraps data**: float->int truncates and clamps; int->smaller-int drops high bits (wraps). And in-expression int arithmetic overflows *before* a later widening to long. A library forces these choices on every caller, so the design decisions compound. ## The four JLS conversion contexts (where conversions implicitly happen) 1. **Assignment context** (`x = expr;`): allows widening; allows constant narrowing if the constant fits (e.g. `byte b = 100;`); otherwise narrowing needs a cast. Also allows boxing/unboxing. 2. **Method invocation context** (passing arguments): allows widening and boxing but **not** narrowing. Overload resolution proceeds in phases -- (1) without boxing/varargs (so it prefers a **widening** match like `f(long)` over `f(Integer)`), (2) with boxing, (3) with varargs. Knowing this prevents surprising overload selection. 3. **Casting context** (`(T) expr`): allows the full set, including narrowing -- the developer's explicit assertion. 4. **Numeric promotion** (inside arithmetic): unary (smaller-than-int -> int) and binary (both operands to the widest, min int). This is where `byte+byte` becomes int and `int*int` can overflow before widening. There is also **string conversion** context (concatenation), separate from the numeric ones. ## Design tactics to prevent silent loss ### 1. Choose types that don't force lossy conversions - Use `long` for IDs, counters, timestamps, sizes that can exceed ~2.1 billion. (`int` byte-counts overflow at 2GB -- a classic bug.) - Accept and return the **widest reasonable** numeric type so callers widen (safe) instead of narrow (lossy). - Never represent money/currency as `double`/`float` -- use `BigDecimal` (or integer minor units, e.g. cents in a `long`). `0.1 + 0.2 != 0.3` in double. ### 2. Make narrowing explicit and checked When you must shrink, use the JDK's checked helpers, which **throw** instead of silently wrapping: ```java int count = Math.toIntExact(longCount); // ArithmeticException on overflow long total = Math.addExact(a, b); // throws on overflow long prod = Math.multiplyExact(a, b); ``` For floating->integer, decide rounding mode deliberately (`Math.round`, `RoundingMode` with BigDecimal) rather than relying on truncation. ### 3. Avoid pre-widening overflow ```java long ms = (long) hours * 3600 * 1000; // widen first, then multiply ``` Widen an operand before the arithmetic so the operation runs in the wide type. ### 4. Guard floating precision - Be aware that `long` -> `double` is lossy beyond 2^53. If you need exact large integers, keep them as `long`/`BigInteger`. - For exact decimal math, `BigDecimal` with an explicit `MathContext`/`RoundingMode`. ### 5. Enforce with tooling and contracts - Enable compiler/linters or static analysis (e.g. Error Prone, SpotBugs) to flag implicit narrowing, int-overflow-then-long-assign, and `float`/`double` for money. - Document numeric ranges, units, and rounding in Javadoc; consider value types or domain wrappers (e.g. a `Money` type) so the compiler enforces the contract. ## The principal-level framing The goal is to **move loss from runtime-silent to compile-time-explicit or fail-fast**. Language conversions will always exist; good design routes callers toward safe (widening) edges, makes the unsafe ones loud (checked exceptions, explicit casts, domain types), and backs it with static analysis -- so a precision/overflow bug surfaces in review or a test, never as a corrupted number in production.

  • In overload resolution, given f(long) and f(Integer), which does f(5) pick?
    f(long). Phase 1 of method-invocation resolution allows widening but not boxing, so the widening match f(long) wins over the boxing match f(Integer), which would only be considered in phase 2.
  • Why is double a poor choice for money, and what should you use?
    double is binary floating point and cannot represent most decimal fractions exactly (0.1+0.2 != 0.3), so rounding errors accumulate. Use BigDecimal with an explicit RoundingMode, or store integer minor units (e.g. cents) in a long.
  • How is long->double lossy if double has a larger range?
    Range is larger but precision is only ~53 bits. Integers above 2^53 cannot all be represented exactly in double, so a large long can round to a nearby value when widened.

saying these in an interview costs you the question

  • Using double/float for currency
  • Assuming long->double is always exact (loses beyond 2^53)
  • Relying on silent narrowing/wrap in public APIs
  • Thinking method-invocation context permits narrowing (it does not)
  • Ignoring overload-resolution phases (widening preferred over boxing)

context