typeid

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: May 6, 2026 License: Apache-2.0, MIT Imports: 10 Imported by: 0

README

typeid

Prefixed, base32-encoded, k-sortable identifiers for Go. Inspired by Stripe API IDs and the TypeID spec.

GoDoc Widget Apache 2.0 License MIT License

Identifier format

user_01kmfjypewe1wrfeb01wjfxand       UUID  — 26-char suffix
└──┘ └────────────────────────┘
type   Crockford base32

org_01kmfjypewdwg                     Int64 — 13-char suffix
└─┘ └───────────┘
type  Crockford base32

The alphabet is Crockford base32 (lowercase) and excludes ambiguous characters: i, l, o, u.

Two flavours

Both are UUIDv7-based and sort by creation time (no UUIDv4 — sortability gives good DB locality and time-ordered IDs).

Type Backing Postgres Suffix When to use
UUID[P] 128 bit uuid 26 chars Any throughput. Users, events, logs — use by default.
Int64[P] 63 bit BIGINT 13 chars <~100 IDs/sec. Orgs, tenants — compact IDs, 15 random bits; use UNIQUE + retry on conflict.

Currently, the UUID type is backed by github.com/google/uuid but we plan to switch to Go's uuid package. once available. This will likely be a breaking change before we release v1.

Usage

Define typed IDs
import "github.com/0xPolygon/typeid"

type userPrefix struct{}
func (userPrefix) Prefix() string { return "user" }

type UserID = typeid.UUID[userPrefix]

type orgPrefix struct{}
func (orgPrefix) Prefix() string { return "org" }

type OrgID = typeid.Int64[orgPrefix]
Create new IDs
userID, err := typeid.NewUUID[userPrefix]()   // user_01kmfjypewe1wrfeb01wjfxand
orgID,  err := typeid.NewInt64[orgPrefix]()   // org_01kmfjypewdwg
Parse from string
id, err := typeid.ParseUUID[userPrefix]("user_01kmfjypewe1wrfeb01wjfxand")
id, err := typeid.ParseInt64[orgPrefix]("org_01kmfjypewdwg")

Parsing validates the prefix at compile time — passing "org_..." to ParseUUID[userPrefix] returns an error.

Wrap raw values
id, err := typeid.UUIDFrom[userPrefix](rawUUID)   // rejects non-UUIDv7
id, err := typeid.Int64From[orgPrefix](rawInt64)   // rejects non-positive
Use in structs
type User struct {
    ID   UserID `json:"id"`
    Name string `json:"name"`
}

type Org struct {
    ID   OrgID  `json:"id"`
    Name string `json:"name"`
}

Serialisation

Both types implement:

Interface Behaviour
fmt.Stringer "prefix_base32suffix"
encoding.TextMarshaler / TextUnmarshaler Same text form (JSON uses this automatically)
driver.Valuer UUID[P] → UUID string, Int64[P]int64
sql.Scanner UUID[P]string/[]byte/[16]byte, Int64[P]int64

Int64 bit layout

[48-bit unix ms timestamp][15-bit crypto/rand] = 63 bits, always positive

Stored as Postgres BIGINT. Collision table: 10 IDs/sec → ~1 per 7,500 days; 100/sec → ~1 per 1.8 hours; 1,000/sec → ~1 per 65 seconds.

Benchmarks

Apple M4 Pro, Go 1.26.1:

BenchmarkInt64_String         ~19 ns/op    24 B/op    1 allocs/op
BenchmarkInt64_MarshalText    ~18 ns/op    24 B/op    1 allocs/op
BenchmarkInt64_Parse          ~18 ns/op     0 B/op    0 allocs/op
BenchmarkUUID_String          ~24 ns/op    32 B/op    1 allocs/op
BenchmarkUUID_MarshalText     ~23 ns/op    32 B/op    1 allocs/op
BenchmarkUUID_Parse           ~33 ns/op     0 B/op    0 allocs/op

Parse is zero-allocation. Encode paths do a single allocation for the output buffer.

License

Copyright (c) 2026 PT Services DMCC

