Last Updated: May 22, 2026
Every type in C# inherits from System.Object, whether you write it out or not. That makes Object the implicit root of the entire .NET type system, and the small handful of methods it gives you (ToString, Equals, GetHashCode, GetType) show up on every value you ever touch. This chapter looks at what those methods do by default, when you'd override them, and how value types get treated as objects through boxing.
When you write a class without an explicit base, the compiler adds one for you. These two declarations produce identical IL:
There is no way to opt out. Even a struct, a record, an enum, or int itself ends up rooted at Object. Structs and enums go through System.ValueType, which itself derives from Object, but the chain still terminates at the same place.
A small hierarchy from the e-commerce examples in this section looks like this:
The diagram shows two paths from your types to Object. Reference types like Product, Customer, and their subclasses inherit from Object directly. Value types like decimal and the OrderStatus enum go through System.ValueType, which is itself a class that inherits from Object. The end result is the same: every variable in C# is either an Object or something that "is a" Object through inheritance.
That single root is the reason you can write code like this:
The variable thing is declared as object, so it can hold any value: a string, a decimal, an array of ints. The compiler accepts each assignment because every type in C# is (or converts to) an Object.
object vs ObjectThe lowercase object is a C# keyword. The capitalized System.Object is the actual type in the Base Class Library. They refer to the same thing, the way int is a C# alias for System.Int32.
The two typeof calls return the same Type instance because the keyword and the BCL name are aliases. Style-wise, most C# codebases use the lowercase object everywhere except when explicitly qualifying with the namespace (System.Object). The course uses object for that reason from here on, and only writes Object when talking about the class itself in prose.
Object is a small class. It declares a handful of methods, and because every type inherits from it, those methods are available on every value in your program. Here's the full list, with the default behavior and when you'd typically override it:
| Method | Default behavior | Typical override |
|---|---|---|
ToString() | Returns the fully qualified type name (e.g., "MyApp.Product") | Almost always, to produce a useful string for logs and debugging |
Equals(object? obj) | Reference equality for classes, value equality for structs | When two distinct instances should be considered "the same" by content |
GetHashCode() | Implementation-defined, paired with default Equals | Always override together with Equals |
GetType() | Returns the runtime Type of the object | Never (it's not virtual) |
MemberwiseClone() | Returns a shallow copy of the object | Rarely, and only inside the class itself (it's protected) |
ReferenceEquals(object?, object?) (static) | Compares references regardless of any override | Never (it's static and not virtual) |
Equals(object?, object?) (static) | Null-safe wrapper that delegates to instance Equals | Never |
Finalize() | Called by the garbage collector before reclaiming memory | Almost never. Use IDisposable instead |
The next sections walk through the methods that matter day to day. Finalize isn't covered further here.
ToString() and Why You Override ItToString is the method you'll override most often. The default implementation returns the type's full name, which is rarely useful:
Console.WriteLine calls ToString on whatever you pass in, so the two lines produce the same output. The + in Program+Product is how the runtime names a nested type; the inherited default just spits out the type identity, not anything about the product itself.
That's almost never what you want. Override ToString to return something a human can read:
override is required because ToString is declared virtual on Object. The new method is called automatically by Console.WriteLine, string interpolation, the debugger's display, and any other code that asks an object for its string form. The expression-bodied form fits well here because the body is one expression.
String interpolation goes through ToString too:
Inside $"Item: {product}", the runtime calls product.ToString() to fill in the placeholder. That's the whole reason a custom ToString is worth writing: it makes your types behave correctly everywhere a string representation is expected, without callers having to know which method to call.
A few practical rules for ToString overrides:
Product, the SKU plus name is usually enough. The full price might be overkill in some logs.ToString. Debuggers and logging code call it, and a throw inside ToString makes the call site harder to diagnose.ToString is a recipe for surprise. Format whatever the object already holds.A common pattern is to override ToString on every domain type (Product, Order, Customer, Cart), because logs, exception messages, and watch windows all benefit from it.
Equals() and the Default EqualityEquals is where the value-type vs reference-type distinction really starts to bite. The default behavior depends on what kind of type you're calling it on.
For reference types (classes), the default Object.Equals(object? obj) is reference equality. Two variables are equal only if they point to the exact same instance on the heap:
a and b describe the same product, but they're two separate objects on the heap, so Equals returns false. a and c share a single reference, so Equals returns true. The == operator on classes also defaults to reference equality, which matches what Equals does here.
For value types (structs and enums), the default is value equality. The runtime compares every field:
a and b have identical field values, so the default ValueType.Equals says they're equal. a and c differ in Currency, so they're not.
The default ValueType.Equals uses reflection to walk every field. It works correctly, but it's slow. Production code that compares structs in hot paths overrides Equals (and GetHashCode) explicitly to skip the reflection.
The contrast matters: with classes, you have to opt in to value equality by overriding Equals. With structs, value equality is the default, but at a runtime cost unless you override too. The _Equals() & GetHashCode()_ lesson covers how to write a correct override that satisfies the equality contract.
A record type changes the default for classes. Records get a compiler-generated Equals based on their properties:
Records are still reference types, so they still inherit from Object, but the compiler emits a value-equality Equals and GetHashCode for them. That's one of the reasons records exist as a feature: most domain types want value equality, and writing it by hand is tedious.
GetHashCode() and Why It Pairs With EqualsEvery object has a hash code, exposed through GetHashCode(). The number doesn't mean anything on its own; it's a fingerprint used by hash-based collections like Dictionary<TKey, TValue> and HashSet<T> to decide which bucket an item lives in.
Output (sample, values vary by run):
Two different Product instances produce two different hash codes, even though their data is identical. That's the default for reference types: the hash is based on the object's identity, not its contents.
The rule that ties Equals and GetHashCode together: if two objects are equal according to `Equals`, they must return the same `GetHashCode`. Hash-based collections rely on this. If you put a Product into a HashSet<Product> and then look it up with a logically equal but separately constructed Product, the lookup will fail unless both methods agree.
The default behavior already satisfies this rule:
Equals says two references are equal only if they're the same instance.GetHashCode returns an identity-based number, so the same instance always produces the same hash.The moment you override Equals to use content equality, you have to override GetHashCode to match. Otherwise a Dictionary will look in the wrong bucket and miss entries that "should" be there. The compiler doesn't force the override, but it warns you (CS0659):
The full mechanics, including how to write a stable GetHashCode using HashCode.Combine, belong to the _Equals() & GetHashCode()_ lesson. For now, the rule to remember is: override them together, always.
GetHashCode is called every time you add or look up an entry in a Dictionary or HashSet. A slow GetHashCode (one that recomputes work or allocates) shows up immediately in profiles of collection-heavy code.
GetType() and Runtime Type InfoGetType returns a Type object that describes the runtime type of the instance. It's not virtual, so subclasses can't override it. Whatever the variable's declared type, GetType reflects what the value actually is at runtime.
The variable p is declared as Product, but it points to a DigitalProduct instance. GetType returns the runtime type, which is DigitalProduct. That's different from typeof, which works at compile time and asks "what type is named here?".
A quick comparison of the two:
| Operator/method | When it runs | Input | Result |
|---|---|---|---|
typeof(T) | Compile time | A type name | The Type for T |
obj.GetType() | Runtime | An instance | The runtime Type of that instance |
Both return a Type object you can compare with ==. Use GetType when you have an object in hand and want to ask "what are you, really?" The _is & as Operators_ lesson covers the related operators that give you cleaner ways to perform type checks and safe casts. For this lesson, treating GetType as a brief introduction to runtime type identity is enough.
MemberwiseClone() and Shallow CopiesObject.MemberwiseClone produces a shallow copy: a new instance of the same type, with each field copied directly. It's protected, so you can only call it from inside the class itself, usually through a public Clone method.
The Owner field is independent between the two carts: each one has its own copy of the string reference. The Items list, on the other hand, is shared, because the field holds a reference to a List<string> and MemberwiseClone copies the reference, not the list itself. Adding Monitor to the copy also shows up in the original.
It's called shallow because it's one level deep. Most production code that needs cloning either implements ICloneable (which most C# style guides advise against because it doesn't specify shallow vs deep), exposes an explicit DeepCopy method, or relies on with expressions on records. MemberwiseClone itself is a building block, not the usual public API.
ReferenceEquals and the Static HelpersObject.ReferenceEquals(object? a, object? b) is a static method that compares references no matter what. Even if both classes have overridden Equals and ==, ReferenceEquals ignores those overrides and asks "do these two variables point to the same object?".
a and b are records with the same field values, so the record-generated Equals and == say they're equal. But they're two distinct heap objects, and ReferenceEquals reports that honestly. The method is the way to bypass any override and ask the underlying identity question.
There's also a static object.Equals(object? a, object? b) method. It's a null-safe wrapper around instance Equals: it returns true if both arguments are null, false if exactly one is null, and a.Equals(b) otherwise. It's useful in generic code where either argument might be null and you don't want to write the null check yourself:
Calling a.Equals(b) directly when a is null would throw a NullReferenceException. The static object.Equals avoids that by checking for null first. Most everyday code uses the instance method, but the static version shows up in equality implementations and library code that has to handle nullable inputs.
There's one consequence of "every type inherits from Object" that surprises new C# developers. Value types like int and decimal are stored directly, usually on the stack. Object is a reference type, and reference types live on the heap. How does int "inherit" from a heap-only thing?
The answer is boxing. When a value type is treated as Object (or assigned to a variable typed as object, or passed to a method that takes object), the runtime allocates a heap object, copies the value into it, and hands back a reference. That heap object is the "box."
The cast (int)boxed is unboxing. It checks that the boxed value is actually an int (throwing InvalidCastException if not) and copies the value back out.
Visually:
The diagram shows the data flow. stock lives on the stack as a 32-bit integer. The assignment object boxed = stock allocates a small heap object that carries the type tag (System.Int32) plus the value, and the boxed variable holds a reference to that heap object. Unboxing with (int)boxed copies the value field back into a stack-resident int.
Once a value type is boxed, the box has its own identity. Changes to the original don't affect the box, and changes to a re-unboxed copy don't affect the original:
That's the consequence of "the box is a copy": the heap object captured the value at the moment of boxing.
Boxing happens implicitly in plenty of everyday situations:
object variable.object, such as Console.WriteLine(object).ArrayList.Object (some cases).int into an object[] array.Each boxing operation allocates a small object on the heap and adds to garbage-collection pressure. In tight loops that box and unbox repeatedly, this is often the largest source of allocations in a profile. Generic collections (List<int> instead of ArrayList) avoid boxing entirely because they store value types unboxed.
Here's a cost example that's easy to overlook:
Both produce the same end result, but the first loop allocates 1,000 small heap objects. That's the single biggest reason ArrayList is rare in modern C# and List<T> is the default: List<T> keeps value types unboxed in an underlying T[].
There are also a few ways an inherited Object method gets called on a struct without an obvious cast. Consider:
Int32 overrides ToString and GetHashCode, so calling them on an int doesn't box. If you call an inherited (non-overridden) virtual method on a value type, the runtime might need to box to dispatch the call through Object. In practice, the framework types you use day to day override all the relevant methods, so this rarely bites unless you're writing your own struct.
A small program that uses several Object members together. The Product class overrides ToString for display, the static object.ReferenceEquals shows that two separately constructed products are not the same instance, and a quick boxing example makes clear that an object reference can hold a Product or any other type.
The first two lines show the overridden ToString working through Console.WriteLine. The comparison line shows the default Equals for classes: even though mouse and alsoMouse describe the same product, they're separate instances, and the default equality is reference-based. The List<object> holds three completely different types because every one of them is rooted at Object. And thing.GetType().Name reports the runtime type of each item, so the int shows up as Int32 even though the loop variable was declared as object.
If you want mouse.Equals(alsoMouse) to return true based on the SKU, you'd override Equals and GetHashCode. The contract (reflexive, symmetric, transitive, consistent with hash code) is what real-world dictionaries and sets depend on, and getting it right takes more care than just typing override.
Object is also why generic T : class constraints make sense, why List<object> can hold anything, and why Console.WriteLine accepts every type without overloads for each one. The single-root design isn't unique to C#, but C# leans on it heavily.