Skip to content

ssangervasi/narrow-minded

Repository files navigation

narrow-minded

npm docs test status

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