RefactorMCP catalog

Introduce Null Object

Generates a class that implements a field's interface by doing nothing, makes the field hold it instead of null, and removes the field's null checks across the solution.

This is a generator: it adds a type and changes what runs, so the fixtures pin one chosen design rather than the only correct answer.

Arguments

None. The target is the field, by symbol: "target": { "symbol": "F:Shop.Order._logger" }.

Precondition

  • The field's type is an interface. A field typed as a class is refused; Extract Interface comes first.
  • The field is checked for null at least once, and every check has one of the shapes listed under Transformation.
  • Where checks fall back to a value for the same member, they agree on it, and that value is a constant.
  • The interface's namespace has no type with the null object's name, and its folder no file with that name.
  • The result compiles.

Transformation

The generated type:

  • Is named after the interface without its leading I and prefixed with Null: ILogger gives NullLogger. A generic interface gives a generic class with the same type parameters and constraints.
  • Is a public sealed class in a new file named after it, in the interface's folder and namespace, following the namespace style (block or file-scoped) of the interface's file. When the interface is not in source, the field's file is used instead.
  • Has a single instance, public static readonly NullLogger Instance, and a private constructor.
  • Implements every abstract member of the interface and of the interfaces it extends, publicly and in declaration order:
  • void methods have empty bodies; out parameters are set to default.
  • A member whose checks fell back to a value returns that value. Otherwise arrays, IEnumerable<T>, IReadOnlyCollection<T> and IReadOnlyList<T> return Array.Empty<T>(), and everything else returns default (default! for a non-nullable reference type when nullable analysis is enabled).
  • Get-only properties are expression-bodied; a setter does nothing. Events have empty add and remove accessors.

The field and its uses:

  • if (_f != null) { ... } and if (_f is not null) ... without an else become the statements they guarded, keeping the comments above the if.
  • _f?.M(); as a statement becomes _f.M();.
  • _f?.M() ?? fallback, _f != null ? _f.M() : fallback and _f == null ? fallback : _f.M() become _f.M(), and M returns fallback in the null object. The same applies to properties.
  • Assigning null assigns NullLogger.Instance. Assigning any other value that may be null assigns value ?? NullLogger.Instance; a new object, this or the field itself is left alone.
  • A field without an initialiser that some constructor leaves unassigned is initialised to NullLogger.Instance.
  • A nullable field type, ILogger?, becomes ILogger.

Behaviour changes

  • The field is never null, so code that read it without a check and would have thrown NullReferenceException now calls the null object.
  • Members nobody checked return default (or an empty sequence) from the null object instead of throwing.

Preserved

  • The behaviour of every removed check: the guarded calls did nothing when the field was null, and the fallbacks become the null object's return values.
  • Comments next to the removed checks.

Limitations

  • Checks with an else, guards that return early (if (_f == null) return;), _f ?? other, _f ??= other and null comparisons in other expressions are refused rather than rewritten.
  • Fallbacks must be constants; one computed from local state is refused.
  • Only a field is supported, not a property, parameter or local.
  • Passing the field by ref or out is not treated as an assignment, so a method that sets it to null through the reference is not caught.

Error codes

CodeMeaning
not-an-interfacethe field's type is not an interface
no-null-checksthe field is never checked for null
unsupported-null-checka null check has a shape the null object cannot replace, or falls back to a non-constant value
inconsistent-fallbackstwo checks fall back to different values for the same member
type-already-existsthe null object's name is already taken
breaks-compilationthe result would not compile

Cases

·

Diffs show each file from before to after. /*[*/ … /*]*/ marks a selection and /*^*/ a caret; the runner removes them before the refactoring runs.

#comments-around-checks

Keeps the comments on and inside a removed if, and unwraps an if without braces

success
Targetsymbol F:Shop.Order._logger
NullLogger.csadded
1+namespace Shop
2+{
3+ public sealed class NullLogger : ILogger
4+ {
5+ public static readonly NullLogger Instance = new NullLogger();
6+
7+ private NullLogger()
8+ {
9+ }
10+
11+ public void Log(string message)
12+ {
13+ }
14+ }
15+}
Order.csmodified
11 namespace Shop
22 {
33 public class Order
44 {
55 private readonly ILogger _logger;
66 private int _saved;
77
88 public Order(ILogger logger)
99 {
10− _logger = logger;
10+ _logger = logger ?? NullLogger.Instance;
1111 }
1212
1313 public void Submit()
1414 {
1515 // Tell the log first.
16− if (_logger != null)
17− {
18− // Before saving
19− _logger.Log("Submitting"); // not yet saved
20− }
16+ // Before saving
17+ _logger.Log("Submitting"); // not yet saved
2118
2219 _saved++;
2320
2421 #region Audit
25− if (_logger != null) _logger.Log("Saved");
22+ _logger.Log("Saved");
2623 #endregion
2724 }
2825 }
2926 }
ILogger.csunchanged
11 namespace Shop
22 {
33 public interface ILogger
44 {
55 void Log(string message);
66 }
77 }

