figureout

package module
v0.7.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 21, 2026 License: MIT Imports: 15 Imported by: 0

README

figureout alpha

Descriptor-driven configuration for Go: declare the configuration once, derive decoding, validation, defaults, documentation and schemas from that one declaration.

go get github.com/go-faster/figureout

The core, four sources (JSON, YAML, environment variables, mounted files) and two targets (JSON Schema, Markdown reference), wired end to end.

type Config struct {
	Server  Server
	Timeout figureout.OptionalOf[time.Duration]
	Level   LogLevel

	logger any
}

var ConfigDescriptor = figureout.MustDerive(
	func(c *Config, s *figureout.Schema[Config]) {
		figureout.Object(s, &c.Server, "server", ServerDescriptor)
		figureout.Optional(s, &c.Timeout, "timeout").AtLeast(time.Second)
		figureout.Enum(s, &c.Level, "level").ApplyDefault(LogInfo)
		figureout.Ignore(s, &c.logger, figureout.Reason("runtime dependency"))
	},
)

cfg, report, err := ConfigDescriptor.Resolve(
	yaml.File("config.yaml"),
	env.Current(env.Prefix("APP_")),   // later sources win
)
schema, diags, err := jsonschema.Generate(ConfigDescriptor, jsonschema.Semantic())
reference, diags, err := docs.Generate(ConfigDescriptor,
	docs.ForSource(yaml.File("config.yaml")),
	docs.ForSource(env.Current(env.Prefix("APP_"))),
)

Examples

Runnable documentation lives in example_test.go — layering, erasing, optional values, enums, unions, schema generation and completeness, each with verified output.

examples/service is a small program that puts them together:

go run ./examples/service                        # resolve and print provenance
APP_SERVER_LISTEN_PORT=9090 go run ./examples/service  # env wins over the file
APP_SERVER_TIMEOUT=null go run ./examples/service # erase a value from the file
go run ./examples/service -schema                # JSON Schema for JSON input
go run ./examples/service -docs                  # the Markdown reference below
go run ./examples/service -paths                 # every path, type and default
listening on 0.0.0.0:9090
request timeout 30s
storage s3 bucket=service-data region=eu-central-1 prefix=""
level=warn tags=[service production] limits=map[cpu:4 memory:8]

provenance:
  server.address       yaml server.address examples/service/config.yaml:8:3
  server.port          env APP_SERVER_LISTEN_PORT
  server.timeout       yaml server.timeout examples/service/config.yaml:10:3

Deriving a descriptor

Derive compiles the whole model before returning, so every mistake in a description — a mistyped registration, a duplicate name, an unregistered field — surfaces at once, as diagnostics. MustDerive turns them into a panic carrying the same list.

Which one to use is a question of where the failure should appear, and the answer differs by program shape:

// A library: a broken descriptor is a programming error, and a panic in init
// is the right way to report one. This is the idiom the examples use.
var ConfigDescriptor = figureout.MustDerive(describe)

// A service: derive once, on the path that can report an error and exit 1.
var descriptor = sync.OnceValues(func() (*figureout.Descriptor[Config], error) {
	return figureout.Derive(describe)
})

func Load(paths ...string) (Config, *figureout.Report, error) {
	d, err := descriptor()
	if err != nil {
		return Config{}, nil, errors.Wrap(err, "descriptor")
	}
	return d.Resolve(yaml.File(paths[0]), env.Current(env.Prefix("APP_")))
}

sync.OnceValues keeps the compile-once property of a package variable while moving the failure into main, where it prints as a configuration error rather than as a crash. For a descriptor with a few hundred registrations that difference is worth the four extra lines; below that, the package variable is fine either way.

Packages

Package Contents
figureout descriptor, builder, pointer binding, completeness, constraints, invariants, enums, unions, carriers, secrets, diagnostics, resolution
figureout/source/json JSON source, with file:line:column provenance
figureout/source/yaml YAML source, tag-aware, anchors resolved
figureout/source/env environment variable source
figureout/source/file one value per file, for mounted secrets
figureout/schema/jsonschema JSON Schema target
figureout/schema/docs Markdown reference target

Sources

Every source decodes into a layer rather than writing into the struct; layers merge in order, and validation runs once on the merged value. The report keeps the origin of each value:

cfg, report, err := ConfigDescriptor.Resolve(
	json.File("config.json"),
	yaml.File("config.yaml", yaml.Optional()),
	env.Current(env.Prefix("APP_")),
)

report.OriginOf("server.port")   // env APP_PORT
port must be at most 65535
  value: 70000
  source: json config.json:4:13

JSON and YAML stay separate adapters, as the design requires, sharing only an internal document tree and the text scalar parser that env and YAML both need. The differences are the point:

JSON YAML env
8080 vs "8080" distinct; a string needs json.Accepts(json.String()) distinct by tag: !!int vs !!str everything is text
null (erases) null null or ~ opt-in env.NullLiteral
empty "" is a value "" is a value absent; env.AllowEmpty() opts out
positions line and column line and column variable name
anchors resolved before binding

Both name their fields with Name, Alias and Skip, and both accept DisallowUnknownFields() to report members no field claims.

$schema at the document root is always accepted. It names the schema that describes the file, which is how an editor finds it, so DisallowUnknownFields() lets it through and schema/jsonschema declares it alongside the registered properties — otherwise the generated schema would reject the very member that attaches it. It is accepted at the root only, and a descriptor that registers $schema itself keeps its own declaration.

An empty variable is absent, not an empty value. Container tooling materializes a variable whether or not an operator supplied one — the :- in APP_TOKEN: ${APP_TOKEN:-} is what you write so compose does not warn — and a .env template ships with APP_TOKEN= on purpose. Reading that as a value would blank the credential a file layer set, on the deploy that adopts the env layer, with nothing logged: as far as the resolver is concerned the operator set the field. source/file treats a zero-length file the same way, because a Secret key that exists but is blank mounts as exactly that. Both take AllowEmpty() where the empty string is a value an operator picks on purpose:

env.Current(env.Prefix("APP_"))                     // APP_TOKEN= is absent
env.Current(env.Prefix("APP_"), env.AllowEmpty())   // APP_TOKEN= is ""
file.Dir("/run/secrets")                            // a zero-length file is absent

Erasing keeps its own spelling, because erasing is a decision: null in a document, or env.NullLiteral where a variable should carry it.

A name is relative to the object that declares it, so nesting composes and a nested field can never silently claim a top-level field's variable:

// inside ServerDescriptor, nested under "server"
figureout.Value(s, &c.Port, "port", env.Name("LISTEN_PORT"))
// reads APP_SERVER_LISTEN_PORT, not APP_LISTEN_PORT

For env, the whole derivation is pluggable — the default joins the segments with underscores and upper-cases them:

env.Current(env.Names(func(f *figureout.FieldModel, segments []string) []string {
	return []string{strings.ToUpper(strings.Join(segments, "__"))}
}))

Whatever the naming produces is still collision-checked, so a function that flattens away a level is reported rather than silently binding two fields to one variable.

Nesting

A nested object is either its own descriptor or an inline description:

figureout.Object(s, &c.Server, "server", ServerDescriptor)  // shared or exported

figureout.ObjectFunc(s, &c.Server, "server", func(c *Server, s *figureout.Schema[Server]) {
	figureout.Explicit(s, &c.Port, "port", env.Name("LISTEN_PORT")).InRange(1, 65535)
})

ObjectFunc runs describe against a nested Schema rooted at the field, so pointer binding, completeness and name collisions are scoped to Server exactly as a separate Derive would scope them — including env.Name, which still replaces only that field's segment and reads SERVER_LISTEN_PORT. A pointer that leaves the nested struct is a foreign-pointer diagnostic rather than a silent binding.

Use Object for a descriptor several parents share or that you want to export, and ObjectFunc for a section with exactly one parent — which is most of them.

The configuration path is not welded to the Go nesting. The shape that reads well in a file and the shape consumers want in Go are rarely the same shape, and a configuration that has been around a while has both mismatches. Group opens a path level with no Go struct behind it:

figureout.Group(s, "webhook", func(s *figureout.Schema[GitLab]) {
	figureout.Value(s, &c.WebhookEnabled, "enabled")
	figureout.Value(s, &c.WebhookSecret, "secret", figureout.Hidden())
})
webhook:
  enabled: true
  secret: hunter2      # GITLAB_WEBHOOK_SECRET

Only the path nests. The fields still bind to the declaring struct, so completeness and duplicate registration see exactly what they would have seen without the group — registering the same field inside and outside one is still a duplicate. A group contributes a segment everywhere a nested object would: environment variable names, provenance paths and generated schemas.

The inverse mismatch — a nested Go struct that is flat in the file — needs no function, because registering descendants already covers the parent:

figureout.Value(s, &c.Database.DSN, "dsn")   // Config.Database.DSN, spelled "dsn"

Lists and maps of objects

The lists in a configuration file are the part an operator actually edits, and describing their elements is what gives them names, defaults, constraints, provenance and a schema:

figureout.ListOf(s, &c.Sites, "sites", func(e *Site, s *figureout.Schema[Site]) {
	figureout.Explicit(s, &e.Name, "name").NonEmpty()
	figureout.Value(s, &e.MaxBytes, "max_bytes")
})

figureout.MapOf(s, &c.Proxies, "proxies", describeProxy).MergeByKey()
figureout.List(s, &c.Projects, "projects", ProjectDescriptor)   // shared elements

Each element binds to a path of its own, so nothing about collections is special: merging, defaults, validation, report.OriginOf and null-erasure are the same per-path machinery everything else uses.

sites[0].max_bytes          an unkeyed list, by position
sites[name=docs].max_bytes  a list merged by key
proxies[gitlab].url         a map, by key

A list of structs that nobody described is a derivation error naming ListOf, rather than a descriptor that compiles clean and fails at resolve time. An absent collection resolves to an empty one — see Presence — and sites: null erases it back to empty.

Merging elements

Merging needs to know which element in a later layer is which in an earlier one, and a list does not carry that. So the policy says where identity comes from:

Identity A later layer can
MergeReplace (default) none needed replace the whole list
MergeAppend none needed add elements
MergeByKey("name") an element field edit, and add
figureout.ListOf(s, &c.Sites, "sites", describeSite).MergeByKey("name")
# base.yaml            # override.yaml        # result
sites:                 sites:                 sites:
  - name: docs           - name: docs           - name: docs
    max_bytes: 10            max_bytes: 20          max_bytes: 20
  - name: wiki                                  - name: wiki
    max_bytes: 10                                   max_bytes: 10

Fields merge individually, so a later layer changes only what it names. Positions deliberately do not merge: sites[0] in two files is the same element only by accident, and prepending one entry would otherwise re-target every override silently.

Keying a list makes its key field mandatory, and repeating a key within one layer is an error rather than last-wins. Base order is preserved and unseen keys are appended, so a later layer cannot reorder — do not key a list whose order is meaningful. A map already identifies its entries, so MergeByKey() there takes no argument, and gitlab: null removes an entry.

Environment variables and mounted files cannot express a collection of objects and simply skip the field. An index convention would be a second, worse way to write the same configuration.

Presence

Presence is spelled by the registration function, and the value type is inferred from the carrier. Constraints are then typed as the element, never as the carrier:

type Config struct {
	Port    int
	Timeout figureout.OptionalOf[time.Duration]   // missing | present
}

figureout.Explicit(s, &c.Port, "port").InRange(1, 65535)           // T = int
figureout.Optional(s, &c.Timeout, "timeout").AtLeast(time.Second)  // T = time.Duration

The type carries the Of suffix so the plain name stays free for the function. Value and Explicit reject a carrier field with a diagnostic naming the function to use instead, so the two cannot be mixed up silently. Stacking carriers — a *OptionalOf[T], an OptionalOf[OptionalOf[T]] — is rejected outright: which of the two nils means missing has no defensible answer.

What absence means is the registration function, not a modifier. A plain field is one of two things, and the call site says which:

figureout.Explicit(s, &c.Database.DSN, "dsn")   // absent is an error
figureout.Value(s, &c.Jira.URL, "url")          // absent is ""
figureout.Value(s, &c.Jira.MaxResults, "max")   // absent is 0

Most optional scalars have no meaningful default beyond the zero value, and the zero value is already visible in the Go type; Value says so in one word instead of a hundred repetitions of ApplyDefault(""). Explicit is for what an operator has to decide — an address, a credential, a port.

A fallback the field itself rejects never resolves silently. Value on a field constrained by NonEmpty, InRange, Enum or a Check, and any collection constrained by MinItems, is a compilation diagnostic naming the way out, because a value no source could have written is a descriptor that cannot work:

constraint.type_mismatch [patterns]: an empty list does not satisfy the field's
own constraints (length must be at least 1, got 0); register it with Explicit,
or mark it Required, so absence is an error

Value(...).Required() is Explicit spelled the long way, and ApplyDefault replaces the fallback with a value of your own.

A section is optional the same way a scalar is. OptionalObject and OptionalObjectFunc register a nested object a source may leave out, and the carrier is what distinguishes what a zero struct cannot: "there is no cache" is not "there is a cache and every one of its fields defaulted".

type Config struct {
	Cache figureout.OptionalOf[CacheConfig]
}

figureout.OptionalObjectFunc(s, &c.Cache, "cache", func(c *CacheConfig, s *figureout.Schema[CacheConfig]) {
	figureout.Explicit(s, &c.Dir, "dir")
	figureout.Value(s, &c.Bytes, "bytes").ApplyDefault(1024)
})

