The C# Language

Lesson 1 of 5

Types, Value and Reference

"The beginning of wisdom is to call things by their proper name." — Confucius, traditional rendering of the rectification of names in the Analects

Part I was about the machine: what the compiler emits, how an assembly is loaded, what the JIT does to IL, where values live and how they are reclaimed. Part II turns to the language that runs on it, and this first chapter covers the thing C# is built on top of — its type system.

That sounds like the most skippable chapter in the volume. It is not, for a reason worth stating plainly: C# has a single type hierarchy with object at the root, and int is in it. Not "behaves a bit like", not "can be converted to" — System.Int32 genuinely inherits from System.ValueType, which genuinely inherits from System.Object, and you can call ToString() on the literal 3 because it is a method it actually has. Java, by contrast, has primitives outside the object hierarchy and a parallel set of wrapper classes to bridge them. C# made the opposite choice in 2000, and nearly every behaviour in this chapter — boxing, Nullable<T>, why generics avoid allocations that interfaces do not — falls out of it.

The practical reason to care is that the unification is almost seamless, and the seams are exactly where interview questions live. A value type can be used where an object is expected, but doing so allocates. A Nullable<int> is a struct pretending to be nullable, and the pretence leaks in specific, memorable places. A conversion between two numeric types may be silent, lossy, or refuse to compile, and which one depends on rules most people have absorbed by habit rather than learned.

Everything here is checkable, and most of it in two or three lines. By the end of this chapter you should be able to answer, for any expression in your codebase, three questions: what type is this really, what happens when it is converted, and does using it this way allocate.

The big question

If int really is an object — inheriting from ValueType, inheriting from Object — then what exactly is the difference between a value type and a reference type, given that both are objects?

One hierarchy, with everything in it

Start by asking the runtime for the inheritance chain of several types that feel like they belong to different worlds.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Four things in that output are worth naming properly.

int is System.Int32. The keyword is an alias the compiler expands; there is no separate primitive type hiding behind it. typeof(int) == typeof(Int32) is True because they are the same type written two ways, which is why you can use either in source and why int.Parse and Int32.Parse are the same method.

Every value type inherits from ValueType, which inherits from Object. That is the unification: a Money struct and an Invoice class share a root, so both have ToString(), Equals() and GetHashCode(), and both can be stored in a variable of type object.

Enums have an extra step. Status → Enum → ValueType → Object, which is why enums get ToString() that prints the member name, and why Enum.Parse and Enum.GetValues exist as a general facility rather than per-enum magic.

Arrays inherit from Array. int[] → Array → Object, which is where Length, CopyTo and GetEnumerator come from. Arrays are reference types even when their elements are not — new int[10] is one object on the heap containing ten integers, as the previous chapter measured.

So what is the difference between a value type and a reference type, given both are objects? It is the one Part I kept returning to: assignment copies the value for one and copies the reference for the other. ValueType exists precisely to mark the types for which the runtime uses copy semantics, and overriding Equals and GetHashCode with sensible field-by-field defaults.

Everyday analogy

Think of a national identity system that covers everyone, including people most registries would treat as special cases. The uniform scheme means one query answers "tell me about this person" for anybody, which is what makes everything downstream simpler — one form, one lookup, one set of rules. The cost appears at the edges: issuing a card for someone who was never going to need one is pure overhead, and that is boxing. C# chose the uniform registry and then spent twenty years adding ways to skip the card when nobody asks for it: generics, constraints, and Span<T>.

Conversions: silent, lossy, and refused

A type system is as much about what it lets you convert as what it lets you declare. C# has three kinds of conversion, and the difference between them is a judgement about information loss.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Implicit conversions are the ones the compiler performs without being asked, and the rule governing them is that no information may be lost: int to long, float to double, a derived class to its base. If a conversion can lose data it must be explicit, which is C#'s way of making you write down that you accepted the loss.

(byte)300 gives 44, which is 300 modulo 256, and it does so silently because C# arithmetic is unchecked by default. The checked version throws instead — the same opcode swap from Part I chapter 1, add becoming add.ovf, applied to conversion. This is the one place where C#'s default is arguably the wrong one, and it is why money and identifiers should be sized deliberately rather than narrowed casually.

(int)3.99 is 3 and (int)-3.99 is -3. The cast truncates toward zero. It does not round, and it does not floor — so for negative numbers it goes up. Anybody who has written (int)(value / 2) on a quantity that can be negative has met this.