Licensed under either:

as your option.

The SPDX license identifier for this project is MIT OR Apache-2.0.

Contribution

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

Documentation

Overview

Example
package main

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

	"github.com/0xPolygon/typeid"
)

// Prefix definitions — in practice these live next to each domain entity.

type userPrefix struct{}

func (userPrefix) Prefix() string { return "user" }

type orgPrefix struct{}

func (orgPrefix) Prefix() string { return "org" }

// Type aliases give readable names.
type (
	UserID = typeid.UUID[userPrefix]
	OrgID  = typeid.Int64[orgPrefix]
)

// Compile-time interface checks.
var (
	_ fmt.Stringer             = UserID{}
	_ fmt.Stringer             = OrgID{}
	_ fmt.Stringer             = typeid.AnyUUID{}
	_ fmt.Stringer             = typeid.AnyInt64{}
	_ encoding.TextMarshaler   = UserID{}
	_ encoding.TextMarshaler   = OrgID{}
	_ encoding.TextMarshaler   = typeid.AnyUUID{}
	_ encoding.TextMarshaler   = typeid.AnyInt64{}
	_ encoding.TextUnmarshaler = (*UserID)(nil)
	_ encoding.TextUnmarshaler = (*OrgID)(nil)
	_ encoding.TextUnmarshaler = (*typeid.AnyUUID)(nil)
	_ encoding.TextUnmarshaler = (*typeid.AnyInt64)(nil)
	_ driver.Valuer            = UserID{}
	_ driver.Valuer            = OrgID{}
	_ driver.Valuer            = typeid.AnyUUID{}
	_ driver.Valuer            = typeid.AnyInt64{}
	_ sql.Scanner              = (*UserID)(nil)
	_ sql.Scanner              = (*OrgID)(nil)
	_ sql.Scanner              = (*typeid.AnyUUID)(nil)
	_ sql.Scanner              = (*typeid.AnyInt64)(nil)
)

func main() {
	orgID, err := typeid.NewInt64[orgPrefix]()
	if err != nil {
		panic(err)
	}

	userID, err := typeid.NewUUID[userPrefix]()
	if err != nil {
		panic(err)
	}

	fmt.Println(strings.HasPrefix(orgID.String(), "org_"))
	fmt.Println(strings.HasPrefix(userID.String(), "user_"))
}
Output:
true
true

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	ErrOnlyV7         = errors.New("typeid: only UUIDv7 is supported")
	ErrZeroUUID       = errors.New("typeid: zero UUID")
	ErrNonPositiveInt = errors.New("typeid: non-positive Int64")
	ErrOverflowBase32 = errors.New("typeid: base32 overflow at pos 0")
	ErrOverflowInt64  = errors.New("typeid: value overflows int64")
)

Functions

This section is empty.

Types

type AnyInt64

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

AnyInt64 is a compact identifier with a runtime-configurable prefix. Unlike Int64, the prefix is not fixed at compile time.

Example (SwitchToTypedInt64)

ExampleAnyInt64_switchToTypedInt64 narrows AnyInt64 to Int64 after a prefix switch.

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 AnyInt64From

func AnyInt64From(prefix string, v int64) (AnyInt64, error)

func NewAnyInt64

func NewAnyInt64(prefix string) (AnyInt64, error)

func ParseAnyInt64

func ParseAnyInt64(s string) (AnyInt64, error)

func (AnyInt64) GetTime added in v0.3.0

func (id AnyInt64) GetTime() time.Time

GetTime extracts the millisecond-precision creation timestamp from the upper 48 bits of the value.

func (AnyInt64) Int64

func (id AnyInt64) Int64() int64

func (AnyInt64) IsZero

func (id AnyInt64) IsZero() bool

func (AnyInt64) MarshalCBOR added in v0.2.0

func (id AnyInt64) MarshalCBOR() ([]byte, error)

MarshalCBOR encodes the value as CBOR tag 39 wrapping an unsigned integer. Output is always 11 bytes (2-byte tag + 9-byte uint64) — fixed-width by design, not RFC 8949 §4.2.1 deterministic encoding. The decoder accepts all CBOR unsigned integer widths for interop.

