Expressão with no C#

A expressão with no C# cria uma cópia de um objeto com algumas propriedades alteradas, sem modificar o objeto original. Ela chegou junto com os records e virou peça central para quem trabalha com dados imutáveis.

Esse recurso tem um nome técnico: mutação não destrutiva. Por isso, ele aparece com frequência em programação funcional, em modelagem de domínio e na construção de objetos em interfaces desacopladas.

O que é a expressão with

A expressão with cria uma nova instância com base em outra e modifica apenas os membros que você indicar. Ou seja, o novo objeto nasce como uma cópia do original, já com as alterações aplicadas.

A sintaxe reaproveita os inicializadores de objeto. Assim, dentro das chaves, você lista apenas os membros que devem mudar e os novos valores de cada um.

Exemplo de expressão with com record

O exemplo clássico da documentação usa um record posicional com três propriedades:

public record NamedPoint(string Name, int X, int Y);

var p1 = new NamedPoint("A", 0, 0);
var p2 = p1 with { Name = "B", X = 5 };

Console.WriteLine(p1); // NamedPoint { Name = A, X = 0, Y = 0 }
Console.WriteLine(p2); // NamedPoint { Name = B, X = 5, Y = 0 }

O objeto p1 permanece intacto depois da operação. Já o p2 é uma nova instância, com as propriedades Name e X modificadas e o Y herdado do original.

Repare que você não precisou repetir o valor de Y. Essa é a grande vantagem da expressão with: você descreve só a diferença, e o compilador cuida do resto.

Como o compilador monta a cópia

Por trás da sintaxe enxuta, o compilador faz duas etapas. Primeiro, ele chama o construtor de cópia do record, que copia o estado do objeto original para uma nova instância.

Em seguida, ele aplica as atribuições que você escreveu entre as chaves. Isso funciona porque os records posicionais geram propriedades com o acessor init, que aceita valores durante a inicialização.

Na prática, esse detalhe explica por que a expressão with respeita a imutabilidade. A cópia recebe os novos valores no momento da criação e, depois disso, ninguém altera mais nada.

Como era a vida antes da expressão with

Antes dos records, quem queria objetos imutáveis em C# precisava escrever a cópia na mão. O padrão mais comum era criar um método WithSomething para cada propriedade que podia mudar.

public sealed class Address
{
    public string Street { get; }
    public string City { get; }

    public Address(string street, string city)
    {
        Street = street;
        City = city;
    }

    // Um método de cópia para cada propriedade alterável
    public Address WithStreet(string street) => new Address(street, City);
    public Address WithCity(string city) => new Address(Street, city);
}

Esse código funciona, mas cresce rápido e exige manutenção a cada nova propriedade. Por exemplo, se você adicionar um campo ZipCode, precisa atualizar o construtor e todos os métodos de cópia.

A expressão with elimina esse trabalho repetitivo. Com um record, o mesmo resultado sai em uma linha:

public record Address(string Street, string City);

var home = new Address("Main Street", "Springfield");
var work = home with { Street = "Second Avenue" };

Além de menos código, você ganha consistência. Afinal, o compilador sempre gera a cópia completa, então nenhum campo novo fica esquecido.

Expressão with e propriedades nominais

Os exemplos até aqui usam records posicionais, mas a expressão with também funciona com propriedades declaradas no corpo do tipo. Nesse caso, basta usar o acessor init:

public record Product
{
    public string Name { get; init; } = string.Empty;
    public decimal Price { get; init; }
}

var keyboard = new Product { Name = "Keyboard", Price = 150m };
var onSale = keyboard with { Price = 120m };

Se a propriedade tiver apenas get, sem init nem set, o compilador recusa a atribuição dentro da expressão. Portanto, o acessor init é o par natural da expressão with.

Também vale notar que um set público funciona tecnicamente. No entanto, ele quebra a proposta de imutabilidade, então prefira init sempre que o objetivo for evitar mutações.

Quais tipos aceitam a expressão with

No C# 9, a expressão with funcionava apenas com records. A partir do C# 10, a linguagem estendeu o recurso para outros tipos.

Segundo a documentação oficial da Microsoft, o operando da esquerda pode ser um record, uma struct, uma tupla ou um tipo anônimo. Com isso, o recurso vale tanto para tipos de referência quanto para tipos de valor.

Na linha do tempo do .NET, o C# 9 acompanhou o .NET 5, enquanto o C# 10 chegou com o .NET 6. Portanto, se o seu projeto ainda roda em uma versão anterior, apenas os records aceitam a expressão with.