cache: {} and no cache key are different statements, and both resolve to what they say: the first is a section whose every member defaulted, the second is no section. A nesting source marks the section itself; a flat source such as environment variables has no name for it, so there a section is present whenever it provided a member — the same statement in the only way that source can make it.

Absence materializes nothing inside, which is what makes Explicit within an optional section mean something: dir is demanded where the section is present and nowhere else. An explicit null erases the section along with whatever earlier layers put in it.

A pointer is indirection, and nothing else. OptionalOf is the only thing that says a value may be missing. A pointer says only that the value is held behind one, so a *C field is required like any other: resolution allocates it, and it is never nil in a resolved configuration.

type Config struct {
	S3      *S3Config                            // always there, held by pointer
	Cache   figureout.OptionalOf[*CacheConfig]   // may be missing, held by pointer
}

figureout.ObjectFunc(s, &c.S3, "s3", describeS3)
figureout.OptionalObjectFunc(s, &c.Cache, "cache", describeCache)

That is the whole rule, and it is why there is no OptionalPtr: a nil pointer never means "no value". A configuration being adopted that spells presence as *T converts the field to a carrier rather than teaching the pointer a second meaning — OptionalOf[T] where the pointer was only ever standing in for absence, OptionalOf[*T] where consumers also want the pointer. The carrier marshals as the value it holds in both JSON and YAML (missing is null), so converting does not change how the struct serializes.

A pointer to a scalar is refused outright. It is not presence, and a scalar has no identity or size a pointer would preserve, so it buys nothing and costs pointer-typed constraints:

field.unsupported_type [retries]: Config.Retries is a pointer to a scalar;
write the value itself, or OptionalOf[int] if it may be missing

Two carriers are two answers to one question, so a second one below the first is refused wherever it sits — *OptionalOf[T], OptionalOf[OptionalOf[T]] — and so is a **T, whose second nil answers nothing either.

The two optional carriers are one presence spelled two ways, and OptionalObject/OptionalObjectFunc take either:

Field Section is Held
OptionalOf[C] absent unless a source contained it inline
OptionalOf[*C] absent unless a source contained it behind a pointer resolution allocates

A Group is never one: it nests the document without nesting the Go struct, so it has no field to be absent from.

An optional section is the part a zero struct cannot express:

# no cache key      -> unset
cache: {}           # -> set, a section that defaulted throughout
cache: {dir: /x}    # -> set, with dir
cache: null         # -> unset, and whatever an earlier layer put in it is gone

A member registered with Explicit is demanded where the section is present and nowhere else, which is what makes "required inside an optional section" mean something. A source with no nesting has no name for the section itself, so there a section is present whenever any of its members is.

A collection has a fallback of its own. An absent list and an empty one are the same statement about the world, so a list or map nobody configured resolves to an empty one rather than to a diagnostic — and to an empty value, not a nil one, so it encodes as [] rather than null one layer further out. Explicit and Required() both opt back in where a section really must be declared:

figureout.ListOf(s, &c.Sites, "sites", describeSite)              // absent is empty
figureout.ListOf(s, &c.Backends, "backends", describeBackend).Required()
figureout.Explicit(s, &c.Patterns, "patterns").MinItems(1)        // absent is an error

There is no nullable carrier. Optional and nullable are orthogonal in a schema language, where an external spec forces the split, but configuration has no such spec — and layering gives null a more useful job. See Merging.

Merging

Sources decode into layers; layers merge in order; validation runs once, on the merged value. Each field decides how its layers combine:

figureout.Value(s, &c.Args,   "args")               // replace, the default
figureout.Value(s, &c.Tags,   "tags").MergeAppend() // lists accumulate
figureout.Value(s, &c.Limits, "limits").MergeByKey()// maps merge per entry
# base.yaml            # override.yaml        # result
tags: [a, b]           tags: [c]              tags: [a, b, c]
limits: {cpu: 1, mem: 8}  limits: {mem: 16}   limits: {cpu: 1, mem: 16}
args: [x]              args: [y]              args: [y]

An explicit null erases. A null in a later layer drops what earlier layers set, so the field falls back to its default, or to missing:

# base.yaml       # override.yaml     # result
timeout: 30s      timeout: null       timeout is missing again
level: debug      level: null         level falls back to its default

That is why there is no nullable carrier: null never reaches the Go value, so no consumer of a resolved config has to handle a third state. report.ErasedBy(path) names the layer that erased a value, just as OriginOf names the one that set it. A required field with no default that gets erased is an error, which is the intended cost.

Environment variables have no null, so the spelling is opt-in per field:

figureout.Optional(s, &c.Timeout, "timeout", env.NullLiteral("null"))
// APP_TIMEOUT=null erases; without the declaration, "null" is just text

Because null is a directive rather than a value, it appears in a schema only when that schema describes what a source accepts, and only where erasing leaves something to fall back on — jsonschema.ForSource(...) emits it, jsonschema.Semantic() never does.

Durations and units

Unit-suffixed integer keys outlive the configurations that introduced them. Moving timeout_seconds onto time.Duration normally changes what the key accepts — 180 would have to become "180s" — which breaks every deployment already running. Unit keeps the key and still resolves a time.Duration:

figureout.Value(s, &c.Timeout, "timeout_seconds", figureout.Unit(time.Second)).
	AtLeast(time.Second).
	AtMost(10 * time.Minute)
timeout_seconds: 180     # 180 * time.Second
timeout_seconds: "3m"    # still accepted

Constraints stay typed as time.Duration, so the bound reads AtLeast(time.Second) rather than AtLeast(1). The generated schema describes the canonical form — {"type": "integer", "minimum": 1, "description": "… In seconds."} — because that is what the key is actually written as. Durations without a unit stay strings, with their defaults and bounds spelled the way a source accepts them.

Because the duration spelling keeps working, migrating away is two safe steps: add the unit, then add the duration-spelled key and deprecate the old one.

A type that parses itself

A named scalar carrying its own UnmarshalText decides what its spellings mean, and figureout defers to it. No registration is involved: implementing encoding.TextUnmarshaler is the declaration.

type Bytes int64   // UnmarshalText reads "256MiB"
type Level int8    // UnmarshalText reads "debug"
max_bytes: 256MiB     # the type's parser
max_bytes: 256        # the type's parser, which also takes a bare count
ch_log_level: debug   # the type's parser
ch_log_level: 1       # unrecognized level "1" — an error, at ch_log_level

That last line is the reason this is not merely a convenience. Bound as the int8 underneath it, 1 resolves cleanly to warn, and a document loads meaning something other than what it says. The type rejects it, so the descriptor does.

The underlying kind still sets the semantic type, so constraints stay typed as Bytes and the schema keeps its integer — with string alongside it, because that is the other thing the field accepts. YAML hands the type the scalar's text whatever its resolved tag; JSON hands it a string and reads a number as the underlying kind, which is what encoding/json does with the same type. Only a named scalar qualifies: a struct or a slice is a shape a descriptor can describe, and collapsing it to text would hide the description rather than add one.

Secrets

Hidden is documentation metadata and does nothing else, which leaves a token one Pattern or MinLength failure away from a log. Secret has teeth:

figureout.Explicit(s, &c.Token, "token", figureout.Secret()).Pattern(`^sk-[a-z0-9]+$`)
token: must match "^sk-[a-z0-9]+$"     # never "value: hunter2"
  source: config.yaml:3:8

A secret's value never appears in a message the library formats — not in a constraint failure, not in a decoding error from a source. Secret implies Hidden, and JSON Schema marks the property writeOnly. report.Secret(path) and report.Secrets() let a consumer walking report.Origins() apply the same rule to its own logging.

Where a secret comes from is a deliberate choice. figureout owns the two mechanisms an operator actually deploys, and neither is a value-level indirection written into the configuration file:

an environment variable env.Current(env.Prefix("APP_")) binds database.dsn to APP_DATABASE_DSN directly
a mounted file file.Dir("/run/secrets") reads database.dsn from a file of that name

source/file is the shape a Kubernetes secret mount, a Docker secret and systemd's LoadCredential all present: a directory whose entries are named after the values they hold. One trailing newline is stripped, so a secret written with echo reads back as written; a missing file leaves the field to earlier layers. Names compose exactly as env's do, and file.Names replaces the derivation wholesale.

An in-document {value, env, file} carrier is deliberately not provided. Its env: half is redundant — the env source already binds the field directly, which is strictly better than an indirection the file has to spell — and its file: half is source/file with the mapping written out by hand. If a configuration must keep that shape for compatibility, it is a WithDecoder plus the shapes it accepts, not something the core owns.

Cross-field invariants

Constraints are per field; real configurations are full of rules that are not. Invariant gives them somewhere to live that keeps the provenance the descriptor already has:

figureout.Invariant(s, "proxy-exists", func(c *Config) error {
	for i, site := range c.Fetch.Sites {
		if _, ok := c.Proxies[site.Proxy]; !ok {
			return figureout.At(fmt.Sprintf("fetch.sites[%d].proxy", i)).
				Errorf("no proxy named %q is configured", site.Proxy)
		}
	}
	return nil
})
fetch.sites[0].proxy: no proxy named "gitlab" is configured
  source: config.yaml:41:5

Invariants run last, on a configuration whose every field already resolved and validated, so a violation is never a knock-on effect of an error already reported. At attaches the paths a rule is about — an element path resolves to its nearest field for provenance — and errors.Join reports several violations as several diagnostics. A plain error works too, without a path.

A rule is a Go function, so no target can emit it; model.Invariants() lists the names so generated documentation can say a rule exists that the schema does not describe.

Deprecating and moving a key

Deprecated is metadata, and metadata alone tells an operator nothing. Setting a deprecated key now lands a SeverityWarning in report.Diagnostics, with the origin that set it, so a binary can say "you are using a key that is going away" and still start.

A warning is data, not only prose. Where the deprecation is a move, the diagnostic carries the superseding path in MovedTo, so an application that phrases its own warnings never parses Message:

for _, d := range report.Diagnostics {
	if d.Code == figureout.CodeDeprecated && d.MovedTo != "" {
		warnf("%s is deprecated; use %s instead (%s)", d.FieldPath, d.MovedTo, d.Origin)
	}
}

CodeMovedConflict carries it too, so "set one of X or Y" is expressible without either path being a substring of a sentence. It is empty for a Deprecated with no replacement.

MovedFrom is the behavior a configuration needs while it is being reshaped:

figureout.Group(s, "api", func(s *figureout.Schema[Config]) {
	figureout.Value(s, &c.HTTPAddr, "http_addr",
		figureout.MovedFrom("http_addr", "legacy.addr"))
})
  • the old spelling still resolves, with a warning naming both paths
  • setting both spellings is an error, not a precedence rule — two spellings in one configuration are two intentions, and silently picking one is the worst available answer. report.OriginOf answers "was this set?" correctly, so setting the new key explicitly to its default value alongside the old one is caught too
  • the old path appears in generated schemas as a deprecated property, never as a second field; a level that no longer exists is rebuilt as a deprecated object, so old nesting keeps parsing
  • a former path covers the whole subtree beneath it, so a moved object hands over every member, and a moved ScalarOr field moves whichever spelling was used

A former path is a fact about documents. env and file derive their names from the path, and database_dsn and database.dsn derive the same variable, so a shadow would collide with its own target; both sources skip former paths and read the field under its current name. Where a variable really did exist under an old name, that is what env.Alias is for — an env-side fact, independent of the file-side rename.

The path is relative to the declaring descriptor, so it can name a former level. That scope is also what decides whether a former path is expressible at all: Group above keeps the field declared by the root schema, so root-relative http_addr is in scope. The same registration inside ObjectFunc is not — the field belongs to the nested descriptor, where http_addr resolves to the field itself, and derivation says so. A former path running through a nested descriptor rather than a group is reported too, because that descriptor may be shared.

Enum and OneOf

The two are separate concepts, and the API keeps them apart.

Enum is a set of allowed values for one type. Values come from the type itself wherever possible, so a stringer derivative stays the single source of truth:

figureout.Enum(s, &c.Level, "level")                        // AllValues() iter.Seq[LogLevel]
figureout.EnumSlice(s, &c.Mode, "mode")                     // Values() []Mode
figureout.EnumFunc(s, &c.Kind, "kind", KindValues)          // enumer's package-level func
figureout.EnumValues(s, &c.Mode, "mode", []Mode{ModeFast})  // explicit

Enum and EnumSlice take the provider as a constraint, so a type without values is a compile error, not a descriptor diagnostic. EnumFunc covers generators that emit a package-level function rather than a method. An enum carried by an OptionalOf uses the option form: figureout.Optional(s, &c.Level, "level", figureout.EnumOf[LogLevel]()). An ad-hoc value set with no provider is .Enum(values...) on the builder.

OneOf is a type sum: a tagged union selecting between alternative shapes. A discriminator is required, so decoding failures name the tag rather than reporting every variant's errors, and generation maps onto JSON Schema oneOf + const. A union is why the tree sources parse a whole document before binding: the tag has to be read before its siblings can be interpreted.

figureout.OneOf(s, &c.Backend, "backend",
	figureout.Discriminator("type"),
	figureout.Variant("s3", &c.Backend.S3, S3Descriptor),
	figureout.Variant("local", &c.Backend.Local, LocalDescriptor),
)

Variant fields must be pointers: the non-nil pointer is what records the selection. The tag is laid out inline, as a sibling of the variant's members, which is what the emitted JSON Schema describes and what the env source does with BACKEND_TYPE alongside BACKEND_BUCKET:

backend:
  type: s3
  bucket: configs

A scalar, or an object

