# Data types

Source: https://codewiki.com/csharp/data-types/

> - **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

| Category | What the variable holds | Result of assignment | Can directly be `null` |
| --- | --- | --- | --- |
| Non-nullable value type | The complete value | Copies the value | No |
| Nullable value type `T?` | A `T` value or a no-value state | Copies the whole nullable value | Yes |
| Reference type | A reference to an object | Copies the reference | At runtime, yes |
| Nullable reference type `T?` | The same runtime reference plus nullable intent | Copies the reference | Yes |

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`. 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.

<!-- quick -->

```csharp
// file: 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; }
}
```

```text
readings: 21, 25
cart items: book, pen
```

<!-- /quick -->

`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.

```csharp
// file: 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;
}
```

```text
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.

```csharp
// file: 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]}");
```

```text
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

> **Pitfall:** "Value types are on the stack; reference types are on the heap" mixes implementation with semantics. It can't explain struct fields inside classes, value-type arrays, captured locals, or boxed values.

**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 `!`

> **Pitfall:** Generated code often adds `!` beside a warning, as in `customer!.Address.City`. If `customer` really is `null` at runtime, the code still throws `NullReferenceException`.

**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

> **Pitfall:** `decimal.Parse(text)` uses the current culture. The same `"1,234"` can mean a different value or fail under another configuration, so success on a development machine doesn't prove production behavior.

**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

> **Pitfall:** `long total = quantity * unitCents;` can still multiply as `int` and overflow before the damaged result converts to `long`. A wider destination doesn't change the types of intermediate expressions.

**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

> **Pitfall:** A boxed `int` can't be unboxed directly with `(long)`. The cast looks plausible, but the boxed type doesn't match and the runtime throws `InvalidCastException`.

**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

> **Pitfall:** Reading a struct through a property, collection indexer, or `foreach` variable often produces a copy. Calling a mutating method on that copy might not change the original storage, and some forms fail to compile.

**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

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` 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`. 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 type | Representation focus | Common use |
| --- | --- | --- | --- |
| `byte` | `System.Byte` | 8-bit unsigned integer | Binary data |
| `int` | `System.Int32` | 32-bit signed integer | Counts and indexes |
| `long` | `System.Int64` | 64-bit signed integer | Larger integer ranges |
| `float` | `System.Single` | 32-bit binary floating point | Data tolerating lower precision |
| `double` | `System.Double` | 64-bit binary floating point | General floating-point work |
| `decimal` | `System.Decimal` | 128-bit decimal representation | Money 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.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` 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.

| Type | Result of `default` | Common misunderstanding |
| --- | --- | --- |
| `int` | `0` | Zero can be real data or an unset sentinel |
| `bool` | `false` | It can't represent an unknown state by itself |
| `string` | `null` | The runtime default is still `null` with a non-nullable annotation |
| `int?` | `null` | This differs from an instance containing numeric zero |
| Custom struct | The zero-initialized state of every field | Constructor 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.

<!-- /deep -->

[Checkpoint: csharp/data-types](https://codewiki.com/csharp/data-types/#checkpoint)

## Further reading

- [The C# type system](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/types/)
- [Value types in C#](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/value-types)
- [Nullable reference types](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/null-safety/nullable-reference-types)
- [Type conversions, casting, and boxing](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/types/conversions)
- [C# language specification: types](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/types)