Vale lembrar também que o C# 10 trouxe o record struct, que une a sintaxe enxuta dos records à semântica de tipo de valor. Dessa forma, você escolhe entre record class e record struct conforme o custo de alocação e o tamanho dos dados.

Na prática, essa escolha raramente muda a forma de usar a expressão with. A sintaxe continua idêntica, e apenas o comportamento de memória por trás da cópia se altera.

Expressão with com tipos anônimos

Os tipos anônimos também aceitam a expressão with. Isso ajuda com objetos temporários usados em consultas LINQ, projeções ou transformações de dados.

var product = new { Item = "Apples", Price = 1.19m };
var promo = product with { Price = 0.79m };

Console.WriteLine(product); // { Item = Apples, Price = 1.19 }
Console.WriteLine(promo);   // { Item = Apples, Price = 0.79 }

Como as propriedades de um tipo anônimo são somente leitura, antes do C# 10 você precisava criar outro objeto anônimo do zero. Se quiser revisar o assunto, veja o artigo sobre tipos anônimos no C#.

Expressão with com structs e tuplas

Structs comuns, record structs e tuplas também aceitam a expressão. Veja os três casos lado a lado:

public struct Coordinate
{
    public double Latitude { get; init; }
    public double Longitude { get; init; }
}

public readonly record struct Money(decimal Amount, string Currency);

var origin = new Coordinate { Latitude = -23.5, Longitude = -46.6 };
var moved = origin with { Longitude = -46.7 };

var price = new Money(100m, "BRL");
var discounted = price with { Amount = 90m };

var counter = (Name: "visits", Count: 1);
var next = counter with { Count = 2 };

Nos tipos de valor, a cópia já acontece naturalmente na atribuição. Mesmo assim, a expressão with deixa a intenção clara e combina muito bem com propriedades init e structs readonly.

Expressão with e herança

Mesmo quando a variável usa um tipo base, a cópia mantém o tipo real que o objeto tem em tempo de execução. Em outras palavras, a expressão with não "corta" a instância para o tipo declarado.

public record Point(int X, int Y);
public record NamedPoint(string Name, int X, int Y) : Point(X, Y);

Point p1 = new NamedPoint("A", 0, 0);
Point p2 = p1 with { X = 5 };

Console.WriteLine(p2);               // NamedPoint { X = 5, Y = 0, Name = A }
Console.WriteLine(p2 is NamedPoint); // True

A nova instância preserva o tipo real da original, NamedPoint, mesmo com a variável declarada como Point. Por isso, a propriedade Name continua presente na cópia.

Esse comportamento existe porque o compilador gera um método virtual de clonagem nos records. Assim, cada tipo derivado sabe criar a cópia correta de si mesmo.

Referência versus valor na expressão with

Aqui está a armadilha mais comum. A expressão with faz uma cópia rasa, então membros de tipo de referência continuam apontando para a mesma instância.

Isso significa que uma alteração em um objeto interno afeta tanto a cópia quanto o original:

public record TaggedNumber(int Number, List<string> Tags);

var original = new TaggedNumber(1, new List<string> { "A", "B" });
var copy = original with { Number = 2 };

original.Tags.Add("C");

Console.WriteLine(string.Join(", ", copy.Tags)); // A, B, C

Nesse exemplo, a propriedade Tags guarda um List<string>, e as duas instâncias compartilham a mesma lista. Portanto, o item "C" aparece na cópia, mesmo que você só tenha mexido no original.

Em resumo, o record é imutável na superfície, mas não protege os objetos mutáveis que ele carrega. Quem garante a imutabilidade profunda é você.

Controlando a cópia da expressão with

Para evitar o compartilhamento de referências em membros mutáveis, você pode declarar um construtor de cópia personalizado. Ele substitui o construtor que o compilador gera e define como cada membro deve ser copiado.

public record TaggedNumber(int Number, List<string> Tags)
{
    protected TaggedNumber(TaggedNumber original)
    {
        Number = original.Number;
        Tags = new List<string>(original.Tags); // Nova lista com os mesmos itens
    }
}

Agora, cada cópia feita com a expressão with recebe uma lista própria:

var original = new TaggedNumber(1, new List<string> { "A", "B" });
var copy = original with { Number = 2 };

original.Tags.Add("C");

Console.WriteLine(string.Join(", ", copy.Tags)); // A, B

