Typed scalar concepts
An author ID and an editor ID can share the same UUID wire format without being
interchangeable Go values. Define a nominal type, declare its scalar with
ConceptValue, and forward its codecs. You retain domain identity in Go while
serializers and schema builders can discover the exact scalar representation.
For a complete first program, start with the JSON round-trip tutorial. For scalar signatures, ranges, zero values and SQL support, use the scalar reference. This page covers the v0.3.2 concept contract.
Author a UUID-backed concept
Section titled “Author a UUID-backed concept”This declaration uses only github.com/cratis/fundamentals.go/concepts. You can
place it in your domain package:
package domain
import "github.com/cratis/fundamentals.go/concepts"
type AuthorID concepts.UUID
var _ concepts.Concept[concepts.UUID] = AuthorID{}
func (id AuthorID) ConceptValue() concepts.UUID { return concepts.UUID(id) }func (id AuthorID) MarshalText() ([]byte, error) { return concepts.UUID(id).MarshalText() }func (id AuthorID) MarshalJSON() ([]byte, error) { return concepts.UUID(id).MarshalJSON() }func (id *AuthorID) UnmarshalText(data []byte) error { return (*concepts.UUID)(id).UnmarshalText(data)}func (id *AuthorID) UnmarshalJSON(data []byte) error { return (*concepts.UUID)(id).UnmarshalJSON(data)}Defined types do not inherit methods. Without forwarding, a UUID-derived type
encodes as an array of bytes, not a UUID string. Value encoders work even for
unaddressable map values. Pointer decoders use ordinary Go pointer conversions,
not unsafe, to delegate to UUID’s failure-atomic codecs. Malformed input and
JSON null leave your value unchanged; successful decoding updates it directly.
Text codecs also support JSON map keys; see rule 12 for a Go 1.26 decoding difference.
Keep manual defined types when you need caller-owned domain methods. An alias shares the aliased type’s identity; an alias to an instantiated generic type cannot acquire your own receiver methods. A generic tagged-ID convenience would add another type-identity model, while shared generation would add tool ownership and distribution contracts. The small amount of forwarding here does not justify either; no generic ID or generator is needed.
The compile-time assertion checks the marker’s exact return type; it does not
check codecs. Run Underlying to validate the complete declaration, and test
round trips for actual values. Compiling examples live in
concepts/example_test.go.
For concepts stored in SQL databases, optionally add these forwarding methods
to AuthorID and import database/sql/driver in the same file:
func (id AuthorID) Value() (driver.Value, error) { return concepts.UUID(id).Value() }func (id *AuthorID) Scan(src any) error { return (*concepts.UUID)(id).Scan(src)}Value returns canonical lowercase dashed text, including for the zero UUID.
Scan accepts strict dashed text as a string or byte slice; exactly 16 bytes
are copied as RFC/network-order UUID bytes. Invalid input and SQL NULL return
errors without changing the target. These optional methods do not affect
Underlying recognition. For nullable columns, use sql.Null[AuthorID] (or
sql.Null[concepts.UUID] for the scalar) or pointers, not a non-nullable UUID.
SQL Server uniqueidentifier raw bytes use .NET mixed-endian order, not RFC
order. Read them as text or convert explicitly before scanning; Scan never
silently reorders bytes. The forwarding pattern is compiled and tested in
concepts/uuid_sql_test.go using AuthorID from
the examples.
Validate before assigning
Section titled “Validate before assigning”Direct pointer forwarding is for passthrough codecs only. If your application
rejects a syntactically valid UUID, parse into a temporary, validate, then assign.
This decode-only example assumes fmt and concepts are imported:
type RequiredAuthorID concepts.UUID
func (id RequiredAuthorID) IsZero() bool { return concepts.UUID(id).IsZero() }func (id *RequiredAuthorID) UnmarshalText(data []byte) error { var value concepts.UUID if err := value.UnmarshalText(data); err != nil { return err } candidate := RequiredAuthorID(value) if candidate.IsZero() { return fmt.Errorf("author ID must be nonzero") } *id = candidate return nil}The all-zero UUID parses successfully but fails this application’s rule without
changing the original ID. Use the same temporary/validate/assign sequence for
validating JSON and SQL decoders; validating after direct delegation is too late.
The compiled example and regressions keep
passthrough AuthorID and validating RequiredAuthorID separate.
A defined array type does not enforce constructor privacy or invariants: literals, explicit conversions and byte mutation can bypass your decoder. Use a named private-field wrapper with controlled constructors when you need to restrict construction. Domain validity does not establish authorization or stream identity.
Convert legacy GUID input explicitly
Section titled “Convert legacy GUID input explicitly”The forwarding methods above remain strict. For a separate compatibility-conversion
boundary, concepts.ParseDotNetGUID(text) accepts the pinned .NET 10.0.12
Guid.Parse(string) profile and returns a UUID you can convert to AuthorID
after checking the error. This API is available beginning with v0.3.0.
See explicit .NET GUID conversion
for legacy truncation, zero-prefix and conditional-NUL behavior and input limits.
Do not use compatibility conversion as canonical-input validation or correlation ID admission, and do not substitute it into these text/JSON/SQL forwarders implicitly. Consumer binding policy and standard JSON map-key handling stay unchanged.
Author a calendar-backed concept
Section titled “Author a calendar-backed concept”Use the same pattern for dates, times, and durations. For example, in the domain
package above, a birthday forwards DateOnly instead of exposing its storage:
type Birthday concepts.DateOnly
var _ concepts.Concept[concepts.DateOnly] = Birthday{}
func (d Birthday) ConceptValue() concepts.DateOnly { return concepts.DateOnly(d) }func (d Birthday) MarshalText() ([]byte, error) { return concepts.DateOnly(d).MarshalText() }func (d Birthday) MarshalJSON() ([]byte, error) { return concepts.DateOnly(d).MarshalJSON() }func (d *Birthday) UnmarshalText(data []byte) error { var value concepts.DateOnly if err := value.UnmarshalText(data); err != nil { return err } *d = Birthday(value) return nil}func (d *Birthday) UnmarshalJSON(data []byte) error { var value concepts.DateOnly if err := value.UnmarshalJSON(data); err != nil { return err } *d = Birthday(value) return nil}For invariant-protecting wrappers, use a named private field and explicit constructors. Anonymous fields can promote methods and accidentally declare a concept; they are rejected in concept candidates.
Plain named primitives such as type DisplayName string need nothing for
ordinary Go scalar serialization. Add ConceptValue and all four codecs only
when you need explicit concept discovery. Unmarked primitives are not concepts.
Normative recognition rules
Section titled “Normative recognition rules”Underlying(t reflect.Type) (Representation, bool, error) follows these rules:
- Nil input is invalid (
nil-type). Pointer layers are removed and counted; cyclic pointers are invalid (recursive-type).PointerDepthdescribes type structure only, not nullability or omission policy. - Exact
UUID,DateOnly,TimeOnly, andTimeSpantypes, including aliases, are recognized directly. They need noConceptValue. TheirDeclaredandTypefields are identical. - Candidacy comes only from
ConceptValuein the outer value or pointer method set. Ordinary fields and collection elements are not traversed. Ambiguous promotion inBook{AuthorID; EditorID}makes it not a concept. Query a field or element type separately when needed. - An unmarked struct convertible to
DateOnlyorTimeOnly, but not identical, is invalid (missing-forwarding) only when its value type implements neitherjson.Marshalernorencoding.TextMarshalerand would encode as{}. A type forwarding either value encoder is codec-only, not a concept, and returns zero, false, nil, like other unmarked application types. - A concept-bearing interface is invalid (
interface). A concept-bearing struct with any anonymous field is invalid (embedded-fields), even with explicit overrides. Reflection cannot reliably distinguish declared methods from promoted methods. This check applies only to candidates. ConceptValuemust be on the value method set (pointer-methodotherwise), take no arguments, be nonvariadic, and return exactly one value (invalid-methodotherwise).- The result must be exactly one of these 18 types:
string,bool,int,int8,int16,int32,int64,uint,uint8,uint16,uint32,uint64,float32,float64,UUID,DateOnly,TimeOnly,TimeSpan. Aliases, includingbyteandrune, are identical and accepted. Defined replacements, pointers,uintptr, complex values, containers, interfaces,any, andtime.Timeare invalid (unsupported-type). - Self-reference, including through pointers, is invalid (
recursive-type). A result that declares another concept is invalid (nested-concept), never flattened. A new type derived from an existing concept can declare the final allowlisted scalar directly with its own methods. - The value must implement
encoding.TextMarshalerthenjson.Marshaler; the pointer must implementencoding.TextUnmarshalerthenjson.Unmarshaler. Missing or incorrectly typed codecs are invalid (missing-codec). For each decoder, implementation on the value method set is checked first and rejected (value-unmarshaler), because decoding a copy can silently lose changes. - Success returns the exact scalar
Type, itsScalarKindinKind, pointer-stripped inputDeclared, andPointerDepth, with true, nil.Kindis set for every recognized result and agrees withType. Kind values are stable and never renumbered; the reflect-freeconceptstypespackage shares these constants. Every error returns zero, false and*TypeErrorwrappingErrInvalidConcept. Useerrors.Isanderrors.As;TypeError.Typeretains the original input andUnderlyingits marker result when known.Methodnames an offending or missing method when applicable. Reason string values are a stable contract and are never renamed or reused. New reasons may be added; handle unknown values.Error()message wording is not stable. - Discovery inspects metadata only: no application methods, fabricated values, global caches, registries, or unsafe operations. Concurrent calls are safe. Discovery does not certify arbitrary codec output.
- JSON map keys use text codecs (
MarshalText/UnmarshalText) rather than JSON codecs inencoding/json. A recognized concept’s text codec must round-trip and produce the same canonical text as its JSON string, or the canonical scalar literal for a non-string representation such asint32.encoding/jsonuses a string-kinded key type directly, ignoringMarshalText; it checksMarshalTextbefore integer kinds. Choose a named-field wrapper when a string-kinded type needs custom key encoding. Decoding a key differs betweenencoding/jsonimplementations. The v1 implementation (the Go 1.26 default, or Go 1.27 withGOEXPERIMENT=nojsonv2) callsUnmarshalJSONwith the quoted key text when the type implementsjson.Unmarshaler. The jsonv2-backed implementation (the Go 1.27 default) callsUnmarshalText. String-shaped concepts (UUID, DateOnly, TimeOnly, TimeSpan and string-backed concepts) decode under both, because theirUnmarshalJSONaccepts a JSON string. Number- and bool-backed concepts used as map keys decode only with the jsonv2-backed implementation.
The shared declarations and expected outcomes live in
concepts/internal/corpus. Both reflect and
go/types tests run the same table, including aliases, generic instantiations,
exact scalar types, codec signatures and failures. Any disagreement fails a test.
This does not establish proxy-generation agreement; that remains Arc.Go’s
responsibility.
Compile-time recognition
Section titled “Compile-time recognition”Generators can inspect concepts without running application code. Import
github.com/cratis/fundamentals.go/concepts/conceptstypes and pass a fully
type-checked types.Type to conceptstypes.Underlying. You own package loading;
the package uses only the standard library and adds no loader dependency.
The API follows the normative rules above, returning a Representation with
types.Type fields and the same concepts.ScalarKind constants. Aliases,
including generic aliases, are resolved with types.Unalias. Shared scalars
are identified by their canonical concepts package path and type name. Errors
wrap concepts.ErrInvalidConcept; errors.As exposes
*conceptstypes.TypeError with the shared concepts.InvalidReason values.
| Runtime API | Compile-time counterpart |
|---|---|
concepts.Underlying(t reflect.Type) (Representation, bool, error) | conceptstypes.Underlying(t types.Type) (Representation, bool, error) |
Representation.Type / Declared are reflect.Type | Both fields are types.Type; preserve declared identity separately from the scalar |
Representation.Kind / PointerDepth | Same concepts.ScalarKind / int meaning |
*concepts.TypeError | *conceptstypes.TypeError, with Type / Underlying as types.Type, plus shared Reason and Method |
concepts.CheckJSON on runtime representations | No compile-time JSON checker; analysis never runs a codec |
Pass actual types from one coherent type-checking/importer universe. Do not infer an ID’s representation from an array shape or compare types by their printed names. A nil type is an error; an ordinary unmarked type returns zero, false, nil. A malformed concept returns zero, false and an inspectable error. Preserve the distinction in a generator: unsupported domain declarations must not silently fall back to ordinary objects. Pointer depth is not a nullability policy.
The package-loading recipe shows an
optional go/packages integration. Neither recognizer generates proxies or
owns the consumer’s schema, property naming, validation or wire policies.
Although go/types can distinguish promoted methods, this recognizer deliberately
keeps reflect’s conservative rule: any anonymous field invalidates a
concept-bearing struct, even with explicit overrides. See the
compiling generator example for
source type-checking and UUID-backed concept recognition.
Validate actual JSON output
Section titled “Validate actual JSON output”Use CheckJSON(r Representation, data []byte) error after encoding a real,
non-nil concept value. It checks the declared scalar’s wire shape:
- Exactly one JSON value, allowing surrounding JSON whitespace; no
null, objects, arrays, trailing values, or wrong scalar tokens. - Valid JSON strings and UTF-8 input, rejecting unpaired
\uD800–\uDFFFsurrogate escapes; booleans must betrueorfalse. - Integer literal text parsed at the exact signedness and width, including
platform-sized
intanduint. No float64 round trip. Fractions and exponents are rejected for integers even when their mathematical value is integral. - Floats parsed with
strconv.ParseFloatat 32 or 64 bits, rejecting overflow. - UUID/date/time/duration strings accepted by the package parser and equal to its canonical re-encoding. Comparison uses decoded string contents, so equivalent JSON escapes are allowed; uppercase UUIDs and shortened time fractions are not canonical.
A zero, inconsistent, or otherwise invalid representation returns an error
wrapping ErrInvalidConcept. CheckJSON checks only Type, Kind and
PointerDepth: Kind must be valid and agree with Type, and PointerDepth
must be nonnegative. Declared is informational and is not inspected, so a
hand-built representation is accepted.
Bad encoded data returns an ordinary error, not ErrInvalidConcept.
CheckJSON never calls application code. Its allocations are bounded by the
input size: constant plus O(len(data)), independent of declaration complexity.
Emit the same validated bytes. Calling a stateful codec again could produce
something different. This validation checks shape and range, not equality to
ConceptValue, domain invariants, decoder correctness, or product-specific
limits such as BSON ranges and dictionary precision. These remain your
serializer’s responsibility. A UUID concept whose codec emits 42 passes
metadata discovery but fails CheckJSON.
Limits and null handling
Section titled “Limits and null handling”A defined TimeSpan replacement without a marker is indistinguishable from an
ordinary named int64 through reflection. It is not recognized and encodes as
ticks unless you forward codecs. UUID storage alone likewise cannot prove a UUID
declaration. Use the marker and compile-time assertion to make intent explicit.
For standard encoding/json, nil concept pointers encode as null, and pointer
fields can decode null to nil without invoking the scalar decoder. Non-pointer
shared scalar codecs reject null; your forwarding decoders should preserve that
behavior. CheckJSON always rejects null because pointer presence belongs to the
consumer, not the scalar representation.
There is no universal wrapper, serializer, factory, decimal, enum conversion, DateTime/DateTimeOffset concept representation, or reflective value extraction. See the parity map for the C# disposition and deliberate differences.