Layers of Logic

2.3

Type Conversion, Promotion, Casting

Follow the source type, the conversion rule, and the expression type to predict what Java keeps and what it loses.

Core24 min read4 exercises
01

Previously on

Section 2.1 defined the primitive types. Section 2.2 showed how fixed-width integers wrap and why binary floating-point values can be rounded.

Now values move between those types. The important question is not whether the current number appears to fit. The compiler starts from the source and destination types, then applies specific conversion and promotion rules.

02

The problem

These two assignments contain the same numeric value:

byte small = 24;
int wide = small;

int source = 24;
// byte narrow = source; // compile-time error: possible lossy conversion

The first conversion is permitted without a cast. The second is not.

The compiler knows that every byte value fits in int. It cannot infer from an ordinary int variable that its value will always fit in byte. A cast changes the second line from a rejected implicit conversion into an accepted explicit conversion:

byte narrow = (byte) source;

That cast is permission to apply Java’s narrowing rule. It is not a promise that the value will survive.

03

The idea

Widening primitive conversions

Java permits these numeric paths without a cast:

byte  -> short -> int -> long -> float -> double
          |       |      |       |
          +-------+------+-------+
char  ------------> int -> long -> float -> double

The diagram shows legal language conversions, not guaranteed exactness.

Source and destination Exact for every source value?
byte to short, int, long, float, or double yes
short or char to int, long, float, or double yes
int to long or double yes
int to float no
long to float or double no
float to double yes, including its already rounded binary value

char and short are both 16 bits, but neither contains the other’s full value set. char is 0 through 65,535. short includes negatives and stops at 32,767. Neither converts to the other without a cast.

boolean does not convert to or from a numeric type, even with a cast.

Narrowing primitive conversions

Moving in the other direction usually requires a cast:

long population = 8_100_000_000L;
int reduced = (int) population;

double measurement = 16.9;
int whole = (int) measurement;

Different source and destination families use different narrowing mechanisms. Integer to narrower integer keeps low-order bits. Floating point to integer truncates, clamps out-of-range values, and gives NaN a defined result.

Trace one assignment

For every numeric expression, ask these questions in order:

  1. What is the type of each operand?
  2. Which promotion happens before the operation?
  3. What is the type and value of the result?
  4. Which conversion happens during assignment?

That sequence explains the examples in the rest of the section.

04

Under the hood

Going deeper

Narrowing integers keeps low bits

300 as a 32-bit int ends with these bits:

000000000000000000000001
00101100
kept
300 as int
Narrowing to byte discards the upper 24 bits and keeps 00101100.

0010_1100 is 44 as a signed byte, so (byte) 300 is 44.

For (byte) 200, the kept pattern is 1100_1000. Read as an eight-bit signed value, it is 200 - 256 = -56.

System.out.println((byte) 300); // 44
System.out.println((byte) 200); // -56

This is not equivalent to Java’s remainder operator for all values:

System.out.println(200 % 256);  // 200
System.out.println((byte) 200); // -56

The reliable model is: keep the low bits, then interpret them in the destination type.

Floating point to integer has explicit edge rules

For a direct cast to int, Java applies these rules:

Floating-point source int result
NaN 0
finite and in range fractional part discarded toward zero
positive value too large, including positive infinity Integer.MAX_VALUE
negative value too small, including negative infinity Integer.MIN_VALUE
public class FloatToInt {
    public static void main(String[] args) {
        System.out.println((int) 16.9);
        System.out.println((int) -16.9);
        System.out.println((int) Double.NaN);
        System.out.println((int) Double.POSITIVE_INFINITY);
    }
}

Output:

16
-16
0
2147483647

A direct cast from floating point to byte, short, or char first follows the int conversion behavior, then narrows that integer to the final width.

Constant-expression narrowing

Java has a compile-time exception to the usual assignment rule. An int constant expression may initialize byte, short, or char when its value is representable.

byte a = 100;
short b = 20_000;
char c = 65;

final int LIMIT = 127;
byte d = LIMIT;

// byte tooLarge = 128; // compile-time error

int runtimeValue = 100;
// byte e = runtimeValue; // compile-time error

LIMIT is a constant variable because it is final, has primitive type, and is initialized with a constant expression. An ordinary variable does not qualify.

This special assignment conversion does not make an integer literal fit a method parameter:

static void accept(byte value) {}

// accept(1);       // compile-time error
accept((byte) 1);   // valid

Unary and binary numeric promotion

Most arithmetic does not operate in byte, short, or char. Unary numeric promotion converts them to int for operators such as unary +, unary -, and bitwise ~.