OneOf cannot express "a scalar, or an object": a union needs a discriminator, and a bare scalar has nowhere to put one. Written by hand it is a WithDecoder plus Shape values that duplicate the descriptor already describing the same thing — and drift the moment a field is added to it. ScalarOr derives both:

figureout.ScalarOr(s, &c.AuthToken, "auth_token", SecretDescriptor,
	func(v string) Secret { return Secret{Value: v} })
auth_token: sk-live-...          # widened by the function
auth_token: {file: /run/token}   # decoded by the descriptor

The accepted shapes come from the descriptor and from the scalar type, so they cannot drift from what the binder accepts: JSON Schema emits oneOf over the two, and a source with no object syntax — environment variables, mounted files — takes the scalar at the object's own name (AUTH_TOKEN), while its members still bind under it (AUTH_TOKEN_FILE).

The two spellings never half-merge across layers. A widened scalar stands for the whole object, so whichever spelling a later layer uses replaces the other outright.

Another program's configuration

A configuration that embeds another program's has a block it cannot describe and must not validate. Opaque carries it verbatim:

// an OpenTelemetry Collector configuration, handed to the collector as-is
Collector map[string]any `yaml:"otelcol"`

figureout.Opaque(s, &c.Collector, "otelcol",
	figureout.Reason("handed to the collector verbatim"))

The field decodes to whatever the document held — objects as map[string]any, arrays as []any, scalars as the format resolved them, so a quoted "512" stays a string — and its whole subtree is exempt from DisallowUnknownFields(). That exemption is the load-bearing part: strict decoding is why a descriptor is worth adopting, and a passthrough is precisely where strictness has to stop, because figureout cannot know which keys the other program accepts and a version skew in that program is not this one's business.

Reason is therefore required, so the hole reads as one at the declaration site. It is documentation: JSON Schema emits a permissive object carrying it, the Markdown reference renders the field as a documented passthrough rather than omitting it, and a source with no nesting skips it the way it skips a collection of objects.

Nothing inside a passthrough has a name, a constraint, a default, provenance or a schema. It is not an escape hatch for a block whose shape is yours to state — reach for ObjectFunc there. Ignore remains the other end: it drops the value, Opaque passes it through.

Reference documentation

schema/docs renders the compiled model as Markdown, so the documented configuration cannot drift from the decoded one:

reference, diags, err := docs.Generate(ConfigDescriptor,
	docs.Title("Service configuration"),
	docs.ForSource(yaml.File("config.yaml")),
	docs.ForSource(env.Current(env.Prefix("APP_"))),
)

One table per object, nested objects and collection elements as sections of their own, a union rendered once per variant with the tag that selects it. Hidden fields are omitted, deprecated ones are marked rather than dropped, and a rule with no prose form — an opaque Check — is reported the way jsonschema reports what it cannot represent.

ForSource takes a configured source rather than a SourceID, because the names it documents belong to that configuration: env.Prefix("APP_") is what makes the variable APP_SERVER_LISTEN_PORT. The source answers through figureout.SourceNamer, so a column quotes the names actually read.

examples/service/CONFIG.md is generated this way, and a test fails when it falls behind the descriptor.

Build returns the Page that Generate renders, for a caller that wants another format.

Library choices

JSON uses encoding/json. Its Token and InputOffset are exported, so every node carries an exact offset and a diagnostic can say config.json:4:13. go-faster/jx is faster, but its offset() is unexported, so provenance would degrade to the property path. encoding/json/v2 is excluded by build constraints on the current toolchain, and a library cannot ask its consumers to set GOEXPERIMENT=jsonv2.

YAML uses go-faster/yaml. Node carries Line and Column, resolves tags, and exposes anchors. yaml.v4 was considered but is not yet available here.

Design notes

Six decisions worth stating outright, because each rules out an approach that looks reasonable from the outside.

Presence selects the function; the type is inferred. The obvious API is one helper per semantic type (Int, String, Duration), doubled for optionals. A single generic Carrier[T] constraint collapsing those pairs cannot be written: Go forbids a bare type parameter as a union term. So the split runs the other way — Value, Explicit and Optional infer the element type from the carrier, one function per presence rather than two per semantic type.

The trade is that a wrong semantic kind is a compilation diagnostic rather than a compile error, since Value[T] accepts any T. In exchange, constraints are typed as the element: AtLeast(time.Second) on an OptionalOf[Duration], not AtLeast(any).

FieldOption is not generic. A FieldOption[V] would force every option call site to spell its type argument, because Go cannot infer a type argument for a nested call such as env.Name("PORT"). Options are untyped; value-typed operations (ApplyDefault, Check) live on the fluent builder or on generic top-level helpers where inference works from the argument, as in figureout.Check("even", func(v int) error { … }).

No nullable carrier. Optionality and nullability are orthogonal in a schema language, where an external spec forces the split. Configuration has no such spec, and layering gives null a better job: an explicit null is far more useful as an erase directive than as a value, and once it is one, nothing nullable ever reaches the Go type.

Model and carrier types are suffixed. Object and Variant are registration functions, so the model types are FieldModel, ObjectModel and VariantModel. For the same reason the carriers are OptionalOf[T] and NullableOf[T], leaving Optional and Nullable free as registration functions.

Merge policies are per field. Append applies to lists and by-key to maps; a policy that does not fit the field's semantic kind is a compilation diagnostic. Objects are not deep-merged: their leaves merge individually, which is the same result without the surprise of a block that cannot be replaced wholesale.

Registering descendants covers the parent. Registering &c.Server.Port without registering c.Server is accepted, and completeness then checks Server's remaining fields individually. Registering both a parent and its descendants is rejected as a duplicate.

Not yet implemented

TOML and flag sources; CUE source and schema output; code generation; optimized unsafe accessors. ScalarOr covers a field, not yet a list element: projects: [group/docs] alongside [{ref: group/docs}] still needs a decoder. Invariants are declared on the configuration that owns a list, not on its elements.

Development

make test        # go test, then go test -race
make test_fast   # go test ./...
make coverage    # profile.out plus a per-function summary
make fuzz        # the JSON and text scalar parsers
make golden      # refresh golden files
make docs        # refresh examples/service/CONFIG.md
make example     # run examples/service end to end
make lint fmt    # golangci-lint

License

MIT

Documentation

Overview

Package figureout derives configuration decoding, validation, defaults and schema generation from a single typed declaration.

An application declares its configuration once with Derive, binding Go fields by pointer:

var ConfigDescriptor = figureout.MustDerive(
	func(c *Config, s *figureout.Schema[Config]) {
		figureout.Explicit(s, &c.Host, "host").NonEmpty()
		figureout.Explicit(s, &c.Port, "port").InRange(1, 65535)
		figureout.Value(s, &c.Banner, "banner")
		figureout.Optional(s, &c.Timeout, "timeout").AtLeast(time.Second)
	},
)

The registration function says what absence means: Explicit demands a value, Value resolves to the zero one, and Optional keeps the difference visible to the consumer.

The resulting Descriptor is immutable and format-neutral: sources project it into wire representations, schema targets emit it as JSON Schema or CUE.

Example

Declare the configuration once, then resolve it from any source.

package main

import (
	"fmt"

	"github.com/go-faster/figureout"
	"github.com/go-faster/figureout/source/yaml"
)

type quickstart struct {
	Address string
	Port    int
	Debug   bool
}

func main() {
	descriptor := figureout.MustDerive(func(c *quickstart, s *figureout.Schema[quickstart]) {
		figureout.Explicit(s, &c.Address, "address").NonEmpty()
		figureout.Value(s, &c.Port, "port").InRange(1, 65535).ApplyDefault(8080)
		figureout.Value(s, &c.Debug, "debug").ApplyDefault(false)
	})

	cfg, _, err := descriptor.Resolve(
		yaml.Bytes([]byte("address: 0.0.0.0\ndebug: true\n")),
	)
	if err != nil {
		panic(err)
	}

	fmt.Printf("%s:%d debug=%v\n", cfg.Address, cfg.Port, cfg.Debug)
}
Output:
0.0.0.0:8080 debug=true
Example (Completeness)

Every exported field must be registered or explicitly ignored, so a struct and its description cannot drift apart.

package main

import (
	"fmt"

	"github.com/go-faster/figureout"
)

type incomplete struct {
	Address string
	Port    int
}

func main() {
	_, err := figureout.Derive(func(c *incomplete, s *figureout.Schema[incomplete]) {
		figureout.Value(s, &c.Address, "address")
	})
	fmt.Println(err)
}
Output:
field.missing_definition [incomplete.Port]: incomplete.Port is neither registered nor explicitly ignored
Example (Enum)

An enum takes its values from the type, so a stringer derivative stays the single source of truth.

descriptor := figureout.MustDerive(func(c *leveled, s *figureout.Schema[leveled]) {
	figureout.Enum(s, &c.Level, "level").ApplyDefault(LogInfo)
})

cfg, _, err := descriptor.Resolve(env.Values(map[string]string{"LEVEL": "warn"}))
if err != nil {
	panic(err)
}
fmt.Println("level:", cfg.Level)

_, _, err = descriptor.Resolve(env.Values(map[string]string{"LEVEL": "verbose"}))
fmt.Println("bad value:", err)
Output:
level: warn
bad value: constraint.type_mismatch [level]: must be one of [debug, info, warn, error], got verbose (env LEVEL)
Example (Erasing)

An explicit null in a later layer erases what earlier layers set.

package main

import (
	"fmt"
	"time"

	"github.com/go-faster/figureout"
	"github.com/go-faster/figureout/source/yaml"
)

type erasable struct {
	Level   string
	Timeout figureout.OptionalOf[time.Duration]
}

func main() {
	descriptor := figureout.MustDerive(func(c *erasable, s *figureout.Schema[erasable]) {
		figureout.Value(s, &c.Level, "level").ApplyDefault("info")
		figureout.Optional(s, &c.Timeout, "timeout")
	})

	cfg, report, err := descriptor.Resolve(
		yaml.Bytes([]byte("level: debug\ntimeout: 30s\n")),
		yaml.Bytes([]byte("level: null\ntimeout: null\n")),
	)
	if err != nil {
		panic(err)
	}

	erasedBy, _ := report.ErasedBy("level")

	fmt.Printf("level=%s (erased by %s, so the default applies)\n", cfg.Level, erasedBy.Source)
	fmt.Printf("timeout set=%v\n", cfg.Timeout.IsSet())
}
Output:
level=info (erased by yaml, so the default applies)
timeout set=false
Example (JsonSchema)

The same descriptor generates a JSON Schema.

package main

import (
	"fmt"

	"github.com/go-faster/figureout"
	"github.com/go-faster/figureout/schema/jsonschema"
)

type documented struct {
	Address string
	Port    int
}

func main() {
	descriptor := figureout.MustDerive(func(c *documented, s *figureout.Schema[documented]) {
		figureout.Explicit(s, &c.Address, "address").Doc("Listen address.").NonEmpty()
		figureout.Explicit(s, &c.Port, "port").InRange(1, 65535)
	})

	schema, _, err := jsonschema.Generate(descriptor, jsonschema.Semantic())
	if err != nil {
		panic(err)
	}
	fmt.Println(string(schema))
}
Output:
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "additionalProperties": false,
  "properties": {
    "$schema": {
      "type": "string"
    },
    "address": {
      "description": "Listen address.",
      "minLength": 1,
      "type": "string"
    },
    "port": {
      "maximum": 65535,
      "minimum": 1,
      "type": "integer"
    }
  },
  "required": [
    "address",
    "port"
  ],
  "type": "object"
}
Example (Layering)

Sources merge in order, and the report says where each value came from.

package main

import (
	"fmt"

	"github.com/go-faster/figureout"
	"github.com/go-faster/figureout/source/env"
	"github.com/go-faster/figureout/source/yaml"
)

type layered struct {
	Address string
	Port    int
	Tags    []string
}

func main() {
	descriptor := figureout.MustDerive(func(c *layered, s *figureout.Schema[layered]) {
		figureout.Value(s, &c.Address, "address")
		figureout.Value(s, &c.Port, "port")
		figureout.Value(s, &c.Tags, "tags").MergeAppend().ApplyDefault([]string{})
	})

	cfg, report, err := descriptor.Resolve(
		yaml.Bytes([]byte("address: 127.0.0.1\nport: 80\ntags: [base]\n")),
		env.Values(map[string]string{"APP_PORT": "9090", "APP_TAGS": "extra"}, env.Prefix("APP_")),
	)
	if err != nil {
		panic(err)
	}

	address, _ := report.OriginOf("address")
	port, _ := report.OriginOf("port")

	fmt.Printf("address=%s from %s\n", cfg.Address, address.Source)
	fmt.Printf("port=%d from %s %s\n", cfg.Port, port.Source, port.Name)
	fmt.Printf("tags=%v\n", cfg.Tags)
}
Output:
address=127.0.0.1 from yaml
port=9090 from env APP_PORT
tags=[base extra]
Example (OneOf)

A union selects between alternative shapes, tagged by a discriminator.

package main

import (
	"fmt"

	"github.com/go-faster/figureout"
	"github.com/go-faster/figureout/source/yaml"
)

type storage struct {
	Backend backend
}

type backend struct {
	S3    *s3Backend
	Local *localBackend
}

type s3Backend struct{ Bucket string }

type localBackend struct{ Path string }

