RefactorMCP catalog

Convert Record to Class

Turns a record into a class that behaves the same: a positional record's constructor, properties and Deconstruct are written out, and the value equality, ToString and == operators the compiler generated for the record are generated as source.

Arguments

None. The target is the record, by symbol.

Precondition

  • The type is a record class, not a record struct.
  • The record neither derives from another record nor has records deriving from it; the equality contract of a record hierarchy has no plain class equivalent.
  • No with expression copies an instance, since a class has no clone method for it to call.
  • The record has one declaration.

Transformation

  • record becomes class, keeping the modifiers, type parameters, base types and constraints, and IEquatable<T> is added to the base types.
  • A positional parameter list becomes a public constructor assigning each parameter to a public { get; init; } property of the same name, and a Deconstruct method. The constructor parameters are camel case, and named arguments at every call site in the solution are renamed to match.
  • After the existing members come, unless the record declares them itself:
  • Equals(T other), comparing every instance field, including private fields and the backing fields of auto-properties, with EqualityComparer<TField>.Default; virtual and checking the runtime type when the record is not sealed.
  • Equals(object) calling it, and GetHashCode combining the same fields.
  • ToString in the record format, Name { A = 1, B = 2 }, listing the public fields and readable properties.
  • == and !=.
  • using System; and using System.Collections.Generic; are added when the generated members need them and the file does not already import them.
  • Parameters accepting null are annotated where nullable annotations are enabled.

Preserved

  • Construction, property access, deconstruction, equality, hashing into the same buckets, ToString output and ==.

Limitations

  • The record's protected copy constructor and EqualityContract are not generated, which is why with expressions and record hierarchies are refused.
  • Hash codes are equal for equal instances but not the same numbers the record produced.
  • A declared PrintMembers is kept but no longer used by ToString.
  • Attributes targeting the generated property ([property: ...]) on a positional parameter are not moved to the property.

Error codes

CodeMeaning
record-structthe target is a record struct
record-hierarchythe record derives from, or is derived by, another record
with-expressiona with expression copies an instance
partial-typethe record has more than one declaration

Cases

·

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

#derived-record-rejected

Refuses a record that another record derives from

refusal
Targetsymbol T:Shop.Adjustment
Refusesrecord-hierarchy mentioning “Discount”; every file is left unchanged
Adjustment.csinput
1 namespace Shop;
2
3 public abstract record Adjustment(decimal Amount);
Discount.csinput
1 namespace Shop;
2
3 public sealed record Discount(decimal Amount, string Code) : Adjustment(Amount);

#generic-record

Converts a generic positional record, comparing its members with the default comparer for the type parameter