Binary numeric promotion chooses a common type for operators such as +, -, *, /, %, and numeric comparisons:

Binary numeric promotion

  1. If either operand is doubleConvert the other numeric operand to double. The numeric result is double.
  2. Otherwise, if either operand is floatConvert the other operand to float. The numeric result is float.
  3. Otherwise, if either operand is longConvert the other operand to long. The numeric result is long.
  4. OtherwiseConvert both operands to int. This includes byte, short, and char.

That final rule explains this error:

byte a = 10;
byte b = 20;
// byte sum = a + b;       // a + b has type int
byte sum = (byte) (a + b);

The parentheses matter. (byte) a + b casts only a, which is promoted back to int before addition. Casting (a + b) narrows the result.

Compile-time constants create a useful contrast:

byte oneHundred = 50 * 2;  // constant expression, value fits

byte fifty = 50;
// byte result = fifty * 2; // runtime expression has type int

Compound assignment includes a conversion

These statements are not equivalent at compile time:

byte count = 127;
// count = count + 1; // error: int cannot be assigned to byte
count += 1;           // valid, equivalent to a cast back to byte
System.out.println(count); // -128

Conceptually, count += 1 behaves like count = (byte) (count + 1). The implicit narrowing can overflow, so compact syntax does not remove the risk.

Integer division happens before assignment

int total = 7;
int count = 2;

double wrong = total / count;
double stillWrong = (double) (total / count);
double correct = (double) total / count;
Expression Operation type Intermediate result Assigned result
total / count int 3 3.0
(double) (total / count) int, then cast 3 3.0
(double) total / count double 3.5 3.5

The assignment target does not change how the right side was evaluated. Convert an operand before division when a fractional result is required.

Widening can round

int exact = 16_777_216;
int next = 16_777_217;

float first = exact;
float second = next;

System.out.println((long) first);
System.out.println((long) second);
System.out.println(first == second);

Output:

16777216
16777216
true

A float has 24 bits of binary precision, including its implied leading bit. 2^24 + 1 needs another precision bit, so it rounds. The conversion is widening in the Java language because no cast is required, but it is not value-preserving for every int.

05

What it costs

Implicit conversion keeps common code concise. The category name “widening” does not guarantee that every numeric value stays exact.

A cast documents permission to narrow. It does not check that the value fits. Validate a range before casting when wraparound or saturation would be an error.

Promotion prevents small-width intermediate arithmetic, but it can make an expression’s type wider than its variables. Trace operand types before looking at the assignment target.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. Why does `byte sum = a + b;` fail when both operands are byte and the result happens to fit?

    Show the answer

    Binary numeric promotion converts both byte operands to int before addition. The expression therefore has type int. Assigning that expression to byte needs an explicit narrowing cast.

  2. Can a widening primitive conversion lose information?

    Show the answer

    Yes. Java classifies int to float, long to float, and long to double as widening conversions, but they can round because the floating-point target has fewer precision bits. For example, 16,777,217 becomes 16,777,216 as a float.

  3. What does `(int) Double.NaN` produce, and what happens to a finite double above `Integer.MAX_VALUE`?

    Show the answer

    NaN converts to 0. A value above the int range converts to Integer.MAX_VALUE; a value below the range converts to Integer.MIN_VALUE. A finite in-range value has its fractional part discarded toward zero.

  4. Why does `byte b = 100;` compile while `int n = 100; byte b = n;` does not?

    Show the answer

    The first initializer is an int constant expression whose value is known at compile time and fits in byte. Java permits this limited constant-expression narrowing. The variable n is not a constant variable, so assigning it requires a cast even though its current value is 100.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

4 exercises85 pointsabout 95 minutes

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Predict Then Run

Warm up·20 min·15 points

checkedex-2-3-a

Twelve lines. Each one either compiles or it does not.

Write your twelve answers down first, on paper, before you touch the compiler. That is the whole exercise. Uncommenting and seeing what happens teaches you nothing if you had no prediction to be wrong about.

Then uncomment them one at a time and check.

Pay special attention to the two lines involving char and short. Both fail, and working out why will tell you that the widening ladder is not quite the straight line it looks like.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Ladder {
    static boolean widensWithoutCast(String from, String to)
}

Given two type names, answer whether Java performs that conversion with no cast. Work it out from the ladder rather than listing every pair, since the checks include combinations the starter does not mention.

What your program must do

  • Predict all twelve before writing any code
  • Implement the rule rather than listing the pairs
  • Write the twelve as real declarations and confirm the compiler agrees with you
  • Explain why short and char do not convert into each other in either direction
