Skip to content

feat(schema): add Deriver.withInstance and Deriver.withModifier APIs#1348

Merged
987Nabil merged 4 commits into
zio:mainfrom
987Nabil:feat/deriver-with-instance
Apr 28, 2026
Merged

feat(schema): add Deriver.withInstance and Deriver.withModifier APIs#1348
987Nabil merged 4 commits into
zio:mainfrom
987Nabil:feat/deriver-with-instance

Conversation

@987Nabil

Copy link
Copy Markdown
Contributor

Summary

Adds withInstance and withModifier methods to the Deriver[TC] trait, enabling users to configure a deriver once and reuse it across all derivations — the configure-once-use-everywhere pattern.

Closes #1346

Problem

Users cannot figure out how to customize codec behavior (e.g., custom date formats). The existing DerivationBuilder path requires repeating overrides on every Schema[A].derive(...) call, which doesn't scale.

Solution

Four final methods on Deriver[TC]:

// Type-level: override codec for ALL occurrences of type A
val deriver = JsonCodecDeriver.withInstance[LocalDate](customDateCodec)

// Field-level: override codec for a specific field inside type A
val deriver = JsonCodecDeriver.withInstance[MyRecord](TypeId.of[MyRecord], "date", customCodec)

// Modifier: rename a field
val deriver = JsonCodecDeriver.withModifier(TypeId.of[Named], "firstName", Modifier.rename("first_name"))

// Chainable — configure once, use everywhere:
val myDeriver = JsonCodecDeriver
  .withInstance[LocalDate](customDateCodec)
  .withInstance[Instant](customInstantCodec)
  .withModifier(TypeId.of[Event], "name", Modifier.rename("title"))

val codec1 = Schema[Record1].deriving(myDeriver).derive
val codec2 = Schema[Record2].deriving(myDeriver).derive
val codec3 = Schema[Record3].deriving(myDeriver).derive
// All three get the overrides automatically

Implementation: A private[derive] DeriverWithOverrides class wraps any deriver, delegates all abstract methods, and returns accumulated overrides from instanceOverrides/modifierOverrides. Zero changes to any format-specific deriver — the existing DerivationBuilder.derive resolution machinery handles everything.

Tests

  • JSON (9 tests): type-level, field-level, Option[A], nested record, withModifier rename, chained, multiple withInstance, backward compat (Schema.derive + DerivationBuilder path)
  • CSV (3 tests): wrapped deriver roundtrip, chained modifiers, default CsvFormat backward compat
  • Documentation example: CustomCodecOverrideExample.scala showing configure-once-use-everywhere pattern

Copilot AI review requested due to automatic review settings April 26, 2026 12:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds reusable “configure-once, use-everywhere” derivation configuration by introducing Deriver.withInstance and Deriver.withModifier, implemented via a wrapper deriver that accumulates overrides and relies on existing DerivationBuilder override-resolution logic.

Changes:

  • Add final withInstance / withModifier APIs to Deriver[TC] that return a wrapped deriver with accumulated overrides.
  • Introduce DeriverWithOverrides wrapper to delegate derivation while exposing combined override lists.
  • Add JSON/CSV tests and a runnable example demonstrating deriver reuse across multiple derivations.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
schema/shared/src/main/scala/zio/blocks/schema/derive/Deriver.scala Adds new public withInstance / withModifier APIs to Deriver.
schema/shared/src/main/scala/zio/blocks/schema/derive/DeriverWithOverrides.scala Implements wrapper deriver that delegates derivation methods and unions override lists.
schema/shared/src/test/scala/zio/blocks/schema/derive/DeriverWithInstanceSpec.scala Adds JSON-focused test coverage for deriver-level overrides and chaining.
schema-csv/src/test/scala/zio/blocks/schema/csv/DeriverWithInstanceCsvSpec.scala Adds CSV-focused tests intended to cover the new deriver override behavior.
schema-examples/src/main/scala/customcodec/CustomCodecOverrideExample.scala Adds an end-to-end example showing configure-once deriver reuse.

