RefactorMCP catalog

Convert Class to Record

Turns a class whose state is fixed by its constructor into a record: a positional record where the constructor only assigns its parameters to get-only properties, otherwise the same members with class changed to record.

Arguments

None. The target is the class, by symbol.

Precondition

A record changes three behaviours of a class: Equals, GetHashCode and == compare values instead of references, and ToString lists the members instead of printing the type name. The conversion is refused wherever the solution could observe the difference.

  • The type is a non-static class, and the project's language version is C# 9 or later.
  • The class neither derives from a class other than object nor is derived from, since records only derive from records.
  • Its state is fixed at construction: every instance field is readonly and no instance property has a set accessor.
  • It does not override Equals(object), which a record generates.
  • Nothing in the solution observes equality: no == or != between instances (comparing with null is fine), no Equals or GetHashCode call on or with an instance, no use as a dictionary key or set element (a type argument for a TKey type parameter, or the element type of a hash set), and no call to Contains, IndexOf, LastIndexOf, Remove, Distinct, Union, Intersect, Except, ToHashSet or SequenceEqual over the type.
  • Unless the class overrides ToString, nothing formats an instance: no ToString call, interpolation or string concatenation of one.

Transformation

  • class becomes record on every declaration, keeping modifiers, type parameters, base types and constraints.
  • The record is positional when the class has one declaration and a single public constructor whose body only assigns each parameter to a public get-only auto-property of the same type, and neither the constructor nor those properties carry attributes or documentation. The parameter list, named after the properties and keeping default values, follows the name and type parameters; the constructor and the properties are removed, and a record left with no members ends with ;.
  • Named arguments at every call of the constructor in the solution are renamed to the property names.
  • Otherwise the constructor and properties stay in the body.

Preserved

  • Construction, member access and every observable behaviour the precondition checks.

Limitations

  • Positional properties get init accessors, so object initializers and with can set them afterwards; nothing that compiled before changes.
  • An instance passed as object to a method that formats or compares it, such as string.Format or a non-generic collection, is not detected.
  • Records also gain Deconstruct, a copy constructor and EqualityContract; a class already declaring a conflicting member is refused by the compile check.

Error codes

CodeMeaning
unsupported-typethe type is a static class, a struct or an interface
language-versionthe project's language version predates records
class-hierarchythe class derives from, or is derived by, another class
mutable-statean instance field is not readonly or a property has a setter
declares-equalitythe class overrides Equals(object)
equality-observedthe solution compares, hashes or looks up instances
to-string-observedthe solution formats an instance and the class does not override ToString

Cases

·

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

#base-class-rejected

Refuses a class that derives from another class, since a record can only derive from a record

refusal
Targetsymbol T:Shop.Discount
Refusesclass-hierarchy; every file is left unchanged
Discount.csinput
1 namespace Shop;
2
3 public abstract class Adjustment
4 {
5 }
6
7 public sealed class Discount : Adjustment
8 {
9 public Discount(decimal amount)
10 {
11 Amount = amount;
12 }
13
14 public decimal Amount { get; }
15 }

#compared-with-operator-rejected

Refuses when instances are compared with ==, which would switch from reference to value equality

refusal
Targetsymbol T:Shop.Point
Refusesequality-observed mentioning “Route.cs”; every file is left unchanged
Point.csinput
1 namespace Shop;
2
3 public sealed class Point
4 {
5 public Point(int x, int y)
6 {
7 X = x;
8 Y = y;
9 }
10
11 public int X { get; }
12
13 public int Y { get; }
14 }
Route.csinput
1 namespace Shop;
2
3 public class Route
4 {
5 public Point Start { get; } = new Point(0, 0);
6
7 public bool EndsAt(Point point) => point == Start;
8 }

#derived-class-rejected

Refuses a class another class derives from

refusal
Targetsymbol T:Shop.Adjustment
Refusesclass-hierarchy mentioning “Discount”; every file is left unchanged
Adjustment.csinput
1 namespace Shop;
2
3 public abstract class Adjustment
4 {
5 protected Adjustment(decimal amount)
6 {
7 Amount = amount;
8 }
9
10 public decimal Amount { get; }
11 }
12
13 public sealed class Discount : Adjustment
14 {
15 public Discount(decimal amount) : base(amount)
16 {
17 }
18 }

#dictionary-key-rejected

Refuses when the class is used as a dictionary key, whose lookups would start matching equal instances

refusal
Targetsymbol T:Shop.Sku
Refusesequality-observed mentioning “dictionary key or set element”; every file is left unchanged
Sku.csinput
1 namespace Shop;
2
3 public sealed class Sku
4 {
5 public Sku(string code)
6 {
7 Code = code;
8 }
9
10 public string Code { get; }
11 }
Stock.csinput
1 using System.Collections.Generic;
2
3 namespace Shop;
4
5 public class Stock
6 {
7 private readonly Dictionary<Sku, int> _levels = new();
8
9 public void Add(Sku sku, int quantity) => _levels[sku] = quantity;
10 }

#documented-properties-stay-in-body

Keeps the constructor and properties in the body when the properties carry documentation a positional parameter would lose

success
Targetsymbol T:Shop.Customer
Customer.csmodified
11 namespace Shop;
22
3−public class Customer
3+public record Customer
44 {
55 public Customer(string name)
66 {
77 Name = name;
88 }
99
1010 /// <summary>The name printed on invoices.</summary>
1111 public string Name { get; }
1212 }

#generic-with-constraint

Puts the positional parameters of a generic record after its type parameters and before its constraint

