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 🙏

© 2024 – Pkg Stats / Ryan Hefner

narrow-minded

v1.3.0-alpha

Published

Easy typeof validations with sophisticated TypeScript inference.

Downloads

4,214

Readme

narrow-minded

Easy typeof validations with sophisticated TypeScript inference

This package exists to make type safety easy for unstructured data. This includes runtime access if you're only using JavaScript, but gets even nicer with TypeScript.

The gist is to take code like this:

// JSON is a common case, but any questionable value can be used.
const value: unknown = JSON.parse(mysteryString)

// This gives up type checking, breaks on non-object values,
// and doesn't narrow the type but instead relies on type assertion.
if ((value as any).someProp !== undefined) {
  handleObj(value as any)
}

and replace it with code like this:

import { narrow } from 'narrow-minded'

// Succinct, does not break on strange values, and uses type guards so that
// no assertions are necessary within the conditional block.
if (narrow({ someProp: 'string' }, value)) {
  handleObj(value)
}

That's pretty excellent, but it becomes even better when working with nested objects and more interesting types. In the example below, let's say you want to pass value into handleObj but only if it has the correct structure.

// Some interesting type:
interface Obj {
  str: string
  arr: number[]
}

// Some code that expects that type:
const handleObj = (obj: Obj) => { /*...*/ }

// Some value with an unpredictable structure.
const value = [
  {
    str: 'Only valid object',
    arr: [1, 2, 3],
  },
  {
    str: 'Almost correct, but not quite',
    arr: ['one'],
  },
  3.14,
  null,
][Math.round(Math.random() * 10) % 4]

It is possible to check the value at runtime with a lot of typeof conditions. Checking nested properties becomes verbose, so often these will be omitted which may introduce bugs:

// We need to do a lot of type assertions because TypeScript does not narrow for us
// when using the `in` keyword. Don't forget to use the right checks for arrays ;)
if (
  typeof value === 'object' &&
  value !== null &&
  'str' in value &&
  typeof (value as any).str === 'string' &&
  'arr' in value &&
  Array.isArray((value as any).arr) &&
  typeof (value as any).arr[0] === 'number'
) {
  const obj: Obj = value as any
  handleObj(obj)
}

Or you could use narrow:

// Safer, fits on one line, and infers the nested types!
if (narrow({ str: 'string', arr: ['number'] }, value)) {
  handleObj(value)
}

How to use narrow

Like this:

if (narrow(schema, value)) {
  // Do something with value...
}

Where value can be anything and schema is a string, object, or array as described below.

The narrow function is a type predicate, meaning it returns a boolean and informs TypeScript of the type. That makes it very useful in if (narrow(...)) and if (!narrow(...)) blocks.

Primitives

Values are checked with JS's built-in typeof operator. Any string supported by typeof is a valid schema. Here is a type that includes all possible primitives:

type Primitive =
  | 'string'
  | 'number'
  | 'bigint'
  | 'boolean'
  | 'symbol'
  | 'undefined'
  | 'object'
  | 'function'

Objects

If the schema is an object, all keys of the schema are checked against the keys of the value. The schema can be recursive, and the narrowing will continue as long as the values match.

Keys that are missing from the value object are treated as if they are present, but are equal to undefined. This means you can use { key: some('undefined', ...) } to allow for missing/optional keys (some).

Arrays

Including multiple type strings in an array schema allows for mixed types. Each item in the value array must satisfy at least one of the types.

narrow(['number', 'string'], [1, 'two']) //=> true

narrow(['number'], [1, 'two']) //=> false

An array is an object. Not all objects are arrays. You can use this to confuse yourself and your friends.

narrow(['string']          , ['an', 'array']) //=> true
narrow('object'            , ['an', 'array']) //=> true <-- gotcha
narrow({ length: 'number' }, ['an', 'array']) //=> true <-- don't do this
narrow({ length: 'number' }, { length: 2 }  ) //=> true <-- well, okay, fine.

narrow(['string'], {}                               )) //=> false
narrow(['string'], { length: 2 }                    )) //=> false <-- obviously
narrow(['string'], { length: 2, 0: 'an', 1: 'array' }) //=> false <-- oh good!

An empty array is a valid schema. The value must be an array, but its contents will not be checked. The contents will be inferred as unknown.

narrow([], [1, 'two', null, undefined, { key: 'value' }]) //=> true

narrow([], {}) //=> false

some (Optionals)

Arrays can contain a mix of types. What if you want a single value that can be a variety of types? Use some(schemaA, schemaB, ...).

import { narrow, some } from 'narrow-minded'

