En esta página

Records y pattern matching en C# 14

12 min lectura TextoCap. 3 — C# moderno

¿Qué son los records?

Los records son tipos de referencia (o valor) diseñados para modelar datos inmutables. A diferencia de las clases, los records tienen:

  • Igualdad por valor — dos records con los mismos valores son iguales
  • Deconstrucción automática
  • ToString() descriptivo generado automáticamente
  • Expresión with para clonar con cambios
// Clase: igualdad por referencia
class PersonaClase { public string Nombre { get; set; } = ""; }
var c1 = new PersonaClase { Nombre = "David" };
var c2 = new PersonaClase { Nombre = "David" };
Console.WriteLine(c1 == c2); // false — referencias distintas

// Record: igualdad por valor
record PersonaRecord(string Nombre, int Edad);
var r1 = new PersonaRecord("David", 30);
var r2 = new PersonaRecord("David", 30);
Console.WriteLine(r1 == r2); // true — mismos valores

Records posicionales

La forma más concisa. Los parámetros del constructor primario se convierten en propiedades init-only:

// Sintaxis posicional
record Coordenada(double Latitud, double Longitud);

var bogota = new Coordenada(-4.53, -75.67);
Console.WriteLine(bogota); // Coordenada { Latitud = -4.53, Longitud = -75.67 }

// Deconstrucción
var (lat, lon) = bogota;
Console.WriteLine($"Lat: {lat}, Lon: {lon}");

// LINQ con records
var ciudades = new List<Coordenada>
{
    new(-4.53,  -75.67),
    new(-16.50, -68.15),
    new(-17.39, -66.16),
};

var cercaDelEcuador = ciudades.Where(c => Math.Abs(c.Latitud) < 10);

Expresión with

Crea una copia del record con propiedades modificadas:

record Pedido(int Id, string Cliente, decimal Total, string Estado);

var pedido = new Pedido(1, "David", 299.99m, "pendiente");

// Cambiar el estado sin mutar el original
var pedidoAprobado   = pedido with { Estado = "aprobado" };
var pedidoCancelado  = pedido with { Estado = "cancelado", Total = 0m };

Console.WriteLine(pedido.Estado);         // pendiente — intacto
Console.WriteLine(pedidoAprobado.Estado); // aprobado

// Útil en cadenas de transformación
var pedidoFinal = pedido
    with { Estado = "procesando" }
    with { Total = pedido.Total * 0.9m }; // descuento 10%

record struct

Para tipos pequeños que se copian frecuentemente, record struct es más eficiente al vivir en el stack:

// record struct — semántica de valor (se copia, no se comparte referencia)
record struct Color(byte R, byte G, byte B, byte A = 255)
{
    public static readonly Color Rojo   = new(255, 0, 0);
    public static readonly Color Verde  = new(0, 255, 0);
    public static readonly Color Azul   = new(0, 0, 255);
    public static readonly Color Blanco = new(255, 255, 255);

    // Método de instancia
    public Color Mezclar(Color otro) =>
        new((byte)((R + otro.R) / 2),
            (byte)((G + otro.G) / 2),
            (byte)((B + otro.B) / 2));

    public string ToHex() => $"#{R:X2}{G:X2}{B:X2}";
}

var morado = Color.Rojo.Mezclar(Color.Azul);
Console.WriteLine(morado.ToHex()); // #7F007F

Pattern Matching

El pattern matching permite comprobar la forma de un valor y extraer información de forma declarativa.

is expression

object obj = "Hola .NET";

// Type pattern
if (obj is string s)
    Console.WriteLine($"Es un string de {s.Length} caracteres");

// Null check
if (obj is not null)
    Console.WriteLine("No es null");

// Constant pattern
if (obj is "Hola .NET")
    Console.WriteLine("Match exacto");

switch expression avanzado

// Property patterns
record Empleado(string Nombre, string Rol, decimal Salario, bool Activo);

static decimal CalcularBonus(Empleado emp) => emp switch
{
    { Activo: false }                              => 0,
    { Rol: "CEO" }                                 => emp.Salario * 0.5m,
    { Rol: "Manager", Salario: > 5000 }            => emp.Salario * 0.2m,
    { Rol: "Developer", Salario: > 3000 }          => emp.Salario * 0.15m,
    { Activo: true }                               => emp.Salario * 0.05m,
    _                                              => 0
};

var dev = new Empleado("David", "Developer", 4000m, true);
Console.WriteLine($"Bonus: ${CalcularBonus(dev):F2}"); // $600.00

Patrones relacionales y lógicos