success
Targetsymbol T:Shop.Pair`1
Pair.csmodified
11 using System;
22
33 namespace Shop;
44
5−public sealed class Pair<T> where T : IComparable<T>
5+public sealed record Pair<T>(T First, T Second) where T : IComparable<T>
66 {
7− public Pair(T first, T second)
8− {
9− First = first;
10− Second = second;
11− }
12−
13− public T First { get; }
14−
15− public T Second { get; }
16−
177 public T Larger => First.CompareTo(Second) >= 0 ? First : Second;
188 }

#language-version-rejected

Refuses when the project's language version predates records

refusal
Targetsymbol T:Shop.Point
ProjectlangVersion 8
Refuseslanguage-version; every file is left unchanged
Point.csinput
1 namespace Shop
2 {
3 public sealed class Point
4 {
5 public Point(int x, int y)
6 {
7 X = x;
8 Y = y;
9 }
10
11 public int X { get; }
12
13 public int Y { get; }
14 }
15 }

#mutable-state-rejected

Refuses a class with a settable property, whose state is not fixed at construction

refusal
Targetsymbol T:Shop.Basket
Refusesmutable-state mentioning “Count”; every file is left unchanged
Basket.csinput
1 namespace Shop;
2
3 public class Basket
4 {
5 public Basket(string owner)
6 {
7 Owner = owner;
8 }
9
10 public string Owner { get; }
11
12 public int Count { get; set; }
13 }

#named-arguments-in-another-file

Declares a record with no body when nothing but the positional state remains, renaming named arguments in another file to the property names

success
Targetsymbol T:Shop.Size
Parcel.csmodified
11 namespace Shop;
22
33 public class Parcel
44 {
5− public Size Box { get; } = new Size(width: 2m, height: 3m);
5+ public Size Box { get; } = new Size(Width: 2m, Height: 3m);
66
77 public Size Flat() => new(Box.Width);
88
99 public decimal Area() => Box.Width * Box.Height;
1010 }
Size.csmodified
11 namespace Shop;
22
3−public sealed class Size
4−{
5− public Size(decimal width, decimal height = 1m)
6− {
7− this.Width = width;
8− Height = height;
9− }
10−
11− public decimal Width { get; }
12−
13− public decimal Height { get; }
14−}
3+public sealed record Size(decimal Width, decimal Height = 1m);

#nullable-enabled

Keeps the nullable annotation of a parameter that becomes a positional property

success
Targetsymbol T:Shop.Contact
Projectnullable enable
Contact.csmodified
11 namespace Shop;
22
3−public sealed class Contact
4−{
5− public Contact(string name, string? email)
6− {
7− Name = name;
8− Email = email;
9− }
10−
11− public string Name { get; }
12−
13− public string? Email { get; }
14−}
3+public sealed record Contact(string Name, string? Email);

#overrides-equals-rejected

Refuses a class that overrides Equals(object), which a record generates itself

refusal
Targetsymbol T:Shop.Sku
Refusesdeclares-equality; every file is left unchanged
Sku.csinput
1 namespace Shop;
2
3 public sealed class Sku
4 {
5 public Sku(string code)
6 {
7 Code = code;
8 }
9
10 public string Code { get; }
11
12 public override bool Equals(object obj) => obj is Sku other && other.Code == Code;
13
14 public override int GetHashCode() => Code.GetHashCode();
15 }

#partial-class

Changes every part of a partial class, keeping the constructor in the body

success
Targetsymbol T:Shop.Tag
Tag.Display.csmodified
11 namespace Shop;
22
3−partial class Tag
3+partial record Tag
44 {
55 public string Display => "#" + Name;
66 }
Tag.csmodified
11 namespace Shop;
22
3−public sealed partial class Tag
3+public sealed partial record Tag
44 {
55 public Tag(string name)
66 {
77 Name = name;
88 }
99
1010 public string Name { get; }
1111 }

#positional-with-methods

Turns a class whose constructor only sets get-only properties into a positional record, keeping its other members and comments

success
Targetsymbol T:Shop.Point
Point.csmodified
11 using System;
22
33 namespace Shop;
44
55 /// <summary>A point on the warehouse floor.</summary>
6−public sealed class Point
6+public sealed record Point(int X, int Y)
77 {
8− public Point(int x, int y)
9− {
10− X = x;
11− Y = y;
12− }
13−
14− public int X { get; }
15−
16− public int Y { get; }
17−
188 // Straight-line distance from the loading bay.
199 public double Length() => Math.Sqrt(X * X + Y * Y);
2010 }

#printed-rejected

Refuses when an instance is formatted in a string, where the record's ToString would replace the type name

refusal
Targetsymbol T:Shop.Sku
Refusesto-string-observed; every file is left unchanged
Sku.csinput
1 namespace Shop;
2
3 public sealed class Sku
4 {
5 public Sku(string code)
6 {
7 Code = code;
8 }
9
10 public string Code { get; }
11
12 public string Label() => $"Item {this}";
13 }

#static-class-rejected

Refuses a static class, which has no instances to compare

refusal
Targetsymbol T:Shop.Pricing
Refusesunsupported-type; every file is left unchanged
Pricing.csinput
1 namespace Shop;
2
3 public static class Pricing
4 {
5 public static decimal Rate => 0.2m;
6 }

#validating-constructor-stays-in-body

Keeps a constructor that validates its arguments, changing only class to record

success
Targetsymbol T:Shop.Money
Money.csmodified
11 using System;
22
33 namespace Shop;
44
5−public sealed class Money
5+public sealed record Money
66 {
77 private readonly int _scale = 2;
88
99 public Money(decimal amount, string currency)
1010 {
1111 if (amount < 0)
1212 throw new ArgumentOutOfRangeException(nameof(amount));
1313
1414 Amount = amount;
1515 Currency = currency;
1616 }
1717
1818 public decimal Amount { get; }
1919
2020 public string Currency { get; }
2121
2222 public decimal Rounded => decimal.Round(Amount, _scale);
2323 }