Comment on lines +189 to +212
test("multiple withInstance calls accumulate") {
val stringifyInt: JsonCodec[Int] = new JsonCodec[Int] {
def decodeValue(in: JsonReader): Int = in.readStringAsInt()

def encodeValue(x: Int, out: JsonWriter): Unit = out.writeValAsString(x)

override def decodeValue(json: Json): Int = json match {
case s: Json.String => s.value.toInt
case _ => error("expected Json.String")
}

override def encodeValue(x: Int): Json = new Json.String(x.toString)
}

val deriver = JsonCodecDeriver
.withInstance[LocalDate](customLocalDateCodec)
.withInstance[Int](stringifyInt)

val codec = Schema[Event].deriving(deriver).derive
val event = Event("test", LocalDate.of(2025, 1, 1))
val json = jsonEncode(codec, event)
// LocalDate uses custom format, but Event has no Int field so just check date
assertTrue(json == """{"name":"test","date":"01/01/2025"}""")
}

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test "multiple withInstance calls accumulate" doesn’t actually validate that multiple overrides are honored: the derived Event codec has no Int field, so a broken withInstance[Int](...) implementation (or broken accumulation) would still pass. Consider deriving a type that contains both LocalDate and Int (or nesting one) and asserting both custom encodings are applied.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +57
def spec = suite("DeriverWithInstanceCsvSpec")(
test("withInstance wraps CsvCodecDeriver and still roundtrips") {
val deriver = CsvCodecDeriver.withModifier(
TypeId.of[Meeting],
"title",
Modifier.alias("subject")
)
val codec = Schema[Meeting].deriving(deriver).derive
val meeting = Meeting("retro", LocalDate.of(2025, 8, 1))
assertTrue(csvRoundTrip(codec, meeting) == Right(meeting))
},
test("chained overrides accumulate and still roundtrip") {
val deriver = CsvCodecDeriver
.withModifier(TypeId.of[Meeting], "title", Modifier.alias("subject"))
.withModifier(TypeId.of[Meeting], "date", Modifier.alias("when"))
val codec = Schema[Meeting].deriving(deriver).derive
val meeting = Meeting("standup", LocalDate.of(2025, 7, 4))
assertTrue(csvRoundTrip(codec, meeting) == Right(meeting))

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This spec is named DeriverWithInstanceCsvSpec, but the first two tests only exercise withModifier and the first test name says "withInstance wraps..." even though it doesn’t call withInstance. To cover the new API across formats, add at least one CSV test that uses CsvCodecDeriver.withInstance(...) (e.g., override the LocalDate codec) and adjust the test name(s) accordingly.

Copilot uses AI. Check for mistakes.
Comment on lines +121 to +125
*/
final def withInstance[A](typeId: TypeId[A], termName: String, instance: => TC[Any]): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk(new InstanceOverrideByTypeAndTermName[TC, A, Any](typeId, termName, Lazy(instance))),

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

withInstance(typeId, termName, ...) takes instance: => TC[Any], which forces callers to cast (see tests) and loses useful type inference. Consider changing the signature to accept a type parameter for the field instance (e.g., def withInstance[P, B](typeId: TypeId[P], termName: String, instance: => TC[B])) or a wildcard (TC[_]) so typical usage does not require asInstanceOf at the call site.

Suggested change
*/
final def withInstance[A](typeId: TypeId[A], termName: String, instance: => TC[Any]): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk(new InstanceOverrideByTypeAndTermName[TC, A, Any](typeId, termName, Lazy(instance))),
*
* @param typeId
* the identifier of the parent type containing the field override
* @param termName
* the name of the field to override
* @param instance
* the type class instance to use for the matching field
* @tparam A
* the parent type containing the field
* @tparam B
* the field type for which the instance is supplied
* @return
* a new deriver that uses the supplied instance for the matching field
*
* @example
* {{{
* val overridden = deriver.withInstance(TypeId[Parent], "fieldName", fieldInstance)
* }}}
*/
final def withInstance[A, B](typeId: TypeId[A], termName: String, instance: => TC[B]): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk(new InstanceOverrideByTypeAndTermName[TC, A, B](typeId, termName, Lazy(instance))),

Copilot uses AI. Check for mistakes.
Comment on lines +105 to +151
/**
* Returns a new deriver that pre-registers a type-level instance override.
* During derivation, every occurrence of type `A` will use the supplied
* instance instead of deriving one.
*/
final def withInstance[A](instance: => TC[A])(implicit typeId: TypeId[A]): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk(new InstanceOverrideByType[TC, A](typeId, Lazy(instance))),
Chunk.empty
)

/**
* Returns a new deriver that pre-registers a field-level instance override.
* During derivation, the field named `termName` inside the parent type
* identified by `typeId` will use the supplied instance.
*/
final def withInstance[A](typeId: TypeId[A], termName: String, instance: => TC[Any]): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk(new InstanceOverrideByTypeAndTermName[TC, A, Any](typeId, termName, Lazy(instance))),
Chunk.empty
)

