C# Compiler Behavior: Hidden Member Fallback in Inheritance
Summary
When a derived class hides a base class member using the new keyword, the C# compiler exhibits unexpected fallback behavior if the hiding member is inaccessible. Rather than throwing a compiler error, the compiler silently falls back to the accessible base class member. This behavior contradicts documented expectations and can lead to subtle API design challenges when attempting to deprecate or rename inherited members.
Root Cause
The core issue stems from the compiler’s member resolution order and accessibility checking:
- The C# compiler resolves member access by first checking the compile-time type of the expression
- When a derived class uses
newto hide a base member, the compiler initially binds to the derived type’s member - If that member is inaccessible (private, protected in wrong context, etc.), the compiler should throw CS0122 (“inaccessible due to its protection level”)
- However, the actual implementation allows fallback to the base class member when the derived member is inaccessible
This creates a loophole where access modifiers alone cannot enforce API deprecation in inheritance hierarchies.
Why This Happens in Real Systems
This behavior manifests in common scenarios:
- API evolution: Teams need to rename or deprecate inherited members while maintaining backward compatibility
- Layered architectures: Different layers may have different visibility requirements for the same conceptual member
- Legacy migration: Refactoring old APIs where member semantics change between base and derived classes
- Library versioning: Maintaining compatibility while introducing breaking changes in controlled ways
The compiler’s fallback mechanism was likely implemented as a developer convenience, but it undermines intentional API design decisions.
Real-World Impact
Teams face several challenges due to this behavior:
- False security: Developers believe marking members as
privateorinternalwill prevent direct access - Inconsistent behavior: Public hiding members throw exceptions, but inaccessible ones silently fall back
- Debugging complexity: Runtime behavior differs from what developers expect based on access modifiers
- API confusion: Users can still access “deprecated” members through inheritance chains
For example, a team might mark a base class property as obsolete with ObsoleteAttribute, expecting the compiler to enforce this when accessed through derived instances—but the fallback behavior bypasses this protection.
Example or Code
public class Base
{
private readonly int value = 42;
public int Value => value;
}
public sealed class Derived : Base
{
private new int Value => throw new NotSupportedException();
}
static void Main()
{
Base b = new();
int bValue = b.Value; // Works: accesses Base.Value
Derived d = new();
int dValue = d.Value; // Works: falls back to Base.Value (not Derived.Value!)
Console.WriteLine($"bValue: {bValue}; dValue: {dValue}");
}
Compare with the public version:
public sealed class DerivedPublic : Base
{
public new int Value => throw new NotSupportedException();
}
static void Main()
{
DerivedPublic d = new();
int dValue = d.Value; // Throws NotSupportedException at runtime
}
How Senior Engineers Fix It
Senior engineers employ several strategies to work around this limitation:
- Use
ObsoleteAttributewitherror: true: While not foolproof, this provides compile-time warnings - Implement explicit interface segregation: Force consumers to use specific interfaces for different behaviors
- Apply the Adapter pattern: Create separate types that expose only the desired members
- Leverage nullable reference types: Make the “deprecated” path return
nullor require explicit casting - Document the workaround: Clearly communicate the expected usage patterns to consumers
Example using ObsoleteAttribute:
public class Base
{
[Obsolete("Use NewProperty instead", error: true)]
public virtual int OldProperty => 42;
}
public class Derived : Base
{
public override int OldProperty => throw new NotSupportedException();
public int NewProperty => 100;
}
Why Juniors Miss It
Junior developers often overlook this behavior because:
- It contradicts intuition: Most developers expect inaccessible members to always cause compiler errors
- Documentation gaps: Official documentation doesn’t clearly explain the fallback mechanism
- Testing oversight: Unit tests might not cover edge cases with inaccessible hiding members
- IDE assistance: Visual Studio and Rider don’t always highlight this scenario during development
- Limited inheritance experience: Junior engineers may not have encountered complex API evolution scenarios
- Assumption of consistency: They assume compiler behavior matches other object-oriented languages like Java or C++