Skip to content
This repository was archived by the owner on Apr 29, 2026. It is now read-only.
109 changes: 109 additions & 0 deletions anyint64.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package typeid

import (
"crypto/rand"
"database/sql/driver"
"encoding/binary"
"fmt"
"strings"
"time"
)

// AnyInt64 is a compact identifier with a runtime-configurable prefix.
// Unlike [Int64], the prefix is not fixed at compile time.
type AnyInt64 struct {
val int64
prefix string
}

func NewAnyInt64(prefix string) (AnyInt64, error) {
ms := time.Now().UnixMilli()

var rb [2]byte
if _, err := rand.Read(rb[:]); err != nil {
return AnyInt64{}, fmt.Errorf("typeid: crypto/rand: %w", err)
}
r := int64(binary.BigEndian.Uint16(rb[:]) & 0x7FFF)

return AnyInt64{val: (ms << randomBits) | r, prefix: prefix}, nil
}

func AnyInt64From(prefix string, v int64) (AnyInt64, error) {
if v <= 0 {
return AnyInt64{}, ErrNonPositiveInt
}
return AnyInt64{val: v, prefix: prefix}, nil
}

func ParseAnyInt64(s string) (AnyInt64, error) {
j := strings.LastIndex(s, "_") + 1
pref, suffix := s[:max(0, j-1)], s[j:]
if len(suffix) != int64SuffixLen {
return AnyInt64{}, fmt.Errorf("typeid: invalid format: %q", s)
}
v, err := decodeBase32Int64(suffix)
if err != nil {
return AnyInt64{}, err
}
if v <= 0 {
return AnyInt64{}, ErrNonPositiveInt
}
return AnyInt64{val: v, prefix: pref}, nil
}

func (id AnyInt64) Int64() int64 { return id.val }
func (id AnyInt64) Prefix() string { return id.prefix }
func (id *AnyInt64) SetPrefix(s string) {
id.prefix = s
}

func (id AnyInt64) appendText(dst []byte) []byte {
return appendBase32Int64(dst, id.prefix, id.val)
}

func (id AnyInt64) String() string {
var buf [64]byte
return string(id.appendText(buf[:0]))
}

func (id AnyInt64) IsZero() bool { return id.val == 0 }

func (id AnyInt64) MarshalText() ([]byte, error) {
if id.val <= 0 {
return nil, ErrNonPositiveInt
}
return id.appendText(nil), nil
}

func (id *AnyInt64) UnmarshalText(data []byte) error {
parsed, err := ParseAnyInt64(string(data))
if err != nil {
return err
}
*id = parsed
return nil
}

func (id AnyInt64) Value() (driver.Value, error) {
if id.val <= 0 {
return nil, ErrNonPositiveInt
}
return id.val, nil
}

func (id *AnyInt64) Scan(src any) error {
var v int64
switch sv := src.(type) {
case int64:
v = sv
case int:
v = int64(sv)
default:
return fmt.Errorf("typeid: cannot scan %T into AnyInt64", src)
}
if v <= 0 {
return ErrNonPositiveInt
}
id.val = v
return nil
}
124 changes: 124 additions & 0 deletions anyuuid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package typeid

import (
"database/sql/driver"
"encoding/binary"
"fmt"
"strings"
"time"

"github.com/google/uuid"
)

// AnyUUID is a UUIDv7 identifier with a runtime-configurable prefix.
// Unlike [UUID], the prefix is not fixed at compile time.
type AnyUUID struct {
val uuid.UUID
prefix string
}

func NewAnyUUID(prefix string) (AnyUUID, error) {
u, err := uuid.NewV7()
if err != nil {
return AnyUUID{}, err
}
return AnyUUID{val: u, prefix: prefix}, nil
}

func AnyUUIDFrom(prefix string, u uuid.UUID) (AnyUUID, error) {
if u.Version() != 7 {
return AnyUUID{}, ErrOnlyV7
}
return AnyUUID{val: u, prefix: prefix}, nil
}

