Skip to content

[configoptional][WIP] Allow configoptional.Optional to wrap scalar values#13524

Closed
evan-bradley wants to merge 27 commits intoopen-telemetry:mainfrom
evan-bradley:configoptional-scalars
Closed

[configoptional][WIP] Allow configoptional.Optional to wrap scalar values#13524
evan-bradley wants to merge 27 commits intoopen-telemetry:mainfrom
evan-bradley:configoptional-scalars

Conversation

@evan-bradley
Copy link
Copy Markdown
Contributor

@evan-bradley evan-bradley commented Jul 29, 2025

Description

This shows a little bit of what scalar support may look like in configoptional. I am still working through the API and may refine it a little more, or at least offer some options. I also need to test this much more extensively, so far I've only been aiming to resolve the recent issue with using the optional type in exporterhelper.

I've made this so we can keep it experimental since confmap is stable, even if it causes some churn now and will cause more if we stabilize it.

Link to tracking issue

Fixes #13421

@evan-bradley
Copy link
Copy Markdown
Contributor Author

evan-bradley commented Aug 5, 2025

To help inform the design here, I wanted to compare another option based on a PoC that @jade-guiton-dd wrote which implements a FieldUnmarshaler interface: jade-guiton-dd@4d46d63#diff-6db0a4635dd17034f10c152a274d4da2ca8b213b9249e5eecf52f6dcfe2f74a2R11-R24.

I think the implementation in this PR has the following advantages:

  1. Is able to rely more heavily on mapstructure to do decoding/encoding and is simpler for structs to implement. The interfaces in this PR mostly provide methods to obtain the relevant value out of the struct. Custom marshaling/unmarshaling is permitted, but works in conjunction with mapstructure: values are unmarshaled before the struct gets them from the hook and continue to be marshaled after the struct provides them to the hook.
  2. Handles types that need further unmarshaling, e.g. component.ID which implements encoding.TextUnmarshaler. I think this would be more complicated with the FieldUnmarshaler interface.
  3. Has fairly symmetric interfaces between unmarshaling and marshaling that somewhat closely match confmap.Unmarshaler/confmap.Marshaler and encoding.TextUnmarshaler/encoding.TextMarshaler. An equivalent "FieldMarshaler" interface isn't included in the PoC, so I don't want to draw any firm conclusions here.

I see the following disadvantages, both of which I think are acceptable:

  1. The interfaces in this PR fit into the (un/)marshaling process, as where FieldUnmarshaler allows breaking out of the process into a custom implementation. I think this is okay since I don't see a strong requirement for this level of flexibility with any of our existing config structs.
  2. FieldUnmarshaler allows checking within the map for whether a value has been set and setting default values for fields within an Optional-wrapped struct. We should be okay without this since Optional itself allows handling default values, so we could solve this by simply having Optional-wrapped fields inside the Optional-wrapped parent struct. However, I don't have a case in mind where we need to have nested optional types.

Overall, I think FieldUnmarshaler solves a slightly different problem (allowing structs significant control of their unmarshaling), so it may be something we want to implement in the future, but for now I think the interfaces in this PR are the most straightforward solution to the problem of allowing structs which have a scalar representation in YAML to unmarshal themselves without doing manual unmarshaling with encoding.TextUnmarshaler.

@jade-guiton-dd I would appreciate your thoughts here as the author of the PoC, let me know if I've misunderstood any part of it or if you think we should incorporate additional considerations while evaluating this.

@mx-psi let me know if you have any thoughts as well. If there are no concerns, I will continue to bring this PR to a state where it's ready to review.

@evan-bradley
Copy link
Copy Markdown
Contributor Author

One additional thought based on yesterday's discussion on the stability call: if we were working on confmap v2, I would suggest we could explore revising the Unmarshaler and Marshaler interfaces to handle both scalar and fully custom unmarshaling, but since that interface is stable, I think this is the best alternative.

@jade-guiton-dd
Copy link
Copy Markdown
Contributor

jade-guiton-dd commented Aug 5, 2025

  1. Is able to rely more heavily on mapstructure to do decoding/encoding and is simpler for structs to implement. The interfaces in this PR mostly provide methods to obtain the relevant value out of the struct. Custom marshaling/unmarshaling is permitted, but works in conjunction with mapstructure: values are unmarshaled before the struct gets them from the hook and continue to be marshaled after the struct provides them to the hook.
  2. Handles types that need further unmarshaling, e.g. component.ID which implements encoding.TextUnmarshaler. I think this would be more complicated with the FieldUnmarshaler interface.

