This is part 3. Part 2 read a program whose variables each had one type and stayed there. Real programs move values between types constantly, and that is where languages quietly differ: what happens to a number that will not fit where it is being put, and whether you were told.

Mica’s answer is one rule with two halves. A value moving to a wider type is promoted on its own, because nothing can be lost. Every conversion that can lose something is written down as an explicit as cast. Nothing narrows silently, and a narrowing that loses bits has a defined answer, which is the part worth understanding properly.

The example is examples/Cast. Keep it open.

make -C mica-container/examples/Cast run
Cast — promotion and explicit casts

Widening happens on its own
  constant into int64:          42
  int32 value into int64:       1000
  float32 value into float64:   2.500000

Narrowing is written down
  127 as int8:                  127
  -42 as int16:                 -42
  3.14159265359 as float32:     3.1415927410
  ... widened back to float64:  3.1415927410
  2^53 + 1 as float64:          9007199254740992

What a narrowing cast costs
  3000 as int8:                 -72
  300 as uint8:                 44

The example, walked

Widening needs nothing

i64v := 42;

source := 1000;
i64v := source;

The first line puts a constant into an int64. A constant has no width of its own: the use that receives it gives it a type, and the value arrives exactly as written. The second puts a 32-bit value into a 64-bit variable, and says nothing about it either. Every int32 value is an int64 value, so there is nothing to decide and nothing to lose. Writing a cast here would add a word that says only what the two types already say.

Note the difference between the two. For a value in a variable, the types decide whether a conversion is safe. For a constant, the value decides, because the compiler knows it exactly.

Floating point works the same way:

f32v := 2.5;
f64v := f32v;

The second line is a plain promotion. The first is a constant again: a real constant stays exact until its use and is rounded once where it is used, here to the nearest float32. 2.5 is exact in binary, so the rounding changes nothing.

Narrowing is written down

source := 127;
i8v := source as int8;

source := -42;
i16v := source as int16;

as is the explicit cast, and it is required whenever a value’s type is wider than its destination. Both values above fit comfortably, so nothing is lost — but the cast is still there, and that is deliberate. A reader scanning this file never has to work out where a value quietly changed width; the places where it could have are the places where as appears.

A constant needs no cast to get there: i8v := 127 compiles, because the compiler knows the value and the value fits. One that does not fit is refused before the program exists, naming the domain it missed:

analyzer error 5233: constant value 300 is outside the domain -128..127 of 'int8'

What float32 does to π

f64v := 3.14159265359;
f32v := f64v as float32;
f64v := f32v;
  3.14159265359 as float32:     3.1415927410
  ... widened back to float64:  3.1415927410

A float32 holds roughly seven decimal digits. π arrives with far more, and the cast keeps what fits: 3.1415927. The second line is the one to look at. Widening back to float64 does not restore the lost digits — it only gives the value room to be printed. A cast changes a value’s type; it cannot recover its history.

What float64 does to a large integer

i64v := 9007199254740993;
f64v := i64v as float64;
  2^53 + 1 as float64:          9007199254740992

An int64 and a float64 are both eight bytes, and still this conversion is written. A float64 carries fifty-three bits of precision, so it holds every integer exactly only up to 2^53, and an int64 reaches past that: 2^53 + 1 is the first integer it cannot hold, and the cast rounds it to the nearest one it can. Leave the cast out and the compiler refuses the assignment, and says what to write:

analyzer error 5599: assignment to f64v: int64 to float64 is written, never
implicit, because float64 holds every integer exactly only up to 2^53 and int64
reaches past it — convert the int64 value with 'as float64', which rounds once
where the program asks for it

What a narrowing cast costs

i64v := 3000;
i8v := i64v as int8;
  3000 as int8:                 -72

3000 does not fit in an int8. Mica does not refuse this, and at the debug and release tiers it does not trap: the cast keeps the low eight bits of the two’s-complement representation. 3000 is 0x0BB8, the surviving byte is 0xB8, and read as a signed int8 that is −72.

This is the design worth being explicit about. Mica does not stop you from narrowing a value that does not fit — it stops you from doing it by accident. The information loss is real, it is defined rather than undefined, and it is visible in the source at the exact point it happens.

The checked tier goes one step further and range-checks every narrowing cast, because a checked build is the one run to find where values stop fitting. Run this example at that tier, make -C mica-container/examples/Cast run MICA_OPTS=checked, and it stops on this line:

Mica runtime failure: reason=cast_out_of_range (4), token_stream_index=203
Mica runtime context: file=Cast.mica, line=99, column=17
Mica runtime source:     i8v := i64v as int8;

A truncation meant on every tier says so by masking first: with i32v holding 300, (i32v and 255) as uint8 is 44 at every tier, because the value the cast receives already fits.

One place the rule is stricter: typed constants. A constant declared with a type holds its value to that type, so after const Ceiling : int32 = 2147483647; the sum Ceiling + 1 computes in int32. Both operands are known at compile time, so the exact sum is too, and an operation that leaves the type it computes in is refused outright, on every tier, naming the exact value and a way to give it room (5421). A constant declared with no type has no type to leave: 2147483647 + 1 is exactly 2147483648. A cast of a typed constant is held the same way: Ceiling as int16 is refused when compiling, on every tier (5233), while 300 as int8, a number with no type, is 44 on every tier.

The unsigned case truncates identically and reads the surviving bits as unsigned: 300 is 0x012C, the byte that survives is 0x2C, and that is 44.

Try it

1. Delete a cast. Remove as int8 from the 3000 line, so it reads i8v := i64v;, and rebuild:

analyzer error 5143: incompatible data types in assignment to i8v: int64 to int8

The compiler will not narrow on your behalf. A conversion that can lose information exists in this program only where you can read it, and this message is that rule holding its ground. Put the cast back.

2. Predict a truncation, then check it. Change 3000 to 200 and work out the answer before you build: 200 is above int8’s maximum of 127, so it wraps to 200 − 256. Rebuild:

  3000 as int8:                 -56

−56, as predicted. (The label still says 3000 — it is a fixed string in the WriteLn beside the calculation. Change it too, and notice that nothing in the language kept those two in step for you.)

3. Find where float32 gives up. Change the printed precision on the π line from %.10f to %.20f and rebuild:

  3.14159265359 as float32:     3.14159274101257324219

Line those digits up against π itself — 3.14159265358979… — and they agree to 3.1415927 and then part company. What follows is not π and not noise: it is exactly what the stored 32-bit value means, printed in full. Nothing invented those digits and nothing rounded them away; they were simply never π’s.

What the compiler proved

Every conversion in this program that could lose information is visible in the source, because the compiler refuses to perform one you did not write. Every conversion that cannot lose information happened without ceremony. And the narrowing that does lose information has a defined result rather than an undefined one: the same −72 on x86-64 and ARM64 at the debug and release tiers, and a stop on its own line at the checked tier.

What Mica takes from you here is the ability to be surprised. What it does not take is the ability to truncate deliberately, which systems code needs.

Next

Choices and repetition — part 4: the two ways a program decides and the three ways it repeats, each loop stating what it knows before it runs. Every example behind the series lives in the tutorial repository, in learning order, each with a header that says what it teaches.