func main() {
	s3 := figureout.MustDerive(func(b *s3Backend, s *figureout.Schema[s3Backend]) {
		figureout.Explicit(s, &b.Bucket, "bucket").NonEmpty()
	})
	local := figureout.MustDerive(func(b *localBackend, s *figureout.Schema[localBackend]) {
		figureout.Explicit(s, &b.Path, "path").NonEmpty()
	})

	descriptor := figureout.MustDerive(func(c *storage, s *figureout.Schema[storage]) {
		figureout.OneOf(s, &c.Backend, "backend",
			figureout.Discriminator("type"),
			figureout.Variant("s3", &c.Backend.S3, s3),
			figureout.Variant("local", &c.Backend.Local, local),
		)
	})

	cfg, _, err := descriptor.Resolve(yaml.Bytes([]byte("backend:\n  type: s3\n  bucket: configs\n")))
	if err != nil {
		panic(err)
	}
	fmt.Println("bucket:", cfg.Backend.S3.Bucket)
	fmt.Println("local selected:", cfg.Backend.Local != nil)

	_, _, err = descriptor.Resolve(yaml.Bytes([]byte("backend:\n  type: gcs\n")))
	fmt.Println("bad tag:", err)
}
Output:
bucket: configs
local selected: false
bad tag: union.invalid [backend.type]: unknown variant "gcs", want one of [s3, local] (yaml backend.type)
Example (Optional)

Optional distinguishes "no source provided it" from "provided as zero".

package main

import (
	"fmt"
	"time"

	"github.com/go-faster/figureout"
	"github.com/go-faster/figureout/source/json"
)

type optionalConfig struct {
	Timeout figureout.OptionalOf[time.Duration]
}

func main() {
	descriptor := figureout.MustDerive(func(c *optionalConfig, s *figureout.Schema[optionalConfig]) {
		figureout.Optional(s, &c.Timeout, "timeout").AtLeast(time.Second)
	})

	for _, document := range []string{"{}", `{"timeout": "1m"}`} {
		cfg, _, err := descriptor.Resolve(json.Bytes([]byte(document)))
		if err != nil {
			panic(err)
		}
		fmt.Printf("%-20s -> %v\n", document, cfg.Timeout)
	}
}
Output:
{}                   -> none
{"timeout": "1m"}    -> some(1m0s)

Index

Examples

Constants

View Source
const (
	CodeMissingDefinition   = "field.missing_definition"
	CodeDuplicateField      = "field.duplicate_registration"
	CodeForeignPointer      = "field.foreign_pointer"
	CodeAmbiguousZeroSize   = "field.ambiguous_zero_size"
	CodeUnsupportedType     = "field.unsupported_type"
	CodeDuplicateName       = "name.duplicate"
	CodeSourceNameCollision = "source.name_collision"
	CodeSourceUnsupported   = "source.unsupported"
	CodeConstraintMismatch  = "constraint.type_mismatch"
	CodeDefaultMismatch     = "default.type_mismatch"
	CodeUnionInvalid        = "union.invalid"
	CodeDeprecated          = "field.deprecated"
	CodeMovedConflict       = "field.moved_conflict"
	CodeInvariantViolated   = "invariant.violated"
	CodeValidatorNotExport  = "validator.not_exportable"
)

Diagnostic codes reported by descriptor compilation and schema generation.

View Source
const Redacted = "[redacted]"

Redacted replaces a secret value wherever the library would otherwise format one.

Variables

This section is empty.

Functions

func CanonicalPath added in v0.3.0

func CanonicalPath(path string) string

CanonicalPath rewrites every element subscript to the empty one, turning a concrete path into the model path describing it.

"sites[name=docs].max_bytes" becomes "sites[].max_bytes", which is what Model.FieldByPath indexes.

func DiscriminatorPath

func DiscriminatorPath(f *FieldModel) (string, bool)

DiscriminatorPath returns the canonical path of a union field's discriminator property.

func ElementKey added in v0.3.0

func ElementKey(path string) (collection, key string, ok bool)

ElementKey returns the subscript of an element path, and the collection it belongs to.

func ElementPath added in v0.3.0

func ElementPath(path, key string) string

ElementPath returns the path of one element of the collection at path.

func EnumValuesOf

func EnumValuesOf(f *FieldModel) ([]any, bool)

EnumValuesOf returns the allowed values declared for a field, if any.

func Ignore

func Ignore[R, C any](s *Schema[R], field *C, opts ...IgnoreOption)

Ignore marks a field as deliberately not part of the configuration.

Only the field itself is ignored; nested fields still have to be accounted for. Use IgnoreRecursive to ignore a whole subtree.

func IgnorePath

func IgnorePath[R any](s *Schema[R], path string, opts ...IgnoreOption)

IgnorePath ignores a field by Go path, such as "Server.Marker".

Distinct zero-sized fields share an address, so pointer identity cannot select between them; path-based ignores are the supported way to handle them.

func IgnoreRecursive

func IgnoreRecursive[R, C any](s *Schema[R], field *C, opts ...IgnoreOption)

IgnoreRecursive marks a field and every field below it as deliberately not part of the configuration.

func IgnoreRecursivePath

func IgnoreRecursivePath[R any](s *Schema[R], path string, opts ...IgnoreOption)

IgnoreRecursivePath ignores a field and its subtree by Go path.

func Invariant added in v0.2.0

func Invariant[T any](s *Schema[T], name string, check func(*T) error)

Invariant registers a rule that spans fields, checked once the configuration has resolved.

Constraints are per field, and real configurations are full of rules that are not: a key that must name an entry in another map, a flag that only takes effect when a credential is set, a lease that must outlast a timeout. Without somewhere to put them they become a hand-written pass after Resolve, which loses both the schema and the provenance the descriptor already has.

figureout.Invariant(s, "proxy-exists", func(c *Config) error {
	for i, site := range c.Fetch.Sites {
		if _, ok := c.Proxies[site.Proxy]; !ok {
			return figureout.At(fmt.Sprintf("fetch.sites[%d].proxy", i)).
				Errorf("no proxy named %q is configured", site.Proxy)
		}
	}
	return nil
})

Invariants run only after every field resolved and validated, so a failure is never a consequence of an error already reported. Returning At keeps the origin of the offending value; a plain error is reported without one. Return several with errors.Join.

Registered on a nested ObjectFunc schema, an invariant sees the nested struct and its violation paths are prefixed with the nested object's path.

func KeyedElementPath added in v0.3.0

func KeyedElementPath(path, field, value string) string

KeyedElementPath returns the path of a list element identified by a key field, as "sites[name=docs]".

func MustRegisterType

func MustRegisterType[T any](r *TypeRegistry, opts ...TypeOption)

MustRegisterType is like RegisterType but panics on error.

func Redact added in v0.2.0

func Redact(f *FieldModel, msg string, values ...any) string

Redact removes every rendering of values from msg when f is a secret field.

Sources call it before reporting a decoding failure, so that "invalid integer \"hunter2\"" never reaches a log. Over-redaction is the safe direction: a message may lose more than the value itself, and that is preferred to leaking it.

func RegisterType

func RegisterType[T any](r *TypeRegistry, opts ...TypeOption) error

RegisterType describes the named type T.

Types

type Assignment

type Assignment struct {
	// Path is the canonical dotted path of the field.
	Path   string
	State  ValueState
	Value  any
	Origin Origin
}

Assignment is one value produced by a source.

type CheckConstraint

type CheckConstraint struct {
	Name string
	Func func(v any) error
}

CheckConstraint is an opaque runtime validator.

It never contributes to generated schemas; emitters report CodeValidatorNotExport instead of silently implying coverage.

func (CheckConstraint) Applies

func (CheckConstraint) Applies(TypeKind) bool

Applies implements Constraint.

func (CheckConstraint) Kind

func (CheckConstraint) Kind() string

Kind implements Constraint.

func (CheckConstraint) Validate

func (c CheckConstraint) Validate(v any) error

Validate implements Constraint.

type Collection added in v0.3.0

type Collection struct{}

Collection is the value a source assigns to a list or map of objects itself, to say that this layer provided the collection.

Its elements arrive as assignments of their own, so the merge policy needs some way to tell "this layer replaced the list" from "this layer said nothing about it" — an empty list has no elements to speak for it either way.

type CompletenessMode

type CompletenessMode uint8

CompletenessMode selects which Go fields must be accounted for.

const (
	// CompletenessExported requires every exported field to be registered,
	// delegated, covered by registered descendants, or ignored.
	CompletenessExported CompletenessMode = iota
	// CompletenessStrict additionally requires unexported fields to be
	// explicitly ignored.
	CompletenessStrict
	// CompletenessTagged considers only fields carrying the configured tag.
	CompletenessTagged
	// CompletenessDisabled performs no completeness validation.
	CompletenessDisabled
)

Completeness modes.

type Constraint

type Constraint interface {
	// Kind identifies the constraint for emitters and diagnostics.
	Kind() string
	// Validate reports whether v satisfies the constraint.
	Validate(v any) error
	// Applies reports whether the constraint is meaningful for kind.
	Applies(kind TypeKind) bool
}

Constraint is a declarative rule over a semantic value.

Constraints are values rather than closures so that validators, schema emitters and documentation can all consume the same declaration. Rules that cannot be expressed declaratively use CheckConstraint, which is runtime-only.

type Decoder

type Decoder interface {
	DecodeValue(raw any) (any, error)
}

Decoder converts a raw source value into a semantic value.

A decoder is opaque, so it must be accompanied by the shapes it accepts; otherwise schema generation cannot describe the field.

type Default

type Default struct {
	Value   any
	Applied bool
}

Default is a field default.

Applied defaults change the resolved value; documented defaults only contribute metadata. The distinction matters for OptionalOf, where applying a default turns a missing value into a present one.

type Descriptor

type Descriptor[T any] struct {
	// contains filtered or unexported fields
}

Descriptor is an immutable compiled configuration description.

It is safe for concurrent use. Build one with Derive or MustDerive.

func Derive

func Derive[T any](describe func(*T, *Schema[T]), opts ...SchemaOption) (*Descriptor[T], error)

Derive compiles a descriptor for T.

It allocates a synthetic zero value of T, passes its address to describe, resolves every registered pointer to a Go field, validates completeness and consistency, and compiles an immutable descriptor. The runtime values of the synthetic object are never used as defaults.

func MustDerive

func MustDerive[T any](describe func(*T, *Schema[T]), opts ...SchemaOption) *Descriptor[T]

MustDerive is like Derive but panics on error.

The panic message contains every compilation diagnostic, not only the first.

A failed derivation is a programming error, so a package-level "var ConfigDescriptor = figureout.MustDerive(...)" is the intended idiom for a library. A binary that would rather report the failure than crash in init should derive inside its own loader instead:

var descriptor = sync.OnceValues(func() (*figureout.Descriptor[Config], error) {
	return figureout.Derive(describe)
})

func (*Descriptor[T]) Model

func (d *Descriptor[T]) Model() *Model

Model returns the compiled model. The returned value must not be mutated.

func (*Descriptor[T]) Resolve

func (d *Descriptor[T]) Resolve(sources ...Source) (T, *Report, error)

Resolve decodes, merges, validates and materializes a configuration.

Later sources override earlier ones. The returned report carries provenance and diagnostics even when an error is returned.

func (*Descriptor[T]) ResolveContext

func (d *Descriptor[T]) ResolveContext(ctx context.Context, sources ...Source) (T, *Report, error)

ResolveContext is Descriptor.Resolve with a context.

func (*Descriptor[T]) Value

func (d *Descriptor[T]) Value(cfg *T, path string) (any, bool)

Value reads the value at a canonical path out of a resolved configuration.

It reports false when the path is unknown, when the value is carried by an unset OptionalOf, or when the path is inside a variant that was not selected.

type Diagnostic

type Diagnostic struct {
	Severity Severity
	Code     string
	Message  string

	// FieldPath is the canonical configuration path, such as "server.port".
	FieldPath string
	// GoPath is the Go path, such as "Config.Server.Port".
	GoPath string
	// MovedTo is the path superseding [Diagnostic.FieldPath], carried by
	// [CodeDeprecated] and [CodeMovedConflict] when the deprecation is a move.
	//
	// It is what the message names in prose, as data: an application that
	// phrases its own warnings reads it instead of parsing [Diagnostic.Message].
	// It is empty for a field deprecated without a replacement, and for every
	// other code.
	MovedTo string
	Source  SourceID
	Target  TargetID
	Origin  *Origin
}

Diagnostic is a structured problem report.

func (Diagnostic) Error

func (d Diagnostic) Error() string

Error implements [error].

type Diagnostics

type Diagnostics []Diagnostic

Diagnostics is a collection of Diagnostic.

func (Diagnostics) Err

func (ds Diagnostics) Err() error

Err returns ds if it contains errors, and nil otherwise.

func (Diagnostics) Error

func (ds Diagnostics) Error() string

Error implements [error]. It formats every diagnostic, not just the first.

func (Diagnostics) HasErrors

func (ds Diagnostics) HasErrors() bool

HasErrors reports whether any diagnostic has SeverityError.

type Element added in v0.7.0

type Element struct{}

Element is the value a source assigns to one element of a collection, to say that this layer contained it.

An element whose every member is absent has no assignment of its own to allocate its slot, and an element that resolves entirely to defaults is still an element: without a marker of its own it would vanish from the list rather than materialize with its defaults.

type Encoder

type Encoder interface {
	EncodeValue(v any) (any, error)
}

Encoder converts a semantic value back into a raw source value.

type EnumConstraint

type EnumConstraint struct {
	Values []any
}

EnumConstraint restricts a value to a set of allowed values.

It is deliberately distinct from a union: an enum narrows one scalar type, while a Union selects between alternative shapes.

func (EnumConstraint) Applies

func (EnumConstraint) Applies(kind TypeKind) bool

Applies implements Constraint.

func (EnumConstraint) Kind

func (EnumConstraint) Kind() string