These points are basically the same, but I agree that it's nice to let mapstructure do more of the work.

Note however that this is a departure from how the Unmarshaler interface works: Optional[T] has to call conf.Unmarshal(&o.value) manually in its Unmarshal method to get confmap/mapstructure to unmarshal the inner type.

And I do exactly the same in my PoC to "handle types that require further unmarshaling": I call confmap.NewFromStringMap(data).Unmarshal(&o.Value), the difference being that I have to create the Conf myself instead of being passed it as argument.

In that regard, FieldUnmarshaler has the same interface as Unmarshaller, except it accepts values that don't fit in a Conf (ie. scalars), and has additional context information. It's more or less the "revised" version of Unmarshaler that you suggest in your second comment, except it's specialized to cases where your type is a field of another struct.

FieldUnmarshaler allows checking within the map for whether a value has been set and setting default values for fields within an Optional-wrapped struct. We should be okay without this since Optional itself allows handling default values, so we could solve this by simply having Optional-wrapped fields inside the Optional-wrapped parent struct.

I think there may be a misunderstanding here: FieldUnmarshaler is implemented on the type of the field, not the type of the surrounding struct. Just like Unmarshaler and your ScalarUnmarshaler, the point is for this new interface to be implemented by Optional (and in fact it's the only one it needs to implement).

@jade-guiton-dd
Copy link
Copy Markdown
Contributor

jade-guiton-dd commented Aug 5, 2025

To give a bit more context, the main point of the FieldUnmarshaler POC was to offer a more generic alternative to Unmarshaler, which removes some of its limitations that constrain Optional implementations:

  • Unmarshal isn't called when a field isn't set, so Optional can't directly reproduce otlpreceiver's old logic (ie. "set the section to nil in Unmarshal if the field isn't set"). Instead, we had to add a third state to an Optional type which logically should only have two: Default, which contains a value but doesn't "HasValue()", and which only becomes "enabled" once Unmarshal is called, signifying that the field is set. It's a rather confusing state machine instead of a simple yes/no type.

  • Unmarshal doesn't support scalars, which makes it impossible to support scalars with it.

  • Until recently, Unmarshal also didn't support telling the difference between null and {}. We had to hack confmap to add support for that in order to properly implement logic equivalent to the otlpreceiver's.

I think making it easier for component authors to build their own Optional with whatever semantics they want without understanding these quirks or implementing two different interfaces is pretty important, because our current Optional implementation has unintuitive semantics inherited from pointers/otlpreceiver (doesn't support disabling a field which is enabled by default, and treats null differently for None vs. Default), which I think some component authors won't want to use directly.

However, I agree that this PR is somewhat simpler to use, so as long as we limit our goal to "supporting scalar values in our own Optional type", I will not push for changing this PR's design.

@evan-bradley
Copy link
Copy Markdown
Contributor Author

@jade-guiton-dd Really appreciate the detailed feedback here and context on FieldUnmarshaler, I understand the intent behind the design of the interface better now. You're right, it does closely match what I had in mind for revising the Unmarshaler interface.

My overall goal here is to support any kind of wrapper interface similar to Optional[T] to the best of our abilities, while acknowledging that we have no additional examples to inform our design. I'll see what I can do to address the concerns you raised, either by modifying our approach to this or at a minimum by documenting somewhere how we want to handle them.

@github-actions
Copy link
Copy Markdown
Contributor

This PR was marked stale due to lack of activity. It will be closed in 14 days.

@github-actions github-actions Bot added the Stale label Aug 20, 2025
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Sep 4, 2025

Closed as inactive. Feel free to reopen if this PR is still being worked on.

@github-actions
Copy link
Copy Markdown
Contributor

This PR was marked stale due to lack of activity. It will be closed in 14 days.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Oct 4, 2025

This PR was marked stale due to lack of activity. It will be closed in 14 days.

@evan-bradley evan-bradley force-pushed the configoptional-scalars branch from 308b5ca to 14f8552 Compare October 14, 2025 18:15
@codecov
Copy link
Copy Markdown

codecov Bot commented Oct 14, 2025

Codecov Report

❌ Patch coverage is 63.86555% with 43 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.58%. Comparing base (f5bc377) to head (7f0f48b).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
config/configoptional/optional.go 0.00% 18 Missing and 2 partials ⚠️
confmap/xconfmap/scalarunmarshaler.go 66.66% 8 Missing and 4 partials ⚠️
confmap/internal/encoder.go 84.21% 2 Missing and 1 partial ⚠️
confmap/xconfmap/scalarmarshaler.go 80.00% 2 Missing and 1 partial ⚠️
confmap/internal/marshaloption.go 0.00% 2 Missing ⚠️
confmap/internal/unmarshaloption.go 0.00% 2 Missing ⚠️
...orter/exporterhelper/internal/queuebatch/config.go 66.66% 0 Missing and 1 partial ⚠️

❌ Your patch check has failed because the patch coverage (63.86%) is below the target coverage (95.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #13524      +/-   ##
==========================================
- Coverage   91.65%   91.58%   -0.08%     
==========================================
  Files         654      656       +2     
  Lines       42659    42747      +88     
==========================================
+ Hits        39100    39148      +48     
- Misses       2744     2778      +34     
- Partials      815      821       +6     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@evan-bradley
Copy link
Copy Markdown
Contributor Author

evan-bradley commented Oct 16, 2025

@jade-guiton-dd @mx-psi follow-up from our discussion yesterday:

I've looked through contrib and found a few use cases for the current Unmarshal interface:

  1. Setting default values. This is the most popular method and something I think we can capture a little more succinctly with the Optional type.
  2. Config transformations/migrations. The Transform Processor and Datadog package are probably the best examples of this. We don't have any facilities to address this right now, but I think there are ways we could accomplish this in the future.
  3. Custom config unmarshaling. The Prometheus Receiver, Receiver Creator, and Host Metrics Receiver show this off.

Of these, I think (3) will be hardest to solve in a way that doesn't provide a *confmap.Conf to the developer. My overall goal is that in the future we provide the ability to achieve these goals while also providing the developer with as much structure as possible. I think we could solve (1) with what I have right now for "UnmarshalV2", but the other two would require additional facilities to implement.

I can think of two ways to go about this. Note that we are bound to our decision for any methods we put on the Optional type, but for confmap we can change our decision or plan for a breaking change for confmap v2.

  1. Continue with ScalarUnmarshaler and reevaluate in confmap v2.
  2. Rename the ScalarUnmarshaler interface in this PR to ValueUnmarshaler/TypedUnmarshaler/etc. and open it up to working with structs as well as scalar values (like it is in my "UnmarshalV2 prototype" commit). This would allow us to either deprecate or rename the existing Unmarshaler interface in confmap v2 depending on our needs in the future. I think Unmarshaler would become ConfUnmarshaler/MapUnmarshaler/RawUnmarshaler/etc. if we keep it.

I don't think there's really a large difference between them given it largely comes down to naming, and I'm not sure which types would want to forgo implementing Unmarshaler in favor of ValueUnmarshaler. I did make Optional work without implementing Unmarshaler and only implementing the new interface, but we will have to keep the Unmarshal method regardless since the module is stable. Note also that I've only talked about unmarshaling here, but everything I've said applies to marshaling as well.

Let me know what you think and if you have any other approaches you would like me to investigate.

Comment thread config/configoptional/optional.go Outdated
Comment on lines +201 to +205
if o.flavor == noneFlavor && val == nil {
// If the Optional is None and the configuration is nil, we do nothing.
// This replicates the behavior of unmarshaling into a field with a nil pointer.
return nil
}
Copy link
Copy Markdown
Contributor

@jade-guiton-dd jade-guiton-dd Oct 27, 2025

Choose a reason for hiding this comment

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

I don't think this condition will ever trigger, because val is guaranteed by the hook to be a non-nil value of type T (more generally, of whatever type we returned from ScalarType).

And I think this showcases the restrictiveness of the current ScalarUnmarshaler design: unlike in Unmarshaler, where you can perform basic tests and transformations on the input data before calling confmap.Unmarshal against the appropriate type, here you're forced into choosing a single type to Decode into upfront.

But we want configoptional to be unmarshallable both from a null and from (for example, if T is int) an integer literal. So we need to find a single Go type which can handle both kinds of input and return that from ScalarType.

In our case, it's not very difficult; I think *T should do the trick. But it seems rather inelegant, and more importantly, not really extensible to more diverse forms of input data (for instance, unmarshalling from either an array OR a map, like in #13996), which I think other use cases of a ScalarUnmarshaler interface will want to handle.

So I'm of the opinion that if we want a truly generic "UnmarshalerV2" which can handle all known use cases, including scalars, it should have a similar design to the current Unmarshaler, where we manually call confmap.Unmarshal against the appropriate type, after whatever checks or modifications of the raw YAML data we want to do.

To handle scalars, this means we would need to essentially expose internal.Decode, or more generally something similar to confmap.Unmarshal which can unmarshall an any instead of a confmap.Conf, in the same way that the method receives an any instead of a Conf.

I realize this doesn't really answer the question of "should we have an interface which handles just scalars, or one with both scalars and structs". But I think that even when handling just scalars, there will be use cases that run into this issue.

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.

Really appreciate your thoughts on this. I want to respond to a few points right now, but I will see if I can provide some additional details soon.

I think this showcases the restrictiveness of the current ScalarUnmarshaler design: unlike in Unmarshaler, where you can perform basic tests and transformations on the input data before calling confmap.Unmarshal against the appropriate type, here you're forced into choosing a single type to Decode into upfront.

This is a fair point, the interface is much more restrictive. It's possible we're not there yet and should revise this interface accordingly, but my overall goal is that all config structs can be fully declarative/introspectable from a structural perspective (vs. making validation introspectable, which isn't really feasible). If they were fully declarative, the user would never need to call any Decode or Unmarshal methods themselves, and would likely use as-yet-unwritten interfaces to do the transformations you're describing.

I called this out in point 2 above as something this interface explicitly can't handle. It's possible that it's not feasible to express our config use cases declaratively within the Go type system, but I think it would be an overall quality-of-life improvement if we can pull it off. I wouldn't expect us to land on this, at least fully, until confmap v2.

But we want configoptional to be unmarshallable both from a null and from (for example, if T is int) an integer literal. So we need to find a single Go type which can handle both kinds of input and return that from ScalarType.

I'm sorry, my memory is awful. My understanding was that null is unmarshaled as a nil map[string]any value and we weren't expecting null to be a valid value for scalar fields, but I can't remember where we ended that conversation. Right now we typically use it as a way to say "please use the default value", but given our existing use of e.g. *int right now, I could see it being interpreted differently for scalars. Was the goal to use null as enabled: false for scalar fields? I think it would be feasible to pull that off, if so.

Copy link
Copy Markdown
Contributor

@jade-guiton-dd jade-guiton-dd Oct 28, 2025

Choose a reason for hiding this comment

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

I think that we discussed this before on the maintainers' call, but I think that using null as enabled: false for scalar fields is probably our best option. As you say, it's feasible to do with the current design (by having ScalarType return *T).

I guess my main problem is that I view Unmarshaler as an escape hatch from the default unmarshaling logic that mapstructure provides. Hardcoding a call to Decode on a fixed type restricts this escape hatch enough that I'm not sure I understand the pratical use cases it targets.

If you can afford to unmarshal into a fixed type and extract the data you want from there in UnmarshalScalar, why not use that fixed type as the config struct, and do the extraction logic at component startup? (In the case of configoptional: if we're going to use a workaround to make configoptional[int] behave like *int with regards to unmarshaling, why not just use a *int directly? Maybe wrap it in a defined type and add convenience methods on it.)

But I understand that, like you say, having arbitrary parsing logic prevents introspection of the YAML's expected structure based on mapstructure tags. I think we have to support arbitrary parsing, because while we can afford to modify confmap to suit our use cases, third-party component authors can't. But we can try to limit the need for using it as much as possible, to make introspection useful in 99% of cases.

And I think one way would be to keep making config utility types like configoptional to avoid component authors having to reach for the escape hatch. Of course, configoptional and co will probably need to use the escape hatch to work, but we can hardcode support for them in our hypothetical future introspection logic.

So for example, one possible practical use case for custom unmarshaling logic, which mapstructure doesn't handle, is "union types", where you want to be able to handle two or more different types of YAML input (maybe null vs int, maybe two different versions of a config struct). For that use case, we could define a configunion wrapper, which uses Unmarshaler + ScalarUnmarshaler or a generic ValueUnmarshaler under the hood, to provide this functionality in a uniform way that we can handle in our introspection logic. (And maybe configoptional[T] could actually just be defined as something like configunion[T, null] for scalars?)

This is a lot of hypotheticals, but what do you think?

Copy link
Copy Markdown
Contributor Author

@evan-bradley evan-bradley Nov 3, 2025

Choose a reason for hiding this comment

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

I think that we discussed this before on the maintainers' call, but I think that using null as enabled: false for scalar fields is probably our best option. As you say, it's feasible to do with the current design (by having ScalarType return *T).

I was actually able to get this to work by tweaking the hook so it's not necessary to make significant changes to the Optional type. I'll put something up soon once I feel confident it's fairly solid.

So for example, one possible practical use case for custom unmarshaling logic, which mapstructure doesn't handle, is "union types", where you want to be able to handle two or more different types of YAML input (maybe null vs int, maybe two different versions of a config struct). For that use case, we could define a configunion wrapper, which uses Unmarshaler + ScalarUnmarshaler or a generic ValueUnmarshaler under the hood, to provide this functionality in a uniform way that we can handle in our introspection logic. (And maybe configoptional[T] could actually just be defined as something like configunion[T, null] for scalars?)

I think we're converging on a common idea for how to approach this, I think this describes something similar to point (2) in my comment on the PR. I'll spend some time thinking about how a configunion type would look. Since you can't pull type parameters out of a type with Go's reflection facilities, we would need to implement an interface that has methods like ScalarType to pull these out. I'm a little concerned about the nesting depth of the types if we want to have a lot of types in a union, so I may want to explore a slightly different approach to this. I'll see if I can write a POC that accomplishes this.

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.

@jade-guiton-dd following up from our conversation at the 2025-11-03 stability meeting, please see this commit for how I envision allowing config structs to use schemas that differ from their fields while still allowing the final schema to be introspectable: adb7f03 (#13524)

Please note that this is in a rough state right now; I'm missing all sorts of edge cases, the tests don't pass, and the code is a mess (including the scalarunmarshaler hook handling two interfaces). However, this commit should hopefully showcase:

  • Removing the need for configopaque.MapList and configoptional.Optional to implement confmap.Unmarshaler. They can rely on the new interfaces in this PR to do all unmarshaling without needing to touch the raw map.
  • Two new interfaces: MigrateableConfig for config types that allow fields to be set in YAML that don't correspond to a config struct field, and ConfigMigration for additional config structs that define these extra schemas. I don't know that I like the use of "migrate" here, I think we may want to go with "layer" or some other term in the future. However, this functionality could be used to migrate between different schema versions for a given component's config.

I think this achieves our goals reasonably well:

  • The config is introspectable through the MigrateableConfig interface: getting the list of additional structs will be sufficient to programmatically determine the actual schema for a given config struct.
  • Structs can "hide" fields that can be set in YAML (as in Optional) or declare two entirely different schemas (as with MapList).
  • The config is typed at every step of the process, with confmap handling all the ugly details.
  • Removes the need for recursion outside confmap.

I think the main point of detraction for this approach is that the implementation in confmap seems like it will be rather intricate. I'm not sure whether we can avoid this or not, or if it's possible to re-use some of our internal logic for working with mapstructure to simplify e.g. cleaning up the input config as we process the layers/migrations/whatever. I stopped cleaning up the implementation so we can stop and see whether we like the general idea.

Feel free to let me know if this doesn't make any sense and I'll do what I can to clear things up.

Copy link
Copy Markdown
Contributor

@jade-guiton-dd jade-guiton-dd Nov 4, 2025

Choose a reason for hiding this comment

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

I'm a little bit confused about the exact semantics of "layers", but it seems interesting.

It kind of looks like you implemented something like the configunion we talked about, but with the requirement that everything maps into a single struct at the end using an Unmarshaler-like interface? It seems to me like the MigratableConfig interface is essentially ScalarUnmarshaler, but where ScalarType can return multiple possible types to unmarshal into.

But it looks like there is also support for "intersection types", where you can consume certain fields along the way? (Or at least that's my interpretation; I'm not sure if the current code works properly for intersections without adding WithIgnoreUnused in the call to Decode?) That feature seems superfluous since you can usually just have one big struct with all the fields you want to be able to parse, but it sounds like it may be necessary for configoptional specifically, because we can't declare a struct { T `mapstructure:,squash`, Enabled bool }...

I'm also not sure if ScalarUnmarshaler is still useful if there is MigratableConfig. Is it only useful for handling null values?

I think that something like this, with some polish, could hopefully cover most use cases of custom unmarshaling I can think of while keeping things introspectable.

But it still seems to me like a complicated way to prevent users from calling Decode directly, this time on a list of different types instead of just one. This makes the implementation of unmarshaling and the later implementation of introspection more complicated, and if it turns out there is another pattern of recursive Unmarshal calls we want to handle, we would have a hard time adapting the hook + the introspection logic again to support it.

I would suggest an adaptation of one of my previous proposals: what if we have an internal ValueUnmarshaler interface that allows recursive calls to Decode AND provides whatever methods we want for introspection? We then build utilities like configoptional and configunion on top of that (for the latter, probably with a design similar to your MigratableConfig). This would make it easy for us to build more wrappers to replace Unmarshal usages without adding more and more features to the same hook, and while still allowing for introspectability, with no way for users to "accidentally" make their config unintrospectable. (Well, unless they have an any field, or one tagged with remain...)

Something like this:

// this would be in an internal package so it can't be misused
type ValueUnmarshaler interface {
    // cfg is unprocessed YAML input, decoder allows calling Decode recursively while forwarding decode options or whatever else we want to avoid recursion for
    UnmarshalValue(cfg any, decoder Decoder) error

    // kind of similar to `ScalarType` or `Migrations`. although i think more specific methods would be better, it depends what you want out of introspection
    GetConfigSchema() any
}

(Note: Since both configopaque and configoptional are stable, I don't think we can remove their Unmarshal implementation, so I think refactoring things to use a generic unmarshaling interface would have to wait until a v2.0 anyway unfortunately...)

(Note 2: I think I mentioned this before, but I think that if we can't come to an agreement in a reasonable time frame, it's best that you go with what you think is best. As long as it fulfills the goal for configoptional, I'm willing to approve it.)

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.

Sorry, I should have annotated the code better, cleaned it up, or both. Thanks through trudging through it.

It kind of looks like you implemented something like the configunion we talked about, but with the requirement that everything maps into a single struct at the end using an Unmarshaler-like interface?

Yes, that's basically it.

It seems to me like the MigratableConfig interface is essentially ScalarUnmarshaler, but where ScalarType can return multiple possible types to unmarshal into.

That's an interesting observation. It's possible we could consolidate, but I will have to think about it.

But it looks like there is also support for "intersection types", where you can consume certain fields along the way? (Or at least that's my interpretation; I'm not sure if the current code works properly for intersections without adding WithIgnoreUnused in the call to Decode?) That feature seems superfluous [...]

This more or less touches on what I meant by "layers" (maybe "fragments" would be better?). Basically you have a config struct that contains a subset of the keys for the component config struct, or even an entirely different set of keys. You unmarshal into that smaller config struct from the user's YAML input, then map from the small config struct to the final component config struct. Along the way it removes these keys with the assumption that they are either not in the resulting config struct, or have already been taken care of by one of the smaller config structs. In addition to the case with configoptional you called out, this also handles MapList, where the user can put either a list or a map for their YAML config in a MapList-typed field.

(Note: Since both configopaque and configoptional are stable, I don't think we can remove their Unmarshal implementation, so I think refactoring things to use a generic unmarshaling interface would have to wait until a v2.0 anyway unfortunately...)

Agreed, I just wanted to show that this new interface can support our existing use cases. It's also worth calling out that the configunion/MigrateableConfig thing isn't tied to this PR and would likely be implemented later.

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.

I don't have time to look into the internal ValueUnmarshaler interface you proposed, but overall it looks like it could work. I do see the opportunity for simplifying things if we don't try to hide Decode from the user, but I'll have to think about it more to make sure it wouldn't make the implementations feel too low-level. I'll take a closer look once I'm back on 2025-11-17.

This would make it easy for us to build more wrappers to replace Unmarshal usages without adding more and more features to the same hook

I think we're looking at two sides of the same coin. My goal would more or less be to have three interfaces, each with its own hook inside confmap:

  • ValueUnmarshaler to unmarshal wrapped config types (the current ScalarUnmarshaler interface)
  • FragmentedConfig to union multiple config types into a single result type (the current MigrateableConfig interface, I think I like "fragments" the most so far)
  • MapUnmarshaler to provide the user with a no-limits *confmap.Conf experience.

I would expect config structs to implement these as-needed and that they wouldn't conflict with each other. It sounds like the ValueUnmarshaler type you outlined is a combination of these three, which I think would be a developer UX improvement if we can make it work.

@github-actions
Copy link
Copy Markdown
Contributor

This PR was marked stale due to lack of activity. It will be closed in 14 days.

@github-actions github-actions Bot added the Stale label Nov 19, 2025
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Dec 3, 2025

Closed as inactive. Feel free to reopen if this PR is still being worked on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[configoptional] Define direction in scalar values support

3 participants