func (AnyInt64) MarshalText

func (id AnyInt64) MarshalText() ([]byte, error)

func (AnyInt64) Prefix

func (id AnyInt64) Prefix() string

func (*AnyInt64) Scan

func (id *AnyInt64) Scan(src any) error

func (*AnyInt64) SetPrefix

func (id *AnyInt64) SetPrefix(s string)

func (AnyInt64) String

func (id AnyInt64) String() string

func (*AnyInt64) UnmarshalCBOR added in v0.2.0

func (id *AnyInt64) UnmarshalCBOR(data []byte) error

UnmarshalCBOR decodes CBOR tag 39 wrapping an unsigned integer into the AnyInt64. The prefix is not restored — call SetPrefix after unmarshaling if needed.

func (*AnyInt64) UnmarshalText

func (id *AnyInt64) UnmarshalText(data []byte) error

func (AnyInt64) Value

func (id AnyInt64) Value() (driver.Value, error)

type AnyUUID

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

AnyUUID is a UUIDv7 identifier with a runtime-configurable prefix. Unlike UUID, the prefix is not fixed at compile time.

Example (Json)
package main

import (
	"encoding/json"
	"fmt"

	"github.com/0xPolygon/typeid"
)

type Mode string

const (
	ModeLive    Mode = "key"
	ModeSandbox Mode = "key_sandbox"
)

type ApiKeyID struct {
	typeid.AnyUUID
}

func NewApiKeyID(mode Mode) ApiKeyID {
	u, err := typeid.NewAnyUUID(string(mode))
	if err != nil {
		panic(err) // can't happen unless crypto/rand is not available
	}
	return ApiKeyID{AnyUUID: u}
}

func (id *ApiKeyID) UnmarshalText(data []byte) error {
	if err := id.AnyUUID.UnmarshalText(data); err != nil {
		return err
	}
	switch id.AnyUUID.Prefix() {
	case string(ModeLive), string(ModeSandbox):
		return nil
	default:
		return fmt.Errorf("invalid api key prefix: %q", id.AnyUUID.Prefix())
	}
}

type Request struct {
	ID          ApiKeyID `json:"id"`
	Description string   `json:"description"`
}

func main() {
	// Sandbox
	data, _ := json.Marshal(Request{ID: NewApiKeyID(ModeSandbox), Description: "Sandbox API Key"})
	var sandboxRequest Request
	_ = json.Unmarshal(data, &sandboxRequest)

	// Live
	data, _ = json.Marshal(Request{ID: NewApiKeyID(ModeLive), Description: "Live API Key"})
	var liveRequest Request
	_ = json.Unmarshal(data, &liveRequest)

	// Invalid prefix, expect error
	data = []byte(`{"id":"key_invalid_prefix_01jcp1ss00edg828t5cy4tqkff", "description":"Invalid API Key"}`)
	var unknownRequest Request
	err := json.Unmarshal(data, &unknownRequest)

	fmt.Println(sandboxRequest.ID.Prefix())
	fmt.Println(liveRequest.ID.Prefix())
	fmt.Println(unknownRequest.ID.Prefix(), err)

}
Output:
key_sandbox
key
key_invalid_prefix invalid api key prefix: "key_invalid_prefix"
Example (SwitchToTypedUUID)

ExampleAnyUUID_switchToTypedUUID shows narrowing AnyUUID to UUID after inspecting AnyUUID.Prefix. Use UUIDFrom when the prefix matches; it keeps the same UUID bytes under the typed wrapper.

const payload = `{"id":"user_01jcp1ss00edg828t5cy4tqkff"}`
type Request struct {
	ID typeid.AnyUUID `json:"id"`
}
var req Request
if err := json.Unmarshal([]byte(payload), &req); err != nil {
	fmt.Println("unmarshal:", err)
	return
}