Com isso, a cópia fica completamente independente em relação à propriedade Tags. Vale lembrar, no entanto, que essa personalização só existe para records, porque a documentação deixa claro que não dá para customizar a cópia de structs.

Outra saída é evitar coleções mutáveis desde o início. Por exemplo, tipos como IReadOnlyList<T> ou as coleções imutáveis do .NET reduzem bastante o risco de efeitos colaterais.

Igualdade por valor após a expressão with

Os records comparam instâncias pelo valor das propriedades, e não pela referência. Esse detalhe muda a forma de pensar as cópias feitas com a expressão with.

public record Point(int X, int Y);

var a = new Point(1, 2);
var b = a with { };

Console.WriteLine(a == b);                    // True
Console.WriteLine(ReferenceEquals(a, b));     // False

Uma expressão with com chaves vazias gera um clone exato. Assim, os dois objetos são iguais pelo valor, mas ocupam lugares diferentes na memória.

Na prática, isso permite comparar estados com segurança. Por exemplo, você pode verificar se uma operação realmente mudou algo comparando o objeto antigo com o novo.

Desempenho da expressão with

Em um record class, cada expressão with cria um novo objeto no heap. Na grande maioria das aplicações, esse custo é irrelevante diante da clareza que o recurso oferece.

Em trechos muito quentes, porém, como laços que rodam milhões de vezes, as alocações extras podem pressionar o coletor de lixo. Nesses casos, um record struct costuma ser uma alternativa interessante, porque a cópia acontece por valor.

Mesmo assim, meça antes de otimizar. Ferramentas como o BenchmarkDotNet mostram com precisão se a expressão with realmente pesa no seu cenário.

Cuidado com propriedades calculadas

A documentação alerta para um detalhe sutil. Como a expressão with primeiro copia a instância e só depois aplica as mudanças, uma propriedade calculada na criação fica desatualizada.

public record Order(decimal Price, int Quantity)
{
    // Calculado na criação: fica desatualizado após o with
    public decimal Total { get; } = Price * Quantity;
}

var order = new Order(10m, 2);
var bigger = order with { Quantity = 5 };

Console.WriteLine(bigger.Total); // 20

O valor correto seria 50, mas a cópia herdou o total do objeto original. Para resolver, calcule a propriedade no momento do acesso:

public record Order(decimal Price, int Quantity)
{
    // Calculado a cada leitura: sempre reflete os valores atuais
    public decimal Total => Price * Quantity;
}

Dessa forma, a expressão with funciona como esperado, porque Total passa a refletir os valores atuais de Price e Quantity.

Expressão with em record e class

A expressão with não funciona com classes comuns. Afinal, uma class não tem o mecanismo de cópia que o compilador gera para os records.

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
}

// Isso causa erro de compilação:
// var newPerson = person with { Age = 30 };

Com um record, o mesmo cenário funciona sem nenhum código extra:

public record Person(string Name, int Age);

var p1 = new Person("Mary", 25);
var p2 = p1 with { Age = 30 };

Console.WriteLine(p2); // Person { Name = Mary, Age = 30 }

Se você precisa manter uma classe, a alternativa é escrever um método de cópia manual. Porém, na maioria dos cenários de dados imutáveis, migrar para um record resolve o problema com menos código.

Onde usar a expressão with no dia a dia

Na modelagem de domínio, a expressão with combina muito bem com value objects. Um objeto como Money ou Address nunca muda; quando algo precisa mudar, você cria outro valor.

O artigo sobre como criar bons value objects em C# aprofunda esse padrão. Além disso, os records simplificam a igualdade por valor, que esses objetos exigem.

Em gerenciamento de estado, a mesma ideia aparece com frequência. Por exemplo, uma tela pode trocar o estado inteiro a cada ação, sem mutar o estado anterior:

public record ScreenState(bool IsLoading, string? ErrorMessage, IReadOnlyList<string> Items);

var state = new ScreenState(false, null, Array.Empty<string>());

// Cada transição gera um novo estado, preservando o anterior
state = state with { IsLoading = true };
state = state with { IsLoading = false, Items = new[] { "first", "second" } };

Nos testes automatizados, a expressão with também ajuda bastante. Você cria um objeto base válido e, em cada teste, altera só a propriedade que interessa ao cenário.

public record Customer(string Name, string Email, bool IsActive);

var validCustomer = new Customer("Mary", "mary@example.com", IsActive: true);
var inactiveCustomer = validCustomer with { IsActive = false };