Ladder.java
public class Ladder {

    // Does Java do this conversion with no cast?
    // Work it out from the ladder. Do not hard code every pair.
    static boolean widensWithoutCast(String from, String to) {
        return false; // TODO
    }

    public static void main(String[] args) {
        // For each pair: predict yes or no BEFORE running.
        //   byte->short  short->byte  int->long   long->int
        //   char->int    int->char    float->double  double->float
        //   char->short  short->char  int->float  long->double
        // TODO: print your method's answer for all twelve
        // TODO: then write each one as real code and confirm the compiler agrees
    }
}
Hint 1
The numeric conversion path is byte, short, int, long, float, double. Moving right needs no cast, but int to float and long to float or double can lose precision.
Hint 2
char sits off to the side. It widens to int and above, and nothing widens into it, not even short.
Hint 3almost the answer
short and char are both 16 bits, and short runs -32768 to 32767 while char runs 0 to 65535. Each one has values the other cannot hold, so neither direction is safe.
What this is really testing

Whether you can apply the widening ladder without checking. Twelve lines, each one either compiles or does not, and you should be able to say which before the compiler tells you.

B

Trace Narrowing at the Bit Level

Real work·25 min·20 points

checkedex-2-3-b

Seven narrowing casts. Predict every single one before you run anything.

Do not write “data is lost”. That is not a prediction. Write the actual number, and show the bit pattern that produced it.

Then test the popular shortcut. Somebody will tell you that casting to byte is the same as % 256. Check it against all seven of your cases and find out exactly where it stops being true.

Finish by explaining (byte) 200 from the retained eight bits and their signed interpretation.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Narrow {
    static byte toByte(int v)
    static short toShort(int v)
    static int toInt(long v)
    static int keepLowBits(long value, int bits)
    static boolean modShortcutWorks(int value)
}

keepLowBits implements the always-correct rule by hand and must agree with the cast for every value. modShortcutWorks reports whether the commonly quoted percent 256 shortcut gives the same answer as the cast.

What your program must do

  • Predict the exact result of every cast before running, not just that something is lost
  • Implement the low bits rule by hand and show it agrees with the cast every time
  • Find a value where the percent 256 shortcut gives a different answer
  • Say why the shortcut can never produce the right answer for that value
Narrow.java
public class Narrow {

    static byte  toByte(int v)  { return 0; }  // TODO
    static short toShort(int v) { return 0; }  // TODO
    static int   toInt(long v)  { return 0; }  // TODO

    // The rule, written out by hand: keep the low n bits, then read them as signed.
    // This must agree with the cast for EVERY value, including negatives.
    static int keepLowBits(long value, int bits) {
        return 0; // TODO
    }

    // People say a byte cast is the same as % 256. Is it?
    static boolean modShortcutWorks(int value) {
        return true; // TODO
    }

    public static void main(String[] args) {
        // PREDICT each of these before running:
        //   (byte) 300, (byte) 200, (byte) 128, (byte) 127, (byte) -300
        //   (short) 70000, (int) 9000000000L
    }
}
Hint 1
Mask with (1L << bits) - 1 to keep the low bits, then check the top bit of what is left. If it is set, subtract 2 to the power of bits.
Hint 2
(byte) 300 is 44 and 300 % 256 is also 44, so the shortcut looks right. Try 200.
Hint 3almost the answer
(byte) 200 is -56 and 200 % 256 is 200. The shortcut has no way to produce a negative number, so it can never be the same rule.
What this is really testing

Whether you can predict the exact result of a narrowing cast, not just say "data is lost". Anyone can say data is lost. Saying it will be -56 requires actually understanding what happened.

C

Convert Before Division

Real work·20 min·20 points

checkedex-2-3-c

If the required average is fractional, 7 / 2 must not be evaluated as integer division before assignment to double.

Work out why. The answer is entirely about when the division happens relative to the conversion.

Trace the four attempted fixes by identifying the operand types at the division operator. A cast applied after division cannot recover a discarded fraction.

Finish by finding a fifth fix that is not on the list. There is more than one good answer.

This bug appears in real production code constantly. It never crashes. It just reports the wrong average to somebody who trusts it.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Average {
    static double brokenAverage(int total, int count)
    static double castTheResult(int total, int count)
    static double castTheTop(int total, int count)
    static double castTheBottom(int total, int count)
    static double multiplyByOne(int total, int count)
    static boolean fixWorks(String which)
}

Four attempted fixes, written exactly as named. fixWorks takes one of the four method names and answers whether that fix actually works. Fill it in after you have run them.

