open-feature / open-feature/go-sdk

[FEATURE] Generic typed accessor for object flags (deserialize into a struct)

Open
#535 2 comments 0 reactions 0 assignees View on GitHub
enhancement Needs Triage
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.