Esse padrão deixa cada teste curto e evidencia exatamente o que muda entre um caso e outro.

Por fim, a expressão with também conversa bem com a desconstrução de records. O artigo sobre Deconstruct no C# mostra como extrair os valores de um objeto em variáveis separadas.

Expressão with em DTOs e APIs

Em APIs, os DTOs costumam trafegar entre camadas sem mudar de forma. Quando um deles precisa de um ajuste pontual, a expressão with evita que você altere o objeto que outra parte do código ainda usa.

public record CreateOrderRequest(string CustomerId, string? CouponCode, decimal Amount);

// Normaliza o cupom sem alterar a requisição original
var normalized = request with { CouponCode = request.CouponCode?.Trim().ToUpperInvariant() };

Dessa forma, o objeto recebido continua igual ao que chegou na requisição. Isso facilita logs, auditoria e depuração, porque você sempre consegue comparar a entrada original com a versão tratada.

Boas práticas com a expressão with

Algumas práticas simples deixam o uso do recurso mais seguro. Primeiro, prefira propriedades init e coleções somente leitura nos records que você pretende copiar.

Em seguida, mantenha as propriedades calculadas como expressões de leitura, com =>. Assim, a cópia nunca carrega um valor antigo do objeto original.

Também evite records muito grandes, com dezenas de propriedades. Nesses casos, cada cópia aloca um objeto pesado, e o tipo provavelmente mistura responsabilidades que deveriam estar separadas.

Por fim, escreva testes que validem a independência das cópias quando o record carrega objetos mutáveis. Um teste curto, como o exemplo da lista de tags, revela rapidamente uma cópia rasa indesejada.

Limitações da expressão with

Antes de adotar o recurso em todo lugar, vale conhecer os limites dele:

  • A expressão with não funciona com classes comuns.
  • Não é possível definir um construtor de cópia personalizado para structs.
  • A cópia é rasa, então tipos com membros de referência podem exigir tratamento especial para garantir cópias profundas.
  • Propriedades calculadas na inicialização ficam desatualizadas após a cópia.
  • Os membros alterados precisam aceitar atribuição na inicialização, com init ou set.

Nenhuma dessas limitações impede o uso no dia a dia. Contudo, conhecê-las evita bugs difíceis de rastrear, principalmente os de referência compartilhada.

Conclusão

A expressão with representa uma maneira moderna e segura de trabalhar com dados imutáveis em C#. Ao permitir cópias modificadas de forma simples, ela facilita a escrita de código mais previsível, reutilizável e testável.

Ela brilha especialmente com records, em cenários como modelagem de domínio, DTOs e composição de estados. Além disso, desde o C# 10, também atende structs, tuplas e tipos anônimos.

Por fim, lembre das duas armadilhas principais: a cópia rasa e as propriedades calculadas na criação. Assim, a expressão with contribui para aplicações mais robustas, com menos efeitos colaterais e menos bugs causados por mutações descontroladas.

Para continuar no tema de imutabilidade, leia também sobre strings imutáveis em C#.

FAQ

Em qual versão do C# surgiu a expressão with?

Ela surgiu no C# 9, junto com os records. No C# 10, passou a aceitar também structs, tuplas e tipos anônimos.

A expressão with altera o objeto original?

Não. Ela cria uma nova instância e aplica as mudanças apenas na cópia.

A expressão with faz cópia profunda?

Não, a cópia é rasa. Membros de tipo de referência continuam compartilhados, a menos que você declare um construtor de cópia.

Posso usar a expressão with em uma class comum?

Não, o compilador recusa o código. Use um record ou escreva um método de cópia manual.

Por que uma propriedade calculada ficou com valor antigo?

Porque a cópia herda o valor calculado na criação do original. Calcule a propriedade no acesso, com =>, para evitar o problema.

Glossário

Termo Significado
Record Tipo do C# com igualdade por valor e suporte nativo a cópias com with.
Mutação não destrutiva Criação de uma cópia modificada, mantendo o original intacto.
Construtor de cópia Construtor que recebe uma instância do mesmo tipo e copia seu estado.
Cópia rasa Cópia que duplica os valores, mas compartilha os objetos referenciados.
Cópia profunda Cópia que também duplica os objetos referenciados.
Acessor init Acessor que permite atribuir valor apenas durante a inicialização.
Tipo anônimo Tipo sem nome declarado, criado pelo compilador a partir de um inicializador.
Value object Objeto de domínio definido pelos seus valores, e não por uma identidade.

Fontes