open-feature / open-feature/go-sdk
[FEATURE] Generic typed accessor for object flags (deserialize into a struct)
- Dominant language
- Go
- Stars
- 250
- Forks
- 62
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 25
Description
# Requirements
## Summary
Add a generic, typed accessor that evaluates an object flag and returns the value deserialized into a caller-supplied type `T`, instead of `any`.
Today the only way to read an object flag is `Client.ObjectValue` / `Client.ObjectValueDetails`, which return `any`. For most providers that `any` is a `map[string]any`, so every caller reimplements the same conversion:
```go
raw, err := client.ObjectValue(ctx, "my-flag", defaultValue, evalCtx)
if err != nil { /* ... */ }
b, _ := json.Marshal(raw)
var cfg MyStruct
_ = json.Unmarshal(b, &cfg)
```
Proposed:
```go
cfg, err := openfeature.GetObjectValue[MyStruct](ctx, client, "my-flag", MyStruct{...}, evalCtx)
```
## On the spec
- The generic form `client.getObjectValue(...)` appears only in the **code examples** under [1.3.1](https://openfeature.dev/specification/sections/flag-evaluation#requirement-131) / [1.3.2.1](https://openfeature.dev/specification/sections/flag-evaluation#conditional-requirement-1321) — illustrative, not normative.
- [1.4.4.1](https://openfeature.dev/specification/sections/flag-evaluation#conditional-requirement-1441) is about the *evaluation details structure* accepting a generic argument, which the SDK **already satisfies** via `GenericEvaluationDetails[T]`.
- [1.3.4](https://openfeature.dev/specification/sections/flag-evaluation#requirement-134) (returned value SHOULD be of the expected type, else return the default) is the requirement this actually serves, and drives the error behavior below.
**So this is a DX improvement, not a conformance gap.** For context, JS is the only SDK with a truly generic `getObjectValue`; Java and .NET expose a dynamic `Value` type instead.
## Proposed API
Mirrors the existing `Object` / `ObjectValue` / `ObjectValueDetails` trio:
```go
// Any error results in defaultValue being returned.
func GetObject[T any](ctx context.Context, client *Client, flag string,
defaultValue T, evalCtx EvaluationContext, options ...Option) T
func GetObjectValue[T any](ctx context.Context, client *Client, flag string,
defaultValue T, evalCtx EvaluationContext, options ...Option) (T, error)
func GetObjectValueDetails[T any](ctx context.Context, client *Client, flag string,
defaultValue T, evalCtx EvaluationContext, options ...Option) (GenericEvaluationDetails[T], error)
```
Reuses `GenericEvaluationDetails[T]` (currently only aliased at `[any]`, as `InterfaceEvaluationDetails`) and the existing `Option` type, so `WithHooks` / `WithHookHints` work as usual. `defaultValue T` is passed down as `any` and gives us a correctly typed value on the error path instead of `nil`.
## Function vs. method
Two viable shapes. **Neither can be added to `IClient`** — that is the key constraint, and it is unavoidable in Go.
**Option A — package-level generic functions** (as specified above). Works on the current `go 1.26.0` floor, no toolchain bump, zero risk to `IClient`, the `var _ IClient = (*Client)(nil)` guard, or the generated `MockIClient`.
**Option B — generic methods on `*Client`**, expressible as of [Go 1.27](https://go.dev/doc/go1.27):
```go
func (c *Client) ObjectValueAs[T any](ctx context.Context, flag string,
defaultValue T, evalCtx EvaluationContext, options ...Option) (T, error)
```
Nicer at the call site, but requires bumping `go.mod` to `go 1.27`, raising the minimum Go for all consumers. It also **must use new names**: a type cannot declare two methods named `Object`, and replacing the existing ones would break the `IClient` guard, `MockIClient`, and every existing caller — per the Go 1.27 release notes, *"methods of interfaces may not declare type parameters nor can interface methods be implemented by generic methods."*
**The two compose.** Go 1.27's own `math/rand/v2` kept the generic function `N[Int intType](Int) Int` and *added* the method `(*Rand) N[Int intType](Int) Int` alongside it. So A can ship now and B can follow whenever the floor moves — no deprecation cycle, nothing to undo.
**Isolated API is not a blocker for either.** [1.8.2](https://openfeature.dev/specification/sections/flag-evaluation#requirement-182) requires isolated instances to match the singleton's contract, and both do: `openfeature.NewClient`, `NewDefaultClient`, and `(*EvaluationAPI).NewClient` all return the same concrete `*Client`, and `isolated.NewAPI()` returns `*openfeature.EvaluationAPI`.
**Either way, the `any` trio stays permanently** — with `defaultValue T`, a bare `nil` default is a compile error (`cannot infer T`).
## Implementation sketch
```go
func GetObjectValueDetails[T any](
ctx context.Context, client *Client, flag string,
defaultValue T, evalCtx EvaluationContext, options ...Option,
) (GenericEvaluationDetails[T], error) {
details, err := client.ObjectValueDetails(ctx, flag, defaultValue, evalCtx, options...)
// Carry the provider's metadata (reason, variant, flag metadata) on every
// return path; only Value is specialized to T.
typed := GenericEvaluationDetails[T]{
Value: defaultValue,
EvaluationDetails: details.EvaluationDetails,
}
if err != nil {
return typed, err
}
// Fast path: provider already returned a concrete T.
if v, ok := details.Value.(T); ok {
typed.Value = v
return typed, nil
}
// Fallback: provider returned a decoded structure (map[string]any, ...).
var out T
data, convErr := json.Marshal(details.Value)
if convErr == nil {
convErr = json.Unmarshal(data, &out)
}
if convErr != nil {
err := fmt.Errorf("evaluated value is not a %T: %w", defaultValue, convErr)
typed.ErrorCode = TypeMismatchCode
typed.ErrorMessage = err.Error()
return typed, err
}
typed.Value = out
return typed, nil
}
```
`GetObjectValue[T]` discards the details; `GetObject[T]` wraps it and discards the error, exactly as `Client.Object` wraps `Client.ObjectValue`.
### JSON engine
The JSON library is an internal detail — it does not appear in the signature, so v1↔v2 can be swapped in place without an API change or deprecation. (For that reason the name should reference the concept, not the mechanism — not `JSONValue`.) As of Go 1.27, `encoding/json/v2` is normally importable with no `GOEXPERIMENT`, and `encoding/json` is backed by it. v2 defaults are stricter than v1 (case-sensitive names, rejects duplicate object names), so a first cut should set `MatchCaseInsensitiveNames` and not reject unknown members, since providers often attach extra metadata.
**Decode options are deferred.** We can't add a second variadic to a function already ending in `options ...Option`. Ship opinionated documented defaults; if demand appears, add a `...WithOptions` sibling or a dedicated option type rather than overloading `Option`.
## Error behavior
On conversion failure, return the typed `defaultValue` and stamp `TYPE_MISMATCH`, matching what the existing typed accessors do today:
```go
err := errors.New("evaluated value is not a string")
strEvalDetails.ErrorCode = TypeMismatchCode
strEvalDetails.ErrorMessage = err.Error()
```
A plain error (not a structured `ResolutionError`), `ErrorCode` set, and **`Reason` deliberately left untouched**. The sketch follows this exactly, for consistency with its siblings.
Flagging separately: leaving `Reason` unset arguably conflicts with [1.4.9](https://openfeature.dev/specification/sections/flag-evaluation#requirement-149). If maintainers agree, that is a pre-existing bug across *all* typed accessors and belongs in its own issue, not a one-off fix here.
**Known limitation — hook ordering.** Conversion happens after `Client.evaluate` returns, so the `After` hook observes the raw `map[string]any` rather than `T`, and a conversion `TYPE_MISMATCH` does not trigger `Error` hooks. Identical to the existing typed accessors (their assertions also happen after `evaluate`), so this is consistent rather than novel.
## Backwards compatibility
Purely additive. No existing signatures change. Works with every existing provider, since the round-trip fallback needs nothing from the provider.
## Prior art
- #158 — Generics for v2. [This 2023 comment](https://github.com/open-feature/go-sdk/issues/158#issuecomment-1836448183) from @paddycarver proposes essentially the same shape: `Evaluate[T any](ctx, client, flag, defaultValue, evalCtx, options...)`.
- #408 — Add Generics support
- #401 — Tracking issue for V2
Open question: is this narrow slice worth shipping in v1, or should it fold into the v2 generics work? My case for v1 — it's small, additive, needs no toolchain bump under Option A, and delivers the highest-value piece of the generics story without waiting on a major version.
## Future optimization (out of scope; would need an OFEP)
The round-trip only matters for hot-path, large-object, or in-process-cached providers. A zero-round-trip path requires provider cooperation:
- A `json.RawMessage` convention — the provider returns raw bytes and declines to pre-decode; the SDK unmarshals directly into `T` (one unmarshal, no re-marshal). Only the SDK knows `T`, so it owns that branch.
- An optional capability interface, in the style of the existing `Tracker`, e.g. `ResolveObjectInto(ctx, flag, out any, ...)`, where the SDK passes a `*T` down.
Both are strictly additive and can layer on top of the fallback later; neither is needed for the core feature.
## Scope
- [ ] Decide Option A vs Option B — A is unblocked today, B needs a `go 1.27` bump
- [ ] `GetObject[T]`, `GetObjectValue[T]`, `GetObjectValueDetails[T]`
- [ ] Type-assert fast path + JSON round-trip fallback with `TYPE_MISMATCH` on failure
- [ ] Table tests against `memprovider`: stored concrete `T` (fast path), stored `map[string]any` (fallback), conversion failure
- [ ] Doc comments and a README/example snippet
Per the feature template, this is an SDK-only addition and should not require an OFEP. The future optimizations above would.
Contributor guide
Research direction
Start with the existing Client.Object, Client.ObjectValue, Client.ObjectValueDetails, and GenericEvaluationDetails[T] APIs, then inspect the memprovider tests and current typed-accessor error handling. Resolve whether package-level functions or generic methods fit the supported Go version, and cover concrete values, map[string]any conversion, conversion failure, metadata, and documentation in the README or an example.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- api
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100