static-records
v1.9.0
Published
Static immutable relational data for js
Readme
Static Records
A tiny package for static immutable js data.
Supports
- Circular references
- Out of order definitions
Useful for: configuration data, reference data, game data, or any scenario where you need immutable, interconnected objects defined at runtime.
Installation
$ npm i static-records
Example
person-data.ts
import { staticRecords } from 'static-records'
export type Person = {
readonly id: string;
readonly name: string;
readonly manager: Person | null;
readonly emergencyContact: Person | null;
}
export const PEOPLE = staticRecords<Person>(/* Record Type Name: */ 'Person')
export const JIM = PEOPLE.define(
'JIM', // id property
() => ({
name: 'Jim',
manager: SUE,
emergencyContact: null,
}),
)
export const SUE = PEOPLE.define(
'SUE', // id property
() => ({
name: 'Sue',
manager: null,
emergencyContact: JIM,
}),
)
// creates the records
// no more records can be defined after this
PEOPLE.lock()vehicle-data.ts
import { staticRecords } from 'static-records'
import { JIM, type Person, SUE } from './person-data'
export type Vehicle = {
readonly id: string,
readonly name: string,
readonly driver: Person,
readonly passengers?: Person[],
}
export const VEHICLES = staticRecords<Vehicle>(/* Record Type Name: */ 'Vehicle')
export const CAR = VEHICLES.define(
'CAR',
() => ({
name: 'Car',
driver: SUE,
passengers: [],
}),
)
export const VAN = VEHICLES.define(
'VAN',
() => ({
name: 'Van',
driver: JIM,
passengers: [SUE],
}),
)
VEHICLES.lock()use-example.ts
import { JIM } from './person-data'
import { CAR } from './vehicle-data'
import { getRecordType } from 'static-records'
JIM.id // 'JIM'
JIM.name // 'Jim'
JIM.manager?.id // 'SUE'
JIM.emergencyContact // null
getRecordType(JIM) // 'Person'
CAR.id // 'CAR'
CAR.name // 'Car'
CAR.driver.id // 'SUE'API Reference
import { staticRecords } from 'static-records'
type Contact = {
readonly id: string,
readonly name: string;
};
export const CONTACTS = staticRecords<Contact>('Contact')
export const JIM = CONTACTS.define(
'JIM',
() => ({
name: 'Car',
}),
)
CONTACTS.locked() // false
// always lock before using
CONTACTS.lock()
CONTACTS.locked() // true
CONTACTS.get('JIM') // JIM
CONTACTS.has('JIM') // true
CONTACTS.toArray() // [JIM]
CONTACTS.toObject() // {"JIM": JIM}Default Values and Object Composition
Using a factory like makeTire() below allows for default values and easy object composition.
import { staticRecords } from 'static-records'
type Tire = {
readonly id: string,
readonly name: string;
readonly brand: string,
};
export const TIRES = staticRecords<Tire>('Tire')
export const ECONOMY = TIRES.define(
'ECONOMY',
() => makeTire({
name: 'Economy',
}),
)
export const PERFORMANCE = TIRES.define(
'PERFORMANCE',
() => makeTire({
name: 'Performance',
brand: 'goodyear',
}),
)
TIRES.lock()
function makeTire(input: {
name: string,
brand?: string,
}): Omit<Tire, 'id'> {
return {
brand: 'generic',
...input,
}
}
ECONOMY.id // 'ECONOMY'
ECONOMY.name // 'Economy'
ECONOMY.brand // 'generic'
PERFORMANCE.id // 'PERFORMANCE'
PERFORMANCE.name // 'Performance'
PERFORMANCE.brand // 'goodyear'Static Record Options
The creator, filler, and locker options allow deeper control over object creation.
import { type DefaultProtoItem, recordTypeKey, staticRecords } from 'static-records'
type Widget = {
readonly id: string,
readonly name: string
}
const WIDGETS = staticRecords<Widget>('Widget', {
// creates initial object with id and recordType
// default implementation shown
creator: (id: string, recordType: string): DefaultProtoItem => {
return {
id,
// the recordTypeKey symbol is used by the
// getRecordType() function
// and the frozenLocker() function to determine
// which objects are static records
[recordTypeKey]: recordType,
}
},
// populates existing item with data before it is locked
// default implementation shown
filler: (
// item is the object returned by the creator function
item: DefaultProtoItem,
// input is the object returned by the factory function passed to WIDGETS.define('MY_ID', () => input)
// the type is determined by the second type argument passed to staticRecords()
// the default input type is shown here
input: Omit<Widget, 'id' | typeof recordTypeKey>,
) => {
// typescript doesn't check readonly when using Object.assign()
// inside this function the object is still being created
// so readonly should not be checked yet
// type safety is maintained by the Widget type anyway
Object.assign(item, input)
// this function must mutate the item object (not create a new one)
// for object references to work correctly
},
// after filling all records each finalized record is passed here
// this is where freezing objects can be done see: frozenFiller()
// has no default behavior
locker(item: Widget) {
},
})Using Classes
Static Records can be class instances instead of generic objects.
import { type HasId, type HasRecordKey, recordTypeKey, staticRecords } from 'static-records'
// the interfaces HasRecordKey and HasId are not strictly required here
// but BaseItem will need to match their interfaces
class BaseItem implements HasRecordKey, HasId {
readonly [recordTypeKey]: string
constructor(
public readonly id: string,
recordType: string,
) {
this[recordTypeKey] = recordType
}
}
// it is not required for Seller to extend BaseItem
// Seller could contain the code from BaseItem instead
export class Seller extends BaseItem {
declare readonly id: string
readonly firstName: string = 'unknown'
readonly lastName: string = 'unknown'
get fullName() {
return `${this.firstName} ${this.lastName}`
}
}
type SellerInput = Pick<Seller, 'firstName' | 'lastName'>
const SELLERS = staticRecords<Seller, Seller, SellerInput>(Seller.name, {
creator: (id: string, recordType: string) => {
// create the initial object instance
return new Seller(id, recordType)
},
})
const SUE = SELLERS.define(
'SUE',
// item is the object returned by the creator function
(item) => makeSeller(item, {
firstName: 'Susan',
lastName: 'Smith',
}),
)
const SAM = SELLERS.define(
'SAM',
sellerFactory({
firstName: 'Samuel',
}),
)
SELLERS.lock()
function makeSeller(item: Seller, input: {
firstName?: string,
lastName?: string,
}): SellerInput {
const {
firstName,
lastName,
} = item
return {
// default values from class
firstName,
lastName,
// replace defaults
...input,
}
}
// optionally make a factory for a cleaner api
function sellerFactory(input: {
firstName?: string,
lastName?: string,
}) {
return (item: Seller): SellerInput => makeSeller(item, input)
}
SUE.firstName // 'Susan'
SUE.lastName // 'Smith'
SUE.fullName // 'Susan Smith'
SAM.firstName // 'Samuel'
SAM.lastName // 'unknown'
SAM.fullName // 'Samuel unknown'
SAM instanceof Seller // trueAdvanced Types
import { type DefaultProtoItem, recordTypeKey, staticRecords } from 'static-records'
/*
the above imported type that is the base of all proto objects
type DefaultProtoItem = {
readonly id: string,
readonly [recordTypeKey]: string,
}
*/
type Widget = {
readonly id: string,
readonly name: string
}
type ProtoWidget = DefaultProtoItem & {
readonly something: string,
}
type WidgetInput = {
speed: number
}
const WIDGETS = staticRecords<Widget, ProtoWidget, WidgetInput>('Widget', {
// ts infers return type is ProtoWidget
creator(id: string, recordType: string) {
return {
id,
[recordTypeKey]: recordType,
something: 'extra',
}
},
// ts infers argument types
// item: ProtoWidget,
// input: WidgetInput,
filler(item, input) {
Object.assign(item, input)
},
})
const BOOP = WIDGETS.define(
'BOOP',
// factory must return WidgetInput
() => ({
name: 'Boop',
speed: 99,
}),
)
WIDGETS.lock()Static Records Factories and Default Options
A static records factory can be created to set reusable default options.
import { type DefaultProtoItem, recordTypeKey, staticRecordsFactory } from 'static-records'
export type BaseItem = {
readonly id: string,
readonly uid: string,
}
type BaseProtoItem = BaseItem & DefaultProtoItem & {
readonly uid: string,
}
export const makeStaticRecords = staticRecordsFactory<BaseItem, BaseProtoItem>({
creator(id, recordType) {
return {
// adding unique id
uid: `${recordType}-${id}`,
id,
[recordTypeKey]: recordType,
}
},
})
export type Building = BaseItem & {
readonly name: string,
}
export const BUILDINGS = makeStaticRecords<Building>('Building')
export const TOWER_A = BUILDINGS.define(
'TOWER_A',
() => ({
name: 'Tower A',
}),
)
BUILDINGS.lock()
TOWER_A.id // 'TOWER_A'
TOWER_A.name // 'Tower A'
TOWER_A.uid // 'Building-TOWER_A'Advanced Factory Types
import { type DefaultProtoItem, recordTypeKey, staticRecordsFactory } from 'static-records'
// base type for all items
export type BaseItem = {
id: string,
uid: string,
zone: string,
}
// base type of all proto objects
export type BaseProtoItem = DefaultProtoItem & {
uid: string,
}
// input that will be required by all record inputs
export type BaseInput = {
zone: string,
}
export const makeStaticRecords = staticRecordsFactory<BaseItem, BaseProtoItem, BaseInput>({
// returns BaseProtoItem
creator(id, recordType) {
return {
id,
[recordTypeKey]: recordType,
// adding unique id from BaseProtoItem
uid: `${recordType}-${id}`,
}
},
})
export type Building = BaseItem & {
name: string,
}
// optionally add more to the proto object
export type BuildingProto = BaseProtoItem & {
moreProtoData: string,
}
export type BuildingInput = BaseInput & {
name: string,
}
export const BUILDINGS = makeStaticRecords<Building, BuildingProto, BuildingInput>('Building', {
// options here override the factory options above via Object.assign(factoryOptions, recordOptions)
filler(item, input) {
// @TODO validate item.zone
Object.assign(item, input)
},
})
export const TOWER_A = BUILDINGS.define(
'TOWER_A',
() => ({
name: 'Tower A',
zone: 'Alpha',
}),
)
BUILDINGS.lock()
TOWER_A.id // 'TOWER_A'
TOWER_A.name // 'Tower A'
TOWER_A.uid // 'Building-TOWER_A'
TOWER_A.zone // 'Alpha'Resolver Arguments
Resolver functions are passed the protoItem and recordType arguments.
The protoItem arg has the id and [recordTypeKey] symbol properties.
The recordType is passed as the second arg for convenience.
import { type DefaultProtoItem, staticRecords } from 'static-records'
type Person = {
readonly id: string,
readonly name: string,
readonly slug: string,
}
const PEOPLE = staticRecords<Person>('Person')
const DAN = PEOPLE.define(
'DAN',
// the protoItem arg has the id and recordType symbol keys
// the record type is passed in the second for convenience
(protoItem: DefaultProtoItem, recordType: string) => ({
name: 'Dan',
slug: protoItem.id + '-' + recordType,
}),
)
PEOPLE.lock()
DAN.id // 'DAN'
DAN.name // 'Dan'
DAN.slug // 'DAN-Person'Lazy Filler
makeLazyFiller() creates a filler that can have properties resolve when they are first read.
This allows referencing the properties of other static records directly.
import { lazy, type MakeInput, makeLazyFiller, staticRecords } from 'static-records'
type Person = {
readonly id: string,
readonly name: string,
readonly emergencyContactName: string,
readonly deep: {
readonly val: string,
readonly property: {
readonly text: string,
}
}
}
type PersonInput = MakeInput<Person>
const PEOPLE = staticRecords<Person>('Person', {
filler: makeLazyFiller(),
})
const DAN = PEOPLE.define(
'DAN',
() => ({
name: 'Dan',
emergencyContactName: lazy<PersonInput['emergencyContactName']>(() => SUE.name),
deep: {
val: 'foo',
property: {
text: 'whatever',
},
},
}),
)
const SUE = PEOPLE.define(
'SUE',
() => ({
name: 'Sue',
emergencyContactName: lazy(() => DAN.name),
deep: lazy<PersonInput['deep']>(() => {
return {
val: 'something',
property: {
text: lazy<PersonInput['deep']['property']['text']>(() => DAN.name),
},
}
}),
}),
)
PEOPLE.lock()
DAN.emergencyContactName // 'Sue'
SUE.emergencyContactName // 'Dan'Freezing Lazy Resolvers
Lazy resolvers cannot be directly frozen with Object.freeze(). So you cannot use the frozenLocker() with it.
It does have a freeze option that you can pass that will set things to be read only as much as is possible.
const PEOPLE = staticRecords<Person>('Person', {
filler: makeLazyFiller({
freeze: true
}),
})Lazy Tree Resolvers
import { lazyTree, makeLazyFiller, staticRecords, type To } from 'static-records'
type Person = {
readonly id: string,
readonly name: string,
readonly extra: {
readonly id: string,
readonly slug: {
slugId: string,
rootName: string,
}
readonly deep: {
readonly property: {
readonly idFromParent: string,
readonly idFromRoot: string,
readonly child: {
readonly even: {
readonly deeper: {
readonly idFromAncestor: string
}
}
}
}
}
}
}
const PEOPLE = staticRecords<Person>('Person', {
filler: makeLazyFiller({
lazyTree: true,
}),
})
const DAN = PEOPLE.define(
'DAN',
() => ({
name: 'Dan',
extra: {
id: 'abc',
// lazyTree with no specific types
slug: lazyTree((parent, root) => {
return {
slugId: 'slugId and Name: ' + parent?.id + ' ' + parent?.parent?.name,
rootName: 'rootName: ' + root.name,
}
}),
deep: {
property: lazyTree<
// return type
Person['extra']['deep']['property'],
// parent
Person['extra']['deep'],
// root,
Person
>((parent1, root) => {
return {
idFromParent: 'idFromParent: ' + parent1?.parent?.id,
idFromRoot: 'idFromRoot: ' + root.extra.id,
child: {
even: lazyTree<
// use the To<> helper to provide the return type, parent, and root automatically
// it will autocomplete the dot path properties based on the first arg (Person)
// and will also handle parent chains
To<Person, 'extra.deep.property.child.even'>
>((parent) => {
return {
deeper: {
idFromAncestor: 'idFromAncestor: ' + parent?.parent?.idFromParent,
},
}
}),
},
}
}),
},
},
}),
)
PEOPLE.lock()
DAN.meta.slug.slugId // 'slugId and Name: abc Dan'
DAN.meta.slug.rootName // 'rootName: Dan'
DAN.extra.deep.property.idFromParent // 'idFromParent: abc'
DAN.extra.deep.property.idFromRoot // 'idFromRoot: abc'
DAN.extra.deep.property.child.even.deeper.idFromAncestor // 'idFromAncestor: idFromParent: abc'Lazy Tree Resolvers Custom Parent Key
How do use a custom parent key.
import { lazyTree, makeLazyFiller, staticRecords, type To } from 'static-records'
type Person = {
readonly id: string,
readonly name: string,
readonly extra: {
readonly id: string,
readonly deep: {
readonly property: {
readonly idFromParent: string,
readonly idFromRoot: string,
readonly child: {
readonly even: {
readonly deeper: {
readonly idFromAncestor: string
}
}
}
}
}
}
}
const customParentKey = '__parent'
const PEOPLE = staticRecords<Person>('Person', {
filler: makeLazyFiller({
lazyTree: true,
parentKey: customParentKey,
}),
})
const DAN = PEOPLE.define(
'DAN',
() => ({
name: 'Dan',
extra: {
id: 'abc',
deep: {
property: lazyTree<
// return type
Person['extra']['deep']['property'],
// parent
Person['extra']['deep'],
// root,
Person,
// override default parent key from 'parent' to '__parent'
typeof customParentKey
>((parent, root) => {
return {
idFromParent: 'idFromParent: ' + parent?.__parent?.id,
idFromRoot: 'idFromRoot: ' + root.extra.id,
child: {
even: lazyTree<
To<Person, 'extra.deep.property.child.even', typeof customParentKey>
>((parent) => {
return {
deeper: {
idFromAncestor: 'idFromAncestor: ' + parent?.__parent?.idFromParent,
},
}
}),
},
}
}),
},
},
}),
)
PEOPLE.lock()
DAN.extra.deep.property.idFromParent // 'idFromParent: abc'
DAN.extra.deep.property.idFromRoot // 'idFromRoot: abc'
DAN.extra.deep.property.child.even.deeper.idFromAncestor // 'idFromAncestor: idFromParent: abc'Building
$ pnpm install
$ pnpm run build
$ pnpm run readme Injects README.md code examples
Testing
$ pnpm run test
$ pnpm run test:mutation
Releases Automation
- update
package.jsonfile version (example:1.0.99) - manually create a github release with a tag matching the
package.jsonversion prefixed withv(example:v1.0.99) - npm should be updated automatically