/**
* Returns a new deriver that pre-registers a reflect-level modifier override.
* During derivation, every occurrence of the type identified by `typeId` will
* have the modifier prepended to its modifiers.
*/
final def withModifier[A](typeId: TypeId[A], modifier: Modifier.Reflect): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk.empty,
Chunk(new ModifierReflectOverrideByType[A](typeId, modifier))
)

/**
* Returns a new deriver that pre-registers a term-level modifier override.
* During derivation, the field named `termName` inside the parent type
* identified by `typeId` will have the modifier applied.
*/
final def withModifier[A](typeId: TypeId[A], termName: String, modifier: Modifier.Term): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk.empty,
Chunk(new ModifierTermOverrideByType[A](typeId, termName, modifier))
)

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Override precedence is currently surprising when combining a pre-configured deriver with DerivationBuilder overrides: DerivationBuilder.derive builds maps from instanceOverrides ++ deriver.instanceOverrides, and .toMap keeps the last duplicate key, so deriver-level withInstance / withModifier overrides will win over later call-site builder.instance(...) / builder.modifier(...) overrides for the same target. If the intent is “local builder overrides win”, the merge order (or map construction) needs to be adjusted; otherwise please document this precedence explicitly in these Scaladocs.

Copilot uses AI. Check for mistakes.
Comment on lines +105 to +151
/**
* Returns a new deriver that pre-registers a type-level instance override.
* During derivation, every occurrence of type `A` will use the supplied
* instance instead of deriving one.
*/
final def withInstance[A](instance: => TC[A])(implicit typeId: TypeId[A]): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk(new InstanceOverrideByType[TC, A](typeId, Lazy(instance))),
Chunk.empty
)

/**
* Returns a new deriver that pre-registers a field-level instance override.
* During derivation, the field named `termName` inside the parent type
* identified by `typeId` will use the supplied instance.
*/
final def withInstance[A](typeId: TypeId[A], termName: String, instance: => TC[Any]): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk(new InstanceOverrideByTypeAndTermName[TC, A, Any](typeId, termName, Lazy(instance))),
Chunk.empty
)

/**
* Returns a new deriver that pre-registers a reflect-level modifier override.
* During derivation, every occurrence of the type identified by `typeId` will
* have the modifier prepended to its modifiers.
*/
final def withModifier[A](typeId: TypeId[A], modifier: Modifier.Reflect): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk.empty,
Chunk(new ModifierReflectOverrideByType[A](typeId, modifier))
)

/**
* Returns a new deriver that pre-registers a term-level modifier override.
* During derivation, the field named `termName` inside the parent type
* identified by `typeId` will have the modifier applied.
*/
final def withModifier[A](typeId: TypeId[A], termName: String, modifier: Modifier.Term): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk.empty,
Chunk(new ModifierTermOverrideByType[A](typeId, termName, modifier))
)

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are new public APIs on Deriver, but the Scaladocs are fairly minimal and don’t follow the more detailed style used elsewhere (e.g., missing @param tags and important behavioral notes like “termName overrides are silently ignored if the term doesn’t exist”, matching DerivationBuilder.instance docs). Also, since this is a public API addition, the user-facing docs under docs/reference/schema/type-class-derivation.md should be updated to include withInstance/withModifier and the “configure once, use everywhere” pattern.

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated 4 comments.

