Typed flags¶
Declare each flag once, with its key, type and default, and read it by name. Use your own enums and case classes as flag values.
Why¶
The key-based API restates three things at every call site, and nothing checks that they agree:
// Three call sites, one flag, and nothing stops them disagreeing
val inCheckout = FeatureFlags.boolean("checkout-v2", default = false)
val inCart = FeatureFlags.boolean("checkout-v2", default = true) // a different default
val inAdmin = FeatureFlags.string("checkout-v2", default = "off") // a different type
A FlagDef[A] states the key, the type and the default once. Every call site refers to the definition, so there is no
second default to disagree with and no way to read the flag at another type.
Define your flags¶
A plain object of FlagDefs is your flag catalog:
object Flags:
val CheckoutV2 = FlagDef("checkout-v2", false, "new checkout flow")
val Banner = FlagDef("homepage-banner", "none")
val MaxItems = FlagDef("cart-max-items", 100)
val MonthlyCap = FlagDef("billing-cap-cents", 0L)
val Plan = FlagDef[Tier]("user-plan", Tier.Free)
val Staging = FlagDef("rollout", Rollout("stable"))
val PageSize = FlagDef("page-size", Level(1))
The third argument is an optional description. It is documentation only and never sent to the provider.
Name the type when the default is a case object
FlagDef("k", Tier.Free) with a case object Free infers the type Tier.Free.type, which has no FlagType.
Write FlagDef[Tier]("k", Tier.Free). The parameterless cases of a Scala 3 enum are typed as the enum itself,
so they are not affected, but naming the type does no harm.
Evaluate by definition¶
value, valueOrDefault, valueDetails and resolveOrDefault take a FlagDef in place of the key and default:
val value: ZIO[FeatureFlags, FeatureFlagError, Boolean] = FeatureFlags.value(Flags.CheckoutV2)
val orDefault: ZIO[FeatureFlags, Nothing, Boolean] = FeatureFlags.valueOrDefault(Flags.CheckoutV2)
val details: ZIO[FeatureFlags, FeatureFlagError, FlagResolution[Boolean]] =
FeatureFlags.valueDetails(Flags.CheckoutV2)
val resolution: ZIO[FeatureFlags, Nothing, FlagResolution[Boolean]] = FeatureFlags.resolveOrDefault(Flags.CheckoutV2)
// Each also takes a context, and the two details forms an EvaluationOptions as well
val forUser: ZIO[FeatureFlags, FeatureFlagError, Boolean] = FeatureFlags.value(Flags.CheckoutV2, ctx)
val withOptions: ZIO[FeatureFlags, FeatureFlagError, FlagResolution[Boolean]] =
FeatureFlags.valueDetails(Flags.CheckoutV2, ctx, options)
// Custom types come back as the domain type
val plan: ZIO[FeatureFlags, FeatureFlagError, Tier] = FeatureFlags.value(Flags.Plan)
val staging: ZIO[FeatureFlags, FeatureFlagError, Rollout] = FeatureFlags.value(Flags.Staging)
val pageSize: ZIO[FeatureFlags, FeatureFlagError, Level] = FeatureFlags.value(Flags.PageSize)
| Method | Returns | Use when |
|---|---|---|
value |
the value, or a FeatureFlagError |
a missing or broken flag should fail the call |
valueOrDefault |
the value, never failing | the flag must never take the request down |
valueDetails |
the resolution, or a FeatureFlagError |
you need the reason, variant or metadata |
resolveOrDefault |
the resolution, never failing | both |
These run exactly the same evaluation as the key-based methods: hooks, transactions, timeouts and errors all behave the same. See Evaluating flags for the two tiers.
The FlagDef's default is the one served when the flag is missing or broken. A FlagType has a defaultValue
too, but evaluation never uses it.
Two definitions are equal only if key, default and description all match. Compare keys alone with sameKey:
val equal: Boolean = Flags.CheckoutV2 == FlagDef("checkout-v2", true) // false: the defaults differ
val same: Boolean = Flags.CheckoutV2.sameKey(FlagDef("checkout-v2", true)) // true
Your own types¶
A FlagDef[A] needs a FlagType[A], the codec between A and what the provider stores. Built-in instances cover
Boolean, String, Int, Long, Float, Double, Map[String, Any] (an object flag), and Option and List of
any type that has one.
Option reads its element's type; List reads an object
Option[A] is fetched with A's resolver, so Option[String] against a string flag reads Some(value). A None
default cannot be sent as a scalar, so A's zero (false, "", 0, or a derived enum's first case) is sent in
its place. The result reads None when the provider answers with an error code, or with reason DEFAULT,
DISABLED or ERROR and exactly that zero. So a provider that deliberately serves the zero value with one of
those reasons reads as None; any other value, or the zero with a real reason such as TARGETING_MATCH, reads as
Some. List[A] has no scalar form and is always fetched with the object resolver.
Derive it (Scala 3)¶
// A parameterless enum is carried as a string: "free", "premium", ...
enum Tier derives FlagType:
case Free, Premium, Enterprise
// A case class is carried as an object, field by field
final case class Rollout(tier: String, pct: Int = 10, note: Option[String] = None) derives FlagType
An enum whose cases take no parameters is stored as a string holding the case's name. Reading matches the name
ignoring case, so "premium" reads as Tier.Premium. An unknown name fails with TypeMismatch rather than quietly
serving the default. A case with parameters does not compile; write that type with from or mapped.
A case class is stored as an object, each field through its own FlagType, so nested case classes, Option
and List fields work too. Keys the class does not know are ignored. A missing key takes the field's Scala default if
it has one (pct = 10), otherwise None for an Option, and otherwise fails naming the field.
FlagType.derived is not a given, so it never competes with the built-in instances. Use a derives clause, or write
given FlagType[X] = FlagType.derived[X].
Map it from another type¶
FlagType.mapped builds a codec from an existing one and two functions. The flag is stored as the underlying type:
// A newtype over an Int: carried as an Int, read back as a Level
final case class Level(n: Int)
object Level:
given FlagType[Level] = FlagType.mapped[Level, Int]("Level", Level(0))(Level.apply, _.n)
Write the decoder yourself¶
FlagType.from takes a decoder and an encoder. The flag is stored as an object:
final case class RolloutPlan(tier: String, percentage: Int)
given FlagType[RolloutPlan] = FlagType.from(
name = "RolloutPlan",
default = RolloutPlan("free", 0),
decoder = {
case m: Map[?, ?] =>
val fields = m.asInstanceOf[Map[String, Any]]
for
tier <- FlagType[String].decode(fields.getOrElse("tier", "free"))
pct <- FlagType[Int].decode(fields.getOrElse("percentage", 0)) // numbers arrive as Double
yield RolloutPlan(tier, pct)
case other => Left(s"not a rollout plan: $other")
},
encoder = plan => Map("tier" -> plan.tier, "percentage" -> plan.percentage)
)
Values inside an object flag pass through the OpenFeature SDK on the way, so numbers arrive as Double and null
fields are dropped. Decode numbers through FlagType[Int] or FlagType[Long] rather than casting, and model optional
fields as Option. The encoder matters even if you never read its output: your default is encoded and handed to the
provider.
mapped and from work on Scala 2.13 as well. Only derivation is Scala 3 only.
Wire type and domain type¶
A FlagType has a domain type, typeName, which your code sees, and a wire type, wire, which the provider is
asked for. Evaluation picks the provider method from the wire type, a WireType: Boolean, String, Int, Long,
Float or Double select that type's method, and Object reads an object.
| Built with | Wire type |
|---|---|
| built-in instances | the type itself |
derives on an enum |
String |
derives on a case class |
object |
mapped[A, B] |
B's wire type |
Option[A] |
A's wire type |
List[A] |
object |
from |
object |
Do not use from for a value stored as a string
from asks the provider for an object. If the flag actually holds a string, the provider answers
TYPE_MISMATCH. Use a derived enum or mapped instead.
To store a scalar but reject values you do not recognise (which a total mapped function cannot do), write the instance
by hand. Override wire and encode together: encode must return the boxed type the wire type names (String,
java.lang.Integer, …), or evaluation fails with a TypeMismatch naming both.
enum Region:
case Eu, Us
object Region:
given FlagType[Region] with
def typeName: String = "Region"
override def wire: WireType = WireType.String // ask the provider for a string
def defaultValue: Region = Region.Eu
override def encode(r: Region) = r.toString.toLowerCase // must be a String, to match wire
def decode(v: Any): Either[String, Region] = v match
case "eu" => Right(Region.Eu)
case "us" => Right(Region.Us)
case other => Left(s"unknown region: $other") // a TypeMismatch, not a silent default
wire replaces the string-valued wireType, which is deprecated. An instance that still overrides wireType keeps
working: the name is matched ignoring case ("string" selects String), and any other name reads as an object. Only
the built-in Map[String, Any] instance is served as a raw map; any other object-backed instance has its decode run,
even one built with from("Object", ...).
Hooks filter by the wire type, so a hook scoped to FlagValueType.String sees this flag. In a hook, the
flag's type is the wire type, while the default and the resolved value are domain values.
Test with the same definitions¶
The testkit takes FlagDef := value pairs, checked against the flag's type and stored through its codec. The test
reads the value back the same way production does:
import zio.openfeature.testkit.FlagOverride.Ops // brings := into scope
val fixture: ZLayer[Any, Throwable, TestFeatureProvider & FeatureFlags] =
TestFeatureProvider.layer(
Flags.Plan := Tier.Premium,
Flags.CheckoutV2 := true
)
// TestFeatureProvider.layer(Flags.Plan := "premium") // does not compile: "premium" is not a Tier
Two values for the same key are rejected rather than silently merged. See Testkit for the other typed fixtures.
Check a hand-written FlagType with FlagTypeLaws from the testkit. roundTrip checks that
decode(encode(a)) == Right(a); throughValueBridge also passes the value through the SDK's conversion, which is where
object-backed codecs usually break; all runs both:
When to keep using string keys¶
FlagDef adds to the key-based API; it does not replace it. Keys are still the right tool for a flag you read but do not
own, a key computed at runtime, or a test that a key is not defined. Both styles can read the same flag.