What your program must do

  • Predict all five results for 7 divided by 2 before running
  • Implement each fix exactly as its name describes
  • Fill in fixWorks only after you have the results
  • Say why one of the four fixes changes nothing
Average.java
public class Average {

    // The bug: assigning to a double does not undo integer division.
    static double brokenAverage(int total, int count) { return 0; }  // TODO

    // Four attempted fixes. Write each one exactly as its name says.
    static double castTheResult(int total, int count) { return 0; }  // TODO: (double) (total / count)
    static double castTheTop(int total, int count)    { return 0; }  // TODO: (double) total / count
    static double castTheBottom(int total, int count) { return 0; }  // TODO: total / (double) count
    static double multiplyByOne(int total, int count) { return 0; }  // TODO: total * 1.0 / count

    // Which of the four actually work? Answer AFTER you have run them.
    static boolean fixWorks(String which) {
        return true; // TODO
    }

    public static void main(String[] args) {
        // TODO: print all five for 7 and 2. Predict each one first.
    }
}
Hint 1
Java works out total / count using the types of the operands. Two ints give an int, and the assignment to a double happens afterwards, far too late.
Hint 2
castTheResult is the one that fails. The division has already produced 3 by the time the cast runs, and casting 3 gives 3.0.
Hint 3almost the answer
One double anywhere in the expression promotes the whole thing, which is why casting either side works and why multiplying by 1.0 works too.
What this is really testing

Whether you can see integer division hiding inside an expression that assigns to a double. This is the single most common numeric bug written by new Java programmers, and it never throws an error.

D

Widening Can Round

Hard·30 min·30 points

checkedex-2-3-d

Every conversion in this program is a widening primitive conversion. Java permits each one without a cast. That language category does not promise that every source value remains exact.

Run it and watch two different int values turn into the same float.

Then work out why, from first principles, using what Section 2.2 told you about how a float is built. Do not look it up. The number of mantissa bits is the whole answer, and you already know it.

Once you have that, predict where the same thing starts happening for long into double. You should be able to name the exact number by reasoning alone.

Finish by writing down which widening conversions really are lossless, and which ones only look it. This is the list you will want in your head when someone insists that widening is always safe.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class NotSafe {
    static boolean intSurvivesAsFloat(int value)
    static boolean longSurvivesAsDouble(long value)
    static int firstIntLostAsFloat()
    static long firstLongLostAsDouble()
    static boolean collidesAsFloat(int a, int b)
}

Survives means the value comes back unchanged after a round trip through the wider type. collidesAsFloat answers whether two different ints become the same float.

What your program must do

  • Show that widening from int to float can lose information
  • Work out where it starts from the precision bit count, not by searching
  • Find two different ints that become the same float
  • Find where the same thing happens for long to double
NotSafe.java
public class NotSafe {

    // Does this value come back unchanged after a trip through a float?
    static boolean intSurvivesAsFloat(int value) { return true; }  // TODO

    // And the same question for long through double.
    static boolean longSurvivesAsDouble(long value) { return true; }  // TODO

    // The smallest value that does NOT survive. Work it out from the
    // number of precision bits, do not search for it.
    static int  firstIntLostAsFloat()    { return 0; }  // TODO
    static long firstLongLostAsDouble()  { return 0; }  // TODO

    // Two DIFFERENT ints that become the same float.
    static boolean collidesAsFloat(int a, int b) { return false; }  // TODO

    public static void main(String[] args) {
        // No casts anywhere in this exercise. Every conversion here is a
        // WIDENING one, which Java performs happily and silently.
    }
}
Hint 1
A float has 32 bits: 1 sign, 8 exponent and 23 mantissa, plus one implied leading bit. That gives 24 bits of precision, so every int up to 2^24 is exact.
Hint 2
2^24 is 16777216 and it is fine. 16777217 needs 25 bits and there is nowhere to put the last one, so it rounds to its neighbour.
Hint 3almost the answer
Compare through a long, not through an int. Casting a float back to an int saturates at Integer.MAX_VALUE rather than wrapping, so the very largest values look like they survived when they did not.
What this is really testing

Whether you can connect this section back to Section 2.2. Some widening primitive conversions preserve range without preserving every source value, and the explanation is in the precision bits.

08

After the credits

Overload resolution in Phase V reuses primitive widening rules when Java chooses a method. Boxing in Phase VII adds another conversion category with a different priority.

Shift operators in Phase III have related promotion rules, but their right operand is not converted to the left operand’s type. Its low bits determine the shift distance.

Threads you opened in this section