Kind implements Constraint.

func (EnumConstraint) Validate

func (c EnumConstraint) Validate(v any) error

Validate implements Constraint.

type EnumSliceValuer

type EnumSliceValuer[T any] interface {
	Values() []T
}

EnumSliceValuer is the slice-returning form of EnumValuer, as emitted by stringer derivatives that attach a method to the type.

type EnumValuer

type EnumValuer[T any] interface {
	AllValues() iter.Seq[T]
}

EnumValuer is a type that enumerates its own values.

It is the primary enum contract: because it is a constraint rather than a reflective probe, a type without values is a compile error rather than a descriptor diagnostic.

func (LogLevel) AllValues() iter.Seq[LogLevel] {
	return slices.Values(logLevels)
}

type FieldBuilder

type FieldBuilder struct {
	// contains filtered or unexported fields
}

FieldBuilder is the fluent surface shared by every registered field.

Methods are no-ops when the registration already failed, so a describe callback never panics on a bad pointer; the failure is reported as a compilation diagnostic instead.

func (*FieldBuilder) Deprecated

func (f *FieldBuilder) Deprecated(reason string) *FieldBuilder

Deprecated marks the field as deprecated.

func (*FieldBuilder) Doc

func (f *FieldBuilder) Doc(text string) *FieldBuilder

Doc attaches documentation.

func (*FieldBuilder) Examples

func (f *FieldBuilder) Examples(values ...any) *FieldBuilder

Examples attaches example values.

func (*FieldBuilder) Hidden

func (f *FieldBuilder) Hidden() *FieldBuilder

Hidden hides the field from generated documentation.

func (*FieldBuilder) Name

func (f *FieldBuilder) Name() string

Name returns the canonical name of the field.

func (*FieldBuilder) Required added in v0.4.0

func (f *FieldBuilder) Required() *FieldBuilder

Required makes absence an error instead of a value.

It is meaningful for a collection, whose absence otherwise resolves to empty, and for a Value field, whose absence otherwise resolves to the zero value; "Value(...).Required()" is Explicit spelled the long way. An OptionalOf carrier says the opposite by construction.

func (*FieldBuilder) With

func (f *FieldBuilder) With(opts ...FieldOption) *FieldBuilder

With applies field options after registration.

type FieldID

type FieldID uint32

FieldID identifies a field within one compiled Descriptor.

type FieldModel

type FieldModel struct {
	ID FieldID
	// Name is the canonical name within the declaring object.
	Name string
	// Path is the canonical dotted path from the descriptor root.
	Path string
	// GoPath locates the Go field relative to the declaring object.
	GoPath FieldPath
	// GoName is the Go path for diagnostics, such as "Config.Server.Port".
	GoName string

	Type     Type
	Presence Presence

	Meta        Metadata
	Default     *Default
	Constraints []Constraint
	// Merge decides how the field combines values from several layers.
	Merge MergePolicy

	Sources map[SourceID]*SourceProjection
	Targets map[TargetID][]any

	// MovedFrom lists the former paths of the field, relative to the
	// descriptor that declares it. Each appears in the model as a deprecated
	// shadow field carrying [FieldModel.MovedTo].
	MovedFrom []string
	// MovedTo is the canonical path superseding this field, set on the shadow
	// fields [MovedFrom] creates. It is empty for a field of its own.
	MovedTo string
	// contains filtered or unexported fields
}

FieldModel is a compiled configuration field.

func (*FieldModel) Elements added in v0.3.0

func (f *FieldModel) Elements() (*ObjectModel, bool)

Elements reports whether the field is a collection of described objects.

Sources use it to decide whether to bind elements to their own paths; one that cannot express a collection of objects, such as environment variables, skips the field instead of inventing an index convention.

func (*FieldModel) MergeKey added in v0.3.0

func (f *FieldModel) MergeKey() (*FieldModel, bool)

MergeKey returns the element field identifying a list element across layers, as set by ListField.MergeByKey.

func (*FieldModel) Moved added in v0.2.0

func (f *FieldModel) Moved() bool

Moved reports whether the field is a deprecated former spelling of another one. A moved field is never materialized: its value is redirected to FieldModel.MovedTo during resolution.

func (*FieldModel) Opaque added in v0.7.0

func (f *FieldModel) Opaque() (reason string, ok bool)

Opaque reports whether the field carries a subtree verbatim, and why.

Targets use it to describe a passthrough rather than to describe what is inside it, which nothing knows.

func (*FieldModel) OptionalSection added in v0.7.0

func (f *FieldModel) OptionalSection() bool

carried is the Go type the carrier holds, which is a pointer to elem when the carrier is indirect. OptionalSection reports a nested object a source may leave out, whichever of the optional carriers spells it — an OptionalOf, with or without a pointer inside it, or a bare pointer.

A Group is never one. It nests the document without nesting the Go struct, so it has no field to hold "no section" in, and its members belong to whatever encloses it.

func (*FieldModel) Recursive added in v0.7.0

func (f *FieldModel) Recursive() (*ObjectModel, bool)

Recursive returns the configuration object the field re-enters, and reports whether it re-enters one at all.

A configuration type may refer to itself: a node with a child node, a rule with nested rules. Every value is still a finite tree — recursion reaches a descriptor only through a pointer, a slice or a map, each of which may simply be absent — so the cycle exists in the type graph alone, and the model is a graph rather than an infinitely deep tree. The field closing the cycle points back at an object that already encloses it instead of at a copy of it.

A target that names a shape emits the object once and refers to it. A target that spells one flat name per path, such as environment variables, has no bounded name for an unbounded path and stops here, the way it already stops at a collection of objects.

func (*FieldModel) Required added in v0.4.0

func (f *FieldModel) Required() bool

Required reports whether a source has to provide the field.

A field registered with Explicit is required unless it carries an applied default, a collection included. A field registered with Value is not: its absence resolves to the zero value, and a collection to an empty one, because an absent list and an empty one are the same statement about the world. Both opt back in with FieldBuilder.Required.

func (*FieldModel) Shorthand added in v0.2.0

func (f *FieldModel) Shorthand() (Type, bool)

Shorthand reports whether the field accepts a scalar in place of its object, and returns the scalar type it accepts.

Sources without an object syntax use it to bind the scalar spelling at the object's own name.

func (*FieldModel) Source

func (f *FieldModel) Source(id SourceID) (*SourceProjection, bool)

Source returns the projection of the field for the given source.

func (*FieldModel) Validate

func (f *FieldModel) Validate(v any) error

Validate runs every declarative and opaque constraint against v.

func (*FieldModel) ZeroDefault added in v0.5.0

func (f *FieldModel) ZeroDefault() bool

ZeroDefault reports whether absence resolves to the zero value rather than to a diagnostic. It is what Value declares and Explicit withholds.

A field with an applied Default never reports true: the default is what absence resolves to, and it is visible as one.

type FieldOption

type FieldOption interface {
	ApplyFieldOption(FieldOptionContext) error
}

FieldOption customizes a field registration.

Options are deliberately not generic over the field's value type: Go cannot infer a type argument for a nested call such as env.Name("PORT"), so a generic FieldOption[V] would force every option call site to spell the type. Value-typed operations live on the returned fluent builder instead.

func AcceptShapes

func AcceptShapes(id SourceID, shapes ...Shape) FieldOption

AcceptShapes declares the wire shapes a source accepts for a field.

Source packages normally wrap this in their own option, such as json.Accepts. Declaring shapes is what lets schema generation describe a custom decoder that would otherwise be opaque.

func Check

func Check[T any](name string, fn func(T) error) FieldOption

Check builds an opaque runtime validator option with a typed callback.

The type argument is inferred from fn, so a call site reads as figureout.Check("even", func(v int) error { ... }).

func Deprecated

func Deprecated(reason string) FieldOption

Deprecated marks a field as deprecated with a reason.

Setting a deprecated field is reported as a SeverityWarning diagnostic in the Report. To also accept a former spelling, use MovedFrom.

func Doc

func Doc(text string) FieldOption

Doc attaches documentation to a field.

func EnumOf

func EnumOf[T EnumValuer[T]]() FieldOption

EnumOf builds an enum constraint option for a type that enumerates its own values. Use it with [Field] for carriers the enum helpers do not spell, such as OptionalOf[LogLevel].

func EnumOfFunc

func EnumOfFunc[T any](values func() []T) FieldOption

EnumOfFunc builds an enum constraint option from a values function.

func EnumOfSlice

func EnumOfSlice[T EnumSliceValuer[T]]() FieldOption

EnumOfSlice is EnumOf for types implementing EnumSliceValuer.

func EnumOfValues

func EnumOfValues[T any](values ...T) FieldOption

EnumOfValues builds an enum constraint option from explicit values.

func Examples

func Examples(values ...any) FieldOption

Examples attaches example values to a field.

func Hidden

func Hidden() FieldOption

Hidden hides a field from generated documentation and help text.

func MovedFrom added in v0.2.0

func MovedFrom(paths ...string) FieldOption

MovedFrom accepts a former path of the field and reports its use.

Deprecated is metadata: it says a key is going away without doing anything when the key is set. MovedFrom is the behavior a configuration actually needs while it is being reshaped:

	figureout.Value(s, &c.HTTPAddr, "http_addr", figureout.MovedFrom("addr"))

  - the old spelling still resolves, with a [SeverityWarning] diagnostic in
    the [Report] naming both paths
  - setting both spellings is a [SeverityError], not a precedence rule: two
    spellings in one configuration are two intentions, and silently picking
    one is the worst available answer
  - the old path appears in generated schemas as a deprecated property

The path is relative to the descriptor that declares the field, so it may name a former level: MovedFrom("legacy.addr") reads the old nesting. Levels that no longer exist are synthesized as deprecated objects; a level that is a nested descriptor of its own is reported rather than modified.

That scope decides how to reshape a flat legacy key into a section, which is the main thing MovedFrom exists for. Use Group, which keeps the field declared by the root schema, so a root-relative former path is in scope:

figureout.Group(s, "api", func(s *figureout.Schema[Config]) {
	figureout.Value(s, &c.API.HTTPAddr, "http_addr",
		figureout.MovedFrom("http_addr"))
})

The same registration inside ObjectFunc cannot express it: the field is declared by the nested descriptor, where "http_addr" resolves to the field itself rather than to the document root, and is reported as such.

A former path is a fact about documents, not about environment variables: sources that derive a name from the path skip a former one, because "database_dsn" and "database.dsn" derive the same variable and binding both would collide by construction. Where a variable really did exist under an old name, name it with that source's alias option.

func Secret added in v0.2.0

func Secret() FieldOption

Secret marks a field as carrying a credential.

Hidden is documentation metadata: it keeps a field out of generated docs and does nothing else, so a Pattern or MinLength failure is one constraint away from printing a token into a log. Secret has teeth. A secret field's value never appears in a message the library formats — not in a constraint failure, not in a decoding error from a source — and Report.Secret lets a consumer honor the same rule in its own logging.

figureout.Explicit(s, &c.Token, "token", figureout.Secret()).NonEmpty()

Secret redacts values, not names. A credential still appears in generated documentation, because its name is what an operator needs in order to supply it, while its default and examples render as Redacted. Pair it with Hidden to leave a field out of the reference entirely. Generated JSON Schema marks the property "writeOnly".

func Unit added in v0.2.0

func Unit(u time.Duration) FieldOption

Unit lets a duration field be written as a bare number of u.

Unit-suffixed integer keys outlive the configurations that introduced them, and moving one onto time.Duration normally means changing what the key accepts — 180 would have to become "180s", which breaks every deployment already running. A unit keeps the key and still resolves a time.Duration:

figureout.Value(s, &c.Timeout, "timeout_seconds", figureout.Unit(time.Second))

timeout_seconds: 180     // 180 * time.Second
timeout_seconds: "3m"    // still accepted, so a rename is a pure alias change

Generated schemas describe the canonical form: an integer, with the unit named in the description.

func WithDecoder

func WithDecoder(id SourceID, d Decoder, shapes ...Shape) FieldOption

WithDecoder installs a source decoder together with the shapes it accepts.

A decoder is opaque, so the shapes are mandatory: without them, schema generation cannot describe what the source will accept, and nothing decides what reaches the decoder. A shape the field did not declare is rejected before the decoder runs.

The decoder owns every shape it declared, including object and array ones the semantic type cannot describe — which is what makes it the way to keep parsing a carrier that is going away:

figureout.Value(s, &c.Token, "token",
	figureout.WithDecoder(yaml.Source, carrierDecoder{},
		figureout.Shape{Kind: figureout.ShapeString},
		figureout.Shape{Kind: figureout.ShapeObject, Fields: map[string]figureout.Shape{
			"env": {Kind: figureout.ShapeString},
		})))

A tree source hands over the node as []any, map[string]any or the scalar it decoded; sources whose values are text, such as env and file, hand over the text. Null never reaches a decoder: it stays a merge directive that erases.

type FieldOptionContext

type FieldOptionContext interface {
	// Name returns the canonical field name.
	Name() string
	// GoName returns the Go path of the field, for diagnostics.
	GoName() string
	// Type returns the semantic type of the field.
	Type() Type
	// Presence returns how the field models absence.
	Presence() Presence

	AddConstraint(Constraint) error
	AddMetadata(Metadata) error
	AddTargetAnnotation(TargetID, any) error
	// SetUnit scales bare numbers written for a duration field.
	SetUnit(time.Duration) error
	// AddMovedFrom records a former path of the field.
	AddMovedFrom(string) error
	// SetReason documents why a field is not described. It applies to an
	// opaque passthrough, which is the only field that is not.
	SetReason(string) error

	// SetSourceNames sets the primary name and aliases for a source.
	SetSourceNames(SourceID, ...string) error
	// AddSourceShapes declares the wire shapes a source accepts.
	AddSourceShapes(SourceID, ...Shape) error
	// SetSourceDecoder installs a decoder. A decoder without declared shapes
	// is reported by schema generation.
	SetSourceDecoder(SourceID, Decoder) error
	// SkipSource excludes the field from a source.
	SkipSource(SourceID) error
	// AddSourceOption attaches source-specific settings.
	AddSourceOption(SourceID, any) error
}