Math.Round(3.5) and Math.Round(4.5) are both 4. This is banker's rounding — round half to even — and it is the default because rounding halves consistently upward biases a long series of sums. It is also the single most reported "bug" in .NET that is not a bug. When you want the schoolroom behaviour you ask for it: MidpointRounding.AwayFromZero.

0.1 + 0.2 != 0.3 for the reason it does not in any language with binary floating point, and the :R format shows the actual value. decimal gets it right because it is base-ten, which is why money is decimal and physics is double.

Where this came from

Java shipped in 1995 with eight primitive types outside the object hierarchy, because in 1995 making every integer an object was unaffordable. The consequence was two parallel worlds — int and Integer, boolean and Boolean — and a decade of autoboxing rules to paper over the join. When C# was designed in the late 1990s the team had both that example and the performance headroom to do something else: one hierarchy, with ValueType as a marked branch whose members use copy semantics and can live inline. The decision shaped everything downstream. It is why C# could add real generics in 2005 without erasure, why Nullable<T> is a library type rather than a language hack, and why List<int> stores actual integers where Java's List<Integer> stores pointers to objects.

Asking what something is

Conversions assume you already know the type. Often you do not, and C# gives you three ways to ask — with meaningfully different failure behaviour.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

The last pair is the one that catches people, and it follows directly from the unified hierarchy. A boxed int is an object holding an Int32. Unboxing checks the type exactly — it is not a conversion, it is a reinterpretation of the bytes inside the box — so (long)someBoxedInt fails even though int converts to long without loss. You must unbox to the real type first and then widen: (long)(int)number.

The three forms are not stylistic alternatives:

  • A cast says "I know what this is." If you are wrong, you get an exception, which is correct when being wrong means your program's assumptions are broken.
  • as says "it might be this." You get null instead of an exception, and you must check. It works only on reference types and nullable value types, because it needs null as a failure value.
  • is with a pattern says "if it is this, give it to me as that." It tests and binds in one step, and it is the form to prefer in new code — the next chapter but one is entirely about how far this idea has been extended.

Nullable<T>, and where the pretence leaks

C# lets you write int? and treat it as an int that might be absent. Underneath, there is no such thing: Nullable<T> is an ordinary struct with a bool and a T, and the compiler does a great deal of work to make it feel like a language feature.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Boxing a nullable does not produce a boxed Nullable<int>. If it has a value you get a boxed int; if it does not you get a plain null reference. The runtime special-cases it, which is why int? can be passed to anything expecting object and behave exactly as a nullable thing should.

The comparison result is the one to remember. none > 3 is false and none <= 3 is also false, because comparing with an absent value yields false either way. So !(x > 3) is not equivalent to x <= 3 once x is nullable, and a validation written as if (!(age > 18)) Reject(); will reject nobody when age is null — or accept everybody, depending on which way round you wrote it. Three-valued logic is the same thing that catches people in SQL, for the same reason.

And none == null is true while none.Equals(null) is also true, which is pleasant but not obvious, since Equals is an instance method being called on something that is supposedly null. It works because none is a struct — there is no reference to be null, only a struct whose HasValue is false.

The engineer's notebook

I wanted to open that block with the most famous nullable trick in C#: int? x = null; x.GetType(); throws NullReferenceException, because calling an object method boxes the value first and boxing an empty nullable gives you a plain null. It is a lovely demonstration and I could not use it, because this playground gives the wrong answer. Measured against desktop .NET: none.GetType() returns Nullable`1 here and throws on desktop, and some.GetType() returns Nullable`1 here and Int32 there. Mono on WebAssembly answers from the static type instead of boxing. The explicit (object) cast in the block above behaves correctly on both, which is why the block uses that form — but the bare .GetType() had to be cut. Worth saying out loud: a runtime that is wrong in one small place is more dangerous than one that is slow everywhere, because nothing announces it.

For completeness, here is what your own machine prints for the cut demonstration:

int? some = 5;     some.GetType()  ->  Int32
int? none = null;  none.GetType()  ->  System.NullReferenceException

Where boxing happens when you never wrote a cast

The unified hierarchy's bill arrives as boxing, and the reason it is worth a section is that most boxing in real code is invisible in the source.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

The first three lines are the whole argument for generics. Calling Area() on the struct directly costs nothing. Passing that same struct to a method taking IShape costs a full object, because an interface reference needs something on the heap to point at. Passing it to a generic method constrained to IShape costs nothing again — the runtime generates a version specialised for Circle, calls the method directly, and never boxes.

That is the difference .NET generics make, and it is why the next chapter but one is called "Generics Without Erasure". In Java the same generic method would erase to Object and box anyway.