success
Targetsymbol T:Shop.Pair`1
Pair.csmodified
11 using System;
22 using System.Collections.Generic;
33
44 namespace Shop;
55
6−public sealed record Pair<T>(T First, T Second) where T : IComparable<T>
6+public sealed class Pair<T> : IEquatable<Pair<T>> where T : IComparable<T>
77 {
8+ public Pair(T first, T second)
9+ {
10+ First = first;
11+ Second = second;
12+ }
13+
14+ public T First { get; init; }
15+
16+ public T Second { get; init; }
17+
818 public T Larger => First.CompareTo(Second) >= 0 ? First : Second;
19+
20+ public void Deconstruct(out T first, out T second)
21+ {
22+ first = First;
23+ second = Second;
24+ }
25+
26+ public bool Equals(Pair<T> other)
27+ {
28+ return other is not null
29+ && EqualityComparer<T>.Default.Equals(First, other.First)
30+ && EqualityComparer<T>.Default.Equals(Second, other.Second);
31+ }
32+
33+ public override bool Equals(object obj) => Equals(obj as Pair<T>);
34+
35+ public override int GetHashCode() => HashCode.Combine(First, Second);
36+
37+ public override string ToString() => $"Pair {{ First = {First}, Second = {Second}, Larger = {Larger} }}";
38+
39+ public static bool operator ==(Pair<T> left, Pair<T> right) => left is null ? right is null : left.Equals(right);
40+
41+ public static bool operator !=(Pair<T> left, Pair<T> right) => !(left == right);
942 }

#inherits-record-rejected

Refuses a record that derives from another record

refusal
Targetsymbol T:Shop.Discount
Refusesrecord-hierarchy; every file is left unchanged
Adjustment.csinput
1 namespace Shop;
2
3 public abstract record Adjustment(decimal Amount);
4
5 public sealed record Discount(decimal Amount, string Code) : Adjustment(Amount);

#keeps-declared-members

Keeps the ToString and typed Equals the record declares itself, generating only the members it did not

success
Targetsymbol T:Shop.Sku
Sku.csmodified
11 using System;
22
33 namespace Shop;
44
5−public sealed record Sku(string Code)
5+public sealed class Sku : IEquatable<Sku>
66 {
7+ public Sku(string code)
8+ {
9+ Code = code;
10+ }
11+
12+ public string Code { get; init; }
13+
714 public bool Equals(Sku other) => other is not null && string.Equals(Code, other.Code, StringComparison.OrdinalIgnoreCase);
815
916 public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Code);
1017
1118 public override string ToString() => Code;
19+
20+ public void Deconstruct(out string code)
21+ {
22+ code = Code;
23+ }
24+
25+ public override bool Equals(object obj) => Equals(obj as Sku);
26+
27+ public static bool operator ==(Sku left, Sku right) => left is null ? right is null : left.Equals(right);
28+
29+ public static bool operator !=(Sku left, Sku right) => !(left == right);
1230 }

#named-arguments-and-deconstruction-in-another-file

Renames named arguments to the camel-case constructor parameters; deconstruction and equality in another file keep working

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 decimal Area()
88 {
99 var (width, height) = Box;
1010 return width * height;
1111 }
1212
13− public bool IsSquare() => Box == new Size(Box.Width, Height: Box.Width);
13+ public bool IsSquare() => Box == new Size(Box.Width, height: Box.Width);
1414 }
Size.csmodified
1+using System;
2+using System.Collections.Generic;
3+
14 namespace Shop;
25
3−public sealed record Size(decimal Width, decimal Height);
6+public sealed class Size : IEquatable<Size>
7+{
8+ public Size(decimal width, decimal height)
9+ {
10+ Width = width;
11+ Height = height;
12+ }
13+
14+ public decimal Width { get; init; }
15+
16+ public decimal Height { get; init; }
17+
18+ public void Deconstruct(out decimal width, out decimal height)
19+ {
20+ width = Width;
21+ height = Height;
22+ }
23+
24+ public bool Equals(Size other)
25+ {
26+ return other is not null
27+ && EqualityComparer<decimal>.Default.Equals(Width, other.Width)
28+ && EqualityComparer<decimal>.Default.Equals(Height, other.Height);
29+ }
30+
31+ public override bool Equals(object obj) => Equals(obj as Size);
32+
33+ public override int GetHashCode() => HashCode.Combine(Width, Height);
34+
35+ public override string ToString() => $"Size {{ Width = {Width}, Height = {Height} }}";
36+
37+ public static bool operator ==(Size left, Size right) => left is null ? right is null : left.Equals(right);
38+
39+ public static bool operator !=(Size left, Size right) => !(left == right);
40+}

#nullable-enabled

Annotates the generated parameters that accept null when nullable annotations are enabled

success
Targetsymbol T:Shop.Contact
Projectnullable enable
Contact.csmodified
11 using System;
22 using System.Collections.Generic;
33
44 namespace Shop;
55
6−public sealed record Contact(string Name, string? Email);
6+public sealed class Contact : IEquatable<Contact>
7+{
8+ public Contact(string name, string? email)
9+ {
10+ Name = name;
11+ Email = email;
12+ }
13+
14+ public string Name { get; init; }
15+
16+ public string? Email { get; init; }
17+
18+ public void Deconstruct(out string name, out string? email)
19+ {
20+ name = Name;
21+ email = Email;
22+ }
23+
24+ public bool Equals(Contact? other)
25+ {
26+ return other is not null
27+ && EqualityComparer<string>.Default.Equals(Name, other.Name)
28+ && EqualityComparer<string?>.Default.Equals(Email, other.Email);
29+ }
30+
31+ public override bool Equals(object? obj) => Equals(obj as Contact);
32+
33+ public override int GetHashCode() => HashCode.Combine(Name, Email);
34+
35+ public override string ToString() => $"Contact {{ Name = {Name}, Email = {Email} }}";
36+
37+ public static bool operator ==(Contact? left, Contact? right) => left is null ? right is null : left.Equals(right);
38+
39+ public static bool operator !=(Contact? left, Contact? right) => !(left == right);
40+}

#partial-record-rejected

Refuses a record declared in two parts; merge them first

refusal
Targetsymbol T:Shop.Tag
Refusespartial-type; every file is left unchanged
Tag.Display.csinput
1 namespace Shop;
2
3 public sealed partial record Tag
4 {
5 public string Display => "#" + Name;
6 }
Tag.csinput
1 namespace Shop;
2
3 public sealed partial record Tag(string Name);

#positional-sealed-record

Turns a positional sealed record into a class with a constructor, init-only properties, Deconstruct and the equality, ToString and operator members the record provided

success
Targetsymbol T:Shop.Point
Point.csmodified
11 using System;
2+using System.Collections.Generic;
23
34 namespace Shop;
45
5−public sealed record Point(int X, int Y)
6+public sealed class Point : IEquatable<Point>
67 {
8+ public Point(int x, int y)
9+ {
10+ X = x;
11+ Y = y;
12+ }
13+
14+ public int X { get; init; }
15+
16+ public int Y { get; init; }
17+
718 public double Length() => Math.Sqrt(X * X + Y * Y);
19+
20+ public void Deconstruct(out int x, out int y)
21+ {
22+ x = X;
23+ y = Y;
24+ }
25+
26+ public bool Equals(Point other)
27+ {
28+ return other is not null
29+ && EqualityComparer<int>.Default.Equals(X, other.X)
30+ && EqualityComparer<int>.Default.Equals(Y, other.Y);
31+ }
32+
33+ public override bool Equals(object obj) => Equals(obj as Point);
34+
35+ public override int GetHashCode() => HashCode.Combine(X, Y);
36+
37+ public override string ToString() => $"Point {{ X = {X}, Y = {Y} }}";
38+
39+ public static bool operator ==(Point left, Point right) => left is null ? right is null : left.Equals(right);
40+
41+ public static bool operator !=(Point left, Point right) => !(left == right);
842 }

#record-struct-rejected

Refuses a record struct, which would become a class with different copy semantics

refusal
Targetsymbol T:Shop.Point
Refusesrecord-struct; every file is left unchanged
Point.csinput
1 namespace Shop;
2
3 public readonly record struct Point(int X, int Y);

#record-with-body-members

Converts a record that is not sealed and declares its state in the body: equality covers the private field and checks the runtime type, ToString only public members

success
Targetsymbol T:Shop.Money
Money.csmodified
1+using System;
2+using System.Collections.Generic;
3+
14 namespace Shop
25 {
36 /// <summary>An amount in a currency.</summary>
4− public record Money
7+ public class Money : IEquatable<Money>
58 {
69 // Rounding applies to every amount.
710 private readonly int _scale;
811
912 public Money(decimal amount, string currency, int scale)
1013 {
1114 Amount = amount;
1215 Currency = currency;
1316 _scale = scale;
1417 }
1518
1619 public decimal Amount { get; }
1720
1821 public string Currency { get; }
1922
2023 public decimal Rounded => decimal.Round(Amount, _scale);
24+
25+ public virtual bool Equals(Money other)
26+ {
27+ return other is not null
28+ && GetType() == other.GetType()
29+ && EqualityComparer<int>.Default.Equals(_scale, other._scale)
30+ && EqualityComparer<decimal>.Default.Equals(Amount, other.Amount)
31+ && EqualityComparer<string>.Default.Equals(Currency, other.Currency);
32+ }
33+
34+ public override bool Equals(object obj) => Equals(obj as Money);
35+
36+ public override int GetHashCode() => HashCode.Combine(_scale, Amount, Currency);
37+
38+ public override string ToString() => $"Money {{ Amount = {Amount}, Currency = {Currency}, Rounded = {Rounded} }}";
39+
40+ public static bool operator ==(Money left, Money right) => left is null ? right is null : left.Equals(right);
41+
42+ public static bool operator !=(Money left, Money right) => !(left == right);
2143 }
2244 }

#with-expression-rejected

Refuses a record copied with a with expression, which a class does not support

refusal
Targetsymbol T:Shop.Point
Refuseswith-expression mentioning “Mover.cs”; every file is left unchanged
Mover.csinput
1 namespace Shop;
2
3 public static class Mover
4 {
5 public static Point Right(Point point) => point with { X = point.X + 1 };
6 }
Point.csinput
1 namespace Shop;
2
3 public sealed record Point(int X, int Y);