FieldOptionContext is the controlled surface an option may mutate.

It exposes registration methods rather than internal state, so adapter packages can extend the core without importing its internals.

type FieldOptionFunc

type FieldOptionFunc func(FieldOptionContext) error

FieldOptionFunc adapts a function to FieldOption.

func (FieldOptionFunc) ApplyFieldOption

func (f FieldOptionFunc) ApplyFieldOption(c FieldOptionContext) error

ApplyFieldOption implements FieldOption.

type FieldPath

type FieldPath struct {
	Index  []int
	Offset uintptr
}

FieldPath locates a Go field relative to the object declaring it.

Index is the canonical identity: it survives padding changes and is usable with reflect.Value.FieldByIndex. Offset is kept for pointer validation and fast access, and is never the sole identity.

func (FieldPath) String

func (p FieldPath) String() string

String implements fmt.Stringer.

type IgnoreOption

type IgnoreOption interface {
	// contains filtered or unexported methods
}

IgnoreOption customizes an ignore declaration.

type InvariantModel added in v0.2.0

type InvariantModel struct {
	Name string
}

InvariantModel names a cross-field rule in the compiled model.

Only the name is format-neutral: the rule itself is a Go function, so no target can emit it. Documentation generators list it so that a reader knows a rule exists which the schema does not describe.

type Layer

type Layer struct {
	Source      SourceID
	Assignments []Assignment
	Diagnostics Diagnostics
}

Layer is the partial configuration decoded from one source.

Sources never write into the destination struct: they produce layers, which are merged in precedence order before materialization.

func (*Layer) Set

func (l *Layer) Set(path string, v any, origin Origin)

Set appends an assignment.

func (*Layer) SetNull

func (l *Layer) SetNull(path string, origin Origin)

SetNull appends an explicit null assignment.

type LengthConstraint

type LengthConstraint struct {
	Minimum *uint64
	Maximum *uint64
}

LengthConstraint bounds the length of a string, list, map or byte slice.

func (LengthConstraint) Applies

func (LengthConstraint) Applies(kind TypeKind) bool

Applies implements Constraint.

func (LengthConstraint) Kind

func (LengthConstraint) Kind() string

Kind implements Constraint.

func (LengthConstraint) Validate

func (c LengthConstraint) Validate(v any) error

Validate implements Constraint.

type ListField added in v0.3.0

type ListField struct{ *FieldBuilder }

ListField is the fluent builder for a list of objects.

func List added in v0.3.0

func List[R, E any](
	s *Schema[R],
	field *[]E,
	name string,
	d *Descriptor[E],
	opts ...FieldOption,
) *ListField

List registers a list whose elements are described by their own descriptor.

It is ListOf for an element description shared by several parents, or one an adopter wants to export.

func ListOf added in v0.3.0

func ListOf[R, E any](
	s *Schema[R],
	field *[]E,
	name string,
	describe func(*E, *Schema[E]),
	opts ...FieldOption,
) *ListField

ListOf registers a list whose elements are configuration objects, described inline.

A list of objects is the part of a configuration file an operator actually edits, and describing it is what lets its elements have names, defaults, constraints, provenance and a schema:

figureout.ListOf(s, &c.Sites, "sites", func(e *Site, s *figureout.Schema[Site]) {
	figureout.Explicit(s, &e.Name, "name").NonEmpty()
	figureout.Value(s, &e.MaxBytes, "max_bytes").ApplyDefault(0)
})

Each element binds to its own path — "sites[0].max_bytes" — so merging, erasure and Report.OriginOf all work on an element the way they work on any other field. See ListField.MergeByKey to identify elements by one of their own fields instead of by position.

func (*ListField) Doc added in v0.3.0

func (f *ListField) Doc(text string) *ListField

Doc attaches documentation.

func (*ListField) MergeAppend added in v0.3.0

func (f *ListField) MergeAppend() *ListField

MergeAppend concatenates the elements of every layer, in layer order.

func (*ListField) MergeByKey added in v0.3.0

func (f *ListField) MergeByKey(field string) *ListField

MergeByKey identifies elements by one of their own fields, so a later layer edits an element rather than restating the list.

figureout.ListOf(s, &c.Sites, "sites", describeSite).MergeByKey("name")

# base.yaml            # override.yaml        # result
sites:                 sites:                 sites:
  - name: docs           - name: docs           - name: docs
    max_bytes: 10            max_bytes: 20          max_bytes: 20
  - name: wiki                                  - name: wiki
    max_bytes: 10                                   max_bytes: 10

Elements then bind to "sites[name=docs].max_bytes", which is a stable identity across layers where a position is not: prepending one element would otherwise re-target every override silently. Fields merge individually, so a later layer changes only what it names, and "sites[name=docs]: null" removes the element outright.

The key field becomes mandatory in every element, and repeating it within one layer is an error rather than last-wins. Base order is preserved and unseen keys are appended, so a later layer cannot reorder: do not key a list whose order is meaningful.

func (*ListField) MergeReplace added in v0.3.0

func (f *ListField) MergeReplace() *ListField

MergeReplace takes the list from the last layer that provided one. It is the default.

func (*ListField) Required added in v0.4.0

func (f *ListField) Required() *ListField

Required makes an absent list an error instead of an empty one.

type MapField added in v0.3.0

type MapField struct{ *FieldBuilder }

MapField is the fluent builder for a map of objects.

func Map added in v0.3.0

func Map[R any, K comparable, E any](
	s *Schema[R],
	field *map[K]E,
	name string,
	d *Descriptor[E],
	opts ...FieldOption,
) *MapField

Map registers a map whose values are described by their own descriptor. The pointer is the binding identity: figureout resolves a registration by the address of the field inside the synthetic root, so it cannot take the map itself.

func MapOf added in v0.3.0

func MapOf[R any, K comparable, E any](
	s *Schema[R],
	field *map[K]E,
	name string,
	describe func(*E, *Schema[E]),
	opts ...FieldOption,
) *MapField

MapOf registers a map whose values are configuration objects, described inline. The map key identifies an element, so "proxies[gitlab].url" names one entry. The pointer is the binding identity: figureout resolves a registration by the address of the field inside the synthetic root, so it cannot take the map itself.

func (*MapField) Doc added in v0.3.0

func (f *MapField) Doc(text string) *MapField

Doc attaches documentation.

func (*MapField) MergeByKey added in v0.3.0

func (f *MapField) MergeByKey() *MapField

MergeByKey merges entries across layers, so a later layer changes only the entries it names, and only the fields it names within them.

figureout.MapOf(s, &c.Proxies, "proxies", describeProxy).MergeByKey()

An entry already identifies itself, so no key has to be named. Setting an entry to null removes it.

func (*MapField) MergeReplace added in v0.3.0

func (f *MapField) MergeReplace() *MapField

MergeReplace takes the map from the last layer that provided one. It is the default, as it is everywhere else: a predictable last-one-wins is what a reader of a layered configuration can reason about.

func (*MapField) Required added in v0.4.0

func (f *MapField) Required() *MapField

Required makes an absent map an error instead of an empty one.

type MergePolicy

type MergePolicy uint8

MergePolicy decides how a field combines values from several layers.

The default is MergeReplace, because a predictable "last one wins" is what a reader of a layered configuration can reason about. The other policies exist for the collections where accumulating across layers is the point.

const (
	// MergeReplace takes the value from the last layer that provided one.
	MergeReplace MergePolicy = iota
	// MergeAppend concatenates list values across layers, in layer order.
	MergeAppend
	// MergeByKey merges map entries across layers, so a later layer changes
	// only the keys it names.
	MergeByKey
)

Merge policies.

func (MergePolicy) Applies

func (p MergePolicy) Applies(kind TypeKind) bool

Applies reports whether the policy is meaningful for kind.

func (MergePolicy) String

func (p MergePolicy) String() string

String implements fmt.Stringer.

type Metadata

type Metadata struct {
	Doc        string
	Deprecated string
	Hidden     bool
	// Secret marks a credential. Unlike Hidden it is enforced: see [Secret].
	Secret   bool
	Examples []any
}

Metadata is documentation attached to a field.

type Model

type Model struct {
	Root *ObjectModel
	// contains filtered or unexported fields
}

Model is the compiled, format-neutral descriptor model.

It contains no JSON Schema, CUE or wire types: emitters project it.

func (*Model) FieldByPath

func (m *Model) FieldByPath(path string) (*FieldModel, bool)

FieldByPath looks up a field by canonical dotted path.

A concrete element path resolves to the field describing every element, so "sites[0].max_bytes" and "sites[name=docs].max_bytes" both find "sites[].max_bytes".

func (*Model) Fields

func (m *Model) Fields() []*FieldModel

Fields returns every field in the model, including nested ones, in declaration order.

func (*Model) Invariants added in v0.2.0

func (m *Model) Invariants() []InvariantModel

Invariants returns the cross-field rules the descriptor declares.

type ObjectField

type ObjectField struct{ *FieldBuilder }

ObjectField is a fluent builder for nested object fields.

func Group added in v0.2.0

func Group[T any](s *Schema[T], name string, describe func(*Schema[T]), opts ...FieldOption) *ObjectField

Group opens a configuration path level that has no Go struct behind it.

The shape that reads well in a file and the shape a consumer wants in Go are not always the same shape. A group registers flat Go fields under a nested path, so neither side has to be reshaped to match the other:

figureout.Group(s, "webhook", func(s *figureout.Schema[GitLab]) {
	figureout.Value(s, &c.WebhookEnabled, "enabled").ApplyDefault(false)
	figureout.Value(s, &c.WebhookSecret, "secret", figureout.Hidden())
})

webhook:
  enabled: true
  secret: hunter2

Only the path nests: the fields still bind to the same struct, so the completeness and duplicate-registration checks see exactly the fields they would have seen without the group. A group contributes a segment everywhere a nested object would, including environment variable names (GITLAB_WEBHOOK_SECRET) and generated schemas.

Registering a nested Go struct's fields as siblings of the parent — the inverse mismatch — needs no dedicated function: register the descendants directly, as in figureout.Value(s, &c.Database.DSN, "dsn").

func Object

func Object[R, F, C any](s *Schema[R], field *F, name string, d *Descriptor[C], opts ...FieldOption) *ObjectField

Object registers a nested configuration object described by its own descriptor.

func ObjectFunc added in v0.2.0

func ObjectFunc[R, F, C any](
	s *Schema[R],
	field *F,
	name string,
	describe func(*C, *Schema[C]),
	opts ...FieldOption,
) *ObjectField

ObjectFunc registers a nested configuration object described inline.

It is Object without a descriptor variable: describe runs against a nested Schema rooted at the field, so pointer binding, completeness and name collisions are scoped to C exactly as they would be in a separate Derive.

figureout.ObjectFunc(s, &c.Server, "server", func(c *Server, s *figureout.Schema[Server]) {
	figureout.Explicit(s, &c.Port, "port").InRange(1, 65535)
})

Prefer Object for a descriptor shared by several parents or exported for its own sake, and ObjectFunc for a section that has exactly one parent.

func OptionalObject added in v0.7.0

func OptionalObject[R, F, C any](
	s *Schema[R],
	field *F,
	name string,
	d *Descriptor[C],
	opts ...FieldOption,
) *ObjectField

OptionalObject registers a nested object a source may leave out entirely.

An optional section distinguishes what a zero struct cannot: "there is no cluster" is not "there is a cluster and every one of its fields defaulted". The carrier is unset unless some source contained the section, and a section that was contained is materialized even when every member of it defaulted.

Any of the three optional carriers holds one:

S3 figureout.OptionalOf[S3Config]    // the carrier a new configuration wants
S3 figureout.OptionalOf[*S3Config]   // the same, held behind a pointer
S3 *S3Config                         // the shape an adopted struct already has

figureout.OptionalObject(s, &c.Storage.S3, "s3", s3Descriptor)

A member registered with Explicit is demanded only where the section is present, which is what makes "required inside an optional section" mean something. An explicit null erases the section, along with whatever earlier layers put in it.

func OptionalObjectFunc added in v0.7.0

func OptionalObjectFunc[R, F, C any](
	s *Schema[R],
	field *F,
	name string,
	describe func(*C, *Schema[C]),
	opts ...FieldOption,
) *ObjectField

OptionalObjectFunc registers a nested object a source may leave out, described inline.

It is OptionalObject without a descriptor variable, exactly as ObjectFunc is Object without one.

func ScalarOr added in v0.2.0

func ScalarOr[R, C, S any](
	s *Schema[R],
	field *C,
	name string,
	d *Descriptor[C],
	widen func(S) C,
	opts ...FieldOption,
) *ObjectField

ScalarOr registers a nested object that may also be written as a scalar.

"A scalar, or an object" is one of the most common configuration idioms, and OneOf cannot express it: a union needs a discriminator, and a bare scalar has nowhere to put one. Without ScalarOr every occurrence is a WithDecoder plus hand-written Shape values that duplicate the descriptor already describing the same thing, and drift the moment a field is added to it.