Comment on lines +110 to +114
* When the returned deriver is passed to [[DerivationBuilder]], overrides
* registered here take precedence over builder-level overrides for the same
* target because `DerivationBuilder.derive` merges them as
* `builderOverrides ++ deriver.instanceOverrides` and later entries win in
* the resulting `.toMap`.

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DerivationBuilder.derive currently merges overrides as builder.instanceOverrides ++ deriver.instanceOverrides, which means withInstance/withModifier overrides on the deriver will overwrite/append after any overrides configured on the builder for the same target. That precedence is counter-intuitive (call-site builder overrides typically win) and could surprise users trying to locally override a globally-configured deriver. Consider reversing the merge order (deriver first, builder last) or, if this precedence is intentional, make it explicit in the API docs and add a test that demonstrates the intended precedence when both are set for the same key.

Suggested change
* When the returned deriver is passed to [[DerivationBuilder]], overrides
* registered here take precedence over builder-level overrides for the same
* target because `DerivationBuilder.derive` merges them as
* `builderOverrides ++ deriver.instanceOverrides` and later entries win in
* the resulting `.toMap`.
* When the returned deriver is passed to [[DerivationBuilder]], builder-level
* overrides for the same target take precedence over overrides registered
* here. This allows call-site customization to override globally configured
* deriver defaults.

Copilot uses AI. Check for mistakes.
Comment on lines +105 to +130
/**
* Returns a new deriver that pre-registers a type-level instance override.
* During derivation, every occurrence of type `A` will use the supplied
* instance instead of deriving one.
*
* When the returned deriver is passed to [[DerivationBuilder]], overrides
* registered here take precedence over builder-level overrides for the same
* target because `DerivationBuilder.derive` merges them as
* `builderOverrides ++ deriver.instanceOverrides` and later entries win in
* the resulting `.toMap`.
*
* @tparam A
* the type whose derived instance should be replaced
* @param instance
* the custom type-class instance (evaluated lazily)
* @param typeId
* implicit identifier for `A`, used to match occurrences during derivation
* @return
* a new [[Deriver]] that wraps this deriver with the additional override
*/
final def withInstance[A](instance: => TC[A])(implicit typeId: TypeId[A]): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk(new InstanceOverrideByType[TC, A](typeId, Lazy(instance))),
Chunk.empty
)

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds new public Deriver APIs (withInstance/withModifier) but there are no corresponding docs updates under docs/ (e.g. the existing docs/reference/schema/type-class-derivation.md section on overrides doesn’t mention the configure-once/use-everywhere pattern). Please add/adjust the relevant reference docs and sidebar entries so users can discover these new APIs.

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +52
* Path matching pattern AST — describes the structure of a URL path. Pure data,
* no mutable optimization cache, no TransformOrFail, no Fallback.
*/
sealed trait PathCodec[A]