// Relacionales: <, >, <=, >=
static string ClasificarIMC(double imc) => imc switch
{
    < 18.5              => "Bajo peso",
    >= 18.5 and < 25.0  => "Normal",
    >= 25.0 and < 30.0  => "Sobrepeso",
    >= 30.0             => "Obesidad"
};

// Lógicos: and, or, not
static bool EsHorarioLaboral(int hora) =>
    hora is (>= 8 and <= 12) or (>= 14 and <= 18);

static bool EsDiaLaboral(DayOfWeek dia) =>
    dia is not (DayOfWeek.Saturday or DayOfWeek.Sunday);

List patterns (C# 11+)

// Emparejar listas por estructura
static string AnalizarSecuencia(int[] seq) => seq switch
{
    []                          => "vacía",
    [0]                         => "solo cero",
    [_, 0, _]                   => "cero en el medio",
    [> 0, > 0, > 0]             => "todos positivos (3 elementos)",
    [var a, var b] when a == b  => $"dos iguales: {a}",
    [var h, .. var rest]        => $"inicia con {h}, {rest.Length} más"
};

Console.WriteLine(AnalizarSecuencia(new[] { 5, 3, 8, 1 }));
// "inicia con 5, 3 más"

Deconstruct en patrones

record Punto(double X, double Y);

static string UbicacionPunto(Punto p) => p switch
{
    (0, 0)                    => "Origen",
    (> 0, > 0)                => "Cuadrante I",
    (< 0, > 0)                => "Cuadrante II",
    (< 0, < 0)                => "Cuadrante III",
    (> 0, < 0)                => "Cuadrante IV",
    (_, 0) or (0, _)          => "Sobre un eje"
};

var p = new Punto(3, -1);
Console.WriteLine(UbicacionPunto(p)); // Cuadrante IV

Práctica

  1. Records de dominio: Crea record Factura(int Id, string Cliente, List<LineaFactura> Items) y record LineaFactura(string Producto, decimal Precio, int Cantidad). Usa with para marcar una factura como "pagada".
  2. Pattern matching de formas: Implementa DescribirForma(object forma) que use switch expression con type patterns para Circulo, Rectangulo y Triangulo.
  3. List patterns: Escribe un método que analice los primeros 3 elementos de una lista de precios y devuelva "Tendencia alcista", "Tendencia bajista" o "Estable".

En la siguiente lección comenzaremos con ASP.NET Core 10: cómo construir APIs web con Minimal APIs y el pipeline de middleware.

Records para DTOs e inmutabilidad
Usa records para DTOs (Data Transfer Objects), Value Objects y mensajes de eventos. Son perfectos cuando quieres igualdad por valor y desalentar la mutación. El compilador genera automáticamente Equals, GetHashCode, ToString y el operador ==.
record vs record struct
record class vive en el heap (como las clases normales). record struct vive en el stack (como los structs), es más eficiente para tipos pequeños que se copian mucho. Usa record struct para coordenadas, colores, rangos y otros tipos de valor pequeños.
with no muta el original
La expresión with siempre crea un NUEVO objeto con los cambios especificados. El objeto original queda intacto. Esto es intencional: los records están diseñados para ser inmutables y la inmutabilidad facilita el razonamiento sobre el código.
// Record class — inmutable por convención, value equality
public record Producto(int Id, string Nombre, decimal Precio);

// Posicional: deconstrucción automática
var laptop = new Producto(1, "Laptop Pro", 1299.99m);
var (id, nombre, precio) = laptop;
Console.WriteLine($"#{id}: {nombre} — ${precio:F2}");

// with expression — clonar con cambios (NO muta el original)
var laptopDescuento = laptop with { Precio = 999.99m };
Console.WriteLine(laptop.Precio);          // 1299.99 — sin cambios
Console.WriteLine(laptopDescuento.Precio); // 999.99

// Value equality — comparación por valor (no por referencia)
var p1 = new Producto(1, "Mouse", 29.99m);
var p2 = new Producto(1, "Mouse", 29.99m);
Console.WriteLine(p1 == p2);       // true  (records)
Console.WriteLine(ReferenceEquals(p1, p2)); // false

// Record struct — valor en stack, más eficiente en memoria
public record struct Punto(double X, double Y);
var origen = new Punto(0, 0);
var p = new Punto(3.0, 4.0);
Console.WriteLine(p.ToString()); // Punto { X = 3, Y = 4 }

// Record con propiedades adicionales y método
public record Usuario(int Id, string Email)
{
    public string Nombre { get; init; } = string.Empty;
    public DateTime CreadoEn { get; init; } = DateTime.UtcNow;

    public string Inicial => Nombre.Length > 0 ? Nombre[0].ToString() : "?";
}