func ParseAnyUUID(s string) (AnyUUID, error) {
j := strings.LastIndex(s, "_") + 1
pref, suffix := s[:max(0, j-1)], s[j:]
if len(suffix) != uuidSuffixLen {
return AnyUUID{}, fmt.Errorf("typeid: invalid format: %q", s)
}
b, err := decodeBase32UUID(suffix)
if err != nil {
return AnyUUID{}, err
}
u := uuid.UUID(b)
if u.Version() != 7 {
return AnyUUID{}, ErrOnlyV7
}
return AnyUUID{val: u, prefix: pref}, nil
}

func (id AnyUUID) UUID() uuid.UUID { return id.val }
func (id AnyUUID) Prefix() string { return id.prefix }
func (id *AnyUUID) SetPrefix(s string) {
id.prefix = s
}

func (id AnyUUID) appendText(dst []byte) []byte {
return appendBase32UUID(dst, id.prefix, id.val)
}

func (id AnyUUID) String() string {
var buf [64]byte
return string(id.appendText(buf[:0]))
}

func (id AnyUUID) IsZero() bool { return id.val == uuid.UUID{} }

func (id AnyUUID) MarshalText() ([]byte, error) {
if id.IsZero() {
return nil, ErrZeroUUID
}
return id.appendText(nil), nil
}

func (id *AnyUUID) UnmarshalText(data []byte) error {
parsed, err := ParseAnyUUID(string(data))
if err != nil {
return err
}
*id = parsed
return nil
}

func (id AnyUUID) Value() (driver.Value, error) {
if id.IsZero() {
return nil, ErrZeroUUID
}
return id.val.String(), nil
}

func (id *AnyUUID) Scan(src any) (err error) {
var u uuid.UUID
switch v := src.(type) {
case string:
if u, err = uuid.Parse(v); err != nil {
return err
}
case []byte:
switch {
case len(v) == 16:
copy(u[:], v)
default:
if u, err = uuid.ParseBytes(v); err != nil {
return err
}
}
case [16]byte:
u = uuid.UUID(v)
default:
return fmt.Errorf("typeid: cannot scan %T into AnyUUID", src)
}
if u.Version() != 7 {
return ErrOnlyV7
}
id.val = u
return nil
}

// GetTime extracts the millisecond-precision creation timestamp from the UUIDv7.
func (id AnyUUID) GetTime() time.Time {
ms := int64(binary.BigEndian.Uint16(id.val[:2]))<<32 | int64(binary.BigEndian.Uint32(id.val[2:6]))
return time.UnixMilli(ms)
}
17 changes: 13 additions & 4 deletions crockford.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package typeid
import (
"encoding/binary"
"fmt"
"slices"

"github.com/google/uuid"
)
Expand Down Expand Up @@ -32,8 +33,12 @@ func decodeChar(c byte) (byte, error) {
return v, nil
}