figureout.ScalarOr(s, &c.AuthToken, "auth_token", secretDescriptor,
	func(v string) Secret { return Secret{Value: v} })

auth_token: sk-live-...          # widened by the function
auth_token: {file: /run/token}   # decoded by the descriptor

The accepted shapes are derived from the descriptor and from S, so they cannot drift: generated schemas emit oneOf over the two, and a source that has no object syntax — environment variables, mounted files — accepts the scalar spelling directly at the object's own name.

A scalar and an object spelling never combine across layers: whichever a later layer uses replaces the other outright, because a widened value and a half-filled object have no meaningful merge.

func (*ObjectField) Doc

func (f *ObjectField) Doc(text string) *ObjectField

Doc attaches documentation.

type ObjectModel

type ObjectModel struct {
	// Go is the struct type described.
	Go     reflect.Type
	Fields []*FieldModel
	// contains filtered or unexported fields
}

ObjectModel is a compiled configuration object.

func (*ObjectModel) Field

func (o *ObjectModel) Field(name string) (*FieldModel, bool)

Field looks up a field by its canonical name within the object.

type OpaqueField added in v0.7.0

type OpaqueField struct{ *FieldBuilder }

OpaqueField is the fluent builder for a passthrough subtree.

It carries no constraints and no defaults: there is nothing described to constrain, and a default would be this program's opinion about another one's configuration.

func Opaque added in v0.7.0

func Opaque[R, C any](s *Schema[R], field *C, name string, opts ...FieldOption) *OpaqueField

Opaque registers a subtree carried verbatim, whose shape belongs to another program.

A configuration that embeds another program's configuration has a block it cannot describe and must not validate:

// an OpenTelemetry Collector configuration, handed to the collector as-is
Collector map[string]any `yaml:"otelcol"`

figureout.Opaque(s, &c.Collector, "otelcol",
	figureout.Reason("handed to the collector verbatim"))

The field decodes to whatever the document held — objects as map[string]any, arrays as []any, scalars as the format resolved them — and its whole subtree is exempt from [DisallowUnknownFields]. That exemption is the load-bearing part: strict decoding is why a descriptor is worth adopting, and a passthrough is precisely where strictness has to stop, because figureout cannot know which keys the other program accepts and a version skew in *that* program is not this one's business.

It is therefore a deliberate hole, and Reason is required so that it reads as one at the declaration site. The reason is documentation: generated schemas describe a permissive object carrying it, rather than omitting the field.

Absence resolves to the zero value, as Value does; FieldBuilder.Required opts back in. A source with no nesting, such as environment variables or mounted files, skips the field the way it skips a collection of objects.

Opaque is not an escape hatch for a block that could be described. Whatever is inside it has no names, no constraints, no defaults, no provenance and no schema — reach for ObjectFunc wherever the shape is yours to state.

func (*OpaqueField) Doc added in v0.7.0

func (f *OpaqueField) Doc(text string) *OpaqueField

Doc attaches documentation.

func (*OpaqueField) Required added in v0.7.0

func (f *OpaqueField) Required() *OpaqueField

Required makes an absent passthrough an error instead of the zero value.

type OptionalOf

type OptionalOf[T any] struct {
	// contains filtered or unexported fields
}

OptionalOf represents a value that is either missing or present.

Unlike a pointer, it carries no aliasing and distinguishes "not provided by any source" from "provided as the zero value".

There is no nullable counterpart: an explicit null in a source erases what earlier layers set rather than becoming a value the field holds.

func None

func None[T any]() OptionalOf[T]

None returns a missing OptionalOf.

func Some

func Some[T any](v T) OptionalOf[T]

Some returns a present OptionalOf.

func (*OptionalOf[T]) Clear

func (o *OptionalOf[T]) Clear()

Clear makes the value missing and resets it to the zero value.

func (OptionalOf[T]) IsSet

func (o OptionalOf[T]) IsSet() bool

IsSet reports whether a value is present.

func (OptionalOf[T]) MarshalJSON added in v0.7.0

func (o OptionalOf[T]) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler.

func (OptionalOf[T]) MarshalYAML added in v0.7.0

func (o OptionalOf[T]) MarshalYAML() (any, error)

MarshalYAML implements yaml.Marshaler.

func (OptionalOf[T]) OrElse

func (o OptionalOf[T]) OrElse(v T) T

OrElse returns the value if present, otherwise v.

func (*OptionalOf[T]) Set

func (o *OptionalOf[T]) Set(v T)

Set makes the value present.

func (OptionalOf[T]) String

func (o OptionalOf[T]) String() string

String implements fmt.Stringer.

func (*OptionalOf[T]) UnmarshalJSON added in v0.7.0

func (o *OptionalOf[T]) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler.

func (*OptionalOf[T]) UnmarshalYAML added in v0.7.0

func (o *OptionalOf[T]) UnmarshalYAML(n *yaml.Node) error

UnmarshalYAML implements yaml.Unmarshaler.

func (OptionalOf[T]) Value

func (o OptionalOf[T]) Value() (T, bool)

Value returns the value and whether it is present.

type Origin

type Origin struct {
	Source SourceID
	// Name is the source-specific name, such as an environment variable.
	Name string
	File string
	Line int
	Col  int
}

Origin records where a value came from.

func (Origin) String

func (o Origin) String() string

String implements fmt.Stringer.

type PatternConstraint

type PatternConstraint struct {
	Expression string
	Dialect    PatternDialect
	// contains filtered or unexported fields
}

PatternConstraint restricts a string to a regular expression.

func (PatternConstraint) Applies

func (PatternConstraint) Applies(kind TypeKind) bool

Applies implements Constraint.

func (PatternConstraint) Kind

func (PatternConstraint) Kind() string

Kind implements Constraint.

func (PatternConstraint) Validate

func (c PatternConstraint) Validate(v any) error

Validate implements Constraint.

type PatternDialect

type PatternDialect uint8

PatternDialect names the regular expression syntax of a PatternConstraint.

const (
	// PatternRE2 is Go's [regexp] syntax.
	PatternRE2 PatternDialect = iota
	// PatternECMA is the syntax used by JSON Schema.
	PatternECMA
)

Pattern dialects.

type Presence

type Presence uint8

Presence describes how a field models the absence of a value.

const (
	// PresenceRequired is a plain Go value: it is always materialized, and a
	// missing value is an error unless a default applies.
	PresenceRequired Presence = iota
	// PresenceOptional is an [OptionalOf] carrier: missing or present.
	//
	// There is deliberately no nullable state. An explicit null in a source is
	// a merge directive that erases earlier layers, not a value a field holds,
	// so nullability never reaches the Go type.
	PresenceOptional
)

Presence values.

func (Presence) String

func (p Presence) String() string

String implements fmt.Stringer.

type RangeConstraint

type RangeConstraint struct {
	Minimum          any
	Maximum          any
	ExclusiveMinimum bool
	ExclusiveMaximum bool
}

RangeConstraint bounds a numeric or duration value.

func (RangeConstraint) Applies

func (RangeConstraint) Applies(kind TypeKind) bool

Applies implements Constraint.

func (RangeConstraint) Kind

func (RangeConstraint) Kind() string

Kind implements Constraint.

func (RangeConstraint) Validate

func (c RangeConstraint) Validate(v any) error

Validate implements Constraint.

type ReasonOption added in v0.7.0

type ReasonOption struct {
	// contains filtered or unexported fields
}

ReasonOption is Reason. It is both an IgnoreOption and a FieldOption because both kinds of declaration are a decision not to describe something.

func Reason

func Reason(text string) ReasonOption

Reason documents why a field is not described.

It applies to Ignore, where it says why a Go field is not configuration, and to Opaque, where it is required: a passthrough takes its whole subtree out of unknown-field checking, and a hole in strictness has to read as one at the declaration site.

func (ReasonOption) ApplyFieldOption added in v0.7.0

func (o ReasonOption) ApplyFieldOption(c FieldOptionContext) error

ApplyFieldOption implements FieldOption.

type Report

type Report struct {
	Diagnostics Diagnostics
	// contains filtered or unexported fields
}

Report describes how a configuration was resolved.

func (*Report) ErasedBy

func (r *Report) ErasedBy(path string) (Origin, bool)

ErasedBy returns the layer that erased the value at the canonical path.

A source spells an erase as an explicit null; the field then falls back to its default, or stays missing.

func (*Report) OriginOf

func (r *Report) OriginOf(path string) (Origin, bool)

OriginOf returns where the value at the canonical path came from.

func (*Report) Origins

func (r *Report) Origins() iter.Seq2[string, Origin]

Origins iterates over every resolved path and its origin.

func (*Report) Secret added in v0.2.0

func (r *Report) Secret(path string) bool

Secret reports whether the value at the canonical path is a credential.

A consumer walking Report.Origins to log where its configuration came from can use it to keep the values it prints alongside them out of the log.

func (*Report) Secrets added in v0.2.0

func (r *Report) Secrets() func(func(string) bool)

Secrets iterates over every resolved path holding a credential.

type Schema

type Schema[T any] struct {
	// contains filtered or unexported fields
}

Schema is the mutable registration builder for T.

It is single-use and not safe for concurrent use; the compiled Descriptor is immutable and safe for concurrent use.

func (*Schema[T]) Diagnostics

func (s *Schema[T]) Diagnostics() Diagnostics

Diagnostics returns the diagnostics recorded so far.

type SchemaOption

type SchemaOption interface {
	// contains filtered or unexported methods
}

SchemaOption customizes descriptor construction.

func Completeness

func Completeness(m CompletenessMode) SchemaOption

Completeness selects the completeness mode. The default is CompletenessExported.

func Tag

func Tag(name string) SchemaOption

Tag sets the struct tag consulted by CompletenessTagged. The default is "config".

func WithTypeRegistry

func WithTypeRegistry(r *TypeRegistry) SchemaOption

WithTypeRegistry supplies named type descriptions to descriptor construction.

type Section added in v0.7.0

type Section struct{}

Section is the value a source assigns to an optional object itself, to say that this layer contained the section.

A section that is there and defaulted throughout has no member assignment to speak for it, and a nil pointer has to keep meaning "no section": without a marker of its own, "s3: {}" and no "s3" key at all would resolve alike. It is Collection and Element one level up, for the same reason.

A source with no nesting has nothing to write it with, so an optional section is present there whenever anything under it is. See [Model.sectionPresent].

type Severity

type Severity uint8

Severity classifies a Diagnostic.

const (
	SeverityInfo Severity = iota
	SeverityWarning
	SeverityError
)

Severity levels.

func (Severity) String

func (s Severity) String() string

String implements fmt.Stringer.

type Shape

type Shape struct {
	Kind ShapeKind

	Elem   *Shape
	Key    *Shape
	Fields map[string]Shape
	OneOf  []Shape
}

Shape is a wire representation accepted or produced by a source.

type ShapeKind

type ShapeKind uint8

ShapeKind is a wire-level representation kind.

It describes what a source accepts, not what the value means; see TypeKind for semantics.

const (
	ShapeUnknown ShapeKind = iota
	ShapeNull
	ShapeBoolean
	ShapeInteger
	ShapeNumber
	ShapeString
	ShapeArray
	ShapeObject
)

Shape kinds.

func (ShapeKind) String

func (k ShapeKind) String() string

String implements fmt.Stringer.

type Source

type Source interface {
	ID() SourceID
	Load(ctx context.Context, m *Model) (*Layer, error)
}

Source decodes a configuration layer.

type SourceID

type SourceID string

SourceID identifies an input mechanism such as JSON or environment variables.

type SourceNamer added in v0.6.0

type SourceNamer interface {
	Source

	// ProjectNames maps a canonical field path to the names this source
	// accepts for it, primary first. A field the source cannot read, such as a
	// list of objects in the environment, is absent from the result.
	ProjectNames(m *Model) map[string][]string
}

SourceNamer reports the names a source accepts for every field.

A name belongs to the source rather than to the model: the environment source joins the segments of a path, applies its own naming and prepends the prefix the caller configured, so only that source knows "server.port" is read from APP_SERVER_LISTEN_PORT. A target that documents names asks the source for them instead of re-deriving them, which is what keeps documentation from drifting from what is actually read.

A configured source is therefore the unit that can answer, not a SourceID.

type SourceProjection

type SourceProjection struct {
	Source SourceID
	// Names lists the accepted names, primary first, aliases after.
	Names []string
	// Accepts lists the wire shapes the source accepts for this field. When
	// empty, the shape is derived from the semantic type.
	Accepts []Shape
	// Skip excludes the field from this source entirely.
	Skip bool

	Decoder Decoder
	Encoder Encoder
	// Options carries source-specific settings, owned by the source package.
	Options []any
}

SourceProjection is how one field is represented and decoded by one source.

func (*SourceProjection) DeriveShapes

func (p *SourceProjection) DeriveShapes(t Type) []Shape

DeriveShapes returns the accepted shapes, falling back to the shape implied by the semantic type when the source declares none.

func (*SourceProjection) Name

func (p *SourceProjection) Name() string

Name returns the primary name of the projection.

type TargetID

type TargetID string

TargetID identifies an output representation such as JSON Schema or CUE.

type Type

type Type struct {
	Kind TypeKind

	// Go is the Go type carrying the value, with [OptionalOf] already
	// unwrapped.
	Go reflect.Type

	// Unit scales a bare number written for a [TypeDuration] field, so that
	// "timeout_seconds: 180" resolves to 180 * time.Second. Zero means the
	// field is only spelled as a duration. See [Unit].
	Unit time.Duration

	Elem   *Type        // list element, map value
	Key    *Type        // map key
	Object *ObjectModel // object fields
	Union  *Union       // union variants

	// Scalar is the scalar spelling an object also accepts, as set by
	// [ScalarOr]. It is nil for an object that is only ever written as one.
	Scalar *Type

	// Text reports that the Go type parses itself from text through
	// [encoding.TextUnmarshaler], which then decides what every spelling
	// means. See [textScalar].
	Text bool
}

