Data types

Understand C# copy semantics, nullability, numeric conversions, and boxing, then avoid the type errors common in generated code.

level intermediate time 10 min at Standard depth
version C# 14 / .NET 10
what

C# types fall mainly into value types and reference types. Assignment copies a value of the former and a reference to an object for the latter.

trap

Value type doesn’t mean “stored on the stack,” and T? doesn’t use the same mechanism for value and reference types. Blurring these distinctions causes copy, null, and conversion bugs.

fix

Choose types for the semantics you need, handle nulls, parse failures, and narrowing conversions at boundaries, and use generics to avoid needless boxing.

What it is and why it exists

Every value in C# has a compile-time type, and each runtime object has an actual type. The type determines which operations a value supports, how assignment and argument passing behave, and which conversions require explicit approval. The compiler uses that information to reject many invalid combinations before the code runs.

C# divides types mainly into two categories. A value type variable contains a value directly, while a reference type variable contains a reference to an object. int, bool, enum, and struct are value types. class, interface, arrays, delegates, object, and string are reference types.

The most immediate effect of this split is copy semantics. Assigning a value type to another variable copies its data. Assigning a reference type copies only the reference, so two variables can point to the same mutable object. The same rule applies to ordinary by-value arguments.

C# has a unified type system: any value can be treated as object. A reference type only needs its reference copied. A value type usually needs boxing , which creates an object containing that value. The unified model lets nongeneric APIs accept any value, but it doesn’t erase the cost of copying and allocation.

How it works

The category determines what a variable holds

CategoryWhat the variable holdsResult of assignmentCan directly be null
Non-nullable value typeThe complete valueCopies the valueNo
Nullable value type T?A T value or a no-value stateCopies the whole nullable valueYes
Reference typeA reference to an objectCopies the referenceAt runtime, yes
Nullable reference type T?The same runtime reference plus nullable intentCopies the referenceYes

Don’t reduce “contains a value directly” to “value types always live on the stack.” A value-type field can be inline in a class object or array, while a local value can move into the managed heap through a closure, async state machine, or boxing. Type categories specify semantics, not a fixed memory region.

A reference-type variable has a value too: its value is a reference. Copying the reference doesn’t copy the object, and assigning a new reference to one variable doesn’t change the other variable. The second variable observes a change only when code mutates the shared object through either reference.

Nullable values and nullable references

For a non-nullable value type T, T? is shorthand for Nullable<T>. It records whether a value exists and, when it does, the T value itself. An int? can therefore hold an integer or null, while reading Value when none exists throws InvalidOperationException.

For a reference type, a nullable reference type annotation expresses an API’s intent. string says callers shouldn’t supply null; string? says null is expected. Both are still the same reference type at runtime. The compiler reports warnings through flow analysis and doesn’t insert runtime checks automatically.

?. stops member access when its receiver is null, ?? supplies a fallback, and an is not null pattern narrows the null state of later code. The null-forgiving operator ! only suppresses a compiler warning. It neither checks a reference nor turns null into an object.

Conversions, casts, and parsing

Implicit conversions apply only when the language decides no confirmation is required, such as int to long or a derived-class reference to a base-class reference. An implicit numeric conversion doesn’t always preserve precision: some conversions from integers to floating point can still round. Don’t read “implicit” as “numerically unchanged.”

A narrowing conversion requires an explicit cast, such as long to int. When narrowing integers, an unchecked context discards high bits beyond the target width, while a checked context throws OverflowException. If overflow must be an error, put both the conversion and its related arithmetic inside the checked context.

A reference cast checks the object’s runtime type and throws InvalidCastException on failure. An is pattern works well when you need to test and capture the target value together. as returns null after a failed conversion to a compatible reference type or nullable value type. Text-to-number conversion isn’t a cast; use TryParse with an explicit culture and format policy.

Boxing preserves the exact value type

Converting a value type to object or to an interface it implements usually boxes it. The runtime value inside the box keeps its exact original value type, so a boxed int must first be unboxed as int. Even though int converts implicitly to long, unboxing that same object directly as long throws InvalidCastException.

A generic collection such as List<int> can specialize its storage and operations for int, so it normally doesn’t need to convert every element to object. Check nongeneric containers, APIs that accept object, and conversions from structs to interface references. Whether boxing occurs depends on the actual conversion, not on a variable name or a surface reading of the source.

Examples

Copying values and references

The first example deliberately uses a mutable struct so the copy is visible. Immutable structs are usually a better production design, but the copy rule is the same.

CopySemantics.cs
using System;
using System.Collections.Generic;

var original = new Reading(21);
var copy = original;
copy.Celsius = 25;

var firstCart = new Cart(new List<string> { "book" });
var secondCart = firstCart;
secondCart.Items.Add("pen");

Console.WriteLine($"readings: {original.Celsius}, {copy.Celsius}");
Console.WriteLine($"cart items: {string.Join(", ", firstCart.Items)}");

public struct Reading
{
    public Reading(int celsius) => Celsius = celsius;
    public int Celsius { get; set; }
}

public sealed class Cart
{
    public Cart(List<string> items) => Items = items;
    public List<string> Items { get; }
}
readings: 21, 25
cart items: book, pen

copy receives a complete copy of Reading, so changing it doesn’t touch original. secondCart receives a reference to the same Cart object. After adding an item through it, firstCart.Items observes the new element too.

A struct field can still contain a reference. If Reading held a mutable list, copying the struct would copy the list reference rather than recursively copying the list. Value copying covers the fields the value actually contains.

Keeping absence in the type

Failure is a normal input state when parsing external text, so exceptions shouldn’t control the ordinary branch. This example uses decimal? to mean “a valid price or no price” and fixes a format that doesn’t depend on the machine’s regional settings.

NullableParsing.cs
#nullable enable
using System;
using System.Globalization;

string?[] inputs = ["19.95", null, "free"];

foreach (string? input in inputs)
{
    decimal? price = ParsePrice(input);
    Console.WriteLine(price is decimal value
        ? $"price: {value:F2}"
        : "price: invalid");
}

static decimal? ParsePrice(string? text)
{
    if (decimal.TryParse(
        text,
        NumberStyles.Number,
        CultureInfo.InvariantCulture,
        out decimal value) && value >= 0)
    {
        return value;
    }

    return null;
}
price: 19.95
price: invalid
price: invalid

The ? on text tells callers and the compiler that the parameter might be null. TryParse handles both null and malformed text, and the range condition rejects negative values. The returned decimal? differs from a reference annotation: it really is a Nullable<decimal> value.

A real boundary also needs policies for decimal places and the largest allowed amount. decimal represents the example’s decimal value exactly, but it still has a finite range and 28 to 29 significant digits. It doesn’t choose a business rounding rule for you.

Failing clearly at narrowing and unboxing

This example places two easily confused operations together. Numeric narrowing changes the representation’s range, while unboxing demands an exact match with the type inside the box. Both use cast syntax, but their runtime rules differ.

ConversionsAndBoxing.cs
using System;
using System.Collections.Generic;

long shipmentCount = int.MaxValue;

try
{
    int narrowed = checked((int)(shipmentCount + 1));
    Console.WriteLine(narrowed);
}
catch (OverflowException)
{
    Console.WriteLine("narrowing overflow detected");
}

object boxed = 42;
Console.WriteLine($"boxed runtime type: {boxed.GetType().Name}");

try
{
    long wrong = (long)boxed;
    Console.WriteLine(wrong);
}
catch (InvalidCastException)
{
    Console.WriteLine("unbox must match Int32");
}

long converted = (int)boxed;
var values = new List<int> { (int)converted };
Console.WriteLine($"converted: {values[0]}");
narrowing overflow detected
boxed runtime type: Int32
unbox must match Int32
converted: 42

checked makes the out-of-range conversion to int fail explicitly. The box contains a System.Int32, so (long)boxed isn’t an ordinary numeric conversion from int to long. The correct sequence first unboxes with (int), then widens the result implicitly to long.

The final List<int> stores int values without asking each element to become object first. The extra cast to int here only matches the collection’s element type; the numeric value 42 is still within range.

Pitfalls

Explaining type categories with memory locations

Fix: reason about copying and sharing first. Discuss the stack, managed heap, or registers only when analyzing allocations in specific code with a known runtime and suitable tools. Don’t infer a location directly from the struct or class keyword.

Hiding an unknown null state with !

Fix: validate required values as data enters the system, use ? for legitimate absence, and narrow with pattern matching. Reserve ! for an invariant the program has established but the compiler can’t understand, and prove that invariant with a test.

Letting the current culture change parsing

Fix: specify CultureInfo.InvariantCulture or an explicit business culture at protocol, configuration, and persistence boundaries, and prefer TryParse. Test decimal and group separators, signs, nulls, out-of-range values, and trailing characters.

Checking only the final assignment for overflow

Fix: widen at least one operand before the operation, as in checked((long)quantity * unitCents), and make checked cover the whole expression. Boundary tests should include each input’s maximum and the maximum of their combination.

Treating numeric conversion as unboxing

Fix: if the object contract guarantees a boxed int, capture it with boxed is int value and then convert that value to long. If inputs can hold several numeric types, list the accepted set explicitly. Don’t ask Convert.ChangeType to guess domain rules.

Treating a mutable struct as a shared object

Fix: prefer an immutable readonly struct or readonly record struct for small value objects. Use a class when you need shared mutable identity. If in-place struct updates are necessary, make the ref semantics visible in both the API and the call site.

Deep Storage location depends on context

Storage location depends on context

A type declaration determines layout and copy semantics, while actual storage depends on where the value appears. A struct field inside a class object is normally part of that object’s data. A value-type array also stores its elements inline rather than creating a separate object for each one. Boxing, in the other direction, creates an object for a value type.

Local variables don’t map cleanly to stack slots either. The JIT can keep a value in a register, captured local state becomes fields of a compiler-generated object, and values in an async method can move into its state machine. The specification lets an implementation choose the physical representation as long as observable behavior follows the language rules.

This distinction changes the order of a useful code review. First ask whether values remain independent after assignment, whether variables can point to the same object, and whether a method receives an alias through ref. Only after the semantics are right is it useful to measure allocations or benchmark the real cost.

Parameter passing still copies by default

Ordinary parameters are passed by value. A value-type parameter receives a copy of the value, while a reference-type parameter receives a copy of the reference. The called method can mutate the object through that reference, but assigning a new reference to the parameter alone can’t replace the caller’s variable.

ref, out, and in parameters instead pass an alias to a variable’s storage. ref permits reads and writes, out requires the called method to assign, and in provides read-only access. They alter parameter-passing behavior; they don’t permanently turn a struct into a reference type.

Boxing nullable value types

Nullable<T> has special boxing rules. Boxing a T? with no value produces a null reference. Boxing a T? with a value boxes the underlying T, rather than creating a box whose runtime type is Nullable<T>. You therefore can’t observe Nullable<int> through boxed.GetType().

That behavior can surprise reflection code and general object pipelines. After int? number = 42; object boxed = number;, boxed.GetType() returns System.Int32. Assign null to number and then to object?, and the result is simply null; calling GetType() fails because no object exists.

Pattern matching is usually clearer than handwritten unboxing branches. boxed is int number validates the exact runtime type and captures the underlying value in one step. When handling object?, cover null first and then enumerate allowed types so the input contract stays visible.

When generics avoid boxing

Generics let the runtime use concrete representations for value-type arguments. The elements of List<int> are int, for example, so reading them doesn’t require an object unbox. A generic method that accepts T can also operate directly on a struct in many constrained calls.

This isn’t a promise that “generics never box.” Boxing still happens if generic code explicitly converts a value to object or to an interface reference that requires boxing. Look for conversion boundaries when reasoning about it, then verify performance claims with allocation analysis on the target runtime.

Numeric types aren’t a ladder of precision

Each built-in C# numeric type defines its own range, representation, and operation rules. A wider type can cover a larger range without representing every source value exactly. In particular, long to double is an implicit conversion, but a large integer can round during that conversion.

C# name.NET typeRepresentation focusCommon use
byteSystem.Byte8-bit unsigned integerBinary data
intSystem.Int3232-bit signed integerCounts and indexes
longSystem.Int6464-bit signed integerLarger integer ranges
floatSystem.Single32-bit binary floating pointData tolerating lower precision
doubleSystem.Double64-bit binary floating pointGeneral floating-point work
decimalSystem.Decimal128-bit decimal representationMoney with decimal rounding rules

These keywords are aliases for .NET types, not a second set of types. int and System.Int32 are identical, so the choice between them is normally stylistic. API documentation often uses the .NET name, while C# source usually uses the keyword.

Binary floating point can’t represent many decimal fractions exactly, so the double result of 0.1 + 0.2 needn’t equal the literal 0.3. When comparing measurements, derive a tolerance from the domain’s error model instead of copying an arbitrary constant. Money is often a good fit for decimal, but it still needs explicit policies for scale, rounding mode, and allowed range.

checked primarily changes overflow behavior for integer arithmetic and integer conversions. float and double operations follow floating-point rules and can produce infinity on overflow. A decimal operation throws OverflowException when its result is outside the representable range. Confirm the concrete operand types before discussing an overflow policy.

