Quicklens: a path-based way to update nested case class fields in Scala
Modify deeply nested case class fields
At a glance
- What is it?
- Quicklens is a Scala library that lets you set or transform a field nested several case classes deep by writing a path such as _.address.street.name. It is Apache-2.0 licensed, published on Maven Central, and its distinguishing feature is a set of traversal combinators for containers, options and maps rather than plain path syntax alone.
- Who is it for?
- Quicklens suits a Scala codebase with deeply nested immutable case classes where the alternative is a long chain of copy calls that breaks whenever a field moves. It does not suit a team that wants runtime-checked access to optional fields, because .at and .at on an Option throw NoSuchElementException and IndexOutOfBoundsException rather than returning a value, and .eachWhere leaves the shape of the data to the caller.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 1 day ago.
- What is it written in?
- Mainly Scala, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The core operation is a path plus a function
Quicklens is a Scala library for modifying deeply nested fields in case classes. The whole idea is that you describe where the value lives as a lambda path, and then either set it or transform it.
import com.softwaremill.quicklens._
case class Street(name: String)
case class Address(street: Street)
case class Person(address: Address, age: Int)
val person = Person(Address(Street("1 Functional Rd.")), 35)
val p2 = person.modify(_.address.street.name).using(_.toUpperCase)
val p3 = person.modify(_.address.street.name).setTo("3 OO Ln.")The two verbs are using and setTo. using applies a function to the current value and rebuilds the structure around the result, setTo replaces it. The library is a source of the kind of rewrite that in plain Scala means copying the innermost case class, then the one containing it, and then the one containing that, with each constructor call repeating the fields you did not change.
The path is an ordinary lambda, which is the detail that makes the library pleasant to use. _.address.street.name is checked by the compiler, so a renamed field is a compile error rather than a runtime surprise, and the type at the end of the path is inferred. There is also a standalone form, modify(person)(_.address.street.name), which is useful when the path has to be passed around separately from the value.
Because the path is a lambda rather than a string, there is no parsing, no reflection and no string-based key lookup to go wrong at runtime.
Chained modifications and conditional updates
Because modify returns a new value, several modifications compose in sequence, each applied to the result of the last.
person
.modify(_.address.street.name).using(_.toUpperCase)
.modify(_.age).using(_ - 1)The library also offers a separate top-level form for the same thing, which is what you reach for when the path is being reused.
val p4 = modify(person)(_.address.street.name).using(_.toUpperCase)
val p5 = modify(person)(_.address.street.name).setTo("3 OO Ln.")Conditional updates are a separate pair of methods rather than something you express with the function you pass in. setToIfDefined takes an Option and only writes when the Option holds a value, and setToIf takes a Boolean predicate with the value in a second parameter list.
person.modify(_.address.street.name).setToIfDefined(Some("3 00 Ln."))
person.modify(_.address.street.name).setToIf(shouldChangeAddress)("3 00 Ln.")That distinction matters in practice. If you write the conditional inside using, you are expressing a business rule about a value. setToIfDefined expresses a structural fact, namely that the field is optional, and it keeps the Option handling in one place rather than repeated at each call site.
There is a bulk variant as well, modifyAll, which applies one function to several paths at once. The example in the documentation uses it to capitalise a first name, a middle name and a last name, where the middle name is an Option.
import com.softwaremill.quicklens._
case class Person(firstName: String, middleName: Option[String], lastName: String)
val person = Person("john", Some("steve"), "smith")
person.modifyAll(_.firstName, _.middleName.each, _.lastName).using(_.capitalize)Note the .each on the Option field. That is the first appearance of the traversal combinators, and it is how the library handles the fact that one path in the group is optional and the others are not.
each, eachWhere and the functor that makes them extensible
The traversal combinators are what separate Quicklens from a path-update library that only handles direct field access.
.each unwraps a container inside a modify, so you can reach into elements of a collection, a value inside an Option, or the values of a Map. The documentation states the current support covers Seqs, Options and Maps, and for maps only the values are unwrapped.
import com.softwaremill.quicklens._
case class Street(name: String)
case class Address(street: Option[Street])
case class Person(addresses: List[Address])
val person = Person(List(
Address(Some(Street("1 Functional Rd."))),
Address(Some(Street("2 Imperative Dr.")))
))
val p2 = person.modify(_.addresses.each.street.each.name).using(_.toUpperCase)Two rules come with it. Each can only be used inside a modify, and it is not a general-purpose accessor. If you want it outside, you are in the wrong place.
The extension point is an implicit QuicklensFunctor[C] with the appropriate C type parameter, so a team with its own container type can add support rather than being stuck with the built-in three.
.eachWhere narrows the traversal to elements matching a predicate, which is the version you want when only some entries should change.
def filterAddress: Address => Boolean = ???
person
.modify(_.addresses.eachWhere(filterAddress)
.street.eachWhere(_.name.startsWith("1")).name)
.using(_.toUpperCase)Elements that do not satisfy the predicate are left unchanged. The predicate is a plain function, so it can be a method reference, a partial function or a lambda, and the filtering composes across nesting levels, which is why the example filters on the address and then again on the street name inside it.
at and index differ only in what happens when the element is missing
Targeting a single element is where the library makes a deliberate distinction, and it is the part worth reading carefully before you use it.
.at selects the element at a given index or key and throws when it is absent.
person.modify(_.addresses.at(2).street.at.name).using(_.toUpperCase)If there is no element at that index, an IndexOutOfBoundsException is thrown. The .at on the Option selects the lone possible element, and if that Option is None a NoSuchElementException is thrown. The same applies to map keys.
case class Property(value: String)
case class PersonWithProps(name: String, props: Map[String, Property])
val personWithProps = PersonWithProps(
"Joe",
Map("Role" -> Property("Programmmer"), "Age" -> Property("45"))
)
personWithProps.modify(_.props.at("Age").value).setTo("45").index does the same selection but makes no modification when the element is missing, and it does not throw.
person.modify(_.addresses.index(2).street.index.name).using(_.toUpperCase)personWithProps.modify(_.props.index("Age").value).setTo("45")So the choice is not about which element you target, it is about how you want absence handled: an exception, or a no-op. The documentation is explicit that a missing element under .index means no modification is made, and for a None Option the same.
A third variant, atOrElse, covers the case where absence should produce a value rather than either an error or a no-op. For maps, if the key is present the second parameter is never evaluated, and if it is absent a default is created and the subsequent modification applies to the new value. For Options it takes no argument. For sequences and arrays, an existing element behaves like .at, and an out of bounds index appends the modified default to the end of the sequence.
personWithProps.modify(_.props.atOrElse("NumReports", Property("0")).value).setTo("5")That last behaviour, appending a default to the end of a sequence, is the kind of detail that surprises people, and it is the reason to pick .index or .atOrElse deliberately rather than by habit.
Subtype selection and reusable lenses
Two more capabilities round out the API.
The first is selecting fields by subtype, which the documentation links to the optics concept of a prism.
trait Animal
case class Dog(age: Int) extends Animal
case class Cat(ages: Seq[Int]) extends Animal
case class Zoo(animals: Seq[Animal])
val zoo = Zoo(List(Dog(4), Cat(List(3, 12, 13))))
val olderZoo = zoo.modifyAll(
_.animals.each.when[Dog].age,
_.animals.each.when[Cat].ages.at(0)
).using(_ + 1).when[Dog] narrows the sequence to the elements that are actually a Dog, and .when[Cat] does the same for Cat. The when clause is not restricted to the top of a path, which is what makes the example work: it selects a subtype, then reaches into a field of that subtype only.
The second is reusability. A modification can be lifted into a value and applied to different instances, which is what makes a lens rather than a one-off copy chain.
import com.softwaremill.quicklens._
val modifyStreetName = modify(_: Person)(_.address.street.name)
val p3 = modifyStreetName(person).using(_.toUpperCase)
val p4 = modifyStreetName(anotherPerson).using(_.toLowerCase)The documentation also shows the variant where the function is applied eagerly, producing a modification of a value rather than a modification of a path.
val upperCaseStreetName = modify(_: Person)(_.address.street.name).using(_.toUpperCase)
val p5 = upperCaseStreetName(person)There is an alternate syntax with modifyLens as well, so the naming reflects whether you want the lens or the applied modification.
The Either case has its own pair of combinators, eachLeft and eachRight, which is the natural way to reach into a sum type where the path differs by branch.
case class AuthContext(token: String)
case class AuthRequest(url: String)
case class Resource(auth: Either[AuthContext, AuthRequest])
val devResource = Resource(auth = Left(AuthContext("fake"))
val prodResource = devResource.modify(_.auth.eachLeft.token).setTo("real")Because an Either has two branches with different shapes, a single path cannot describe both, and eachLeft picks the branch explicitly.
Where Quicklens is the wrong tool
The first wrong-tool case is code that must not throw. The .at combinator raises IndexOutOfBoundsException and NoSuchElementException on missing data, which in a data pipeline that processes partially populated records is a defect waiting for the first unexpected record. .index and .atOrElse exist for that reason, but the throwing variants are the ones that read most naturally, which is how the wrong one gets shipped.
The second case is a codebase that has moved to a different approach to nested updates entirely. If your project already uses a lens library with a different path syntax, or has settled on a monadic transformation approach, adding Quicklens means two idioms in one codebase and a rule about which to use in new code.
The third case is heavy reflection-based data work, such as a generic mapping layer driven by field names in configuration. Quicklens is built on typed lambdas, so it gives you compile-time paths and no runtime lookup by name. If your requirement is a generic transformer over arbitrary structures, a path-based library is the wrong shape.
On the practical side, the artifacts are published per Scala version, with a Maven Central badge for the 2.13 build, so the version you depend on has to match the Scala version your project compiles against. That is normal for a Scala library but it does mean the dependency block in your build is not a single coordinate.
On maintenance, the last push was on 2026-10-13, which is recent, and the release history shows v1.9.15 in June 2026, v1.9.12 in January 2025 and v1.9.11 in December 2024, a patch-level cadence across the 1.9 line. The licence is Apache-2.0, in LICENSE.txt at the root, which is a permissive grant with an explicit patent clause and no copyleft obligation on your own code.
Editorial conclusion
Quicklens suits a Scala codebase with deeply nested immutable case classes where the alternative is a long chain of copy calls that breaks whenever a field moves. It does not suit a team that wants runtime-checked access to optional fields, because .at and .at on an Option throw NoSuchElementException and IndexOutOfBoundsException rather than returning a value, and .eachWhere leaves the shape of the data to the caller. Before adopting, add the library at the version your Scala cross-build supports, since the published artifacts are per Scala version, and prefer .index and .atOrElse over .at in any code path that can see missing data.
Frequently asked questions
How do I add Quicklens to a Scala project?
Quicklens is published on Maven Central as com.softwaremill.quicklens, with a separate artifact per Scala version such as quicklens_2.13, so the dependency has to match the Scala version your project compiles against. The source lives in a single sbt module under quicklens/ in the repository.
What is the difference between .at and .index in Quicklens?
Both select the element at a given index or map key. The difference is what happens when it is missing: .at throws an IndexOutOfBoundsException for a sequence and a NoSuchElementException for a None Option, while .index makes no modification and does not throw.
How do I update a field inside an Option or a List with Quicklens?
Use .each inside a modify, which unwraps the container. The documentation states it currently supports Seqs, Options and Maps, with only the values unwrapped for maps, and that .each can only be used inside a modify.
What license is Quicklens released under?
Quicklens is licensed under Apache-2.0, with the licence text in the LICENSE.txt file at the root of the repository. That is a permissive grant with an explicit patent clause.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/softwaremill-quicklens)Was this useful?