trogonobjectid

package module
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Oct 22, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

README

TrogonObjectID

TrogonObjectID gives Go services type-safe, prefixed object IDs such as user_abc-123. It is the Go counterpart of the Elixir package trogon_object_id and uses the same string forms.

Each ID type is a distinct Go type, so the compiler rejects a UserID where an OrderID is expected. A raw string ID loses its meaning when it leaves the function that made it.

The prefix stays in logs even when the database and the wire carry bare UUIDs. String, fmt and log/slog always emit the full form. JSON and database/sql follow the format that you configure.

Getting Started

go get github.com/TrogonStack/trogonobjectid
Define an ID type
type invoiceType struct{}

func (invoiceType) Definition() trogonobjectid.Definition {
	return trogonobjectid.Definition{
		ObjectType:    "inv",
		StorageFormat: trogonobjectid.FormatDropPrefix,
		JSONFormat:    trogonobjectid.FormatDropPrefix,
		Validate:      trogonobjectid.ValidateUUID,
		Generate:      trogonobjectid.GenerateUUIDv7,
	}
}

type InvoiceID = trogonobjectid.ID[invoiceType]
Use it
id, err := trogonobjectid.Generate[invoiceType]()
id, err = trogonobjectid.New[invoiceType]("0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f")
id, err = trogonobjectid.Parse[invoiceType]("inv_0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f")

id.String()  // "inv_0199e3a1-..."  (fmt and slog use this form too)
id.Bare()    // "0199e3a1-..."
id.Storage() // "0199e3a1-..."      (StorageFormat)

slog.Info("charged", "invoice_id", id) // invoice_id=inv_0199e3a1-...
Unions

A Union holds one ID of any of several types and picks the type by prefix:

type principalUnion struct{}

func (principalUnion) Types() []trogonobjectid.MemberType {
	return []trogonobjectid.MemberType{
		trogonobjectid.MemberOf[userType](),
		trogonobjectid.MemberOf[systemType](),
	}
}

type PrincipalID = trogonobjectid.Union[principalUnion]

p, err := trogonobjectid.ParseUnion[principalUnion]("system_cron")
if sys, ok := trogonobjectid.UnionAs[systemType](p); ok {
	// sys.Bare() == "cron"
}

A union always encodes the full form, in JSON, SQL and Spanner too, because the prefix is the only thing that tells the member types apart. When prefixes overlap, such as acme_ and acme_admin_, the longest prefix wins.

Read the prefix from a proto annotation

Package objectidproto reads object_type and separator from the trogon.object_id.v1alpha1.enum_value annotation on an enum value, so every language that uses the proto shares one prefix. The other fields come from the base Definition you pass:

var ticketDefinition = objectidproto.MustDefinition(acmev1.ObjectType_OBJECT_TYPE_TICKET, trogonobjectid.Definition{
	Validate: trogonobjectid.ValidateUUID,
	Generate: trogonobjectid.GenerateUUIDv7,
})

type ticketType struct{}

func (ticketType) Definition() trogonobjectid.Definition { return ticketDefinition }

The package caches each type's definition, so Definition must always return the same value. ObjectTypeOf[T]() and PrefixOf[T]() read it without an ID.

Options

Field Default Effect
ObjectType required Prefix without the separator, for example "user". Lowercase snake_case of at most 63 characters, the TypeID prefix rule
Separator DefaultSeparator ("_") Goes between the object type and the bare value
StorageFormat FormatFull Form for Storage, FromStorage, Value and Scan
JSONFormat FormatFull Form for MarshalJSON and UnmarshalJSON
Validate none Checks the bare value; use ValidateUUID, ValidateInteger or your own function. ValidateUUID accepts only the lowercase hyphenated form
Generate none Makes a bare value for Generate; use GenerateUUIDv7, GenerateUUIDv4 or your own function
Aliases none Earlier object types that parsing still accepts. Encoders always emit ObjectType, so a prefix can be renamed without breaking stored or sent values
Check definitions in a test