#conditional-calls

Generates NullLogger, defaults the constructor's assignment to it and removes the if and ?. checks

success
Targetsymbol F:Shop.Order._logger
NullLogger.csadded
1+namespace Shop
2+{
3+ public sealed class NullLogger : ILogger
4+ {
5+ public static readonly NullLogger Instance = new NullLogger();
6+
7+ private NullLogger()
8+ {
9+ }
10+
11+ public void Log(string message)
12+ {
13+ }
14+ }
15+}
Order.csmodified
11 namespace Shop
22 {
33 public class Order
44 {
55 private readonly ILogger _logger;
66
77 public Order(ILogger logger)
88 {
9− _logger = logger;
9+ _logger = logger ?? NullLogger.Instance;
1010 }
1111
1212 public void Submit()
1313 {
14− if (_logger != null)
15− {
16− _logger.Log("Submitting");
17− _logger.Log("Submitted");
18− }
14+ _logger.Log("Submitting");
15+ _logger.Log("Submitted");
1916 }
2017
2118 public void Cancel()
2219 {
23− _logger?.Log("Cancelled");
20+ _logger.Log("Cancelled");
2421 }
2522 }
2623 }
ILogger.csunchanged
11 namespace Shop
22 {
33 public interface ILogger
44 {
55 void Log(string message);
66 }
77 }

#fallback-does-not-convert

Refuses when the result would not compile, here because a fallback is wider than the member's return type

refusal
Targetsymbol F:Shop.Basket._counter
Refusesbreaks-compilation mentioning “NullCounter.cs”; every file is left unchanged
Basket.csinput
1 namespace Shop
2 {
3 public interface ICounter
4 {
5 int Count();
6 }
7
8 public class Basket
9 {
10 private ICounter _counter;
11
12 public long Total() => _counter?.Count() ?? 0L;
13 }
14 }

#fallback-values

Returns the fallbacks the removed checks used, replaces null assignments and initialises a field no constructor sets

success
Targetsymbol F:Shop.Order._policy
NullDiscountPolicy.csadded
1+namespace Shop
2+{
3+ public sealed class NullDiscountPolicy : IDiscountPolicy
4+ {
5+ public static readonly NullDiscountPolicy Instance = new NullDiscountPolicy();
6+
7+ private NullDiscountPolicy()
8+ {
9+ }
10+
11+ public string Name => "none";
12+
13+ public decimal Rate(decimal total)
14+ {
15+ return 0m;
16+ }
17+
18+ public int Priority()
19+ {
20+ return -1;
21+ }
22+ }
23+}
Order.csmodified
11 namespace Shop
22 {
33 public class Order
44 {
5− private IDiscountPolicy _policy;
5+ private IDiscountPolicy _policy = NullDiscountPolicy.Instance;
66
77 public void Use(IDiscountPolicy policy)
88 {
9− _policy = policy;
9+ _policy = policy ?? NullDiscountPolicy.Instance;
1010 }
1111
1212 public void Clear()
1313 {
14− _policy = null;
14+ _policy = NullDiscountPolicy.Instance;
1515 }
1616
17− public decimal Discount(decimal total) => _policy != null ? _policy.Rate(total) : 0m;
17+ public decimal Discount(decimal total) => _policy.Rate(total);
1818
1919 public decimal Final(decimal total)
2020 {
21− return total - (_policy == null ? 0m : _policy.Rate(total));
21+ return total - _policy.Rate(total);
2222 }
2323
24− public string Describe() => _policy?.Name ?? "none";
24+ public string Describe() => _policy.Name;
2525
26− public int Rank() => _policy?.Priority() ?? -1;
26+ public int Rank() => _policy.Priority();
2727 }
2828 }
IDiscountPolicy.csunchanged
11 namespace Shop
22 {
33 public interface IDiscountPolicy
44 {
55 string Name { get; }
66
77 decimal Rate(decimal total);
88
99 int Priority();
1010 }
1111 }

#generic-interface

Gives the null object the generic interface's type parameters and constraints, in the interface file's namespace style