// UUID encoding (128 bits -> 26 chars, appended to dst)
func appendBase32UUID(dst []byte, u uuid.UUID) []byte {
// appendBase32UUID appends an optional prefix and underscore, then the 26-char typeid suffix for u.
func appendBase32UUID(dst []byte, prefix string, u uuid.UUID) []byte {
dst = slices.Grow(dst, uuidSuffixLen+len(prefix)+min(1, len(prefix)))
if len(prefix) > 0 {
dst = append(append(dst, prefix...), '_')
}
hi := binary.BigEndian.Uint64(u[:8])
lo := binary.BigEndian.Uint64(u[8:])

Expand Down Expand Up @@ -101,8 +106,12 @@ func decodeBase32UUID(s string) ([16]byte, error) {
return out, nil
}

// Int64 encoding (63 bits -> 13 chars, appended to dst)
func appendBase32Int64(dst []byte, n int64) []byte {
// appendBase32Int64 appends an optional prefix and underscore, then the 13-char typeid suffix for n.
func appendBase32Int64(dst []byte, prefix string, n int64) []byte {
dst = slices.Grow(dst, int64SuffixLen+len(prefix)+min(1, len(prefix)))
if len(prefix) > 0 {
dst = append(append(dst, prefix...), '_')
}
u := uint64(n)
var buf [int64SuffixLen]byte

Expand Down
13 changes: 8 additions & 5 deletions int64.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,15 @@ func ParseInt64[P Prefixer](s string) (Int64[P], error) {

func (id Int64[P]) appendText(dst []byte) []byte {
var p P
dst = growSlice(dst, len(p.Prefix())+1+int64SuffixLen)
return appendBase32Int64(appendID[P](dst), id.val)
return appendBase32Int64(dst, p.Prefix(), id.val)
}
func (id Int64[P]) String() string { return string(id.appendText(nil)) }
func (id Int64[P]) Int64() int64 { return id.val }
func (id Int64[P]) IsZero() bool { return id.val == 0 }

func (id Int64[P]) String() string {
var buf [64]byte
return string(id.appendText(buf[:0]))
}
func (id Int64[P]) Int64() int64 { return id.val }
func (id Int64[P]) IsZero() bool { return id.val == 0 }
func (id Int64[P]) MarshalText() ([]byte, error) {
if id.val <= 0 {
return nil, ErrNonPositiveInt
Expand Down
96 changes: 96 additions & 0 deletions int64_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,102 @@ func BenchmarkInt64_Parse(b *testing.B) {
}
}

// ExampleAnyInt64_switchToTypedInt64 narrows [AnyInt64] to [Int64] after a prefix switch.
func ExampleAnyInt64_switchToTypedInt64() {
const payload = `{"id":"org_01hf7yat00c1s"}`
type Request struct {
ID typeid.AnyInt64 `json:"id"`
}
var req Request
if err := json.Unmarshal([]byte(payload), &req); err != nil {
fmt.Println("unmarshal:", err)
return
}

var orgID OrgID
var err error
switch req.ID.Prefix() {
case "org":
orgID, err = typeid.Int64From[orgPrefix](req.ID.Int64())
default:
fmt.Println("unknown prefix")
return
}
if err != nil {
fmt.Println("narrow:", err)
return
}
fmt.Println(orgID.String())
// Output:
// org_01hf7yat00c1s
}

func TestAnyInt64_json(t *testing.T) {
type Request struct {
ID typeid.AnyInt64 `json:"id"`
}

suffix := "01hf7yat00c1s"
inputs := []string{
`{"id":"whatever_` + suffix + `"}`,
`{"id":"other_prefix_` + suffix + `"}`,
`{"id":"` + suffix + `"}`,
}
for _, raw := range inputs {
var req Request
if err := json.Unmarshal([]byte(raw), &req); err != nil {
t.Fatalf("Unmarshal %s: %v", raw, err)
}
if req.ID.Int64() <= 0 {
t.Fatalf("expected positive Int64, got %d", req.ID.Int64())
}
}
}

func TestAnyInt64_prefixAndSetPrefix(t *testing.T) {
suffix := "01hf7yat00c1s"
id, err := typeid.ParseAnyInt64("foo_" + suffix)
if err != nil {
t.Fatal(err)
}
if got := id.Prefix(); got != "foo" {
t.Fatalf("Prefix() = %q, want foo", got)
}

id.SetPrefix("bar")
if got := id.Prefix(); got != "bar" {
t.Fatalf("after SetPrefix, Prefix() = %q, want bar", got)
}
wantText := "bar_" + suffix
if got, _ := id.MarshalText(); string(got) != wantText {
t.Fatalf("MarshalText = %q, want %q", got, wantText)
}
}

func TestAnyInt64_narrowToOrgPrefix(t *testing.T) {
suffix := "01hf7yat00c1s"
anyID, err := typeid.ParseAnyInt64("org_" + suffix)
if err != nil {
t.Fatal(err)
}
var orgID OrgID
switch anyID.Prefix() {
case "org":
orgID, err = typeid.Int64From[orgPrefix](anyID.Int64())
default:
t.Fatalf("unexpected prefix %q", anyID.Prefix())
}
if err != nil {
t.Fatal(err)
}
if orgID.Int64() != anyID.Int64() {
t.Errorf("Int64 mismatch")
}
if got := orgID.String(); got != "org_"+suffix {
t.Errorf("String() = %q", got)
}
}

func TestInt64_Sortable(t *testing.T) {
a, err := typeid.NewInt64[orgPrefix]()
if err != nil {
Expand Down
Loading