Check[T]() and CheckUnion[U]() report a bad definition before the first ID is built: an invalid object type, a generator that the type's own validation rejects, or duplicate union prefixes. The Elixir package does these checks at compile time.

func TestIDDefinitions(t *testing.T) {
	require.NoError(t, trogonobjectid.Check[invoiceType]())
	require.NoError(t, trogonobjectid.CheckUnion[principalUnion]())
}

A value without one of the type's prefixes fails with a *WrongPrefixError, which lists the prefixes the type accepts. It unwraps to ErrInvalidFormat. Every parse error is a *ValidationError with the ObjectTypes it was checked against (every member's for a union) and the input Value.

ObjectType, Separator and Prefix are named string types, so an object type, a separator and a prefix cannot be swapped by mistake. Bare() and the parse inputs stay string, because they are the wire and storage boundary.

Work with UUIDs

FromUUID[T](u) builds an ID from a uuid.UUID, and id.UUID() returns the bare value as a uuid.UUID. Scan and DecodeSpanner also accept uuid.UUID and [16]byte, for drivers and columns that carry native UUIDs. These need StorageFormat: FormatDropPrefix, because a native UUID has no prefix.

Deterministic IDs in tests

Package objectidtest gives a test the same valid ID for the same seed on every run:

buyer := objectidtest.FromSeed[fanType]("buyer") // fan_<UUIDv5 of "buyer">

FromSeedIn takes another UUIDv5 namespace, to match IDs that another service's test helpers derive.

Emitted forms

Method Emits Controlled by
String, fmt verbs Full Always full
LogValue (log/slog) Full Always full
MarshalText, UnmarshalText Full Always full
MarshalJSON, UnmarshalJSON Full or bare JSONFormat
Storage, FromStorage Full or bare StorageFormat
Value, Scan (database/sql) Full or bare StorageFormat
EncodeSpanner, DecodeSpanner (Cloud Spanner) Full or bare; unions always full StorageFormat

LogValue exists because slog.JSONHandler otherwise calls MarshalJSON, which logs the bare value when JSONFormat is FormatDropPrefix. MarshalText always uses the full form because JSON map keys and flag values must parse back without other context.

Decoders accept only the form that the matching encoder emits, so each encoding round trips. The zero ID means "absent": it encodes as SQL NULL, Spanner NULL and JSON null, and the JSON omitzero tag option skips it.

The Spanner methods implement spanner.Encoder and spanner.Decoder by method set, so the package does not import the Spanner client.

Documentation

Overview

Package trogonobjectid provides type-safe, prefixed object IDs such as "user_abc-123". It is the Go counterpart of the Elixir package trogon_object_id and uses the same string forms, so IDs read the same in both languages.

Defining a type

Each ID type is an ID with its own marker type, so the compiler rejects a UserID where an OrderID is expected:

type invoiceType struct{}

func (invoiceType) Definition() trogonobjectid.Definition {
	return trogonobjectid.Definition{
		ObjectType:    "inv",
		StorageFormat: trogonobjectid.FormatDropPrefix,
		JSONFormat:    trogonobjectid.FormatDropPrefix,
		Validate:      trogonobjectid.ValidateUUID,
		Generate:      trogonobjectid.GenerateUUIDv7,
	}
}

type InvoiceID = trogonobjectid.ID[invoiceType]

Emitted forms

An ID has a full form, "inv_0199e3a1-...", and a bare form, "0199e3a1-...".

Method                          Emits             Controlled by
------                          -----             -------------
String, fmt verbs               Full              Always full
LogValue (log/slog)             Full              Always full
MarshalText, UnmarshalText      Full              Always full
MarshalJSON, UnmarshalJSON      Full or bare      Definition.JSONFormat
Storage, FromStorage            Full or bare      Definition.StorageFormat
Value, Scan (database/sql)      Full or bare      Definition.StorageFormat

Logs always carry the full form, so a log line tells you which kind of ID it holds even when the database and the wire carry bare UUIDs. Decoding accepts only the form that the matching encoder emits, so every encoding round trips.