Literals and var don’t remove static types

An integer literal receives a built-in type that can hold its value, subject to suffixes such as L and U that make the intent explicit. A real literal is double by default; F selects float and M selects decimal. A wrong suffix can put the expression under different precision and conversion rules from its first operation.

var asks the compiler to infer a local variable’s static type from its initializer. Once inferred, that variable still has one definite type and can’t later hold an incompatible value. This differs from dynamic, which postpones many member-binding and conversion checks until runtime.

The target type also affects how some expressions are interpreted, including collection expressions, default, and conditional expressions. When overload selection or a conversion result is surprising, inspect both the subexpression types and the type required by the receiving position. Looking only at the final variable declaration can miss rounding or overflow that already occurred.

Equality follows the type’s contract

The value/reference classification doesn’t determine the meaning of == by itself. A class can overload equality, string uses == for text content, and record types generate value-equality logic. Without an overload, == on an ordinary class compares reference identity.

Structs inherit default field-based equality from ValueType, and they can override Equals and define operators. When a struct contains reference fields, each field’s own equality rules still participate. A value used as a dictionary key must keep its equality and hash-code contracts consistent.

Boxing changes the contract in use again. Two separately boxed int values are different objects, so == applied to them as object compares references and returns false; Equals compares according to the boxed value’s type. Boxed int and long values return false from Equals even when their numbers look equal, because their value types differ.

Generic algorithms should usually use EqualityComparer<T>.Default. It selects the default equality implementation for T and can avoid boxing solely for comparison on many value-type paths. When the domain needs another rule, pass an explicit IEqualityComparer<T> instead of quietly converting types inside the algorithm.

Compile-time and runtime types have separate jobs

A variable’s compile-time type determines which members are directly callable and which candidates participate in overload resolution. An object’s runtime type governs virtual dispatch, pattern matching, and reference-cast results. Assigning a string to object doesn’t change the object, but the statically visible member set through that variable becomes the member set of object.

The pattern value is string text checks the runtime type and, on success, introduces a local whose static type is string. Compared with testing GetType() and then casting, a pattern expresses compatibility correctly and lets the compiler track null state. Compare GetType() directly only when the requirement genuinely means “exactly this runtime type.”

dynamic bypasses some compile-time binding, but it doesn’t remove runtime type rules. Missing members, ambiguous overloads, and invalid conversions simply fail later. It belongs at a few dynamic interop boundaries, not as the default container for unknown JSON or a way around generic design.

A default value isn’t necessarily a valid domain value

default(T) produces a default for any T. Numeric types get zero, bool gets false, reference types get null, and a struct gets the default of each field. Array elements and fields without explicit initializers begin with these defaults too.

Those values satisfy runtime initialization rules without necessarily satisfying business invariants. An order ID of 0, a timestamp of DateTime.MinValue, or a null reference field inside a struct can all be invalid states. A value type’s inability to be null doesn’t make every instance meaningful.

TypeResult of defaultCommon misunderstanding
int0Zero can be real data or an unset sentinel
boolfalseIt can’t represent an unknown state by itself
stringnullThe runtime default is still null with a non-nullable annotation
int?nullThis differs from an instance containing numeric zero
Custom structThe zero-initialized state of every fieldConstructor validation isn’t guaranteed

C# lets a struct declare an explicit parameterless constructor, but default(MyStruct) still creates the zero-initialized value without calling that constructor. Creating a struct array also begins with default elements. A struct design must therefore tolerate its default state or validate it at the boundary where it is used.

Nullable reference analysis warns about some uninitialized paths, but it isn’t a runtime validator. Reflection, deserialization, default array elements, and older code with nullable context disabled can still supply null. Types exposed to those boundaries should establish invariants during construction or entry instead of treating annotations as data cleaning.

Representing “unknown” with extra state

When zero and false are legitimate data, they can’t also stand for “missing.” A value type can use T? for an explicit no-value state. A domain model can instead use a named result union or status enum to distinguish “not provided,” “invalid,” and “valid.” The choice depends on whether callers need more than a binary state.

Returning decimal? from a parser is compact, but it collapses every failure reason into null. If a user interface must report malformed text, a negative number, and an out-of-range value separately, return a result type carrying status and error information. A type should preserve distinctions callers need to handle, not details they can’t use.

That choice is an API contract, not a syntax preference. List the states callers must distinguish, then choose the smallest type that represents them.

Further reading

checkpoint

4 questions · 2 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?