var userID UserID
var err error
switch req.ID.Prefix() {
case "user":
	userID, err = typeid.UUIDFrom[userPrefix](req.ID.UUID())
default:
	fmt.Println("unknown prefix")
	return
}
if err != nil {
	fmt.Println("narrow:", err)
	return
}
fmt.Println(userID.String())
Output:
user_01jcp1ss00edg828t5cy4tqkff

func AnyUUIDFrom

func AnyUUIDFrom(prefix string, u uuid.UUID) (AnyUUID, error)

func NewAnyUUID

func NewAnyUUID(prefix string) (AnyUUID, error)

func ParseAnyUUID

func ParseAnyUUID(s string) (AnyUUID, error)

func (AnyUUID) GetTime

func (id AnyUUID) GetTime() time.Time

GetTime extracts the millisecond-precision creation timestamp from the UUIDv7.

func (AnyUUID) IsZero

func (id AnyUUID) IsZero() bool

func (AnyUUID) MarshalCBOR added in v0.2.0

func (id AnyUUID) MarshalCBOR() ([]byte, error)

MarshalCBOR encodes the UUID as CBOR tag 37 wrapping a 16-byte byte string. The prefix is not included in the CBOR encoding.

func (AnyUUID) MarshalText

func (id AnyUUID) MarshalText() ([]byte, error)

func (AnyUUID) Prefix

func (id AnyUUID) Prefix() string

func (*AnyUUID) Scan

func (id *AnyUUID) Scan(src any) (err error)

func (*AnyUUID) SetPrefix

func (id *AnyUUID) SetPrefix(s string)

func (AnyUUID) String

func (id AnyUUID) String() string

func (AnyUUID) UUID

func (id AnyUUID) UUID() uuid.UUID

func (*AnyUUID) UnmarshalCBOR added in v0.2.0

func (id *AnyUUID) UnmarshalCBOR(data []byte) error

UnmarshalCBOR decodes CBOR tag 37 wrapping a 16-byte byte string into the UUID. The prefix is not restored — call SetPrefix after unmarshaling if needed.

func (*AnyUUID) UnmarshalText

func (id *AnyUUID) UnmarshalText(data []byte) error

func (AnyUUID) Value

func (id AnyUUID) Value() (driver.Value, error)

type Int64

type Int64[P Prefixer] struct {
	// contains filtered or unexported fields
}

Int64 is a type-safe compact identifier. Maps to Postgres BIGINT.

Bit layout

[48-bit unix ms timestamp][15-bit crypto/rand] = 63 bits, always positive

Timestamp range

48-bit millisecond timestamp (same as UUIDv7) covers Unix epoch through year 10889. No action needed in our lifetimes.

Collision resistance

15 random bits = 32,768 values per millisecond. Collision probability follows the birthday problem: ~R²/65,536,000 expected collisions per second for R total IDs/sec across all servers.

   10 IDs/sec → ~1 collision per 7,500 days
  100 IDs/sec → ~1 collision per 1.8 hours
1,000 IDs/sec → ~1 collision per 65 seconds

Protect with a UNIQUE constraint and retry on conflict. For high-throughput resources use UUID instead.

Ordering (k-sortable)

IDs are k-sortable: the 48-bit timestamp in the high bits dominates sort order, so IDs sort by creation time at millisecond granularity. Two IDs generated in the exact same millisecond are not ordered relative to each other, but they cluster on the same B-tree leaf pages — no impact on Postgres insert locality. Clock skew between servers may produce out-of-order IDs within that skew window.

Example (Json)
type Org struct {
	ID   OrgID  `json:"id"`
	Name string `json:"name"`
}

id, _ := typeid.NewInt64[orgPrefix]()
original := Org{ID: id, Name: "Polygon"}
data, _ := json.Marshal(original)

var decoded Org
_ = json.Unmarshal(data, &decoded)
fmt.Println(original.ID == decoded.ID)
fmt.Println(strings.Contains(string(data), `"id":"org_`))
Output:
true
true

func Int64From

func Int64From[P Prefixer](v int64) (Int64[P], error)
Example
id, _ := typeid.NewInt64[orgPrefix]()
raw := id.Int64()
reconstructed, err := typeid.Int64From[orgPrefix](raw)
if err != nil {
	fmt.Println("error:", err)
	return
}
fmt.Println(id == reconstructed)
Output:
true
Example (RejectsNonPositive)
_, err := typeid.Int64From[orgPrefix](-1)
fmt.Println(err)
_, err = typeid.Int64From[orgPrefix](0)
fmt.Println(err)
Output:
typeid: non-positive Int64
typeid: non-positive Int64

func NewInt64

func NewInt64[P Prefixer]() (Int64[P], error)
Example
id, err := typeid.NewInt64[orgPrefix]()
if err != nil {
	fmt.Println("error:", err)
	return
}
s := id.String()

prefix, suffix, _ := strings.Cut(s, "_")
fmt.Println(prefix)
fmt.Println(len(suffix))
fmt.Println(id.Int64() > 0)
Output:
org
13
true

func ParseInt64

func ParseInt64[P Prefixer](s string) (Int64[P], error)
Example
original, _ := typeid.NewInt64[orgPrefix]()
parsed, err := typeid.ParseInt64[orgPrefix](original.String())
if err != nil {
	fmt.Println("error:", err)
	return
}
fmt.Println(original == parsed)
Output:
true
Example (WrongPrefix)
_, err := typeid.ParseInt64[orgPrefix]("foo_0h455vb4pex5v")
fmt.Println(err)
Output:
typeid: prefix mismatch: expected "org", got "foo"

func (Int64[P]) GetTime added in v0.3.0

func (id Int64[P]) GetTime() time.Time

GetTime extracts the millisecond-precision creation timestamp from the upper 48 bits of the value.

func (Int64[P]) Int64

func (id Int64[P]) Int64() int64

func (Int64[P]) IsZero

func (id Int64[P]) IsZero() bool
Example
var id OrgID
fmt.Println(id.IsZero())
id, _ = typeid.NewInt64[orgPrefix]()
fmt.Println(id.IsZero())
Output:
true
false

func (Int64[P]) MarshalCBOR added in v0.2.0

func (id Int64[P]) MarshalCBOR() ([]byte, error)

MarshalCBOR encodes the value as CBOR tag 39 wrapping an unsigned integer. Output is always 11 bytes (2-byte tag + 9-byte uint64) — fixed-width by design, not RFC 8949 §4.2.1 deterministic encoding. The decoder accepts all CBOR unsigned integer widths for interop.

func (Int64[P]) MarshalText

func (id Int64[P]) MarshalText() ([]byte, error)

func (*Int64[P]) Scan

func (id *Int64[P]) Scan(src any) error
Example
id, _ := typeid.NewInt64[orgPrefix]()
raw := id.Int64()

var scanned OrgID
err := scanned.Scan(raw)
fmt.Println(err == nil)
fmt.Println(id == scanned)
Output:
true
true

func (Int64[P]) String

func (id Int64[P]) String() string

func (*Int64[P]) UnmarshalCBOR added in v0.2.0

func (id *Int64[P]) UnmarshalCBOR(data []byte) error

UnmarshalCBOR decodes CBOR tag 39 wrapping an unsigned integer into the Int64.

func (*Int64[P]) UnmarshalText

func (id *Int64[P]) UnmarshalText(data []byte) error

func (Int64[P]) Value

func (id Int64[P]) Value() (driver.Value, error)
Example
id, _ := typeid.NewInt64[orgPrefix]()
val, _ := id.Value()
v, ok := val.(int64)
fmt.Println(ok)
fmt.Println(v > 0)
Output:
true
true

type Prefixer

type Prefixer interface {
	Prefix() string
}

Prefixer is the constraint for type-safe ID prefixes.

type UUID

type UUID[P Prefixer] struct {
	// contains filtered or unexported fields
}

UUID is a type-safe UUIDv7 identifier with a compile-time prefix. Maps to Postgres uuid.

Example (Json)
type User struct {
	ID   UserID `json:"id"`
	Name string `json:"name"`
}