Unions

A Union holds one ID of any of several member types and picks the member type by prefix. It differs from the Elixir UnionObjectId in two ways. When prefixes overlap, such as "acme_" and "acme_admin_", the longest prefix wins. JSON always uses the full form, even for a member that drops the prefix, because without the prefix the union cannot tell its member types apart when it decodes.

Zero value

The zero ID means "absent". It encodes as SQL NULL and JSON null, String returns "", and IsZero reports true, so the json "omitzero" tag option skips it.

Example
id := trogonobjectid.MustNew[invoiceType]("0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f")

data, _ := json.Marshal(id)
fmt.Println(id)
fmt.Println(id.Storage())
fmt.Println(string(data))
Output:
inv_0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f
0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f
"0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f"

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrEmptyValue means the bare value is empty.
	ErrEmptyValue = errors.New("empty value")
	// ErrInvalidFormat means the input does not have the expected prefix or Go type.
	ErrInvalidFormat = errors.New("invalid format")
	// ErrInvalidUUID means ValidateUUID rejected the bare value.
	ErrInvalidUUID = errors.New("invalid uuid")
	// ErrInvalidInteger means ValidateInteger rejected the bare value.
	ErrInvalidInteger = errors.New("invalid integer")
	// ErrInvalidDefinition means the marker type's Definition is not usable.
	ErrInvalidDefinition = errors.New("trogonobjectid: invalid definition")
	// ErrNotMember means the ID passed to NewUnion is not one of the union's member types.
	ErrNotMember = errors.New("not a union member")
	// ErrNoGenerator means Generate was called on a type without a Generate function.
	ErrNoGenerator = errors.New("trogonobjectid: no generator")
)

Functions

func Check added in v0.4.0

func Check[T Definer]() error

Check reports whether T's Definition is usable: a valid object type, and a generator whose values pass the type's own Validate. Call it from a test or init, the way the Elixir package checks options at compile time.

func CheckUnion added in v0.4.0

func CheckUnion[U UnionDefiner]() error

CheckUnion reports whether U's member list is usable: at least one member, valid member definitions and no duplicate prefixes.

func GenerateUUIDv4

func GenerateUUIDv4() (string, error)

GenerateUUIDv4 returns a new random UUID string.

func GenerateUUIDv7

func GenerateUUIDv7() (string, error)

GenerateUUIDv7 returns a new time-ordered UUID string.

func ValidateInteger

func ValidateInteger(id string) error

ValidateInteger accepts an optional sign followed by decimal digits, with no size limit, to match the Elixir package.

func ValidateUUID

func ValidateUUID(id string) error

ValidateUUID accepts only the canonical form: 36 characters, lowercase, hyphenated. It rejects upper case, URN and brace forms, so one UUID cannot be spelled as two IDs.

Types

type Definer

type Definer interface {
	Definition() Definition
}

Definer is implemented by the marker type that names an object ID type. The result of Definition is cached per type, so it must always return the same value. Implement it on an empty struct with a value receiver:

type userType struct{}

func (userType) Definition() trogonobjectid.Definition {
	return trogonobjectid.Definition{ObjectType: "user"}
}

type UserID = trogonobjectid.ID[userType]

type Definition

type Definition struct {
	// ObjectType is the prefix without the separator, for example "user". It is required.
	ObjectType ObjectType
	// Separator goes between the object type and the bare value. Empty means DefaultSeparator.
	Separator Separator
	// StorageFormat controls Storage, FromStorage, Value and Scan.
	StorageFormat Format
	// JSONFormat controls MarshalJSON and UnmarshalJSON.
	JSONFormat Format
	// Validate checks the bare value. Nil accepts any non-empty value.
	Validate func(id string) error
	// Generate returns a new bare value for Generate. Nil makes Generate fail.
	Generate func() (string, error)
	// Aliases are earlier object types that parsing still accepts. Every encoder emits
	// ObjectType, so stored and sent values move to the new prefix as they are rewritten.
	Aliases []ObjectType
}