success
Targetsymbol F:Shop.Checkout._orders
Checkout.csmodified
11 namespace Shop;
22
33 public class Order
44 {
55 }
66
77 public class Checkout
88 {
99 private readonly IRepository<Order> _orders;
1010
1111 public Checkout(IRepository<Order> orders)
1212 {
13− _orders = orders;
13+ _orders = orders ?? NullRepository<Order>.Instance;
1414 }
1515
16− public int Pending() => _orders != null ? _orders.Count() : 0;
16+ public int Pending() => _orders.Count();
1717
1818 public void Place(Order order)
1919 {
20− if (_orders is not null)
21− {
22− _orders.Save(order);
23− }
20+ _orders.Save(order);
2421 }
2522 }
NullRepository.csadded
1+using System;
2+using System.Collections.Generic;
3+
4+namespace Shop;
5+
6+public sealed class NullRepository<T> : IRepository<T> where T : class
7+{
8+ public static readonly NullRepository<T> Instance = new NullRepository<T>();
9+
10+ private NullRepository()
11+ {
12+ }
13+
14+ public void Save(T item)
15+ {
16+ }
17+
18+ public IReadOnlyList<T> All()
19+ {
20+ return Array.Empty<T>();
21+ }
22+
23+ public int Count()
24+ {
25+ return 0;
26+ }
27+}
IRepository.csunchanged
11 using System.Collections.Generic;
22
33 namespace Shop;
44
55 public interface IRepository<T> where T : class
66 {
77 void Save(T item);
88
99 IReadOnlyList<T> All();
1010
1111 int Count();
1212 }

#inconsistent-fallbacks

Refuses when two checks fall back to different values for the same member, so no single null object can return both

refusal
Targetsymbol F:Shop.Order._policy
Refusesinconsistent-fallbacks mentioning “Priority”; every file is left unchanged
IDiscountPolicy.csinput
1 namespace Shop
2 {
3 public interface IDiscountPolicy
4 {
5 string Name { get; }
6
7 decimal Rate(decimal total);
8
9 int Priority();
10 }
11 }
Order.csinput
1 namespace Shop
2 {
3 public class Order
4 {
5 private IDiscountPolicy _policy;
6
7 public int Rank() => _policy?.Priority() ?? -1;
8
9 public int Weight() => _policy != null ? _policy.Priority() : 0;
10 }
11 }

#inherited-interface-members

Implements every member of the interface and the interfaces it extends: events, settable properties, out parameters and generic methods

success
Targetsymbol F:Shop.Order._notifier
NullNotifier.csadded
1+using System;
2+
3+namespace Shop
4+{
5+ public sealed class NullNotifier : INotifier
6+ {
7+ public static readonly NullNotifier Instance = new NullNotifier();
8+
9+ private NullNotifier()
10+ {
11+ }
12+
13+ public event EventHandler Sent { add { } remove { } }
14+
15+ public string Channel { get => default; set { } }
16+
17+ public bool TrySend(string message, out int id)
18+ {
19+ id = default;
20+ return default;
21+ }
22+
23+ public T Echo<T>(T value) where T : class
24+ {
25+ return default;
26+ }
27+
28+ public void Dispose()
29+ {
30+ }
31+ }
32+}
Order.csmodified
11 namespace Shop
22 {
33 public class Order
44 {
55 private readonly INotifier _notifier;
66
77 public Order(INotifier notifier)
88 {
9− _notifier = notifier;
9+ _notifier = notifier ?? NullNotifier.Instance;
1010 }
1111
1212 public void Close()
1313 {
14− _notifier?.Dispose();
14+ _notifier.Dispose();
1515 }
1616 }
1717 }
INotifier.csunchanged
11 using System;
22
33 namespace Shop
44 {
55 public interface INotifier : IDisposable
66 {
77 event EventHandler Sent;
88
99 string Channel { get; set; }
1010
1111 bool TrySend(string message, out int id);
1212
1313 T Echo<T>(T value) where T : class;
1414 }
1515 }

#no-null-checks

Refuses a field that is never checked for null, since there is nothing for a null object to replace

refusal
Targetsymbol F:Shop.Order._logger
Refusesno-null-checks; every file is left unchanged
ILogger.csinput
1 namespace Shop
2 {
3 public interface ILogger
4 {
5 void Log(string message);
6 }
7 }
Order.csinput
1 namespace Shop
2 {
3 public class Order
4 {
5 private readonly ILogger _logger;
6
7 public Order(ILogger logger)
8 {
9 _logger = logger;
10 }
11
12 public void Submit()
13 {
14 _logger.Log("Submitted");
15 }
16 }
17 }

#not-an-interface

Refuses a field whose type is a class; Extract Interface comes first

refusal
Targetsymbol F:Shop.Order._logger
Refusesnot-an-interface mentioning “Logger”; every file is left unchanged
Order.csinput
1 namespace Shop
2 {
3 public class Logger
4 {
5 public void Log(string message)
6 {
7 }
8 }
9
10 public class Order
11 {
12 private readonly Logger _logger;
13
14 public Order(Logger logger)
15 {
16 _logger = logger;
17 }
18
19 public void Submit()
20 {
21 _logger?.Log("Submitted");
22 }
23 }
24 }