ArrayList versus List<int> is the same point in its historical form. Before generics, every collection stored object, so every integer put into one was boxed. List<int> stores actual integers.

And n.Equals((object)42) boxes while n == 42 does not, which is the kind of thing that turns up in a profile as an allocation on a line containing no new.

Choosing, in practice

All of which leaves a practical question the type system does not answer for you: when you are declaring something new, which should it be?

The default is class, and the bar for departing from it is higher than most people think. A type should be a struct when all of the following hold: it is small — the guidance is sixteen bytes or so, though as the previous chapter showed, measurement beats the guidance; it is logically a single value rather than an entity with identity; it is immutable; and it is not going to spend its life being passed around as an interface or stuffed into non-generic APIs, because then it will be boxed anyway and you have paid the copying cost for nothing.

The clearest signal is the identity question. A Money, a Point, a DateRange has no identity — two instances with the same contents are the same thing, and it would be strange to ask which one you have. An Invoice, a Customer, an Order does have identity: two invoices with identical fields are still two invoices, and you would want to tell them apart. That distinction predicts the right answer more reliably than counting bytes.

Modern C# narrows the gap between the two choices considerably. record struct gives you a value type with compiler-generated value equality, ToString, and with-expressions — the ergonomics of a record without the allocation. readonly struct makes immutability something the compiler enforces rather than something you remember, and it lets the compiler skip defensive copies it would otherwise make when calling members on a readonly field. ref struct, which Span<T> is, goes further still: it may only ever live on the stack, so the compiler forbids boxing it, storing it in a field, or capturing it in a lambda or an async method.

That last restriction is worth seeing as a feature rather than an annoyance. A ref struct is the type system being asked to guarantee that something is never boxed — exactly the invisible cost this chapter has spent its length chasing — and refusing to compile the code that would break the guarantee. It is the same move as static lambdas from the previous chapter: turning a performance mistake into a compile error.

In the real world

Nearly every struct in a working .NET codebase is one of about five things: a money or quantity type wrapping a decimal, a strongly-typed identifier wrapping an int or a Guid, a coordinate or range pair, a result-or-error wrapper, and Span-adjacent plumbing in the hot paths of a parser or serializer. The strongly-typed identifier is the one most teams adopt first and benefit from most, because record struct OrderId(int Value) costs nothing at runtime and makes GetOrder(customerId) a compile error rather than a support ticket. If you are weighing your first struct, that is the one to start with — and then measure it, since a struct that ends up boxed on every call through an interface has given you the type safety and quietly taken back the allocation saving.

Rendering diagram…

Common Mistakes

  1. Thinking int is not really an object. It is System.Int32, it inherits from ValueType and then Object, and the keyword is an alias. The unification is real, which is what makes boxing both possible and necessary.

  2. Expecting (int) to round. It truncates toward zero, so (int)3.99 is 3 and (int)-3.99 is -3. Use Math.Floor, Math.Round or Math.Truncate and say which you meant.

  3. Being surprised by banker's rounding. Math.Round(3.5) and Math.Round(4.5) are both 4, deliberately, to avoid biasing long sums upward. Ask for MidpointRounding.AwayFromZero when you want the schoolroom rule.

  4. Narrowing without checked. (byte)300 is silently 44, because C# arithmetic is unchecked by default. Where a value should never overflow, say so with checked and let it throw.

  5. Using double for money. 0.1 + 0.2 != 0.3 in binary floating point. decimal is base-ten and gets it right, at the cost of speed and range.

  6. Unboxing to the wrong type. A boxed int can only be unboxed to int. (long)boxedInt throws, because unboxing is an exact type check and not a conversion — unbox then widen: (long)(int)x.

  7. Treating !(x > 3) as x <= 3 when x is nullable. Both comparisons are false for a null, so negating one does not give the other. Check HasValue explicitly, or pattern match.

  8. Assuming a boxed nullable is a boxed Nullable<T>. It is either a boxed underlying value or a plain null; the runtime special-cases it so that nullables behave correctly as objects.

  9. Taking a struct as an interface parameter in a hot path. That boxes on every call. A generic method with the interface as a constraint does the same job and allocates nothing.

  10. Using as on a value type, or a cast where you meant is. as needs null as its failure value, so it does not work on non-nullable value types; and a cast you are not sure about should be is with a pattern, which tests and binds in one step.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