Definition configures an object ID type.

type Format

type Format int

Format selects whether an encoding keeps the prefix.

const (
	// FormatFull emits the prefixed form, for example "user_abc-123".
	FormatFull Format = iota
	// FormatDropPrefix emits only the bare value, for example "abc-123".
	FormatDropPrefix
)

type ID

type ID[T Definer] struct {
	// contains filtered or unexported fields
}

ID is a type-safe object ID. IDs with different marker types are different Go types, so a UserID cannot be passed where an OrderID is expected.

The zero value is an absent ID: it encodes as SQL NULL and JSON null. String, MarshalText and LogValue always use the prefixed form, so logs and URLs stay unambiguous no matter how the type is stored or sent as JSON.

func FromStorage

func FromStorage[T Definer](value string) (ID[T], error)

FromStorage returns the ID for a value in the type's StorageFormat. It is the inverse of Storage.

func FromUUID added in v0.4.0

func FromUUID[T Definer](u uuid.UUID) (ID[T], error)

FromUUID returns the ID for u, after the type's Validate.

Example
u := uuid.MustParse("0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f")
id, _ := trogonobjectid.FromUUID[invoiceType](u)
back, _ := id.UUID()
fmt.Println(id)
fmt.Println(back == u)
Output:
inv_0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f
true

func Generate

func Generate[T Definer]() (ID[T], error)

Generate returns a new ID from the type's Generate function.

func MustNew

func MustNew[T Definer](id string) ID[T]

MustNew is like New but panics on error. Use it for constants and tests.

func MustParse

func MustParse[T Definer](s string) ID[T]

MustParse is like Parse but panics on error. Use it for constants and tests.

func New

func New[T Definer](id string) (ID[T], error)

New returns an ID for the bare value id, after validation.

func Parse

func Parse[T Definer](s string) (ID[T], error)

Parse returns the ID for the prefixed form s, for example "user_abc-123".

Example
id, err := trogonobjectid.Parse[userType]("user_abc-123")
if err != nil {
	panic(err)
}
fmt.Println(id.Bare())

_, err = trogonobjectid.Parse[userType]("order_abc-123")
fmt.Println(err)
Output:
abc-123
trogonobjectid: invalid user value "order_abc-123": invalid format: want prefix ["user_"]

func UnionAs added in v0.4.0

func UnionAs[T Definer, U UnionDefiner](u Union[U]) (ID[T], bool)

UnionAs returns u's member as an ID[T], and false when u holds another type or is zero. It narrows a union without a type switch.

func (ID[T]) Bare

func (id ID[T]) Bare() string

Bare returns the value without the prefix, for example "abc-123".

func (*ID[T]) DecodeSpanner added in v0.2.0

func (id *ID[T]) DecodeSpanner(val any) error

DecodeSpanner implements spanner.Decoder for STRING values in the type's StorageFormat. NULL gives the zero ID.

func (ID[T]) EncodeSpanner added in v0.2.0

func (id ID[T]) EncodeSpanner() (any, error)

EncodeSpanner implements spanner.Encoder with the type's StorageFormat. The zero ID is a NULL STRING; an untyped nil would leave Spanner without a column type.

Example
value, _ := trogonobjectid.MustNew[invoiceType]("0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f").EncodeSpanner()
fmt.Println(value)

null, _ := trogonobjectid.ID[invoiceType]{}.EncodeSpanner()
fmt.Println(null == (*string)(nil))
Output:
0199e3a1-7c2b-7d4e-9f10-2a3b4c5d6e7f
true

func (ID[T]) IsZero

func (id ID[T]) IsZero() bool

IsZero reports whether id is the zero (absent) ID.

func (ID[T]) LogValue

func (id ID[T]) LogValue() slog.Value

LogValue implements slog.LogValuer. Without it, slog.JSONHandler would call MarshalJSON and log the bare value for types that drop the prefix in JSON.

func (ID[T]) MarshalJSON

func (id ID[T]) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler with the type's JSONFormat. The zero ID is null.