narrow(some('number', 'string'), 1) //=> true
narrow(some('number', 'string'), 'two') //=> true
narrow(some('number', 'undefined'), undefined) //=> true
narrow(some('string', {}), { key: 10}) //=> true
narrow({ key: some('number', 'undefined')}, {}) //=> true

narrow(some('number', 'string'), { key: 10 }) //=> false
narrow(some('number', 'string'), ['nested']) //=> false

Null

Thanks to the beauty of JS, typeof null is 'object'. However, null cannot have any keys. This means you should use {} to match an object that is not null.

narrow('object', null) //=> true
narrow({}, { key: 'value' }) //=> true

narrow({}), null) //=> false
narrow(some({}, 'undefined'), null) //=> false

Performance

The time complexity of narrow scales with the size of the schema, not the size of the value. This gives you control over the performance of the checks. A deeply nested schema with a lot of keys will take slightly longer than a shallow one, but calling a simple schema on window will be fast.

Arrays are an exception. In order to check that the array satisfies a type, all of its elements must satisfy one (or more) of the internal types. This means that the time complexity will scale with the size of the value.

If this is a concern, you can use an empty array schema to avoid checking the contents. Individual elements can be narrowed as needed. An example:

const hugeInput = [
	{ data: 1 },
	{ data: 2 },
	// ...
	{ data: 1_000_000 }
]

// SLOW: Will narrow a million objects:
if (narrow([{ data: 'number' }], hugeInput)) {
	// All items are checked, even if we don't need them...
}

// FAST: Will just confirm the input is an array:
if (narrow([], hugeInput)) {
	// Type is unknown:
	const item = hugeInput[0]
	if (narrow({ data: 'number' }, item)) {
		// Use the item...
	}
}

Reusable narrowing with satisfier

Using narrow is convenient for one-off checks, but for repeating the schema would be annoying. Wrap your schema in a satisfier to get a closure that can be used for multiple values:

import { satisfier } from 'narrow-minded'

// Define the schema once:
const satisfied = satisfier({ someProp: 'string' })
// And reuse it:
if (satisfied(value1) && satisfied(value2)) {
	// ...
}

// Instead of repeating the schema:
if (narrow({ someProp: 'string' }, value1) && narrow({ someProp: 'string' }, value2)) {
	// ...
}

Reusable narrowing with Guard

When working with object that are frequently serialized, deserialized, and passed around an application, you may want to use the same narrowing schema repeatedly. The Guard class makes reuse easy and provides more customization options than satisfier:

import { Guard } from 'narrow-minded'

// The class method `narrow` constructs a Guard that reuses the schema.
const MessageGuard = Guard.narrow({
  type: 'string',
  body: {},
})

Whenever you need to test this schema, call .satisfied(value) like so:

const goodValue: unknown = JSON.parse('{ "type": "message", "body": {} }')
if (MessageGuard.satisfied(goodValue)) {
  console.log(goodValue.type) //=> "message"
}

const badValue: unknown = JSON.parse('{ "foo": "bar" }')
if (!MessageGuard.satisfied(badValue)) {
  console.log('bad') //=> "bad"
}

Custom guards

If you're familiar with writing type predicates, you can construct a Guard directly. The example below builds guards that have types that are more specific that primitive typeof narrowing can provide:

// A guard can be constructed with a custom predicate function which allows
// for arbitrary interfaces and comparisons. This guard ensures `sentAt` is
// greater than zero, which would not be possible with primitive checks.
interface Ping {
  type: 'ping'
  body: {
    sentAt: number
  }
}
const PingGuard = new Guard(
  (m): m is Ping =>
    MessageGuard.satisfied(m) &&
    m.type === 'ping' &&
    narrow(
      {
        sentAt: 'number',
      },
      m.body,
    ) &&
    m.body.sentAt > 0,
)

// The guard's function is entirely custom, which means you can choose to sacrifice
// some safety to gain some performance. For example, this guard assumes the body
// has the correct schema without actually checking.
interface Pong {
  type: 'pong'
  body: {
    sentAt: number
    receivedAt: number
  }
}
const PongGuard = new Guard(
  (m): m is Pong => MessageGuard.satisfied(m) && m.type === 'pong',
)

The type within the guard: Payload

If you need to extract the type that a guard wraps, use the type-helper Payload<typeof MyGuard>:

import { Payload } from 'narrow-minded'

const payload: Payload<typeof PingGuard> = {
  type: 'ping',
  body: {
    sentAt: 1234,
  },
}

// Types match.
const ping: Ping = payload