npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

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 // true

Advanced 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.json file version (example: 1.0.99)
  • manually create a github release with a tag matching the package.json version prefixed with v (example: v1.0.99)
  • npm should be updated automatically