func (ID[T]) MarshalText

func (id ID[T]) MarshalText() ([]byte, error)

MarshalText implements encoding.TextMarshaler with the prefixed form. JSON map keys and flag values use it, and they must parse back without other context.

func (ID[T]) ObjectType

func (id ID[T]) ObjectType() ObjectType

ObjectType returns the type's object type, for example "user".

func (ID[T]) Prefix

func (id ID[T]) Prefix() Prefix

Prefix returns the object type and separator, for example "user_".

func (*ID[T]) Scan

func (id *ID[T]) Scan(src any) error

Scan implements sql.Scanner for values in the type's StorageFormat. NULL and the empty string give the zero ID.

func (ID[T]) Storage

func (id ID[T]) Storage() string

Storage returns the value in the type's StorageFormat, or "" for the zero ID.

func (ID[T]) String

func (id ID[T]) String() string

String returns the prefixed form, for example "user_abc-123", or "" for the zero ID.

func (ID[T]) UUID added in v0.4.0

func (id ID[T]) UUID() (uuid.UUID, error)

UUID returns the bare value as a uuid.UUID, so callers that work with UUIDs do not parse Bare themselves. The zero ID fails with ErrEmptyValue.

func (*ID[T]) UnmarshalJSON

func (id *ID[T]) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler with the type's JSONFormat. Null gives the zero ID.

func (*ID[T]) UnmarshalText

func (id *ID[T]) UnmarshalText(text []byte) error

UnmarshalText implements encoding.TextUnmarshaler. Empty text gives the zero ID.

func (ID[T]) Value

func (id ID[T]) Value() (driver.Value, error)

Value implements driver.Valuer with the type's StorageFormat. The zero ID is NULL.

type Member

type Member interface {
	fmt.Stringer
	Prefix() Prefix
	IsZero() bool
}

Member is an ID that a Union can hold. Every ID[T] implements it.

type MemberType

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

MemberType describes one ID type that a Union accepts. Make it with MemberOf.

func MemberOf

func MemberOf[T Definer]() MemberType

MemberOf returns the MemberType for ID[T].

type ObjectType added in v0.4.0

type ObjectType string

ObjectType names an object ID type, for example "user". It is lowercase snake_case of at most 63 characters, the TypeID prefix rule.

func ObjectTypeOf added in v0.4.0

func ObjectTypeOf[T Definer]() ObjectType

ObjectTypeOf returns T's object type, for example "user". It panics if T's Definition is invalid.

type Prefix added in v0.4.0

type Prefix string

Prefix is an object type followed by its separator, for example "user_".

func PrefixOf added in v0.4.0

func PrefixOf[T Definer]() Prefix

PrefixOf returns T's object type and separator, for example "user_". It panics if T's Definition is invalid.

type Separator added in v0.4.0

type Separator string

Separator goes between the object type and the bare value.

const DefaultSeparator Separator = "_"

DefaultSeparator is the separator used when Definition.Separator is empty.

type Union

type Union[U UnionDefiner] struct {
	// contains filtered or unexported fields
}

Union holds one ID of any of its member types, chosen by prefix when parsing. Use UnionAs, or a type switch on Member, to get the typed ID back.

Every encoding uses the member's full form, because the prefix is the only thing that tells the member types apart. The zero value is an absent ID.

func MustNewUnion

func MustNewUnion[U UnionDefiner](m Member) Union[U]

MustNewUnion is like NewUnion but panics on error. Use it for constants and tests.

func MustParseUnion

func MustParseUnion[U UnionDefiner](str string) Union[U]

MustParseUnion is like ParseUnion but panics on error. Use it for constants and tests.

func NewUnion

func NewUnion[U UnionDefiner](m Member) (Union[U], error)

NewUnion wraps m, which must be a non-zero ID of one of the union's member types.

func ParseUnion

func ParseUnion[U UnionDefiner](str string) (Union[U], error)

ParseUnion parses the full form of any member type. When prefixes overlap, the longest matching prefix wins.