Type is a semantic type. It never refers to a wire format; see SourceProjection for the representation accepted by a given source.

func (Type) UnitName added in v0.2.0

func (t Type) UnitName() string

UnitName names what a unit-scaled integer counts, for diagnostics and generated documentation. It is empty when the type declares no unit.

type TypeKind

type TypeKind uint8

TypeKind is the format-neutral meaning of a value.

const (
	TypeInvalid TypeKind = iota
	TypeBoolean
	TypeInteger
	TypeNumber
	TypeString
	TypeBytes
	TypeDuration
	TypeTimestamp
	TypeList
	TypeMap
	TypeObject
	TypeUnion
	// TypeOpaque is a subtree carried verbatim, whose shape belongs to another
	// program. See [Opaque].
	TypeOpaque
)

Semantic kinds.

func (TypeKind) String

func (k TypeKind) String() string

String implements fmt.Stringer.

type TypeOption

type TypeOption interface {
	// contains filtered or unexported methods
}

TypeOption describes a registered type.

func BooleanType

func BooleanType() TypeOption

BooleanType declares boolean semantics.

func Constrain

func Constrain(c Constraint) TypeOption

Constrain attaches a constraint to every field of the type.

func DurationType

func DurationType() TypeOption

DurationType declares duration semantics.

func InRange

func InRange(minimum, maximum any) TypeOption

InRange constrains every field of the type to an inclusive range.

func IntegerType

func IntegerType() TypeOption

IntegerType declares integer semantics.

func NumberType

func NumberType() TypeOption

NumberType declares floating point semantics.

func StringType

func StringType() TypeOption

StringType declares string semantics.

func TimestampType

func TimestampType() TypeOption

TimestampType declares timestamp semantics.

func TypeFieldOptions

func TypeFieldOptions(opts ...FieldOption) TypeOption

TypeFieldOptions applies field options to every field of the type, such as source decoders or accepted shapes.

type TypeRegistry

type TypeRegistry struct {
	// contains filtered or unexported fields
}

TypeRegistry describes named domain types once, so that every field of that type inherits the same semantics, constraints and source options.

Registries are explicit: there is no global registration. A registry must not be modified after it has been passed to Derive.

func NewTypeRegistry

func NewTypeRegistry() *TypeRegistry

NewTypeRegistry returns an empty registry.

type Union

type Union struct {
	// Discriminator is the property carrying the variant tag.
	Discriminator string
	Variants      []*VariantModel
}

Union is a tagged sum of object variants.

A union is distinct from an enumeration: an enum constrains a scalar to a set of values, while a union selects between alternative shapes.

type UnionField

type UnionField struct{ *FieldBuilder }

UnionField is a fluent builder for union fields.

func OneOf

func OneOf[R, C any](s *Schema[R], field *C, name string, opts ...UnionOption) *UnionField

OneOf registers a tagged union: a field whose shape is selected by a discriminator property.

A union is a sum of alternative shapes. To restrict one scalar to a set of values, use Enum instead.

figureout.OneOf(s, &c.Backend, "backend",
	figureout.Discriminator("type"),
	figureout.Variant("s3", &c.Backend.S3, S3Descriptor),
	figureout.Variant("local", &c.Backend.Local, LocalDescriptor),
)

func (*UnionField) Doc

func (f *UnionField) Doc(text string) *UnionField

Doc attaches documentation.

type UnionOption

type UnionOption interface {
	// contains filtered or unexported methods
}

UnionOption declares part of a union.

func Discriminator

func Discriminator(name string) UnionOption

Discriminator names the property carrying the variant tag. It is required.

func Variant

func Variant[V any](tag string, field **V, d *Descriptor[V]) UnionOption

Variant declares one alternative of a union.

The variant field must be a pointer to the variant struct: the pointer being non-nil is what records which variant was selected.

type ValueField

type ValueField[T any] struct {
	*FieldBuilder
}

ValueField is the fluent builder for a value field carrying T.

Constraints are typed: InRange takes two T rather than two any, so a bound that does not belong to the field is a compile error rather than a descriptor diagnostic. Constraints that do not apply to the field's semantic kind, such as MinLength on an integer, are still rejected during compilation.

func Enum

func Enum[R any, T EnumValuer[T]](s *Schema[R], field *T, name string, opts ...FieldOption) *ValueField[T]

Enum registers a field whose type enumerates its own values.

An enum is a set of allowed values for one type. It is distinct from OneOf, which selects between alternative shapes.

An enum field is required, as Explicit is: the zero value of an enumerated type is rarely one of its members, so absence needs a default rather than a fallback nobody declared.

func EnumFunc

func EnumFunc[R, T any](s *Schema[R], field *T, name string, values func() []T, opts ...FieldOption) *ValueField[T]

EnumFunc registers an enumerated field whose values come from a function.

Generators that emit a package-level function rather than a method, such as enumer's LogLevelValues, are registered this way.

func EnumSlice

func EnumSlice[R any, T EnumSliceValuer[T]](s *Schema[R], field *T, name string, opts ...FieldOption) *ValueField[T]

EnumSlice registers a field whose type enumerates its own values through a Values method.

func EnumValues

func EnumValues[R, T any](s *Schema[R], field *T, name string, values []T, opts ...FieldOption) *ValueField[T]

EnumValues registers an enumerated field with an explicit set of values.

func Explicit added in v0.5.0

func Explicit[R, T any](s *Schema[R], field *T, name string, opts ...FieldOption) *ValueField[T]

Explicit registers a plain field some source has to provide.

It is Value without the zero fallback: a missing value is an error unless the field carries an applied default. Use it for what an operator has to decide, such as a database address or a listen port.

A collection is required too, rather than resolving to an empty one: the absent-is-empty rule is what a collection does when nobody says otherwise, and Explicit says otherwise.

func Optional

func Optional[R, T any](s *Schema[R], field *OptionalOf[T], name string, opts ...FieldOption) *ValueField[T]

Optional registers a field that a source may leave out.

The element type is inferred from the carrier, so the builder and its constraints are typed as T rather than as OptionalOf[T].

func Value

func Value[R, T any](s *Schema[R], field *T, name string, opts ...FieldOption) *ValueField[T]

Value registers a plain field whose absence resolves to the zero value of T.

The semantic type is derived from T, so named types such as "type Port uint16" are integers with whatever the type registry adds.

Absence is not an error: a field nobody configured reads as "", 0 or false, which is what an optional scalar with no meaningful default wants, and the zero value is already visible in the Go type. Say so differently when it is not what the field means: Explicit demands a value, ValueField.ApplyDefault substitutes another one, and Optional keeps absence visible to the consumer.

A zero that no source could have written is a compilation error rather than a silent one, so a field constrained by NonEmpty, InRange or Enum cannot fall back to it.

func (*ValueField[T]) ApplyDefault

func (f *ValueField[T]) ApplyDefault(v T) *ValueField[T]

ApplyDefault sets a default that changes the resolved value when no source provides one. For an OptionalOf field this makes the value present.

func (*ValueField[T]) AtLeast

func (f *ValueField[T]) AtLeast(minimum T) *ValueField[T]

AtLeast sets an inclusive lower bound.

func (*ValueField[T]) AtMost

func (f *ValueField[T]) AtMost(maximum T) *ValueField[T]

AtMost sets an inclusive upper bound.

func (*ValueField[T]) Check

func (f *ValueField[T]) Check(name string, fn func(T) error) *ValueField[T]

Check adds an opaque runtime validator. Opaque validators never contribute to generated schemas.

func (*ValueField[T]) Deprecated

func (f *ValueField[T]) Deprecated(reason string) *ValueField[T]

Deprecated marks the field as deprecated.

func (*ValueField[T]) Doc

func (f *ValueField[T]) Doc(text string) *ValueField[T]

Doc attaches documentation.

func (*ValueField[T]) DocumentDefault

func (f *ValueField[T]) DocumentDefault(v T) *ValueField[T]

DocumentDefault records a default for documentation only, without changing resolution.

func (*ValueField[T]) Enum

func (f *ValueField[T]) Enum(values ...T) *ValueField[T]

Enum restricts the field to a set of values.

Prefer Enum or EnumSlice when the type enumerates its own values, so that the value set has a single source of truth.

func (*ValueField[T]) Examples

func (f *ValueField[T]) Examples(values ...T) *ValueField[T]

Examples attaches example values.

func (*ValueField[T]) GreaterThan

func (f *ValueField[T]) GreaterThan(minimum T) *ValueField[T]

GreaterThan sets an exclusive lower bound.

func (*ValueField[T]) Hidden

func (f *ValueField[T]) Hidden() *ValueField[T]

Hidden hides the field from generated documentation.

func (*ValueField[T]) InRange

func (f *ValueField[T]) InRange(minimum, maximum T) *ValueField[T]

InRange bounds the value inclusively.

func (*ValueField[T]) LessThan

func (f *ValueField[T]) LessThan(maximum T) *ValueField[T]

LessThan sets an exclusive upper bound.

func (*ValueField[T]) MaxItems

func (f *ValueField[T]) MaxItems(n uint64) *ValueField[T]

MaxItems allows at most n elements.

func (*ValueField[T]) MaxLength

func (f *ValueField[T]) MaxLength(n uint64) *ValueField[T]

MaxLength allows at most n characters, bytes or elements.

func (*ValueField[T]) MergeAppend

func (f *ValueField[T]) MergeAppend() *ValueField[T]

MergeAppend concatenates list values across layers, in layer order.

func (*ValueField[T]) MergeByKey

func (f *ValueField[T]) MergeByKey() *ValueField[T]

MergeByKey merges map entries across layers, so a later layer changes only the keys it names.

func (*ValueField[T]) MergeReplace

func (f *ValueField[T]) MergeReplace() *ValueField[T]

MergeReplace takes the value from the last layer that provided one. It is the default.

func (*ValueField[T]) MinItems

func (f *ValueField[T]) MinItems(n uint64) *ValueField[T]

MinItems requires at least n elements.

func (*ValueField[T]) MinLength

func (f *ValueField[T]) MinLength(n uint64) *ValueField[T]

MinLength requires at least n characters, bytes or elements.

func (*ValueField[T]) NonEmpty

func (f *ValueField[T]) NonEmpty() *ValueField[T]

NonEmpty requires a length of at least one.

func (*ValueField[T]) Pattern

func (f *ValueField[T]) Pattern(expr string) *ValueField[T]

Pattern requires the value to match an RE2 regular expression.

func (*ValueField[T]) Required added in v0.4.0

func (f *ValueField[T]) Required() *ValueField[T]

Required makes an absent collection an error instead of an empty one.

func (*ValueField[T]) With

func (f *ValueField[T]) With(opts ...FieldOption) *ValueField[T]

With applies field options after registration.

type ValueState

type ValueState uint8

ValueState distinguishes a missing value from an explicit null.

const (
	ValueMissing ValueState = iota
	ValueNull
	ValuePresent
)

Value states.

type VariantModel

type VariantModel struct {
	// Tag is the discriminator value selecting this variant.
	Tag string
	// GoPath locates the variant field inside the union container.
	GoPath FieldPath
	// Object describes the variant payload.
	Object *ObjectModel
	// contains filtered or unexported fields
}

VariantModel is one alternative of a Union.

type Violation added in v0.2.0

type Violation struct {
	// contains filtered or unexported fields
}

Violation builds an error naming the paths a cross-field rule is about.

func At added in v0.2.0

func At(paths ...string) *Violation

At starts a violation about the values at one or more canonical paths.

The paths are what give a cross-field failure the same provenance a constraint failure has: the report already knows that "fetch.sites[0].proxy" came from config.yaml:41:5.

func (*Violation) Errorf added in v0.2.0

func (v *Violation) Errorf(format string, args ...any) error

Errorf returns the violation as an error.

Directories

Path Synopsis
examples
service command
Command service shows figureout in a realistic setup: a layered configuration, provenance in its output, and a JSON Schema generated from the same definition.
Command service shows figureout in a realistic setup: a layered configuration, provenance in its output, and a JSON Schema generated from the same definition.
internal
integration
Package integration checks that the pieces agree on one realistic configuration: what the sources decode, and what the generated schema accepts, are the same document.
Package integration checks that the pieces agree on one realistic configuration: what the sources decode, and what the generated schema accepts, are the same document.
scalar
Package scalar parses textual scalars into semantic configuration values.
Package scalar parses textual scalars into semantic configuration values.
tree
Package tree is the shared document model for hierarchical sources.
Package tree is the shared document model for hierarchical sources.
schema
docs
Package docs emits reference documentation from a configuration descriptor, so that the documented configuration cannot drift from the decoded one.
Package docs emits reference documentation from a configuration descriptor, so that the documented configuration cannot drift from the decoded one.
jsonschema
Package jsonschema emits JSON Schema from a configuration descriptor.
Package jsonschema emits JSON Schema from a configuration descriptor.
source
env
Package env projects a configuration descriptor onto environment variables.
Package env projects a configuration descriptor onto environment variables.
file
Package file reads configuration values from a directory of files, one value per file.
Package file reads configuration values from a directory of files, one value per file.
json
Package json reads configuration from JSON documents.
Package json reads configuration from JSON documents.
yaml
Package yaml reads configuration from YAML documents.
Package yaml reads configuration from YAML documents.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL