10 KiB
Research: Kinds
Topic Summary
The kinds chapter should explain what kinds are without going into detail on any kind in particular (that's saved for domain chapters). Instead, the chapter should create some kind of factory/registry/enum that can be used to add behavior to a given kind — validation, event template factories, interfaces for decryption, methods for converting an event of a particular kind into a domain-specific struct, etc. Applesauce's factory pattern is preferred over welshman's ad-hoc utilities approach.
Philosophy
From building-nostr, several key principles inform this chapter:
Numbers Over Names: Event kinds are 16-bit integers. Using numbers instead of names is deliberate — in a distributed system built on signed data, names can't change. Numbers are meaningless, which allows subjective interpretations while keeping meaning intact. This frees implementers to assign their own terms based on understanding.
Kind Ranges Encode Behavior: The protocol defines ranges with storage/replacement semantics:
- Regular (1-9999, with exceptions): standard events stored by relays
- Replaceable (0, 3, 10000-19999): only latest per (pubkey, kind) kept
- Ephemeral (20000-29999): not stored, just broadcast
- Addressable (30000-39999): latest per (pubkey, kind, d-tag) kept
The author criticizes this design as coupling kind numbers with behavior — these policies are orthogonal to content type and "should have belonged in behavior tags."
More Kinds, Less Ambiguity: Prefer creating specific event kinds over overloading existing ones. "Overloading a single kind for multiple purposes is always a recipe for disaster."
Implementation-First: Nostr takes an implementation-first approach. It's only through implementation that what is actually needed can be understood.
Reference Implementation Analysis
applesauce
The most sophisticated factory pattern found. Uses a layered architecture:
EventFactory base class extends Promise<T> for fluent chaining. Core capabilities:
EventFactory.fromKind<K>(kind)— create from kind numberEventFactory.fromEvent<K>(event)— create from existing event (modify).content(),.created(),.meta()— base operations.modifyPublicTags(),.modifyHiddenTags()— tag manipulation.encryptedContent()— NIP-44 encryption.as(signer)— set signer, propagated through chain.stamp(),.sign()— finalize event
Typed Factory subclasses per kind:
export class NoteFactory extends EventFactory<kinds.ShortTextNote, NoteTemplate> {
static create(content?: string): NoteFactory
static reply(parent: NostrEvent, content?: string): NoteFactory
text(content, options?)
mention(pubkey)
subject(subject)
addHashtag(hashtag)
}
Dual system for create vs consume:
- Factories — for creating/modifying events (builder pattern)
- Casts (
EventCast<KnownEvent<KIND>>) — for consuming events, providing typed properties, validation, and observable streams for relationships
List factory hierarchy for NIP-51:
ListFactory→NIP51RelayListFactory/NIP51UserListFactory/NIP51ItemListFactory- Concrete:
BookmarkSetFactory extends NIP51ItemListFactory
Validation via type guard functions:
function isValidComment(event): event is CommentEvent {
return event.kind === COMMENT_KIND && hasRoot(event) && hasReply(event);
}
No centralized registry — factories distributed across modules, aggregated via index.ts exports.
ndk
Uses class-based kind wrappers with inheritance:
NDKKind enum with 200+ named variants. Each kind gets a dedicated wrapper class extending
NDKEvent:
export class NDKArticle extends NDKEvent {
static kind = NDKKind.Article;
static kinds = [NDKKind.Article]; // supports multi-kind classes
static from(event: NDKEvent): NDKArticle
get title(): string | undefined
set title(title: string | undefined)
}
Multi-kind classes: NDKVideo handles kinds 21, 34235, 34236, 22. NDKRepost handles 6
and 16.
40+ kind wrapper files in core/src/events/kinds/.
Kind-aware behavior methods on base NDKEvent: isReplaceable(), isEphemeral(),
isParamReplaceable().
nostr-gadgets
Functional/storage-oriented approach:
Higher-order factory functions:
export const loadFollowsList = makeListFetcher<string>(3, RELAYS, itemsFromTags(...))
export const loadFollowSets = makeSetFetcher<string>(30000, itemsFromTags(...))
Generic fetcher factories take kind number + processor function, return typed fetchers. Storage interface returns different shapes based on kind classification (addressable vs replaceable).
nostrlib
Go library with multi-layered kind awareness:
Kind type is uint16 with categorization methods: IsRegular(), IsReplaceable(),
IsEphemeral(), IsAddressable().
Factory map pattern for domain objects:
var moderationActionFactories = map[nostr.Kind]func(nostr.Event) (Action, error){
nostr.KindSimpleGroupPutUser: func(evt nostr.Event) (Action, error) { ... },
}
func PrepareModerationAction(evt nostr.Event) (Action, error) {
factory, ok := moderationActionFactories[evt.Kind]
if !ok { return nil, fmt.Errorf("unsupported kind %d", evt.Kind) }
return factory(evt)
}
Schema-based validation from YAML specs per kind (content format, required tags, d-tag rules).
Generic list/set patterns with fetchGenericList[V, I](kind, index, parseTag, cache).
Dataloader registries — index-based arrays for 18 replaceable and 4 addressable kinds.
nostr-tools
Foundational kind system:
Const + typeof pattern:
export const ShortTextNote = 1;
export type ShortTextNote = typeof ShortTextNote;
Classification functions: classifyKind(), isRegularKind(), isReplaceableKind(),
isEphemeralKind(), isAddressableKind().
Type guard: isKind<T>(event, kind): event is NostrEvent & { kind: T }.
65+ kind constants. No factory pattern — this is the lowest-level library.
rust-nostr
Most comprehensive Rust implementation:
Macro-generated Kind enum with 130+ named variants plus Custom(u16) fallback:
pub enum Kind { Metadata, TextNote, ContactList, ..., Custom(u16) }
Kind range constants:
pub const REPLACEABLE_RANGE: Range<u16> = 10_000..20_000;
pub const EPHEMERAL_RANGE: Range<u16> = 20_000..30_000;
pub const ADDRESSABLE_RANGE: Range<u16> = 30_000..40_000;
Behavior methods: is_regular(), is_replaceable(), is_ephemeral(), is_addressable().
EventBuilder with 140+ kind-specific factory methods:
pub fn text_note(content) -> Self
pub fn metadata(metadata) -> Self
pub fn job_request(kind) -> Result<Self, Error> // validates kind range
Coordinate validation enforces kind constraints (replaceable vs addressable, d-tag rules).
No central registry — behavior statically encoded in kind ranges and builder methods.
welshman
Ad-hoc utilities approach:
Kind constants in centralized Kinds.ts (60+ constants).
Classification predicates: isRegularKind(), isPlainReplaceableKind(),
isParameterizedReplaceableKind(), isEphemeralKind(), isDVMKind().
Domain types per kind with read/create/edit functions:
readProfile(event) -> PublishedProfilecreateProfile(profile) -> EventTemplateeditProfile(profile) -> EventTemplatereadRoomMeta(event) -> PublishedRoomMeta(validates kind in reader)readList(event) -> PublishedList(generic across list kinds)
Store integration: deriveItemsByKey({ eventToItem: readProfile, filters: [{kinds: [PROFILE]}] })
Encryptable wrapper for events with encrypted content (lists with private tags).
Pattern: each kind gets constants + types + read/create/edit functions + store wiring. Scattered across files rather than unified in a registry.
Common Patterns
- Kind as integer wrapper — all implementations use u16/number with named constants
- Range-based classification — universal: regular/replaceable/ephemeral/addressable predicates
- Factory methods for creation — builder/factory pattern to construct events of specific kinds
- Parser functions for consumption — event → domain struct conversion
- Validation at boundaries — kind checks when parsing events, kind range validation for coordinates
- No centralized registry — most implementations distribute behavior across modules rather than maintaining a central kind→behavior map
Divergences:
- applesauce uses class hierarchy with fluent builder; welshman uses standalone functions
- rust-nostr uses macro-generated enum; Go uses typed constants
- NDK couples kind behavior to event subclasses; others keep them separate
- Only nostrlib uses external schema-based validation
Considerations for Our Implementation
-
Trait-based factory pattern — Rust traits map well to the factory/cast dual pattern from applesauce. A
KindHandlertrait could define validation, parsing, and creation methods. -
Kind enum with Custom fallback — Follow rust-nostr's
Kindenum pattern with named variants +Custom(u16). This gives compile-time safety for known kinds while remaining extensible. -
Range classification as methods —
Kind::is_replaceable()etc. are universally needed and straightforward to implement. -
Separate create vs parse — The applesauce pattern of factories (create) vs casts (parse) maps to Rust traits: one trait for building
EventTemplatefrom domain data, another for extracting domain data fromEvent. -
Registry for extensibility — A
HashMap<Kind, Box<dyn KindHandler>>or similar registry would let domain chapters register their handlers without modifying the kinds chapter. Alternatively, use static dispatch with an enum if the set of kinds is known at compile time. -
Literate programming flow — The chapter should introduce kinds conceptually, define the
Kindtype, implement classification methods, then build the trait/registry infrastructure that later chapters will use. Keep actual kind implementations (profiles, lists, etc.) for domain chapters. -
Dependencies — This chapter depends on the events chapter (for
Event,EventTemplatetypes). It should not depend on any domain-specific types.