id, _ := typeid.NewUUID[userPrefix]()
original := User{ID: id, Name: "Alice"}
data, _ := json.Marshal(original)

var decoded User
_ = json.Unmarshal(data, &decoded)
fmt.Println(original.ID == decoded.ID)
fmt.Println(strings.Contains(string(data), `"id":"user_`))
Output:
true
true

func NewUUID

func NewUUID[P Prefixer]() (UUID[P], error)
Example
id, err := typeid.NewUUID[userPrefix]()
if err != nil {
	fmt.Println("error:", err)
	return
}
s := id.String()

prefix, suffix, _ := strings.Cut(s, "_")
fmt.Println(prefix)
fmt.Println(len(suffix))
fmt.Println(int(id.UUID().Version()))
Output:
user
26
7

func ParseUUID

func ParseUUID[P Prefixer](s string) (UUID[P], error)
Example
original, _ := typeid.NewUUID[userPrefix]()
parsed, err := typeid.ParseUUID[userPrefix](original.String())
if err != nil {
	fmt.Println("error:", err)
	return
}
fmt.Println(original == parsed)
Output:
true
Example (WrongPrefix)
_, err := typeid.ParseUUID[userPrefix]("team_01h455vb4pex5vsknk084sn02q")
fmt.Println(err)
Output:
typeid: prefix mismatch: expected "user", got "team"

func UUIDFrom

func UUIDFrom[P Prefixer](u uuid.UUID) (UUID[P], error)
Example
raw := uuid.Must(uuid.NewV7())
id, err := typeid.UUIDFrom[userPrefix](raw)
if err != nil {
	fmt.Println("error:", err)
	return
}
fmt.Println(id.UUID() == raw)
Output:
true
Example (RejectsV4)
v4 := uuid.New()
_, err := typeid.UUIDFrom[userPrefix](v4)
fmt.Println(err)
Output:
typeid: only UUIDv7 is supported

func (UUID[P]) Any

func (id UUID[P]) Any() AnyUUID

Any converts a typed UUID to an AnyUUID with the same prefix and value.

func (UUID[P]) GetTime added in v0.3.0

func (id UUID[P]) GetTime() time.Time

GetTime extracts the millisecond-precision creation timestamp from the UUIDv7.

func (UUID[P]) IsZero

func (id UUID[P]) IsZero() bool
Example
var id UserID
fmt.Println(id.IsZero())
id, _ = typeid.NewUUID[userPrefix]()
fmt.Println(id.IsZero())
Output:
true
false

func (UUID[P]) MarshalCBOR added in v0.2.0

func (id UUID[P]) MarshalCBOR() ([]byte, error)

MarshalCBOR encodes the UUID as CBOR tag 37 wrapping a 16-byte byte string. The type prefix is not included — it is determined by the type parameter.

func (UUID[P]) MarshalText

func (id UUID[P]) MarshalText() ([]byte, error)

func (*UUID[P]) Scan

func (id *UUID[P]) Scan(src any) (err error)
Example
id, _ := typeid.NewUUID[userPrefix]()
raw := id.UUID().String()

var scanned UserID
err := scanned.Scan(raw)
fmt.Println(err == nil)
fmt.Println(id == scanned)
Output:
true
true

func (UUID[P]) String

func (id UUID[P]) String() string

func (UUID[P]) UUID

func (id UUID[P]) UUID() uuid.UUID

func (*UUID[P]) UnmarshalCBOR added in v0.2.0

func (id *UUID[P]) UnmarshalCBOR(data []byte) error

UnmarshalCBOR decodes CBOR tag 37 wrapping a 16-byte byte string into the UUID.

func (*UUID[P]) UnmarshalText

func (id *UUID[P]) UnmarshalText(data []byte) error

func (UUID[P]) Value

func (id UUID[P]) Value() (driver.Value, error)
Example
id, _ := typeid.NewUUID[userPrefix]()
val, _ := id.Value()
s, ok := val.(string)
fmt.Println(ok)
_, err := uuid.Parse(s)
fmt.Println(err == nil)
Output:
true
true

Jump to

Keyboard shortcuts

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