What is the difference between a value type and a reference type?
Model answer — Assignment semantics. Assigning a value type copies the value, so two variables are independent; assigning a reference type copies the reference, so two variables can refer to the same object and a change through one is visible through the other. Both are part of a single hierarchy — value types inherit from System.ValueType, which inherits from System.Object — so a struct really does have ToString and Equals, and int is genuinely System.Int32 rather than a primitive outside the type system. People often answer "stack versus heap", but that is a consequence that does not always hold: a struct field inside a class lives on the heap with it.
Intermediate
Why does (long)someBoxedInt throw, when int converts to long freely?
Model answer — Because unboxing is not a conversion. A boxed value is an object whose contents are an Int32, and unboxing reinterprets those bytes as the type you name — so the type must match exactly. The widening conversion from int to long is a separate operation that happens on an actual int, which is why the correct form is (long)(int)x: unbox to the real type, then widen. The same reasoning explains why x is long is false for a boxed int; is is asking what the object actually is, not what it could be converted to.
Advanced
A hot path takes IShape and the profile shows an allocation per call, though nothing in the method says new. Explain and fix it.
Model answer — The callers are passing a struct. An interface-typed parameter needs a reference, so each call boxes the struct onto the heap — one object per call, with a header, invisible in the source. The fix is to make the method generic with the interface as a constraint: void Process<T>(T shape) where T : IShape. The runtime specialises generic code per value type, so the call becomes direct and nothing is allocated, while reference-type callers are unaffected. I would confirm it with GC.GetAllocatedBytesForCurrentThread around a tight loop before and after rather than trusting the reasoning — the same technique finds the other common invisible boxes, such as a struct going into a non-generic collection, or Equals(object) being called instead of a typed overload.
Company lens — how the same topic gets asked
micro1Direct and definitional: value versus reference, what boxing is, and why int has methods. Saying "copying semantics" rather than "stack versus heap" marks you out.
GoogleType-system design — why a unified hierarchy was chosen over Java's primitives, and what it buys generics later.
MetaUsually a profile or a snippet with invisible boxing planted in it, and they want you to spot the interface parameter or the non-generic collection.
AmazonCorrectness over cleverness: rounding and narrowing in money handling, and why decimal rather than double.

Hands-on

Hands-on

Write the type-system rules this chapter has been applying. Three functions: one that decides whether a conversion may be implicit, one that decides whether an operation boxes, and one that evaluates a nullable comparison with three-valued logic. The harness scores ten checks, each pairing a case that must be true with one that must be false. A correct solution prints score 10/10.

C# — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Run it unchanged first: ten failures and score 0/10. Anything above zero before you have written a line means the checks can be satisfied by doing nothing.

Summary

  • C# has one type hierarchy with object at the root, and int is genuinely in it: Int32 → ValueType → Object, with the keyword as an alias. That unification is the source of almost everything else in this chapter.
  • Value and reference types differ in assignment semantics, not reliably in storage location; ValueType is the marked branch whose members are copied rather than referenced.
  • Enums and arrays have their own rungs — Enum → ValueType → Object and Array → Object — which is where their shared behaviour comes from.
  • Implicit conversions are the ones that cannot lose information. Anything lossy must be written as a cast, which is C#'s way of making you record that you accepted the loss.
  • A cast to an integer truncates toward zero, so (int)-3.99 is -3, and Math.Round rounds halves to even, so 3.5 and 4.5 both give 4.
  • Narrowing is unchecked by default — (byte)300 is silently 44 — and checked turns that into an OverflowException.
  • Unboxing is an exact type check, not a conversion, so a boxed int must be unboxed to int before it can be widened to long.
  • A boxed nullable is never a boxed Nullable<T>: it is a boxed underlying value or a plain null, which is what lets int? behave correctly wherever an object is expected.
  • Nullable comparisons are three-valued, so x > 3 and x <= 3 are both false for a null and negation is not the opposite — the same hazard as SQL's NULL.
  • Passing a struct as an interface boxes it; passing it to a generic constrained to that interface does not. That single difference is the practical case for .NET generics, and the subject of the chapter after next.

In the next chapter, Equality, GetHashCode and Records, we take the two methods every type in this hierarchy inherits and ask what it actually means for two things to be equal — why overriding one without the other breaks dictionaries, what records generate for you, and why reference equality and value equality are both right answers to different questions.

Editorial reflection

The unified hierarchy is a good example of a design decision whose cost and benefit arrive at different times. The benefit was immediate and obvious in 2000: one set of rules, no wrapper classes, no parallel universe of primitives. The cost — boxing, and a decade of language features invented to avoid it — arrived gradually, borne by people who had no idea a decision had been made. That is the normal shape of architectural choices, and it is worth remembering when you make one. The question is rarely whether a design is elegant now; it is who pays for it later, and whether they will be able to tell that the bill came from here.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.