func (*Union[U]) DecodeSpanner added in v0.2.0

func (u *Union[U]) DecodeSpanner(val any) error

DecodeSpanner implements spanner.Decoder for STRING values in the full form.

func (Union[U]) EncodeSpanner added in v0.2.0

func (u Union[U]) EncodeSpanner() (any, error)

EncodeSpanner implements spanner.Encoder with the full form. The zero union is a NULL STRING, the same as the zero ID.

func (Union[U]) IsZero

func (u Union[U]) IsZero() bool

IsZero reports whether u is the zero (absent) union.

func (Union[U]) LogValue

func (u Union[U]) LogValue() slog.Value

LogValue implements slog.LogValuer with the full form.

func (Union[U]) MarshalJSON

func (u Union[U]) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler with the full form, ignoring the member's JSONFormat so that UnmarshalJSON can pick the member type again. The zero union is null.

func (Union[U]) MarshalText

func (u Union[U]) MarshalText() ([]byte, error)

MarshalText implements encoding.TextMarshaler with the full form.

func (Union[U]) Member

func (u Union[U]) Member() Member

Member returns the wrapped ID, or nil for the zero union.

func (*Union[U]) Scan

func (u *Union[U]) Scan(src any) error

Scan implements sql.Scanner for the full form. NULL and the empty string give the zero union.

func (Union[U]) String

func (u Union[U]) String() string

String returns the member's full form, or "" for the zero union.

func (*Union[U]) UnmarshalJSON

func (u *Union[U]) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler for the full form. Null gives the zero union.

func (*Union[U]) UnmarshalText

func (u *Union[U]) UnmarshalText(text []byte) error

UnmarshalText implements encoding.TextUnmarshaler. Empty text gives the zero union.

func (Union[U]) Value

func (u Union[U]) Value() (driver.Value, error)

Value implements driver.Valuer with the full form, ignoring the member's StorageFormat. The zero union is NULL.

type UnionDefiner

type UnionDefiner interface {
	Types() []MemberType
}

UnionDefiner is implemented by the marker type that names a Union:

type principalUnion struct{}

func (principalUnion) Types() []trogonobjectid.MemberType {
	return []trogonobjectid.MemberType{
		trogonobjectid.MemberOf[userType](),
		trogonobjectid.MemberOf[systemType](),
	}
}

type PrincipalID = trogonobjectid.Union[principalUnion]

type ValidationError

type ValidationError struct {
	ObjectTypes []ObjectType
	Value       string
	Err         error
}

ValidationError reports a value that is not a valid ID of the listed object types: one for an ID, every member's for a union. Err is ErrEmptyValue, ErrInvalidFormat, a *WrongPrefixError, or the error from Definition.Validate.

func (*ValidationError) Error

func (e *ValidationError) Error() string

func (*ValidationError) Unwrap

func (e *ValidationError) Unwrap() error

type WrongPrefixError added in v0.4.0

type WrongPrefixError struct {
	Want []Prefix
}

WrongPrefixError reports a value without one of the prefixes the type accepts, for example an order ID where an invoice ID is expected. ValidationError.Value holds the input. It unwraps to ErrInvalidFormat.

func (*WrongPrefixError) Error added in v0.4.0

func (e *WrongPrefixError) Error() string

func (*WrongPrefixError) Unwrap added in v0.4.0

func (e *WrongPrefixError) Unwrap() error

Directories

Path Synopsis
Package objectidproto builds a trogonobjectid.Definition from the trogon.object_id.v1alpha1.enum_value annotation on a protobuf enum value:
Package objectidproto builds a trogonobjectid.Definition from the trogon.object_id.v1alpha1.enum_value annotation on a protobuf enum value:
Package objectidtest builds deterministic object IDs for tests, so a test can name "the buyer" or "the seller" and get the same valid ID on every run:
Package objectidtest builds deterministic object IDs for tests, so a test can name "the buyer" or "the seller" and get the same valid ID on every run:

Jump to

Keyboard shortcuts

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