#nullable-annotations

Makes the nullable field non-nullable and keeps the interface's annotations on the null object's members

success
Targetsymbol F:Shop.Order._logger
Projectnullable enable
NullLogger.csadded
1+namespace Shop
2+{
3+ public sealed class NullLogger : ILogger
4+ {
5+ public static readonly NullLogger Instance = new NullLogger();
6+
7+ private NullLogger()
8+ {
9+ }
10+
11+ public string Name => "unnamed";
12+
13+ public void Log(string message, string? category = null)
14+ {
15+ }
16+
17+ public string? LastMessage()
18+ {
19+ return default;
20+ }
21+
22+ public string Format(string message)
23+ {
24+ return default!;
25+ }
26+ }
27+}
Order.csmodified
11 namespace Shop
22 {
33 public class Order
44 {
5− private ILogger? _logger;
5+ private ILogger _logger;
66
77 public Order(ILogger? logger)
88 {
9− _logger = logger;
9+ _logger = logger ?? NullLogger.Instance;
1010 }
1111
1212 public void Detach()
1313 {
14− _logger = null;
14+ _logger = NullLogger.Instance;
1515 }
1616
1717 public void Submit()
1818 {
19− _logger?.Log("Submitted");
19+ _logger.Log("Submitted");
2020 }
2121
22− public string LoggerName() => _logger?.Name ?? "unnamed";
22+ public string LoggerName() => _logger.Name;
2323 }
2424 }
ILogger.csunchanged
11 namespace Shop
22 {
33 public interface ILogger
44 {
55 string Name { get; }
66
77 void Log(string message, string? category = null);
88
99 string? LastMessage();
1010
1111 string Format(string message);
1212 }
1313 }

#partial-class-in-another-file

Removes checks in another part of a partial class, declared in another file

success
Targetsymbol F:Shop.Order._logger
NullLogger.csadded
1+namespace Shop
2+{
3+ public sealed class NullLogger : ILogger
4+ {
5+ public static readonly NullLogger Instance = new NullLogger();
6+
7+ private NullLogger()
8+ {
9+ }
10+
11+ public void Log(string message)
12+ {
13+ }
14+ }
15+}
Order.Audit.csmodified
11 namespace Shop
22 {
33 public partial class Order
44 {
55 public void Audit()
66 {
7− _logger?.Log("Audited");
7+ _logger.Log("Audited");
88 }
99
1010 public void Replace(Order other)
1111 {
1212 other._logger = _logger;
1313 }
1414 }
1515 }
Order.csmodified
11 namespace Shop
22 {
33 public partial class Order
44 {
55 private ILogger _logger;
66
77 public Order(ILogger logger)
88 {
9− _logger = logger;
9+ _logger = logger ?? NullLogger.Instance;
1010 }
1111
1212 public Order()
1313 : this(null)
1414 {
1515 }
1616 }
1717 }
ILogger.csunchanged
11 namespace Shop
22 {
33 public interface ILogger
44 {
55 void Log(string message);
66 }
77 }

#type-already-exists

Refuses when the namespace already has a type with the null object's name

refusal
Targetsymbol F:Shop.Order._logger
Refusestype-already-exists mentioning “NullLogger”; every file is left unchanged
ILogger.csinput
1 namespace Shop
2 {
3 public interface ILogger
4 {
5 void Log(string message);
6 }
7 }
Order.csinput
1 namespace Shop
2 {
3 public class NullLogger
4 {
5 }
6
7 public class Order
8 {
9 private readonly ILogger _logger;
10
11 public Order(ILogger logger)
12 {
13 _logger = logger;
14 }
15
16 public void Submit()
17 {
18 _logger?.Log("Submitted");
19 }
20 }
21 }

#unsupported-null-check

Refuses a null check whose meaning a null object cannot take over, such as a guard that returns early

refusal
Targetsymbol F:Shop.Order._logger
Refusesunsupported-null-check mentioning “Order.cs(20”; every file is left unchanged
ILogger.csinput
1 namespace Shop
2 {
3 public interface ILogger
4 {
5 void Log(string message);
6 }
7 }
Order.csinput
1 namespace Shop
2 {
3 public class Order
4 {
5 private readonly ILogger _logger;
6 private int _submitted;
7
8 public Order(ILogger logger)
9 {
10 _logger = logger;
11 }
12
13 public void Submit()
14 {
15 _logger?.Log("Submitting");
16 }
17
18 public void Count()
19 {
20 if (_logger == null)
21 return;
22
23 _submitted++;
24 }
25 }
26 }