Type Conversions and Casts
C++ is a statically typed language — every variable has a fixed type known at compile time. But programs constantly mix types: you multiply a double price by an int quantity, or store a loop counter into a container that wants a size_t. This lesson explains how C++ handles those mismatches: when it converts automatically, when that gets dangerous, and how to convert safely using explicit casts.
Implicit conversions — what the compiler does for you
Whenever you use two values of different types in the same expression, C++ quietly converts one of them so both operands match. These are called implicit conversions because you do not have to write anything — the compiler inserts them automatically. For fundamental types, the rule is simple: the operand with the lower-ranked type is promoted to the higher-ranked one before the operation runs.
The compiler ranks types from highest to lowest as follows (omitting extended floating-point types for clarity):
| Rank | Type | Typical size |
|---|---|---|
| 1 (highest) | long double | 80-bit or 128-bit |
| 2 | double | 64-bit IEEE 754 |
| 3 | float | 32-bit IEEE 754 |
| 4 | unsigned long long | 64-bit |
| 5 | long long | 64-bit |
| 6 | unsigned long | 32 or 64-bit |
| 7 | long | 32 or 64-bit |
| 8 | unsigned int | 32-bit |
| 9 (lowest) | int | 32-bit |
There is also a rule for the small integer types: any value of type char, signed char, unsigned char, or short is always promoted to at least int before being used in an arithmetic expression. So adding two char values produces an int, not another char.
double fish_length {3.5};
int fish_count {10};
// fish_count is implicitly converted to double before the multiply.
// The result is double.
double total = fish_length * fish_count; // 35.0
// Mixing signed and unsigned: the signed int is converted to unsigned int.
unsigned int x {20u};
int y {30};
auto result = x - y; // result is unsigned! Wraps to 4294967286, not -10The signed/unsigned pitfall above is one of the most common bugs in C++ code. Because y is converted to unsigned int to match x, the subtraction is unsigned arithmetic — and -10 wraps around to 4,294,967,286. Mixing signed and unsigned in comparisons or arithmetic requires care.
Narrowing conversions — silent data loss
A narrowing conversion is an implicit conversion that loses information: assigning a double to an int, or a long long to a short. The compiler will usually allow these without any error — only issuing a warning if you have warnings enabled. Compilers rarely look beyond the immediate statement, so the data loss can occur silently.
double price {9.99};
int whole_dollars = price; // implicit narrowing — 9.99 becomes 9, no error
long long big {1'000'000'000'000LL};
int small = big; // implicit narrowing — result is undefined/truncated
// float cannot exactly represent all 32-bit integers:
int large {1'000'000'160};
float approx {};
approx = large; // stored as 1'000'000'128 — precision lost silentlyNarrowing with assignment notation (=) is permitted and silent. But C++ offers a way to make the compiler refuse a narrowing conversion: brace initialization. If you initialize with {} and the value cannot be represented exactly in the target type, the compiler will emit an error.
double price {9.99};
int whole_dollars = price; // OK (compiles, silently loses 0.99)
int whole_dollars2 {price}; // ERROR: narrowing conversion of price to int
// Brace-init catches narrowing even with integer constants:
double d {3.14};
int i1 = d; // compiles (narrowing via assignment)
int i2 {d}; // compile error: not constant, may lose data
int i3 {3.14}; // compile error: 3.14 is not representable as intBrace initialization is one of the reasons modern C++ style strongly prefers int x {value}; over int x = value; for variable definitions — it is a free safety net that catches a whole class of bugs at compile time.
static_cast — the right tool for explicit conversions
When you genuinely need to convert between types — and you know the conversion may lose precision — the correct approach is to say so explicitly with static_cast. This tells both the compiler and future readers that the conversion is intentional. Compilers will never issue a narrowing warning for an explicit cast, because you have acknowledged the conversion yourself.
static_cast<type_to_convert_to>(expression)
The static_cast keyword means the cast is checked at compile time — the compiler verifies that the conversion between the two types is legal. Casting truncates: converting a floating-point value to an integer discards the fractional part entirely (it does not round). Use std::round() first if you want rounding.
double value1 {10.9};
double value2 {15.9};
// Cast each to int first, then add: truncates each operand → 10 + 15 = 25
int result1 {static_cast<int>(value1) + static_cast<int>(value2)}; // 25
// Add first, then cast: 26.8 is truncated to 26
int result2 {static_cast<int>(value1 + value2)}; // 26
// Round first, then cast: 27
int result3 {static_cast<int>(std::round(value1 + value2))}; // 27
// char to int: useful for printing the numeric code of a character
char ch {'E'};
std::println("'{}' has ASCII code {}", ch, static_cast<int>(ch)); // 'E' has ASCII code 69A practical example: converting a distance in yards to yards, feet, and inches. Note how explicit casts document each conversion point:
#include <cmath> // std::fmod, std::round
#include <print>
int main() {
const unsigned feet_per_yard {3};
const unsigned inches_per_foot {12};
const unsigned inches_per_yard {feet_per_yard * inches_per_foot};
double length {}; // input in yards (e.g., 2.8)
std::print("Enter a length in yards: ");
std::cin >> length;
// Discard fractional yards:
unsigned int yards {static_cast<unsigned int>(length)};
// Fractional yard → whole feet:
unsigned int feet {static_cast<unsigned int>((length - yards) * feet_per_yard)};
// Remaining fraction → whole inches (rounded):
unsigned int inches {static_cast<unsigned int>(
std::round(std::fmod(length * inches_per_yard, inches_per_foot)))};
std::println("{} yards → {} yards {} feet {} inches",
length, yards, feet, inches);
// For 2.8 yards: 2.8 yards → 2 yards 2 feet 5 inches
}The four named casts
C++ provides four named cast operators. You will reach for static_cast in the vast majority of cases; the others address specific niche situations. Knowing all four helps you recognize them when reading code.
static_cast<T>(expr)
Compile-time checked conversions between related types: numeric conversions, pointer up/down casts in a class hierarchy (unsafe downcast — no runtime check), and void* conversions. This is the everyday cast you reach for first.
double d {3.7};
int i {static_cast<int>(d)}; // 3 (truncates)
void* raw_ptr = &d;
double* dp = static_cast<double*>(raw_ptr); // round-trip through void*dynamic_cast<T*>(ptr)
Runtime-checked downcast for polymorphic class hierarchies (classes with virtual functions). Returns nullptr (for pointer form) or throws std::bad_cast (for reference form) if the object is not actually the target type. You will meet this in the Inheritance lesson.
// Base* might actually point to a Derived object
Base* b = get_some_object();
// safe downcast: returns nullptr if b is not a Derived
if (auto* d = dynamic_cast<Derived*>(b); d != nullptr) {
d->derived_only_method();
}
// static_cast would be undefined behavior here if b is not actually a Derivedconst_cast<T>(expr)
Adds or (more commonly) removes const or volatile from a pointer or reference. Only safe when the underlying object was not originally declared const. The primary legitimate use is interfacing with older C APIs that forgot to mark their pointer parameters const.
// Legacy C API: forgot to mark parameter const (but doesn't actually write through it)
void legacy_print(char* str); // should be const char*, but isn't
const std::string msg {"hello"};
legacy_print(const_cast<char*>(msg.c_str())); // OK: legacy_print won't modify it
// Modifying through const_cast when the original was const is undefined behavior:
const int x {42};
int* p = const_cast<int*>(&x);
*p = 99; // undefined behavior — never do thisreinterpret_cast<T>(expr)
Reinterprets the raw bit pattern of a pointer or reference as a completely different type. No conversion occurs — the bits stay identical. This is the cast you reach for in low-level systems code: device driver register maps, serialisation, and platform-specific memory layout work. Misuse leads directly to undefined behavior.
// Inspect the raw bytes of a float:
float f {3.14f};
unsigned char* bytes = reinterpret_cast<unsigned char*>(&f);
for (int i = 0; i < sizeof(float); ++i)
std::print("{:02x} ", bytes[i]); // e.g.: c3 f5 48 40 (little-endian)
// Casting between unrelated pointer types is almost always wrong outside of
// hardware/driver code — there is virtually no safe use at the application level.Old-style casts — never use them in new code
Before the four named casts were introduced to C++ around 1998, explicit casts were written C-style: the target type in parentheses before the expression. You will see this syntax in older codebases:
double d {3.7};
// C-style cast — don't write this:
int i = (int)d;
// The modern equivalent:
int j = static_cast<int>(d); // ← always prefer thisThe problem with C-style casts is that they silently perform whichever of the four named cast operations the compiler thinks you intended — including the dangerous ones like reinterpret_cast and removing const. There is no way to tell from looking at (int)x whether it is a safe numeric conversion or a hazardous pointer reinterpretation. Named casts make the intent unmistakable, are easier to search for in code reviews, and let the compiler reject misuse.
Rule
Never write C-style casts in new C++ code. Always use one of the four named cast operators. Most modern style guides (C++ Core Guidelines, Google C++ Style Guide) ban C-style casts entirely. This is the last time you will see the C-style cast syntax on this site.
Which cast to use — a quick guide
| Situation | Cast to use |
|---|---|
| Converting between numeric types (double→int, int→double, etc.) | static_cast |
| Upcasting or explicit downcasting in a class hierarchy (no virtual) | static_cast |
| Safe downcast where the type might be wrong (virtual classes) | dynamic_cast |
| Calling a legacy API that forgot const on its parameters | const_cast |
| Low-level memory/hardware access — inspecting raw bytes | reinterpret_cast |
| C-style (type) cast | Never — replace with the appropriate named cast |
Key rules to remember
Implicit conversions go toward higher-ranked types
In a mixed expression, the lower-ranked operand is promoted. double + int → double + double. Arithmetic on types below int (char, short) is always promoted to int first.
Casting to integer always truncates — it does not round
static_cast<int>(3.9) is 3, not 4. If you want rounding, use std::round() before casting.
Brace initialization {} prevents narrowing at compile time
int x {3.9}; is a compile error. int x = 3.9; is silent data loss. Prefer braces whenever you initialize a variable.
Signed/unsigned mixing is treacherous
When a signed negative value is converted to unsigned, it wraps to a huge positive number. Compilers can warn about this — enable -Wsign-conversion (GCC/Clang) or /W4 (MSVC).
static_cast is almost always the right cast
The other three casts are specialists: dynamic_cast for runtime polymorphism, const_cast for legacy APIs, reinterpret_cast for low-level memory. If you find yourself reaching for const_cast or reinterpret_cast in application code, reconsider the design.