object PathCodec {

/** A single path segment */
final case class Segment[A](segment: SegmentCodec[A]) extends PathCodec[A]

/** Concatenation of two path patterns with type-level tuple flattening */
final case class Concat[A, B, C](
left: PathCodec[A],
right: PathCodec[B],
combiner: Tuples.Tuples.WithOut[A, B, C]
) extends PathCodec[C]

val empty: PathCodec[Unit] = Segment(SegmentCodec.Empty)

def literal(value: String): PathCodec[Unit] = Segment(SegmentCodec.Literal(value))

def bool(name: String): PathCodec[Boolean] = Segment(SegmentCodec.BoolSeg(name))

def int(name: String): PathCodec[Int] = Segment(SegmentCodec.IntSeg(name))

def long(name: String): PathCodec[Long] = Segment(SegmentCodec.LongSeg(name))

def string(name: String): PathCodec[String] = Segment(SegmentCodec.StringSeg(name))

def uuid(name: String): PathCodec[java.util.UUID] = Segment(SegmentCodec.UUIDSeg(name))

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds new public endpoint descriptor types (PathCodec, SegmentCodec, RoutePattern) but there are no docs/reference updates describing the new zio-blocks-endpoint module or its intended usage. Please add a reference page under docs/reference/ (and a sidebar entry) so users can discover the new module and its core types.

Suggested change
* Path matching pattern AST — describes the structure of a URL path. Pure data,
* no mutable optimization cache, no TransformOrFail, no Fallback.
*/
sealed trait PathCodec[A]
object PathCodec {
/** A single path segment */
final case class Segment[A](segment: SegmentCodec[A]) extends PathCodec[A]
/** Concatenation of two path patterns with type-level tuple flattening */
final case class Concat[A, B, C](
left: PathCodec[A],
right: PathCodec[B],
combiner: Tuples.Tuples.WithOut[A, B, C]
) extends PathCodec[C]
val empty: PathCodec[Unit] = Segment(SegmentCodec.Empty)
def literal(value: String): PathCodec[Unit] = Segment(SegmentCodec.Literal(value))
def bool(name: String): PathCodec[Boolean] = Segment(SegmentCodec.BoolSeg(name))
def int(name: String): PathCodec[Int] = Segment(SegmentCodec.IntSeg(name))
def long(name: String): PathCodec[Long] = Segment(SegmentCodec.LongSeg(name))
def string(name: String): PathCodec[String] = Segment(SegmentCodec.StringSeg(name))
def uuid(name: String): PathCodec[java.util.UUID] = Segment(SegmentCodec.UUIDSeg(name))
* Describes the structure of a URL path as pure endpoint metadata.
*
* A `PathCodec[A]` captures how a path is matched and which typed values are extracted from it.
* Literal segments contribute no extracted values, while dynamic segments contribute values of
* their corresponding Scala types. More complex paths are built by concatenating smaller codecs.
*
* This data type is intentionally a pure AST: it contains only declarative routing information
* and does not embed mutable caches or fallback/transform nodes.
*
* @tparam A the type of values extracted from the matched path
*
* @example
* {{{
* val usersId = PathCodec.literal("users")
* val userId = PathCodec.int("id")
*
* // Combined path descriptors are built from smaller pieces elsewhere in the endpoint module.
* }}}
*
* @see [[SegmentCodec]] for the per-segment descriptor used by [[PathCodec.Segment]]
*/
sealed trait PathCodec[A]
object PathCodec {
/** A path codec consisting of exactly one segment.
*
* @param segment the segment descriptor used to match and decode one path segment
* @tparam A the type extracted from the segment
*/
final case class Segment[A](segment: SegmentCodec[A]) extends PathCodec[A]
/** Concatenates two path codecs into a larger path codec.
*
* The resulting extracted type is computed using type-level tuple flattening.
*
* @param left the left-hand path codec
* @param right the right-hand path codec
* @param combiner evidence describing how the extracted values are combined
* @tparam A the type extracted by the left-hand codec
* @tparam B the type extracted by the right-hand codec
* @tparam C the combined extracted type
*/
final case class Concat[A, B, C](
left: PathCodec[A],
right: PathCodec[B],
combiner: Tuples.Tuples.WithOut[A, B, C]
) extends PathCodec[C]
/** A path codec that matches an empty path contribution.
*
* @return a `PathCodec` that extracts no values
*/
val empty: PathCodec[Unit] = Segment(SegmentCodec.Empty)
/** Creates a path codec that matches a literal segment.
*
* @param value the literal segment text to match
* @return a `PathCodec` that matches the given segment and extracts no values
*/
def literal(value: String): PathCodec[Unit] = Segment(SegmentCodec.Literal(value))
/** Creates a path codec for a boolean path segment.
*
* @param name the logical name of the segment
* @return a `PathCodec` that decodes a `Boolean` from one segment
*/
def bool(name: String): PathCodec[Boolean] = Segment(SegmentCodec.BoolSeg(name))
/** Creates a path codec for an integer path segment.
*
* @param name the logical name of the segment
* @return a `PathCodec` that decodes an `Int` from one segment
*/
def int(name: String): PathCodec[Int] = Segment(SegmentCodec.IntSeg(name))
/** Creates a path codec for a long path segment.
*
* @param name the logical name of the segment
* @return a `PathCodec` that decodes a `Long` from one segment
*/
def long(name: String): PathCodec[Long] = Segment(SegmentCodec.LongSeg(name))
/** Creates a path codec for a string path segment.
*
* @param name the logical name of the segment
* @return a `PathCodec` that decodes a `String` from one segment
*/
def string(name: String): PathCodec[String] = Segment(SegmentCodec.StringSeg(name))
/** Creates a path codec for a UUID path segment.
*
* @param name the logical name of the segment
* @return a `PathCodec` that decodes a `java.util.UUID` from one segment
*/
def uuid(name: String): PathCodec[java.util.UUID] = Segment(SegmentCodec.UUIDSeg(name))
/** A path codec that captures the remaining unmatched path.
*
* @return a `PathCodec` that extracts the trailing `zio.http.Path`
*/

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +159
/**
* Returns a new deriver that pre-registers a field-level instance override.
* During derivation, the field named `termName` inside the parent
* record/variant identified by `typeId` will use the supplied instance
* instead of deriving one.
*
* This is a medium-precision override between optic-based (exact path) and
* type-based (all occurrences).
*
* @tparam A
* the parent record or variant type that owns the field
* @tparam B
* the type of the field being overridden
* @param typeId
* identifier for the parent type `A`
* @param termName
* the name of the field (or variant case) to override
* @param instance
* the custom type-class instance for the field (evaluated lazily)
* @return
* a new [[Deriver]] that wraps this deriver with the additional override
*/
final def withInstance[A, B](typeId: TypeId[A], termName: String, instance: => TC[B]): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk(new InstanceOverrideByTypeAndTermName[TC, A, B](typeId, termName, Lazy(instance))),
Chunk.empty
)

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The withInstance(typeId, termName, ...) Scaladoc doesn’t mention the (current) behavior that unknown termNames are silently ignored during derivation (same as DerivationBuilder.instance(typeId, termName, ...)). Consider documenting that here as well so users know mis-typed field names won’t fail fast.

Copilot uses AI. Check for mistakes.
@987Nabil 987Nabil force-pushed the feat/deriver-with-instance branch from 5586ee4 to 517bcd1 Compare April 26, 2026 20:34
@@ -0,0 +1,54 @@
/*

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes are unrelated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my bad

@987Nabil 987Nabil requested a review from Copilot April 27, 2026 06:38
@987Nabil 987Nabil force-pushed the feat/deriver-with-instance branch from 517bcd1 to 73fdf90 Compare April 27, 2026 06:39
@987Nabil 987Nabil force-pushed the feat/deriver-with-instance branch from 73fdf90 to 0e1df20 Compare April 27, 2026 06:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment on lines +132 to +159
/**
* Returns a new deriver that pre-registers a field-level instance override.
* During derivation, the field named `termName` inside the parent
* record/variant identified by `typeId` will use the supplied instance
* instead of deriving one.
*
* This is a medium-precision override between optic-based (exact path) and
* type-based (all occurrences).
*
* @tparam A
* the parent record or variant type that owns the field
* @tparam B
* the type of the field being overridden
* @param typeId
* identifier for the parent type `A`
* @param termName
* the name of the field (or variant case) to override. If no field with
* this name exists in the target type, the override is silently ignored
* during derivation (matching `DerivationBuilder.instance` behaviour).
* @param instance
* the custom type-class instance for the field (evaluated lazily)
* @return
* a new [[Deriver]] that wraps this deriver with the additional override
*/
final def withInstance[A, B](typeId: TypeId[A], termName: String, instance: => TC[B]): Deriver[TC] =
new DeriverWithOverrides[TC](
self,
Chunk(new InstanceOverrideByTypeAndTermName[TC, A, B](typeId, termName, Lazy(instance))),

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Deriver.withInstance(typeId, termName, instance) Scaladoc implies the override will always apply, but the underlying InstanceOverrideByTypeAndTermName behavior matches DerivationBuilder.instance(...): the field type B is not statically checked and if no term with the given name exists, the override is silently ignored. Please document these two caveats here as well (so users don’t assume this method is type-safe or guaranteed to take effect).

Copilot uses AI. Check for mistakes.
Comment on lines +182 to +203
)

/**
* Returns a new deriver that pre-registers a term-level modifier override.
* During derivation, the field named `termName` inside the parent type
* identified by `typeId` will have the modifier applied.
*
* @tparam A
* the parent record or variant type that owns the field
* @param typeId
* identifier for `A`
* @param termName
* the name of the field (or variant case) to modify. If no field with
* this name exists in the target type, the override is silently ignored.
* @param modifier
* the term-level modifier to apply
* @return
* a new [[Deriver]] that wraps this deriver with the additional override
*/
final def withModifier[A](typeId: TypeId[A], termName: String, modifier: Modifier.Term): Deriver[TC] =
new DeriverWithOverrides[TC](
self,

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to DerivationBuilder.modifier(typeId, termName, ...), this term-level withModifier is silently ignored if the parent type has no field/case named termName. The current Scaladoc reads as unconditional; please note the “silently ignored when term not found” behavior to avoid surprising users.

Copilot uses AI. Check for mistakes.
@987Nabil 987Nabil force-pushed the feat/deriver-with-instance branch 3 times, most recently from b4f6967 to a4bc82d Compare April 27, 2026 08:52
@github-actions

github-actions Bot commented Apr 27, 2026

Copy link
Copy Markdown

🚀 Preview deployed to Netlify: https://zio-blocks-pr-1348--zio-dev.netlify.app

@987Nabil

Copy link
Copy Markdown
Contributor Author

@ghostdogpr check again

@987Nabil 987Nabil force-pushed the feat/deriver-with-instance branch from a4bc82d to fc56575 Compare April 27, 2026 10:13
@987Nabil 987Nabil requested a review from Copilot April 27, 2026 16:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

### Important Notes

- `withInstance` and `withModifier` return a NEW deriver (they are immutable).
- When combined with `DerivationBuilder`, deriver-level overrides take precedence over builder-level ones.

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs claim deriver-level overrides take precedence over builder-level ones, but for modifier overrides (e.g., Modifier.rename) the effective precedence is the opposite because modifier lists are prepended and JSON derivation uses the first rename it sees. With DerivationBuilder currently merging modifierOverrides ++ deriver.modifierOverrides, builder-added modifiers will be applied before deriver-added modifiers for the same target. Please either (a) narrow this note to instance overrides only, or (b) adjust modifier-override merge/order semantics so the statement is true for modifiers too.

Suggested change
- When combined with `DerivationBuilder`, deriver-level overrides take precedence over builder-level ones.
- When combined with `DerivationBuilder`, deriver-level instance overrides take precedence over builder-level instance overrides. Modifier override precedence is order-sensitive and should not be assumed to follow the same rule.

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +107
private def jsonRoundTrip[A](codec: JsonCodec[A], value: A): (String, Either[SchemaError, A]) = {
val buf = ByteBuffer.allocate(4096)
codec.encode(value, buf)
val bytes = java.util.Arrays.copyOf(buf.array, buf.position)
val encoded = new String(bytes, "UTF-8")
val decoded = codec.decode(bytes)
(encoded, decoded)
}

private def jsonEncode[A](codec: JsonCodec[A], value: A): String = {
val buf = ByteBuffer.allocate(4096)
codec.encode(value, buf)
new String(java.util.Arrays.copyOf(buf.array, buf.position), "UTF-8")
}

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jsonRoundTrip / jsonEncode duplicate functionality that already exists in zio.blocks.schema.json.JsonTestUtils (including buffer handling and UTF-8 decoding). Reusing the shared test helper would reduce boilerplate here and keep JSON test encoding/decoding behavior consistent across the suite.

Copilot uses AI. Check for mistakes.
@987Nabil 987Nabil force-pushed the feat/deriver-with-instance branch from fc56575 to 8f28f81 Compare April 28, 2026 06:36
@987Nabil 987Nabil force-pushed the feat/deriver-with-instance branch from 8f28f81 to 9ab59b0 Compare April 28, 2026 07:43
@987Nabil 987Nabil merged commit 1bc4378 into zio:main Apr 28, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Add Deriver.withInstance API and codec factory methods for custom codec overrides

3 participants