# jg-packages
> Small, tested TypeScript packages for coding agents to build on: errors, HTTP requests, storage, files, and processes, with docs inside each one.
---
# jg-exception
> Coded errors for every jg package: an Exception with a stable code and JSON data, message extraction, code checks, and test assertions.
- **Version:** `1.5.0`
- **Runtimes:** Universal
- **Install:** `npm install jg-exception`
- **On npm:** [jg-exception](https://www.npmjs.com/package/jg-exception)
- **Source:** [packages/jg-exception](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-exception)
- **Recipes:** [jg-exception recipes](https://jg-packages.jesusgraterol.dev/packages/jg-exception/recipes/index.md)
## Usage
Throw an `Exception` with a stable code, and optional data that helps whoever handles or debugs it:
```ts
import { Exception } from 'jg-exception';
throw new Exception('The user was not found.', { code: 'USER_NOT_FOUND', data: { id } });
```
Wrap the error underneath as the `cause`. The message says what failed at this step, and the cause keeps the rest:
```ts
try {
await loadUser(id);
} catch (error) {
throw new Exception("Couldn't load the user.", { code: 'USER_LOAD_FAILED', cause: error });
}
```
Read the code back wherever the error ends up. It works on any error with a string `code`, such as a Node.js system error:
```ts
import { getErrorCode, hasErrorCode, hasErrorCodePrefix, isException } from 'jg-exception';
hasErrorCode(error, 'USER_NOT_FOUND'); // true or false
hasErrorCodePrefix(error, 'USER_'); // true for any USER_ code
getErrorCode(error); // 'USER_NOT_FOUND', or null when the error has no code
isException(error); // true, even for an Exception from another installed copy of the package
```
An Exception turns into JSON with its name, code, message, and data, so it can be logged or sent as is:
```ts
JSON.stringify(
new Exception('The user was not found.', { code: 'USER_NOT_FOUND', data: { id: 1 } }),
);
// '{"name":"Exception","code":"USER_NOT_FOUND","message":"The user was not found.","data":{"id":1}}'
String(error); // 'Exception [USER_NOT_FOUND]: The user was not found.'
```
An app's throw sites can also say how serious a failure is, for the code that reports it: `severity` is `'expected'` or `'critical'`, and `shouldLog: true` asks for the error to be logged in full. Neither is serialized, and packages never set them:
```ts
throw new Exception('The user was not found.', { code: 'USER_NOT_FOUND', severity: 'expected' });
```
`data` must be a JSON value, so a `BigInt`, a `Date`, or a class instance is a type error. An object's values may be `undefined`, so `{ email }` works when `email` is optional. An interface has no index signature, so pass an interface-typed value as `{ ...user }`, or declare its type with `type`. Whatever can't be serialized at runtime, such as an object that refers back to itself, becomes `null` in `toJSON()`, and `NaN` and `Infinity` become `null` as in any JSON.
Extract a readable message from anything a `catch` block receives: strings, errors with their causes, ZodErrors, and objects with a `message`, `error`, or `reason`:
```ts
import { extractMessage } from 'jg-exception';
extractMessage(new Error('Request failed', { cause: new Error('Timed out') }));
// 'Request failed; [CAUSE]: Timed out'
```
Turn an error into what users read with `createErrorFeedback`: the copy you wrote for its code, or a fallback, never the error's message, which is written for whoever debugs it:
```ts
import { createErrorFeedback } from 'jg-exception';
const getErrorFeedback = createErrorFeedback({
fallbackMessage: 'Something went wrong.',
messagesByCode: { SESSION_EXPIRED: 'Your session expired. Sign in again to continue.' },
});
getErrorFeedback(error).message; // the copy for the error's code, or the fallback
```
Assert error codes in tests with any test runner:
```ts
import { expectToRejectCode, expectToThrowCode } from 'jg-exception/testing';
expectToThrowCode(() => parseCursor('%%'), 'INVALID_CURSOR');
await expectToRejectCode(loadUser('missing'), 'USER_NOT_FOUND', 'not found');
```
### Keeping secrets out of an error
The error underneath can hold a secret in its message, stack, or fields, such as the token a request was sent with, and logs, `console.log`, and error trackers print the whole chain. When you wrap it, pass that value as `redact`. It's replaced by `[redacted]` throughout the new exception: its message and data, and each cause's message, stack, and fields, all the way down:
```ts
try {
await verifyToken(token);
} catch (error) {
throw new Exception("Couldn't verify the token.", {
code: 'TOKEN_INVALID',
cause: error,
redact: [token],
});
}
```
- **Pass whole secrets.** Each value is matched as written and URL-encoded, so a token in a request URL is found too. A fragment as short as `at` would also rewrite names and every stack frame. A value transformed another way, such as base64-encoded or hashed, isn't found, so pass each form a cause may hold.
- **What it costs:** each cause becomes a copy that keeps its name, code, message, stack, and plain fields, redacted, and leaves out fields holding objects, such as an HTTP client's `config`. The copy runs none of the cause's own code, such as a custom inspect hook, which could hold a value no redaction reaches. So `instanceof` the cause's original class no longer holds: read its `name` and `code` instead. An Exception cause stays an Exception, so `isException`, `hasErrorCode`, and `toJSON()` work on it.
- The values are used while the exception is built, and kept nowhere.
To log an error you caught, without rethrowing it, `extractRedactedMessage` gives its message, causes included, with the values redacted:
```ts
import { extractRedactedMessage } from 'jg-exception';
extractRedactedMessage(error, [apiKey]); // the message with the key replaced by [redacted]
```
When what you report carries the client's data, such as the body of the request that failed, the secrets are in fields you know by name. `redactFields` copies it with those fields' values replaced by `[redacted]`, at any depth and whatever their case:
```ts
import { redactFields } from 'jg-exception';
redactFields(request.body, ['password', 'token']); // { email: '…', password: '[redacted]' }
```
## jg-exception API reference
Every export of `jg-exception@1.5.0`, 23 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc.
### `jg-exception`
- Functions: [`createErrorFeedback`](#api-createErrorFeedback), [`extractMessage`](#api-extractMessage), [`extractRedactedMessage`](#api-extractRedactedMessage), [`getErrorCode`](#api-getErrorCode), [`hasErrorCode`](#api-hasErrorCode), [`hasErrorCodePrefix`](#api-hasErrorCodePrefix), [`isDefaultErrorMessage`](#api-isDefaultErrorMessage), [`isException`](#api-isException), [`redactFields`](#api-redactFields)
- Classes: [`Exception`](#api-Exception)
- Constants: [`DEFAULT_MESSAGE`](#api-DEFAULT_MESSAGE), [`EXCEPTION_SEVERITIES`](#api-EXCEPTION_SEVERITIES), [`REDACTED_VALUE`](#api-REDACTED_VALUE), [`UNKNOWN_ERROR_CODE`](#api-UNKNOWN_ERROR_CODE)
- Types: [`IErrorFeedback`](#api-IErrorFeedback), [`IErrorFeedbackOptions`](#api-IErrorFeedbackOptions), [`IErrorFeedbackOverrides`](#api-IErrorFeedbackOverrides), [`IExceptionJson`](#api-IExceptionJson), [`IExceptionOptions`](#api-IExceptionOptions), [`IExceptionSeverity`](#api-IExceptionSeverity), [`IJsonValue`](#api-IJsonValue)
#### Functions in `jg-exception`
##### `createErrorFeedback`
*Function*
Creates the function a UI turns errors into feedback with: the copy users read for the error's code, the code itself when it's safe to show, and whether the error means the user cancelled the work or isn't allowed to do it. Configure it once, and pass overrides where a screen needs copy of its own. A code is shown only when a table gives it copy or `publicCodePattern` matches it, so a new or internal code never reaches users, and the error's message, written for whoever debugs it, is never shown.
```ts
createErrorFeedback(
options: IErrorFeedbackOptions,
): (error: unknown, overrides?: IErrorFeedbackOverrides) => IErrorFeedback
```
**Parameters**
- `options` `IErrorFeedbackOptions`: The fallback copy, and optionally the copy by code, the access-denied codes, and the pattern of codes that are safe to show.
**Returns** `(error: unknown, overrides?: IErrorFeedbackOverrides) => IErrorFeedback`: A function that gives an error's feedback, with optional overrides for one call. It never throws, whatever error it's given.
**Example 1**
```ts
import { Exception, createErrorFeedback } from 'jg-exception';
const getErrorFeedback = createErrorFeedback({
fallbackMessage: 'Something went wrong. Try again in a moment.',
messagesByCode: { SESSION_EXPIRED: 'Your session expired. Sign in again to continue.' },
accessDeniedCodes: ['SESSION_EXPIRED'],
});
const feedback = getErrorFeedback(new Exception('Expired', { code: 'SESSION_EXPIRED' }));
feedback.message; // => 'Your session expired. Sign in again to continue.'
feedback.code; // => 'SESSION_EXPIRED'
feedback.isAccessDenied; // => true
// an error without copy gets the fallback, and its code stays hidden
getErrorFeedback(new Exception('Disk full', { code: 'DISK_FULL' })).code; // => null
```
**Example 2**
```ts
import { Exception, createErrorFeedback } from 'jg-exception';
const getErrorFeedback = createErrorFeedback({
fallbackMessage: 'Something went wrong.',
// codes such as AVATAR_TOO_LARGE are safe to show next to the fallback, for support requests
publicCodePattern: /^AVATAR_/,
});
const error = new Exception('The avatar is larger than 5 MB.', { code: 'AVATAR_TOO_LARGE' });
getErrorFeedback(error).code; // => 'AVATAR_TOO_LARGE'
// one screen gives the code copy of its own
const overrides = { messagesByCode: { AVATAR_TOO_LARGE: 'Choose an image of 5 MB or less.' } };
getErrorFeedback(error, overrides).message; // => 'Choose an image of 5 MB or less.'
```
##### `extractMessage`
*Function*
Extracts a message from an error of any shape: a string, an `Error` (with its causes), a ZodError, or an object holding the message in a common property such as `message`, `error`, or `reason`. Other objects are stringified. When nothing can be extracted, it returns the default message.
```ts
extractMessage(error: unknown): string
```
**Parameters**
- `error` `unknown`: The error to extract the message from.
**Returns** `string`: The extracted message, or `DEFAULT_MESSAGE`.
**Example**
```ts
import { DEFAULT_MESSAGE, extractMessage } from 'jg-exception';
extractMessage(new Error('Not found')); // => 'Not found'
extractMessage({ reason: 'Timed out' }); // => 'Timed out'
extractMessage(undefined) === DEFAULT_MESSAGE; // => true
```
##### `extractRedactedMessage`
*Function*
Extracts an error message and replaces every exact, case-sensitive occurrence of the sensitive values with `[redacted]`.
```ts
extractRedactedMessage(error: unknown, sensitiveValues: string[]): string
```
**Parameters**
- `error` `unknown`: The error to extract the message from.
- `sensitiveValues` `string[]`: The exact values to redact, such as tokens or passwords.
**Returns** `string`: The extracted message with the sensitive values redacted.
**Example**
```ts
import { extractRedactedMessage } from 'jg-exception';
extractRedactedMessage(new Error('Bad token abc123'), ['abc123']); // => 'Bad token [redacted]'
```
##### `getErrorCode`
*Function*
Reads an error's code: the non-empty string in its `code` property, as on an Exception, a Node.js system error, or a provider's error object.
```ts
getErrorCode(error: unknown): string | null
```
**Parameters**
- `error` `unknown`: The error to read, of any shape.
**Returns** `string | null`: The code, or `null` when the error has none.
**Example**
```ts
import { Exception, getErrorCode } from 'jg-exception';
getErrorCode(new Exception('Not found', { code: 'NOT_FOUND' })); // => 'NOT_FOUND'
getErrorCode(new Error('Not found')); // => null
```
##### `hasErrorCode`
*Function*
Checks whether an error has a code. The error may also be the code itself.
```ts
hasErrorCode(error: unknown, code: string): boolean
```
**Parameters**
- `error` `unknown`: The error to check, of any shape.
- `code` `string`: The code to look for.
**Returns** `boolean`: Whether the error has the code.
**Example**
```ts
import { Exception, hasErrorCode } from 'jg-exception';
hasErrorCode(new Exception('Not found', { code: 'NOT_FOUND' }), 'NOT_FOUND'); // => true
hasErrorCode('NOT_FOUND', 'NOT_FOUND'); // => true
```
##### `hasErrorCodePrefix`
*Function*
Checks whether an error's code, or the error itself when it's a string, starts with a prefix.
```ts
hasErrorCodePrefix(error: unknown, prefix: string): boolean
```
**Parameters**
- `error` `unknown`: The error to check, of any shape.
- `prefix` `string`: The non-empty prefix to look for.
**Returns** `boolean`: Whether the code starts with the prefix; always `false` for an empty prefix.
**Example**
```ts
import { Exception, hasErrorCodePrefix } from 'jg-exception';
hasErrorCodePrefix(new Exception('Denied', { code: 'AUTH_EXPIRED' }), 'AUTH_'); // => true
```
##### `isDefaultErrorMessage`
*Function*
Checks whether a value is, or contains, the default message the package falls back to.
```ts
isDefaultErrorMessage(value: string, fullMatch?: boolean): value is string
```
**Parameters**
- `value` `string`: The value to check.
- `fullMatch?` `boolean`: Whether the whole value must be the default message. Defaults to `false`.
**Returns** `value is string`: Whether the value matches the default message.
**Example**
```ts
import { extractMessage, isDefaultErrorMessage } from 'jg-exception';
isDefaultErrorMessage(extractMessage(undefined), true); // => true
```
##### `isException`
*Function*
Checks whether a value is an `Exception`, including one created by another installed copy of the package, where `instanceof` fails.
```ts
isException(value: unknown): value is Exception
```
**Parameters**
- `value` `unknown`: The value to check.
**Returns** `value is Exception`: Whether the value is an Exception.
**Example**
```ts
import { Exception, isException } from 'jg-exception';
isException(new Exception('Not found', { code: 'NOT_FOUND' })); // => true
isException(new Error('Not found')); // => false
```
##### `redactFields`
*Function*
Copies a value, such as a request body headed for an error report, with the values of the named fields replaced by `[redacted]`, however deep they are. Keys match case-insensitively, so `password` also redacts `Password`. Plain objects and arrays are copied, and anything else, such as a Date, is kept as it is; the original is never changed.
```ts
redactFields(value: unknown, keys: readonly string[]): unknown
```
**Parameters**
- `value` `unknown`: The value.
- `keys` `readonly string[]`: The names of the fields to redact, such as `['password', 'token']`.
**Returns** `unknown`: The copy, typed `unknown` because any field may now hold `[redacted]` instead of its own type.
**Example**
```ts
import { redactFields } from 'jg-exception';
const body = { email: 'ada@example.com', profile: { Password: 'hunter2' } };
redactFields(body, ['password']); // => { email: 'ada@example.com', profile: { Password: '[redacted]' } }
```
#### Classes in `jg-exception`
##### `Exception`
*Class*
An `Error` with a stable string code and optional JSON data, the error every jg package throws. An app's throw sites can also say how serious the failure is with `severity`, and ask for it to be logged with `shouldLog`, for its reporter to read. Neither is serialized.
**Members**
- `code: string` (property): the stable code callers handle the error by
- `data: IJsonValue | undefined` (property): context that helps whoever handles or debugs the error
- `severity: "expected" | "critical"` (property): how serious the failure is, only when the throw site gave a known severity
- `shouldLog: boolean` (property): whether the error is worth logging in full, only when the throw site asked for it
- `new Exception(message: string, options: IExceptionOptions)` (constructor): Creates an exception.
- `toJSON(): IExceptionJson` (method): Converts the exception to a plain object, which `JSON.stringify` calls.
- `toString(): string` (method): Describes the exception in one line, with its code.
**Example 1**
```ts
import { Exception } from 'jg-exception';
const exception = new Exception('The user was not found.', {
code: 'USER_NOT_FOUND',
data: { id: 1 },
});
exception.code; // => 'USER_NOT_FOUND'
exception.data; // => { id: 1 }
```
**Example 2**
```ts
import { Exception } from 'jg-exception';
// the message says what failed at this step, and the cause keeps the error underneath
const readSettings = (text: string): unknown => {
try {
return JSON.parse(text);
} catch (error) {
throw new Exception("Couldn't read the settings.", { code: 'SETTINGS_INVALID', cause: error });
}
};
readSettings('{"theme":"dark"}'); // => { theme: 'dark' }
```
**Example 3**
```ts
import { Exception, extractMessage } from 'jg-exception';
// the values in `redact` are replaced throughout the exception, including in its causes
const exception = new Exception("Couldn't verify the token.", {
code: 'TOKEN_INVALID',
cause: new Error('Invalid token abc123'),
redact: ['abc123'],
});
extractMessage(exception); // => "Couldn't verify the token.; [CAUSE]: Invalid token [redacted]"
```
**Example 4**
```ts
import { Exception } from 'jg-exception';
// an app's throw site says how its reporter treats the failure, without changing the JSON
const exception = new Exception('The user was not found.', {
code: 'USER_NOT_FOUND',
severity: 'expected',
});
exception.severity; // => 'expected'
exception.shouldLog; // => undefined
JSON.stringify(exception).includes('severity'); // => false
```
#### Constants in `jg-exception`
##### `DEFAULT_MESSAGE`
*Constant*
the message used when none can be extracted from an error
```ts
const DEFAULT_MESSAGE: "The error message could not be extracted, check the logs for more information."
```
##### `EXCEPTION_SEVERITIES`
*Constant*
the severities an Exception accepts, from the least to the most serious
```ts
const EXCEPTION_SEVERITIES: readonly ["expected", "critical"]
```
##### `REDACTED_VALUE`
*Constant*
the marker that replaces each redacted value, in messages, data, causes, and fields
```ts
const REDACTED_VALUE: "[redacted]"
```
##### `UNKNOWN_ERROR_CODE`
*Constant*
the code given to an error that has none, such as when it's sent in an API envelope
```ts
const UNKNOWN_ERROR_CODE: "UNKNOWN_ERROR"
```
#### Types in `jg-exception`
##### `IErrorFeedback`
*Type*
what a UI shows for an error
```ts
type IErrorFeedback = {
message: string;
code: string | null;
isCancelled: boolean;
isAccessDenied: boolean;
};
```
**Members**
- `message: string` (property): the copy for the error's code, or the fallback
- `code: string | null` (property): the error's code when it's safe to show, otherwise `null`
- `isCancelled: boolean` (property): whether the error is an `AbortError`, which usually means the user cancelled the work
- `isAccessDenied: boolean` (property): whether the error's code is one of the access-denied codes
##### `IErrorFeedbackOptions`
*Type*
how createErrorFeedback turns errors into what users read
```ts
type IErrorFeedbackOptions = {
fallbackMessage: string;
messagesByCode?: Readonly> | undefined;
accessDeniedCodes?: Iterable | undefined;
publicCodePattern?: RegExp | undefined;
};
```
**Members**
- `fallbackMessage: string` (property): what users read for an error without copy of its own
- `messagesByCode: Readonly>` (property): the copy users read for each code, written for them rather than for the logs
- `accessDeniedCodes: Iterable` (property): the codes that mean the user isn't allowed to do what they tried, such as a revoked session
- `publicCodePattern: RegExp` (property): the codes that are safe to show users without copy of their own, such as `/^AUTH_/`
##### `IErrorFeedbackOverrides`
*Type*
what one call replaces, for a screen whose errors need copy of their own
```ts
type IErrorFeedbackOverrides = {
fallbackMessage?: string | undefined;
messagesByCode?: Readonly> | undefined;
};
```
**Members**
- `fallbackMessage: string` (property): what users read for an error without copy of its own, instead of the configured fallback
- `messagesByCode: Readonly>` (property): the copy users read for each code, which wins over the configured copy for the same code
##### `IExceptionJson`
*Type*
an Exception as `toJSON` returns it, ready to log or send
```ts
type IExceptionJson = {
name: string;
code: string;
message: string;
data: IJsonValue;
};
```
**Members**
- `name: string` (property)
- `code: string` (property)
- `message: string` (property)
- `data: IJsonValue` (property)
##### `IExceptionOptions`
*Type*
the options an Exception is created with
```ts
type IExceptionOptions = {
code: string;
data?: IJsonValue | undefined;
cause?: unknown;
redact?: readonly string[] | undefined;
severity?: IExceptionSeverity | undefined;
shouldLog?: boolean | undefined;
};
```
**Members**
- `code: string` (property): the stable code callers handle the error by, such as `USER_NOT_FOUND`
- `data: IJsonValue` (property): context that helps whoever handles or debugs the error, such as the id that wasn't found
- `cause: unknown` (property): the error underneath, kept as the native `cause`
- `redact: readonly string[]` (property): values to replace with `[redacted]`, as written and URL-encoded, such as a token the cause may hold. They're redacted from the message, the data, and every cause, which becomes a copy that keeps its name, code, and plain fields without running the cause's own code. Nothing keeps them
- `severity: IExceptionSeverity` (property): how serious the failure is, set where an app throws it. Packages never set it, and it's never serialized
- `shouldLog: boolean` (property): whether the error is worth writing to the logs in full, such as one too large for the error tracker's storage, set where an app throws it. Packages never set it, and it's never serialized
##### `IExceptionSeverity`
*Type*
How serious a failure is, for whoever reports it: `expected` for one the app anticipates, such as a resource that doesn't exist, and `critical` for one someone must hear about at once. Leaving it out means an ordinary unexpected failure.
```ts
type IExceptionSeverity = (typeof EXCEPTION_SEVERITIES)[number];
```
##### `IJsonValue`
*Type*
A value that survives a trip through JSON. An object's values may also be `undefined`, because `JSON.stringify` drops them, so an object with optional fields fits. Interfaces and classes don't fit, because they have no index signature: pass an interface-typed value as `{ ...value }`, or declare its type with `type`.
```ts
type IJsonValue = string | number | boolean | null | IJsonValue[] | {
[key: string]: IJsonValue | undefined;
};
```
### `jg-exception/testing`
- Functions: [`expectToRejectCode`](#api-testing-expectToRejectCode), [`expectToThrowCode`](#api-testing-expectToThrowCode)
#### Functions in `jg-exception/testing`
##### `expectToRejectCode`
*Function*
Asserts that a promise rejects with an error containing the expected code and optional message text. Message checks use substring matching. It works with any test runner, because it throws a plain `Error` that the runner reports.
```ts
expectToRejectCode(
promise: PromiseLike,
expectedCode: string,
expectedMessage?: string,
): Promise
```
**Parameters**
- `promise` `PromiseLike`: The promise expected to reject.
- `expectedCode` `string`: The expected error code.
- `expectedMessage?` `string`: Optional text that the extracted rejection message must contain.
**Returns** `Promise`: A promise that resolves with undefined when the rejection matches.
**Throws**
- Expected the promise to reject with error code "{expectedCode}", but it resolved.
- Expected error code "{expectedCode}", but the error did not include a code.
- Expected error code "{expectedCode}", but received error code "{actualCode}".
- Expected error message to include "{expectedMessage}", but received error message "{actualMessage}".
**Example**
```ts
import { expectToRejectCode } from 'jg-exception/testing';
await expectToRejectCode(
Promise.reject(Object.assign(new Error('Access denied'), { code: 'ACCESS_DENIED' })),
'ACCESS_DENIED',
'Access denied',
);
```
##### `expectToThrowCode`
*Function*
Asserts that a synchronous operation throws an error with the expected code and optional message text. Message checks use substring matching. It works with any test runner, because it throws a plain `Error` that the runner reports.
```ts
expectToThrowCode(
operation: () => unknown,
expectedCode: string,
expectedMessage?: string,
): void
```
**Parameters**
- `operation` `() => unknown`: The synchronous operation expected to throw.
- `expectedCode` `string`: The expected error code.
- `expectedMessage?` `string`: Optional text that the extracted error message must contain.
**Throws**
- Expected the operation to throw error code "{expectedCode}", but it did not throw.
- Expected error code "{expectedCode}", but the error did not include a code.
- Expected error code "{expectedCode}", but received error code "{actualCode}".
- Expected error message to include "{expectedMessage}", but received error message "{actualMessage}".
**Example**
```ts
import { expectToThrowCode } from 'jg-exception/testing';
expectToThrowCode(
() => {
throw Object.assign(new Error('Access denied'), { code: 'ACCESS_DENIED' });
},
'ACCESS_DENIED',
'Access denied',
);
```
---
# jg-exception recipes
Patterns for apps that throw coded errors, report them, show them to users, keep secrets out of them, and handle them by their code.
## Severity and logging flags for an error reporter
Use this in an app that reports its errors, such as to its logs and an error tracker, when the place that throws an error knows better than the reporter how serious it is. `severity` says it: `'expected'` for a failure the app anticipates, such as a user that doesn't exist, and `'critical'` for one someone must hear about at once, such as a charge without a matching record. `shouldLog: true` asks for the error to be written to the logs in full, such as one whose details are too large for the error tracker's storage. Leaving both out means an ordinary unexpected failure:
```ts
import { Exception } from 'jg-exception';
type IUser = { id: number; name: string };
// finds a user by id, such as with a database query
declare function findUser(userId: number): Promise;
// compares what a payment provider charged for an invoice with what the ledger recorded
declare function compareLedger(invoiceId: number): Promise<{ charged: bigint; recorded: bigint }>;
export const getUser = async (userId: number): Promise => {
const user = await findUser(userId);
if (user === null) {
// the caller asked for a user that doesn't exist, which the app expects to happen
throw new Exception('The user was not found.', {
code: 'USER_NOT_FOUND',
data: { userId },
severity: 'expected',
});
}
return user;
};
export const checkInvoice = async (invoiceId: number): Promise => {
const { charged, recorded } = await compareLedger(invoiceId);
if (charged !== recorded) {
// money moved without a matching record: someone must look at it now, with the full details
throw new Exception("The ledger doesn't match the charge.", {
code: 'LEDGER_MISMATCH',
data: { invoiceId, charged: String(charged), recorded: String(recorded) },
severity: 'critical',
shouldLog: true,
});
}
};
```
Neither flag is serialized: `toJSON()`, which jg-api-response's `buildResponse` reads, gives only the name, code, message, and data, so clients never see them. They also survive `redact`, so a wrapped cause keeps its own. For an HTTP status, jg-nestjs's `ApiException` is an Exception that adds one.
Read the flags in one place, where every error is reported, such as the app's error handler:
```ts
import { extractMessage, getErrorCode, isException } from 'jg-exception';
// writes a line to the app's logs
declare const logger: { error(message: string, details: object): void };
// sends an error to the error tracker, such as Sentry's captureException
declare function captureError(error: unknown): void;
// pages whoever is on call
declare function notifyOnCall(error: unknown): void;
// logs, captures, and escalates an error as its throw site asked
export const reportError = (error: unknown): void => {
// an error that isn't an Exception, such as a bug's TypeError, has no flags
const severity = isException(error) ? error.severity : undefined;
if (severity === 'expected') {
return;
}
if (!isException(error) || error.shouldLog === true || severity === 'critical') {
// the message joins the causes, which the logs need and the client never sees
logger.error(extractMessage(error), { code: getErrorCode(error) });
}
captureError(error);
if (severity === 'critical') {
notifyOnCall(error);
}
};
```
An error that isn't an Exception wasn't expected, so it's logged and captured. An Exception from another package has neither flag, because packages never set them: it's an ordinary failure, captured but not logged in full. When you expect one, wrap it in an Exception with `severity: 'expected'` where you call the code that throws it, with the error as its `cause`.
## Feedback for the UI
Use this in a browser app, where most errors are expected, such as a file that's too large or a session that expired, and the user needs to know what to do next. What users read comes from copy you write for each code, never from the error's message, which is written for whoever debugs it: it can name an id or an internal step, and it isn't translated. `createErrorFeedback` holds that copy, configured once:
```ts
import { createErrorFeedback, Exception } from 'jg-exception';
// the copy users read for each code, and the codes that send them back to sign in
export const getErrorFeedback = createErrorFeedback({
fallbackMessage: 'Something went wrong. Try again in a moment.',
messagesByCode: {
AVATAR_TOO_LARGE: 'Choose an image of 5 MB or less.',
SESSION_EXPIRED: 'Your session expired. Sign in again to continue.',
USER_NOT_FOUND: 'This account no longer exists.',
},
accessDeniedCodes: ['SESSION_EXPIRED'],
});
// the largest avatar the app uploads, in bytes
const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
// checks the image a user picked before it's uploaded
export const checkAvatar = (file: File): void => {
if (file.size > MAX_AVATAR_BYTES) {
// the user can pick a smaller image, so the error tracker doesn't need it
throw new Exception('The avatar is larger than 5 MB.', {
code: 'AVATAR_TOO_LARGE',
data: { sizeBytes: file.size, maxSizeBytes: MAX_AVATAR_BYTES },
severity: 'expected',
});
}
};
```
Handle errors where the UI catches them, such as in an event handler or an error boundary:
```ts
import { type createErrorFeedback, isException } from 'jg-exception';
// the function from the block above
declare const getErrorFeedback: ReturnType;
// sends an error to the error tracker, such as Sentry's captureException
declare function captureError(error: unknown): void;
// shows a short message to the user, such as in a toast
declare function showToast(message: string): void;
// sends the user to the sign-in page
declare function redirectToSignIn(): void;
// tells the user what happened, and captures the errors the app didn't expect
export const showError = (error: unknown, fallbackMessage?: string): void => {
const feedback = getErrorFeedback(error, { fallbackMessage });
// a cancelled request, such as when the user left the page, needs no message
if (feedback.isCancelled) {
return;
}
if (!isException(error) || error.severity !== 'expected') {
captureError(error);
}
if (feedback.isAccessDenied) {
redirectToSignIn();
return;
}
showToast(feedback.message);
};
```
A screen passes a fallback of its own, such as `showError(error, "Couldn't upload the avatar.")`, and keeps the copy for every code. The codes come from anywhere: your Exceptions, your API's envelope errors, which jg-fetch's FetchException carries as its `code`, and other packages, such as jg-clipboard. A code without copy gets the fallback, so a new code never shows users a developer's message, and `feedback.code` is `null` for it unless `publicCodePattern` marks it safe to show, such as for a support request. If your API already reports its own failures, skip the errors your API client returns for them, so the tracker doesn't get each one twice.
## Keeping secrets out of a cause
Use `redact` when you wrap an error that can hold a credential your code passed in, such as a token, a password, or a one-time code. A library's error can echo the request it sent in its message, its stack, or its fields, and whatever logs or tracks the new exception prints the whole chain. Exchanging an OAuth authorization code for tokens is a typical case: the code, the PKCE verifier, and the client secret all go into the request:
```ts
import { Exception } from 'jg-exception';
type ITokens = { accessToken: string; refreshToken: string };
// the OAuth client's secret, from the environment
declare const clientSecret: string;
// exchanges an authorization code for tokens, such as with an OAuth library, whose errors can
// carry the request it sent
declare function requestTokens(request: {
code: string;
codeVerifier: string;
clientSecret: string;
}): Promise;
export const exchangeCode = async (code: string, codeVerifier: string): Promise => {
try {
return await requestTokens({ code, codeVerifier, clientSecret });
} catch (error) {
// each value the request carried is a credential, wherever the cause holds it
throw new Exception("Couldn't exchange the authorization code for tokens.", {
code: 'OAUTH_CODE_EXCHANGE_FAILED',
cause: error,
redact: [code, codeVerifier, clientSecret],
});
}
};
```
Pass every secret the call was given, not only the one you expect in the error: you can't tell which one a library echoes. Each value is also matched URL-encoded, so a code with a `/` is found where a URL or a form body carries it as `%2F`. A handler that needs to know why the provider refused, such as for a code that expired, reads the cause's `code` or `name`: the cause is now a redacted copy that keeps them, and it isn't `instanceof` the library's error class anymore.
When the error ends where you catch it, because the work goes on without that step, only a log line leaves the `catch`. `extractRedactedMessage` gives that line: the error's message with its causes, with the values redacted. Revoking a refresh token at sign-out is such a step, because the session ends either way:
```ts
import { extractRedactedMessage } from 'jg-exception';
// writes a line to the app's logs
declare const logger: { warn(message: string, details: object): void };
// asks the provider to revoke a token, such as with an OAuth library
declare function revokeToken(token: string): Promise;
// clears the session cookie and the stored tokens
declare function clearSession(): Promise;
export const signOut = async (refreshToken: string): Promise => {
try {
await revokeToken(refreshToken);
} catch (error) {
// signing out goes on without the revocation, so the error ends here, as a log line. Unlike
// redact, this matches values only as written, so the URL-encoded form is passed too
logger.warn("Couldn't revoke the refresh token.", {
error: extractRedactedMessage(error, [refreshToken, encodeURIComponent(refreshToken)]),
});
}
await clearSession();
};
```
Only the message is redacted, so log that string and nothing else: passing `error` itself to the logger or the error tracker would send its stack and fields as they are. To track the failure, wrap it in an Exception with `redact`, and send that.
## Redacting a request in an error report
Use `redactFields` when an error report carries the request that failed, such as its body, so the error tracker shows what the client sent. The body is the client's, so you don't know its secrets' values, as `redact` needs, but you know the fields that hold them: a password, a token, or personal data such as an email address. The copy replaces their values wherever they are, in nested objects and arrays too, and matches the names whatever their case:
```ts
import { redactFields } from 'jg-exception';
// the fields that hold credentials or personal data in the API's requests
const SENSITIVE_FIELDS = ['email', 'googleIdToken', 'password', 'token'];
// sends an error to the error tracker with what to show beside it, such as Sentry's
// captureException
declare function captureError(error: unknown, context: { extra: Record }): void;
export const reportRequestError = (
error: unknown,
request: { method: string; path: string; body: unknown },
): void => {
captureError(error, {
extra: {
request: `${request.method} ${request.path}`,
body: redactFields(request.body, SENSITIVE_FIELDS),
},
});
};
```
Only the fields you name are redacted, so add one whenever a request gains a sensitive field. A class instance in the body, such as a `Date`, is kept as it is rather than walked, and the request's own body is never changed, so the handler that runs after the report still sees what the client sent.
## Handling an error by its code
Branch on codes, never on messages: a message can be reworded in any release, and a code is stable. `hasErrorCode` and `getErrorCode` read a string `code` from any error, so the same check works on an Exception, a Node.js system error, and a database driver's error.
Use `hasErrorCode` for the one failure a step expects, and wrap everything else. A settings file that doesn't exist yet is the usual case: Node.js rejects with `ENOENT`, which means the user never saved settings, not that reading failed:
```ts
import { readFile } from 'node:fs/promises';
import { Exception, hasErrorCode } from 'jg-exception';
type ISettings = { theme: 'light' | 'dark'; language: string };
// a Zod schema, or any validator with parse
declare const SettingsSchema: { parse(value: unknown): ISettings };
// the settings of a user who never saved any
const DEFAULT_SETTINGS: ISettings = { theme: 'light', language: 'en' };
export const loadSettings = async (path: string): Promise => {
let text: string;
try {
text = await readFile(path, 'utf8');
} catch (error) {
if (hasErrorCode(error, 'ENOENT')) {
return DEFAULT_SETTINGS;
}
// any other code, such as EACCES, is a real failure, with the system error as the cause
throw new Exception("Couldn't read the settings file.", {
code: 'SETTINGS_READ_FAILED',
data: { path },
cause: error,
});
}
return SettingsSchema.parse(JSON.parse(text));
};
```
At a module's boundary, turn a library's code into one of your own, so your callers don't depend on the library. Export your codes as an `as const` map, so callers compare against it. Here PostgreSQL's code for a unique violation, `23505`, becomes `EMAIL_TAKEN`:
```ts
import { Exception, hasErrorCode } from 'jg-exception';
// the codes this module throws, which callers compare against
export const ERRORS = {
EMAIL_TAKEN: 'EMAIL_TAKEN',
USER_CREATE_FAILED: 'USER_CREATE_FAILED',
} as const;
// PostgreSQL's code for a unique violation, which drivers such as pg set as the error's code
const UNIQUE_VIOLATION = '23505';
// inserts the user's row, such as with pg's pool.query; email is the table's only unique column
declare function insertUser(email: string): Promise<{ id: number }>;
export const createUser = async (email: string): Promise<{ id: number }> => {
try {
return await insertUser(email);
} catch (error) {
if (hasErrorCode(error, UNIQUE_VIOLATION)) {
throw new Exception('The email is already registered.', {
code: ERRORS.EMAIL_TAKEN,
cause: error,
});
}
throw new Exception("Couldn't create the user.", {
code: ERRORS.USER_CREATE_FAILED,
cause: error,
});
}
};
// a sign-up form shows a taken email next to its field, and rethrows anything else
export const signUp = async (email: string): Promise<{ emailProblem: string } | null> => {
try {
await createUser(email);
return null;
} catch (error) {
if (hasErrorCode(error, ERRORS.EMAIL_TAKEN)) {
return { emailProblem: 'This email is already registered.' };
}
throw error;
}
};
```
Your codes are part of the module's contract: renaming one breaks every caller that compares against it.
When one place chooses between several outcomes, `switch` on `getErrorCode`, which returns `null` for an error without a code, and use `hasErrorCodePrefix` for a family of codes that share a prefix. Here a queue worker decides what to do with an email job that failed:
```ts
import { getErrorCode, hasErrorCodePrefix } from 'jg-exception';
// what the worker does with a job that failed
type IJobOutcome = 'retry' | 'drop' | 'fail';
// chooses a failed job's outcome by the error's code, from the app, a library, or Node.js
export const chooseOutcome = (error: unknown): IJobOutcome => {
// every RECIPIENT_ code, such as RECIPIENT_UNSUBSCRIBED, means the address won't take the
// email, so a retry can't help
if (hasErrorCodePrefix(error, 'RECIPIENT_')) {
return 'drop';
}
switch (getErrorCode(error)) {
// Node.js network errors and the provider's rate limit pass, so the next attempt may succeed
case 'ECONNRESET':
case 'ETIMEDOUT':
case 'RATE_LIMITED':
return 'retry';
// anything else needs a person, so the job fails and the error is reported
default:
return 'fail';
}
};
```
`getErrorCode` and the other checks read the error's own code, not its causes'. So when you wrap an error, give the wrapper the code its callers branch on, as `createUser` does above, instead of leaving that code on the cause.
---
# jg-api-response
> Build and recognise the success, data, and error envelope that services exchange over HTTP, with errors as a code, a message, and data.
- **Version:** `1.1.1`
- **Runtimes:** Universal
- **Install:** `npm install jg-api-response`
- **On npm:** [jg-api-response](https://www.npmjs.com/package/jg-api-response)
- **Source:** [packages/jg-api-response](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-api-response)
- **Recipes:** [jg-api-response recipes](https://jg-packages.jesusgraterol.dev/packages/jg-api-response/recipes/index.md)
## Usage
Build the body a service responds with. A response is successful when no error is passed:
```ts
import { buildResponse } from 'jg-api-response';
buildResponse({ id: 1 });
// { success: true, data: { id: 1 }, error: null }
```
A failed response carries the error as `{ code, message, data }`, so the client gets everything it needs to handle or debug it. An `Exception` from `jg-exception` keeps its code, message, and data:
```ts
import { Exception } from 'jg-exception';
buildResponse(
undefined,
new Exception('The user was not found.', { code: 'USER_NOT_FOUND', data: { id: 1 } }),
);
// {
// success: false,
// data: undefined,
// error: { code: 'USER_NOT_FOUND', message: 'The user was not found.', data: { id: 1 } },
// }
```
Any other error keeps its string `code`, such as a Node.js `ENOENT`, or gets `UNKNOWN_ERROR`, with its message and `null` data:
```ts
buildResponse(undefined, new Error('Request failed'));
// { success: false, data: undefined, error: { code: 'UNKNOWN_ERROR', message: 'Request failed', data: null } }
```
The envelope can always be serialized when its data can: Exception data that JSON can't hold becomes `null`.
Check a body you received, after `JSON.parse` dropped its undefined fields, or from a service that leaves out `error` or its `data`:
```ts
import { isResponse } from 'jg-api-response';
const body: unknown = await response.json();
if (isResponse(body)) {
body.success; // boolean
body.data; // IUser | undefined
body.error; // { code: string; message: string; data?: unknown } | null | undefined
}
```
`isResponse` checks the error's `code` and `message`, but not its `data`, which is `unknown` until you check it.
## jg-api-response API reference
Every export of `jg-api-response@1.1.1`, 6 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc.
- Functions: [`buildResponse`](#api-buildResponse), [`isResponse`](#api-isResponse)
- Types: [`IApiError`](#api-IApiError), [`IApiResponse`](#api-IApiResponse), [`IParsedApiError`](#api-IParsedApiError), [`IParsedApiResponse`](#api-IParsedApiResponse)
### Functions
#### `buildResponse`
*Function*
Builds the envelope for an HTTP body. The response is successful when no error is passed. Any error that is passed, even a falsy one, becomes `{ code, message, data }`: an Exception keeps all three, and any other error keeps its string `code`, or gets `UNKNOWN_ERROR`, and its message. A failed response can carry data too.
```ts
buildResponse(data?: T, error?: unknown): IApiResponse
```
**Parameters**
- `data?` `T`: The data to send.
- `error?` `unknown`: The error the request failed with, of any shape.
**Returns** `IApiResponse`: The envelope, which `JSON.stringify` can always serialize when the data can.
**Example**
```ts
import { buildResponse } from 'jg-api-response';
import { Exception } from 'jg-exception';
buildResponse({ id: 1 }); // => { success: true, data: { id: 1 }, error: null }
buildResponse(undefined, new Exception('Not found', { code: 'NOT_FOUND', data: { id: 1 } }));
// { success: false, data: undefined, error: { code: 'NOT_FOUND', message: 'Not found', data: { id: 1 } } }
```
#### `isResponse`
*Function*
Checks whether a value is an envelope, including one parsed from JSON or sent by another service: an object with a boolean `success`, an `error` that is `null`, missing, or an object with a string `code` and `message`, and any `data` or none. The error's `data` isn't checked.
```ts
isResponse(value: unknown): value is IParsedApiResponse
```
**Parameters**
- `value` `unknown`: The value to check, such as a parsed response body.
**Returns** `value is IParsedApiResponse`: Whether the value is an envelope.
**Example**
```ts
import { isResponse } from 'jg-api-response';
type IUser = { id: number; name: string };
const body: unknown = JSON.parse('{"success":true,"data":{"id":1,"name":"Ada"},"error":null}');
isResponse(body); // => true
if (isResponse(body) && body.success) {
body.data; // IUser | undefined
}
```
### Types
#### `IApiError`
*Type*
an error as an envelope carries it to the client
```ts
type IApiError = {
code: string;
message: string;
data: IJsonValue;
};
```
**Members**
- `code: string` (property): the stable code the client handles the error by, or `UNKNOWN_ERROR` when the error had none
- `message: string` (property): what failed
- `data: IJsonValue` (property): the context the error carried, or `null` when it carried none
#### `IApiResponse`
*Type*
The envelope a service sends in an HTTP body. A response is successful when it has no error, and it carries data either way.
```ts
type IApiResponse = {
success: boolean;
data: T;
error: IApiError | null;
};
```
**Members**
- `success: boolean` (property): whether the request succeeded, which is when `error` is null
- `data: T` (property): the data sent to the client, whatever the outcome
- `error: IApiError | null` (property): the error the request failed with, or null when it succeeded
#### `IParsedApiError`
*Type*
An envelope error as it arrives from any service. `data` may be missing, and it's `unknown`, because `isResponse` doesn't check it: whoever reads it checks it first.
```ts
type IParsedApiError = {
code: string;
message: string;
data?: unknown;
};
```
**Members**
- `code: string` (property)
- `message: string` (property)
- `data: unknown` (property)
#### `IParsedApiResponse`
*Type*
An envelope as it arrives after `JSON.parse`: `undefined` fields are gone, and services may send `error: null` on success or leave `error` out.
```ts
type IParsedApiResponse = {
success: boolean;
data?: T;
error?: IParsedApiError | null;
};
```
**Members**
- `success: boolean` (property)
- `data: T` (property)
- `error: IParsedApiError | null` (property)
---
# jg-api-response recipes
Patterns for APIs that answer every error with a status and an envelope, and for code that reads another service's envelope.
## One error handler for every route
Use one function to turn whatever a route throws into an HTTP status and an envelope, and call it from your framework's error hook, such as Express's error middleware or Hono's `app.onError`, so every route answers errors the same way. It's a plain function, so it fits any framework: the hook sends the body it returns, with its status.
```ts
import { buildResponse, type IApiResponse } from 'jg-api-response';
import { Exception, isException, UNKNOWN_ERROR_CODE } from 'jg-exception';
import { ERRORS as UTILITIES_ERRORS } from 'jg-utilities';
// the app's exception with an HTTP status, such as the ApiException in jg-exception's recipes
declare class ApiException extends Exception {
readonly status: number;
}
// writes an error to the logs and sends it to the error tracker, such as Sentry
declare function reportError(error: unknown): void;
// the status for each code thrown without one, such as by a jg package; any other code is a 500
const STATUS_BY_CODE: ReadonlyMap = new Map([
['INVALID_REQUEST', 400],
[UTILITIES_ERRORS.INVALID_CURSOR, 400],
]);
// what the client reads for a server error, whose own message can name a path, a host, or a query
const INTERNAL_ERROR_MESSAGE = 'Something went wrong on our side. Try again in a moment.';
// a status, and the envelope sent with it
type IErrorResponse = { status: number; body: IApiResponse };
// reads the status an error is answered with
const readStatus = (error: unknown): number => {
if (error instanceof ApiException) {
return error.status;
}
return isException(error) ? (STATUS_BY_CODE.get(error.code) ?? 500) : 500;
};
// turns anything a route throws into the status and the envelope the client gets
export const handleError = (error: unknown): IErrorResponse => {
const status = readStatus(error);
if (status < 500) {
// a client error's message and data say what to fix
return { status, body: buildResponse(null, error) };
}
reportError(error);
// the client keeps the code to branch on, or gets UNKNOWN_ERROR for an error that isn't an
// Exception, such as a bug or a database driver's error, whose own code is an internal
const code = isException(error) ? error.code : UNKNOWN_ERROR_CODE;
return { status, body: buildResponse(null, new Exception(INTERNAL_ERROR_MESSAGE, { code })) };
};
```
- **The status comes from your own class, or from the code.** Don't read a `status` field from any error: jg-fetch's FetchException has one too, the status another service answered you with, and a 404 from a service you call is your server's failure, not the client's. For the same reason, wrap what a service you call throws in a code of your own where you call it, so its codes never reach the table.
- **An error that isn't an Exception is a 500 with `UNKNOWN_ERROR`.** A schema's error is one, so wrap it where you parse, as the route below does, and the client gets a 400 it can act on.
- **The cause never reaches the client.** `buildResponse` sends an Exception's code, message, and data, so a 4xx's cause stays in your logs. A 5xx's message and data stay there too, because the handler sends its code with a fixed message.
A route is then a plain function that returns the envelope for a success and throws for anything else:
```ts
import { buildResponse, type IApiResponse } from 'jg-api-response';
import { Exception, extractMessage } from 'jg-exception';
type IUser = { id: number; name: string };
type IUserChanges = { name: string };
// a Zod schema, or any validator with parse
declare const UserChangesSchema: { parse(value: unknown): IUserChanges };
// saves the changes to a user, and throws an ApiException with a 404 when there's no such user
declare function updateUser(userId: number, changes: IUserChanges): Promise;
// the framework passes the request in and sends the envelope with a 200, and its error hook passes
// anything thrown to handleError
export const updateUserRoute = async (
userId: number,
body: unknown,
): Promise> => {
let changes: IUserChanges;
try {
changes = UserChangesSchema.parse(body);
} catch (error) {
// the schema's error has no code the handler knows, so it's wrapped in one answered with a 400,
// and its first issue goes in the data, so the client can tell which field failed
throw new Exception('The request body is invalid.', {
code: 'INVALID_REQUEST',
data: { issue: extractMessage(error) },
cause: error,
});
}
return buildResponse(await updateUser(userId, changes));
};
```
## Reading another service's envelope
Use `isResponse` where an envelope reaches you without jg-fetch, such as in a body from `fetch` or another HTTP client, a WebSocket message, or a queue job's result; jg-fetch's `request` makes these checks itself, with `parseAs: 'envelope'` and a `schema`. `isResponse` checks the envelope's shape, so you can read `success` and `error`, but not its data, which your schema checks:
```ts
import { isResponse } from 'jg-api-response';
import { Exception } from 'jg-exception';
type IInvoice = { id: string; totalCents: number };
// the codes this module throws
export const ERRORS = {
INVOICE_NOT_FOUND: 'INVOICE_NOT_FOUND',
BILLING_REQUEST_FAILED: 'BILLING_REQUEST_FAILED',
BILLING_RESPONSE_INVALID: 'BILLING_RESPONSE_INVALID',
} as const;
// the billing service's origin
declare const billingUrl: string;
// a Zod schema, or any validator with parse
declare const InvoiceSchema: { parse(value: unknown): IInvoice };
// reads an invoice from the billing service, which answers with an envelope
export const getInvoice = async (invoiceId: string): Promise => {
const response = await fetch(`${billingUrl}/invoices/${encodeURIComponent(invoiceId)}`);
// a proxy's error page isn't JSON, so it's treated like any other body that isn't an envelope
const body: unknown = await response.json().catch(() => null);
if (!isResponse(body)) {
throw new Exception("The billing service's response isn't an envelope.", {
code: ERRORS.BILLING_RESPONSE_INVALID,
data: { status: response.status },
});
}
if (body.error || !body.success) {
// the service's codes are its own: the one callers handle becomes this module's, and the rest
// share one code, so the service's INVALID_REQUEST never reads as your client's
throw new Exception("The billing service couldn't return the invoice.", {
code:
body.error?.code === 'INVOICE_NOT_FOUND'
? ERRORS.INVOICE_NOT_FOUND
: ERRORS.BILLING_REQUEST_FAILED,
data: { invoiceId, status: response.status },
cause: body.error,
});
}
return InvoiceSchema.parse(body.data);
};
```
Leave out the type argument: `isResponse(body)` would type `body.data` as `IInvoice | undefined` without looking at it, so the schema checks the data instead. A body with an `error` failed, whatever its `success` says, and so did one with `success: false` and no `error`, which `isResponse` also accepts. The service's error is the cause, so `extractMessage` and your logs show its message after yours. Its `data` is `unknown`, because `isResponse` doesn't check it: check it before you copy anything from it into your own data.
---
# jg-utilities
> Validations, formatters, secure random generators, pagination cursors, and collection, async, and environment helpers.
- **Version:** `1.4.0`
- **Runtimes:** Universal
- **Install:** `npm install jg-utilities`
- **On npm:** [jg-utilities](https://www.npmjs.com/package/jg-utilities)
- **Source:** [packages/jg-utilities](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-utilities)
- **Recipes:** [jg-utilities recipes](https://jg-packages.jesusgraterol.dev/packages/jg-utilities/recipes/index.md)
## Usage
Validate input before using it:
```ts
import { isBigIntStringValid, isEmailValid, isIntegerValid, isStringValid } from 'jg-utilities';
isStringValid(name, 1, 100); // a non-blank string of 1 to 100 characters
isIntegerValid(page, 1); // an integer of at least 1
isEmailValid(email); // a valid address without a forbidden extension
isBigIntStringValid(id, 1n); // a positive integer string that fits PostgreSQL's BIGINT
```
Format values for people:
```ts
import {
escapeHtml,
pluralize,
prettifyDate,
prettifyFileSize,
prettifyNumber,
toSlug,
} from 'jg-utilities';
prettifyNumber(1234567.891); // '1,234,567.89'
prettifyNumber(-5, { prefix: '$' }); // '-$5'
prettifyFileSize(1536); // '1.50 kB'
prettifyDate(createdAt, 'date-medium'); // 'October 17, 2026'
toSlug('Héllo, World! 2026'); // 'hello-world-2026'
`${count} ${pluralize(count, 'file')}`; // '1 file', '3 files'
escapeHtml(name); // safe to put into HTML, such as an email's body
```
Send someone back where they were after signing in, only within the site and never back to the sign-in page:
```ts
import { resolveSafeInternalPath } from 'jg-utilities';
const returnTo = resolveSafeInternalPath(searchParams.get('returnTo'), ['/sign-in']) ?? '/';
```
Keep secrets out of logs, and read the YouTube links people paste:
```ts
import {
parseYouTubeVideoId,
redactSecrets,
redactUrlQueryParams,
toYouTubeEmbedUrl,
} from 'jg-utilities';
redactUrlQueryParams(request.url, ['token']); // the path alone when the query holds a token
redactSecrets(logLine).text; // GitHub tokens, sk- keys, and private keys become [redacted]
const videoId = parseYouTubeVideoId(pastedUrl); // the id, or null for anything but one video
const embedUrl = videoId === null ? undefined : toYouTubeEmbedUrl(videoId);
```
Generate secure random values, IDs, and timestamps:
```ts
import { createTimestamp, generateRandomString, generateUuid } from 'jg-utilities';
generateRandomString(16); // drawn with crypto.getRandomValues, fit for secrets
generateUuid(7); // a time-ordered UUID
createTimestamp(); // '2026-10-17T08:30:00.000Z'
```
Work with UTC calendar dates, such as the days a report covers, and fit text into a byte budget, such as a push notification's payload limit:
```ts
import {
generateUtcDateSequence,
getTrailingUtcDateRange,
toUtcDate,
truncateToUtf8Bytes,
} from 'jg-utilities';
toUtcDate(createdAt); // '2026-10-17', the UTC day of an instant
const range = getTrailingUtcDateRange(30); // the last 30 UTC days, today included
generateUtcDateSequence(range); // one 'YYYY-MM-DD' per day, oldest first
truncateToUtf8Bytes(body, 2048); // whole code points only, at most 2048 bytes in UTF-8
```
Run side effects away from the code that triggers them with an in-process event bus:
```ts
import { createEventBus } from 'jg-utilities';
type IAppEvents = { userSignedUp: { userId: string } };
const events = createEventBus({ onError: (error) => logger.error(error) });
events.subscribe('userSignedUp', ({ userId }) => sendWelcomeEmail(userId));
events.emit('userSignedUp', { userId }); // returns at once; each handler runs later, on its own
```
Paginate with opaque cursors, and wait or retry with timing in seconds:
```ts
import { decodeCursor, encodeCursor, retryAsyncFunction, waitUntil } from 'jg-utilities';
const cursor = encodeCursor({ createdAt: lastRow.createdAt, id: lastRow.id });
const continuation = decodeCursor(cursorFromClient); // throws INVALID_CURSOR for anything else
await retryAsyncFunction(() => fetchData(), [3, 5]); // retries after 3, then 5 seconds
await waitUntil(() => readStatus() === 'ready' || undefined, { timeoutSeconds: 30 });
```
Give each list a cursor codec that validates its cursors and accepts them only for the filters they were made for, and keep a rate-limited API's requests within its limits:
```ts
import { createCursorCodec, createRequestCoordinator } from 'jg-utilities';
const paymentCursors = createCursorCodec({ schema: PaymentCursorSchema }); // a Zod schema, say
const after = paymentCursors.decode(request.query.cursor, { status }); // INVALID_CURSOR otherwise
const github = createRequestCoordinator({ maxConcurrent: 4, maxQueued: 64 });
const issues = await github.run(() => fetchIssues(), {
caller: installationId,
maxQueuedForCaller: 8,
});
github.deferUntil(retryAt); // queued requests wait out a Retry-After, without holding a slot
```
## jg-utilities API reference
Every export of `jg-utilities@1.4.0`, 125 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc.
- Functions: [`applyDefaults`](#api-applyDefaults), [`applySubstitutions`](#api-applySubstitutions), [`capitalizeFirst`](#api-capitalizeFirst), [`createCursorCodec`](#api-createCursorCodec), [`createEventBus`](#api-createEventBus), [`createRequestCoordinator`](#api-createRequestCoordinator), [`createTimestamp`](#api-createTimestamp), [`decodeBase64Url`](#api-decodeBase64Url), [`decodeCursor`](#api-decodeCursor), [`delay`](#api-delay), [`encodeBase64Url`](#api-encodeBase64Url), [`encodeCursor`](#api-encodeCursor), [`escapeHtml`](#api-escapeHtml), [`estimateReadingTime`](#api-estimateReadingTime), [`extractEmailUsername`](#api-extractEmailUsername), [`extractFirstMarkdownHeadingName`](#api-extractFirstMarkdownHeadingName), [`extractSubstitutionPlaceholderNames`](#api-extractSubstitutionPlaceholderNames), [`extractTokenFromAuthorizationHeader`](#api-extractTokenFromAuthorizationHeader), [`filterByQuery`](#api-filterByQuery), [`generateDateId`](#api-generateDateId), [`generateRandomFloat`](#api-generateRandomFloat), [`generateRandomInteger`](#api-generateRandomInteger), [`generateRandomString`](#api-generateRandomString), [`generateSequence`](#api-generateSequence), [`generateUtcDateSequence`](#api-generateUtcDateSequence), [`generateUuid`](#api-generateUuid), [`getInitials`](#api-getInitials), [`getNextPageParam`](#api-getNextPageParam), [`getTrailingUtcDateRange`](#api-getTrailingUtcDateRange), [`hasNavigator`](#api-hasNavigator), [`hasWindow`](#api-hasWindow), [`isArrayValid`](#api-isArrayValid), [`isAuthorizationHeaderValid`](#api-isAuthorizationHeaderValid), [`isBigIntStringValid`](#api-isBigIntStringValid), [`isEmailValid`](#api-isEmailValid), [`isEqual`](#api-isEqual), [`isFileNameValid`](#api-isFileNameValid), [`isIndexedDbAvailable`](#api-isIndexedDbAvailable), [`isIntegerValid`](#api-isIntegerValid), [`isJwtValid`](#api-isJwtValid), [`isNumberValid`](#api-isNumberValid), [`isNumeric`](#api-isNumeric), [`isObjectValid`](#api-isObjectValid), [`isOTPSecretValid`](#api-isOTPSecretValid), [`isOTPTokenValid`](#api-isOTPTokenValid), [`isPasswordValid`](#api-isPasswordValid), [`isSemverValid`](#api-isSemverValid), [`isSlugValid`](#api-isSlugValid), [`isStringValid`](#api-isStringValid), [`isTimestampValid`](#api-isTimestampValid), [`isUrlValid`](#api-isUrlValid), [`isUuidValid`](#api-isUuidValid), [`isYouTubeVideoIdValid`](#api-isYouTubeVideoIdValid), [`maskMiddle`](#api-maskMiddle), [`normalizeQuery`](#api-normalizeQuery), [`omitProps`](#api-omitProps), [`parseJson`](#api-parseJson), [`parseYouTubeVideoId`](#api-parseYouTubeVideoId), [`pickProps`](#api-pickProps), [`pluralize`](#api-pluralize), [`prefersReducedMotion`](#api-prefersReducedMotion), [`prettifyBadgeCount`](#api-prettifyBadgeCount), [`prettifyDate`](#api-prettifyDate), [`prettifyFileSize`](#api-prettifyFileSize), [`prettifyNumber`](#api-prettifyNumber), [`prettifyPercentage`](#api-prettifyPercentage), [`prettifyTime`](#api-prettifyTime), [`pruneJson`](#api-pruneJson), [`redactSecrets`](#api-redactSecrets), [`redactUrlQueryParams`](#api-redactUrlQueryParams), [`resolveSafeInternalPath`](#api-resolveSafeInternalPath), [`retryAsyncFunction`](#api-retryAsyncFunction), [`shuffleArray`](#api-shuffleArray), [`sortPrimitives`](#api-sortPrimitives), [`sortRecords`](#api-sortRecords), [`sortRecordsWithBigIntString`](#api-sortRecordsWithBigIntString), [`sortRecordsWithDateValue`](#api-sortRecordsWithDateValue), [`splitArrayIntoBatches`](#api-splitArrayIntoBatches), [`stringifyJson`](#api-stringifyJson), [`stringifyJsonDeterministically`](#api-stringifyJsonDeterministically), [`stringifyValue`](#api-stringifyValue), [`toDate`](#api-toDate), [`toMS`](#api-toMS), [`toSlug`](#api-toSlug), [`toTitleCase`](#api-toTitleCase), [`toUtcDate`](#api-toUtcDate), [`toYouTubeEmbedUrl`](#api-toYouTubeEmbedUrl), [`toYouTubeWatchUrl`](#api-toYouTubeWatchUrl), [`truncateText`](#api-truncateText), [`truncateToUtf8Bytes`](#api-truncateToUtf8Bytes), [`waitUntil`](#api-waitUntil)
- Constants: [`ERRORS`](#api-ERRORS), [`MAX_EMAIL_LENGTH`](#api-MAX_EMAIL_LENGTH), [`ONE_DAY_IN_MILLISECONDS`](#api-ONE_DAY_IN_MILLISECONDS), [`ONE_HOUR_IN_MILLISECONDS`](#api-ONE_HOUR_IN_MILLISECONDS), [`ONE_MINUTE_IN_MILLISECONDS`](#api-ONE_MINUTE_IN_MILLISECONDS), [`ONE_MONTH_IN_MILLISECONDS`](#api-ONE_MONTH_IN_MILLISECONDS), [`ONE_SECOND_IN_MILLISECONDS`](#api-ONE_SECOND_IN_MILLISECONDS), [`ONE_WEEK_IN_MILLISECONDS`](#api-ONE_WEEK_IN_MILLISECONDS), [`ONE_YEAR_IN_MILLISECONDS`](#api-ONE_YEAR_IN_MILLISECONDS), [`SECRET_TOKEN_PATTERNS`](#api-SECRET_TOKEN_PATTERNS)
- Types: [`ICursorCodec`](#api-ICursorCodec), [`ICursorCodecOptions`](#api-ICursorCodecOptions), [`ICursorValue`](#api-ICursorValue), [`IDateTemplate`](#api-IDateTemplate), [`IDateValue`](#api-IDateValue), [`IDelayOptions`](#api-IDelayOptions), [`IEventBus`](#api-IEventBus), [`IEventBusOptions`](#api-IEventBusOptions), [`IFilterByQueryOptions`](#api-IFilterByQueryOptions), [`IJsonValue`](#api-IJsonValue), [`INumberFormatConfig`](#api-INumberFormatConfig), [`IRedactSecretsOptions`](#api-IRedactSecretsOptions), [`IRedactSecretsResult`](#api-IRedactSecretsResult), [`IRequestCoordinator`](#api-IRequestCoordinator), [`IRequestCoordinatorOptions`](#api-IRequestCoordinatorOptions), [`IRetryAsyncFunctionOptions`](#api-IRetryAsyncFunctionOptions), [`IRunRequestOptions`](#api-IRunRequestOptions), [`ISchema`](#api-ISchema), [`ISortDirection`](#api-ISortDirection), [`ISubstitutionOptions`](#api-ISubstitutionOptions), [`ITimeString`](#api-ITimeString), [`IUtcDateRange`](#api-IUtcDateRange), [`IUuidVersion`](#api-IUuidVersion), [`IWaitUntilOptions`](#api-IWaitUntilOptions)
### Functions
#### `applyDefaults`
*Function*
Applies non-nullish overrides to a defaults object and returns a new shallow object.
```ts
applyDefaults>(
defaults: T,
overrides: { [Key in string | number | symbol]?: T[Key] | null },
): T
```
**Parameters**
- `defaults` `T`: The object containing the default properties and values.
- `overrides` `{ [Key in string | number | symbol]?: T[Key] | null }`: The values that should replace matching defaults when they are not nullish.
**Returns** `T`: A new object containing the default keys and resolved values.
**Example**
```ts
import { applyDefaults } from 'jg-utilities';
applyDefaults({ retries: 3, timeout: 15 }, { retries: 5, timeout: null }); // => { retries: 5, timeout: 15 }
```
#### `applySubstitutions`
*Function*
Applies substitutions to a string based on a provided object. The string can contain placeholders in the format of {{key}}, which will be replaced by the corresponding value from the substitutions object. If a placeholder does not have a corresponding key in the substitutions object, it will remain unchanged in the output string.
```ts
applySubstitutions(
input: string,
substitutions?: Record,
options?: ISubstitutionOptions,
): string
```
**Parameters**
- `input` `string`: The input string containing placeholders in the format of {{key}}.
- `substitutions?` `Record`: No description. Defaults to `{}`.
- `options?` `ISubstitutionOptions`: No description. Defaults to `...`.
**Returns** `string`: A string with the placeholders replaced by their corresponding values from the substitutions object.
**Example**
```ts
import { applySubstitutions } from 'jg-utilities';
applySubstitutions('Hi {{name}}, you have {{count}} messages.', { name: 'Ada', count: 3 }); // => 'Hi Ada, you have 3 messages.'
```
#### `capitalizeFirst`
*Function*
Capitalizes the first letter of a string and returns the new value.
```ts
capitalizeFirst(value: string): string
```
**Parameters**
- `value` `string`: The string value to capitalize.
**Returns** `string`: A string with the first letter capitalized.
**Example**
```ts
import { capitalizeFirst } from 'jg-utilities';
capitalizeFirst('hello world'); // => 'Hello world'
```
#### `createCursorCodec`
*Function*
Creates a codec for a list's pagination cursors: `encode` validates a continuation, such as the last row's sort keys and the list's filters, with the schema and encodes it as an opaque base64url cursor; `decode` reverses that for a cursor a client sent, and accepts it only for the list it came from. Without `matchesScope`, every key of the scope must equal the payload's.
`decode` returns undefined for a missing cursor, meaning the first page, and throws `INVALID_CURSOR` for any cursor it refuses, with `data.reason` saying whether its encoding, its shape, or its scope was wrong, and the schema's own error, if any, as `cause`: for a client, a cursor that fails its schema is the same mistake as a malformed one.
```ts
createCursorCodec>(
options: ICursorCodecOptions,
): ICursorCodec
```
**Parameters**
- `options` `ICursorCodecOptions`: The schema, the longest cursor, and the scope rule.
**Returns** `ICursorCodec`: The codec.
**Throws**
- `CURSOR_TOO_LONG`: The cursor is longer than the codec's maximum length.
- `INVALID_CURSOR`: The cursor is not valid for this list.
**Example**
```ts
import { createCursorCodec } from 'jg-utilities';
type IPostCursor = { authorId: string; lastId: number };
const isPostCursor = (value: unknown): value is IPostCursor =>
typeof value === 'object' && value !== null && 'authorId' in value && 'lastId' in value;
const postCursors = createCursorCodec({
schema: {
parse: (value) => {
if (!isPostCursor(value)) {
throw new TypeError('Not a post cursor.');
}
return value;
},
},
});
const cursor = postCursors.encode({ authorId: 'ada', lastId: 42 });
postCursors.decode(cursor, { authorId: 'ada' }); // => { authorId: 'ada', lastId: 42 }
postCursors.decode(undefined, { authorId: 'ada' }); // => undefined
```
#### `createEventBus`
*Function*
Creates an in-process event bus that decouples the code that emits an event from the handlers that react to it, such as sending a welcome email after a sign-up. `emit` returns at once, and each event is delivered later, in the order it was emitted. Each handler runs on its own, so one that throws or rejects doesn't stop the others: its error goes to `onError`. A handler unsubscribed before it's called isn't called, and subscribing the same handler twice to one event registers it once. Declare the event map with `type`, because an interface has no index signature.
```ts
createEventBus>(
options: IEventBusOptions,
): IEventBus
```
**Parameters**
- `options` `IEventBusOptions`: The `onError` that receives each handler's error.
**Returns** `IEventBus`: The bus.
**Example**
```ts
import { createEventBus } from 'jg-utilities';
type IAppEvents = { userSignedUp: { userId: string } };
const events = createEventBus({
onError: (error, { name }) => console.error(`A ${String(name)} handler failed`, error),
});
const welcomedUserIds: string[] = [];
const unsubscribe = events.subscribe('userSignedUp', ({ userId }) => {
welcomedUserIds.push(userId);
});
// emit returns at once, and the handler runs later
events.emit('userSignedUp', { userId: '42' });
welcomedUserIds; // => []
// once the handler is no longer needed
setTimeout(unsubscribe, 1_000);
```
#### `createRequestCoordinator`
*Function*
Creates a coordinator that runs tasks, such as requests to a rate-limited API, at most `maxConcurrent` at a time, and queues the rest, first in, first out, up to `maxQueued`. A caller can be capped too, with `caller` and `maxQueuedForCaller`, so one caller can't fill the queue.
`deferUntil` starts a cooldown, such as from a `Retry-After` header: queued tasks wait until it ends, without holding a slot, and tasks run with `rejectDuringCooldown` reject at once instead. A task whose signal aborts while it waits leaves the queue. A task's own result or error passes through unchanged, and its slot is released either way. No timer is left once nothing waits.
`run` rejects with `REQUEST_QUEUE_FULL` when the queue, or the caller's share of it, is full, with `REQUEST_COOLDOWN_ACTIVE` when the task may not wait out a cooldown, with `REQUEST_COORDINATOR_CLOSED` after `close`, and with `INVALID_QUEUE_LIMIT` for a `maxQueuedForCaller` that isn't an integer of at least 0. `deferUntil` throws `INVALID_DATE` for a deadline that isn't a valid date.
```ts
createRequestCoordinator(
options: IRequestCoordinatorOptions,
): IRequestCoordinator
```
**Parameters**
- `options` `IRequestCoordinatorOptions`: The concurrency and queue limits.
**Returns** `IRequestCoordinator`: The coordinator.
**Throws**
- `INVALID_CONCURRENCY_LIMIT`: The concurrency limit must be an integer of at least 1.
- `INVALID_QUEUE_LIMIT`: The queue limit must be an integer of at least 0.
**Example**
```ts
import { createRequestCoordinator } from 'jg-utilities';
const github = createRequestCoordinator({ maxConcurrent: 4, maxQueued: 64 });
const fetchIssues = (): Promise =>
github.run(() => fetch('https://api.github.com/repos/octocat/hello-world/issues'));
```
#### `createTimestamp`
*Function*
Creates an ISO 8601 timestamp, in UTC, from a date, a millisecond epoch, or a date string, or from the current time when no value is given.
```ts
createTimestamp(value?: string | number | Date): string
```
**Parameters**
- `value?` `string | number | Date`: The moment to format. Defaults to now.
**Returns** `string`: The timestamp, such as `2026-10-17T08:30:00.000Z`.
**Throws**
- Invalid time value
**Example**
```ts
import { createTimestamp } from 'jg-utilities';
createTimestamp(); // the current time, such as '2026-10-17T08:30:00.000Z'
createTimestamp(0); // => '1970-01-01T00:00:00.000Z'
```
#### `decodeBase64Url`
*Function*
Decodes a base64url value, without padding, into the UTF-8 string it encodes. Only the canonical encoding of a string is accepted, so every string has exactly one valid encoding.
```ts
decodeBase64Url(value: string): string
```
**Parameters**
- `value` `string`: The base64url value to decode.
**Returns** `string`: The decoded string.
**Throws**
- `INVALID_BASE64URL`: if the value isn't a string of base64url characters with a valid length.
- `INVALID_BASE64URL`: if the decoded bytes aren't valid UTF-8.
- `INVALID_BASE64URL`: if the value isn't the canonical encoding of its result.
**Example**
```ts
import { decodeBase64Url } from 'jg-utilities';
decodeBase64Url('eyJwYWdlIjoyfQ'); // => '{"page":2}'
```
#### `decodeCursor`
*Function*
Decodes a cursor made by `encodeCursor`. The cursor comes from a client, so it's validated before decoding: a non-empty base64url string no longer than `maxLength`. The caller still validates the decoded value's shape, such as with a schema.
```ts
decodeCursor(cursor: string, maxLength?: number): ICursorValue
```
**Parameters**
- `cursor` `string`: The cursor to decode.
- `maxLength?` `number`: The longest cursor accepted, in characters. Defaults to `2048`.
**Returns** `ICursorValue`: The decoded object or array.
**Throws**
- `INVALID_CURSOR`: The cursor is not a valid encoded value.
**Example**
```ts
import { decodeCursor } from 'jg-utilities';
const searchParams = new URLSearchParams('?cursor=eyJpZCI6NDJ9');
decodeCursor(searchParams.get('cursor') ?? ''); // => { id: 42 }
```
#### `delay`
*Function*
Creates an asynchronous delay that resolves once the provided seconds have passed.
```ts
delay(seconds: number, options?: IDelayOptions): Promise
```
**Parameters**
- `seconds` `number`: The number of seconds to delay before the promise resolves.
- `options?` `IDelayOptions`: No description.
**Returns** `Promise`: A promise that resolves after the specified number of seconds has passed.
**Throws**
- The signal's reason when the signal is already aborted or aborts during the wait.
**Example**
```ts
import { delay } from 'jg-utilities';
await delay(0.5); // resumes after half a second
await delay(10, { signal: AbortSignal.timeout(100) }).catch(() => 'stopped'); // => 'stopped'
```
#### `encodeBase64Url`
*Function*
Encodes a string as UTF-8 base64url without padding. For ASCII input, the output equals the btoa-based and Buffer-based base64url encodings.
```ts
encodeBase64Url(value: string): string
```
**Parameters**
- `value` `string`: The string to encode.
**Returns** `string`: The base64url encoding of the string's UTF-8 bytes.
**Example**
```ts
import { encodeBase64Url } from 'jg-utilities';
encodeBase64Url('{"page":2}'); // => 'eyJwYWdlIjoyfQ'
```
#### `encodeCursor`
*Function*
Encodes a pagination continuation, such as the sort keys of the last row, as an opaque cursor: its JSON in base64url, safe for URLs and query strings.
```ts
encodeCursor(value: ICursorValue): string
```
**Parameters**
- `value` `ICursorValue`: The object or array to encode.
**Returns** `string`: The cursor.
**Throws**
- `UNSUPPORTED_DATA_TYPE`: if the value isn't an object or an array
- `UNABLE_TO_SERIALIZE_JSON`: if the value contains data that cannot be serialized
**Example**
```ts
import { encodeCursor } from 'jg-utilities';
encodeCursor({ id: 42 }); // => 'eyJpZCI6NDJ9'
```
#### `escapeHtml`
*Function*
Escapes text for HTML, so it shows as written instead of becoming markup, such as a name in an email template or a Telegram message. It's safe in element content and quoted attribute values, not in a URL, a script, or a style.
```ts
escapeHtml(text: string): string
```
**Parameters**
- `text` `string`: The text.
**Returns** `string`: The escaped text.
**Example**
```ts
import { escapeHtml } from 'jg-utilities';
escapeHtml('"Tom" & Jerry\'s'); // => '<b>"Tom" & Jerry's</b>'
```
#### `estimateReadingTime`
*Function*
Estimates how long text takes to read at 200 words per minute.
```ts
estimateReadingTime(text: unknown): number
```
**Parameters**
- `text` `unknown`: The text whose reading time will be estimated.
**Returns** `number`: The estimated reading time in milliseconds, or 0 for non-string or empty input.
**Example**
```ts
import { estimateReadingTime, prettifyTime } from 'jg-utilities';
const article = 'word '.repeat(600);
estimateReadingTime(article); // milliseconds, about 3 minutes for 600 words
prettifyTime(estimateReadingTime(article)); // => '3m'
```
#### `extractEmailUsername`
*Function*
Validates the format of an email address and extracts the username from it.
```ts
extractEmailUsername(email: string): string
```
**Parameters**
- `email` `string`: The email address to validate and extract the username from.
**Returns** `string`: The extracted username from the email address.
**Throws**
- `INVALID_EMAIL_ADDRESS`: If the email address is not valid or has a forbidden extension.
**Example**
```ts
import { extractEmailUsername } from 'jg-utilities';
extractEmailUsername('Ada.Lovelace@example.com'); // => 'ada.lovelace'
```
#### `extractFirstMarkdownHeadingName`
*Function*
Extracts the name of the first markdown heading in the given content.
```ts
extractFirstMarkdownHeadingName(markdownContent: string): string | null
```
**Parameters**
- `markdownContent` `string`: The markdown-formatted content.
**Returns** `string | null`: The first markdown heading text found in the content, or null if none is found.
**Example**
```ts
import { extractFirstMarkdownHeadingName } from 'jg-utilities';
extractFirstMarkdownHeadingName('Intro\n\n## Getting started\n\nText'); // => 'Getting started'
```
#### `extractSubstitutionPlaceholderNames`
*Function*
Extracts the names of substitution placeholders in the given text.
```ts
extractSubstitutionPlaceholderNames(text: string): string[]
```
**Parameters**
- `text` `string`: The text containing substitution placeholders.
**Returns** `string[]`: An array of substitution placeholder names.
**Example**
```ts
import { extractSubstitutionPlaceholderNames } from 'jg-utilities';
extractSubstitutionPlaceholderNames('Hi {{name}}, you have {{count}} messages, {{name}}.'); // => ['name', 'count']
```
#### `extractTokenFromAuthorizationHeader`
*Function*
Validates the format of an authorization header and extracts the token from it.
```ts
extractTokenFromAuthorizationHeader(header: string): string
```
**Parameters**
- `header` `string`: The authorization header to validate and extract the token from.
**Returns** `string`: The extracted token from the authorization header.
**Throws**
- `INVALID_AUTHORIZATION_HEADER`: If the header does not comply with the expected format.
**Example**
```ts
import { extractTokenFromAuthorizationHeader } from 'jg-utilities';
extractTokenFromAuthorizationHeader('Bearer eyJhbGciOi.payload.signature'); // => 'eyJhbGciOi.payload.signature'
```
#### `filterByQuery`
*Function*
Filters an array of primitives based on a given query and returns a shallow copy.
```ts
filterByQuery(
items: T[],
query: string,
options?: IFilterByQueryOptions,
): T[]
```
**Parameters**
- `items` `T[]`: The array of items to be filtered by the query.
- `query` `string`: The query string to filter the items by.
- `options?` `IFilterByQueryOptions`: No description.
**Returns** `T[]`: A new array containing the items that match the query based on the provided options.
**Example**
```ts
import { filterByQuery } from 'jg-utilities';
filterByQuery([{ name: 'Ada' }, { name: 'Alan' }, { name: 'Grace' }], 'al', { queryProp: 'name' }); // => [{ name: 'Alan' }]
```
#### `generateDateId`
*Function*
Generates a date ID string in the format "YYYY_MM_DD".
```ts
generateDateId(value?: IDateValue): string
```
**Parameters**
- `value?` `IDateValue`: The date value to generate the ID from. Defaults to the current date if not provided.
**Returns** `string`: The generated date ID string.
**Example**
```ts
import { generateDateId } from 'jg-utilities';
generateDateId(new Date(2026, 9, 17)); // => '2026_10_17'
generateDateId(); // today's ID
```
#### `generateRandomFloat`
*Function*
Generates a random decimal in a range, with 53 bits of randomness from `crypto.getRandomValues`.
```ts
generateRandomFloat(min: number, max: number): number
```
**Parameters**
- `min` `number`: The lowest value, included.
- `max` `number`: The highest value, excluded.
**Returns** `number`: A random number greater than or equal to `min` and less than `max`.
**Example**
```ts
import { generateRandomFloat } from 'jg-utilities';
generateRandomFloat(0, 1); // 0.7312...
```
#### `generateRandomInteger`
*Function*
Generates a random integer in an inclusive range. Each integer is equally likely: values come from `crypto.getRandomValues` with rejection sampling.
```ts
generateRandomInteger(min: number, max: number): number
```
**Parameters**
- `min` `number`: The lowest value, included.
- `max` `number`: The highest value, included.
**Returns** `number`: A random integer from `min` to `max`.
**Example**
```ts
import { generateRandomInteger } from 'jg-utilities';
generateRandomInteger(1, 6); // such as 4
```
#### `generateRandomString`
*Function*
Generates a string of characters picked at random from a set. Each character is drawn with `crypto.getRandomValues` and rejection sampling, so every character in the set is equally likely and the result is fit for secrets such as passwords.
```ts
generateRandomString(length: number, characters?: string): string
```
**Parameters**
- `length` `number`: The length of the string.
- `characters?` `string`: The characters to pick from. Defaults to ASCII letters and digits. Defaults to `'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'`.
**Returns** `string`: The random string, or an empty string when the length or the set is empty.
**Example**
```ts
import { generateRandomString } from 'jg-utilities';
generateRandomString(12); // such as 'aZ3kPq9TbW0x'
generateRandomString(6, '0123456789'); // such as '402913'
```
#### `generateSequence`
*Function*
Generates a sequence of numbers within a range based on a number of steps.
```ts
generateSequence(start: number, stop: number, step?: number): number[]
```
**Parameters**
- `start` `number`: The starting value of the sequence.
- `stop` `number`: The ending value of the sequence.
- `step?` `number`: No description. Defaults to `1`.
**Returns** `number[]`: An array of numbers representing the sequence.
**Example**
```ts
import { generateSequence } from 'jg-utilities';
generateSequence(1, 5); // => [1, 2, 3, 4, 5]
generateSequence(0, 10, 5); // => [0, 5, 10]
```
#### `generateUtcDateSequence`
*Function*
Lists every UTC calendar date in an inclusive range, in ascending order, such as the days of a chart's axis. It gives one entry per day, so bound the range before passing one from a client.
```ts
generateUtcDateSequence(range: IUtcDateRange): string[]
```
**Parameters**
- `range` `IUtcDateRange`: The first and last dates, as `YYYY-MM-DD`.
**Returns** `string[]`: The dates, from `fromDate` to `toDate`.
**Throws**
- `INVALID_DATE_RANGE`: The range must hold two real dates as YYYY-MM-DD, the first no later than the second.
**Example**
```ts
import { generateUtcDateSequence } from 'jg-utilities';
generateUtcDateSequence({ fromDate: '2028-02-28', toDate: '2028-03-01' }); // => ['2028-02-28', '2028-02-29', '2028-03-01']
```
#### `generateUuid`
*Function*
Generates a UUID based on a version.
```ts
generateUuid(version: IUuidVersion): string
```
**Parameters**
- `version` `IUuidVersion`: The version of the UUID to be generated.
**Returns** `string`: A UUID string of the specified version.
**Example**
```ts
import { generateUuid } from 'jg-utilities';
generateUuid(4); // such as '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
generateUuid(7); // time-ordered, so later IDs sort after earlier ones
```
#### `getInitials`
*Function*
Extracts a string of initials from the provided value.
```ts
getInitials(value: string, initialsCount?: number): string
```
**Parameters**
- `value` `string`: The string value from which to extract initials.
- `initialsCount?` `number`: No description. Defaults to `1`.
**Returns** `string`: A string with up to the requested number of letter-only initials, or "A" if none exist.
**Example**
```ts
import { getInitials } from 'jg-utilities';
getInitials('Ada Lovelace'); // => 'A'
getInitials('Ada Lovelace', 2); // => 'AL'
```
#### `getNextPageParam`
*Function*
Gets the value of a property from the last item when the array length reaches the initial page size.
```ts
getNextPageParam(
propName: K,
items: T[],
initialPageSize: number,
): T[K] | undefined
```
**Parameters**
- `propName` `K`: The property name to read from the last item.
- `items` `T[]`: The array of items from which to read the last property value.
- `initialPageSize` `number`: The expected page size required before reading the last property value.
**Returns** `T[K] | undefined`: The last item's property value, or undefined when the array length is below the initial page size.
**Example**
```ts
import { getNextPageParam } from 'jg-utilities';
// with a page size of 2, a full page means there may be more
getNextPageParam('id', [{ id: 1 }, { id: 2 }], 2); // => 2
getNextPageParam('id', [{ id: 3 }], 2); // => undefined
```
#### `getTrailingUtcDateRange`
*Function*
Gives the inclusive range of the last `dayCount` UTC days, ending on the UTC date of an instant, such as the 30 days a report covers.
```ts
getTrailingUtcDateRange(dayCount: number, value?: IDateValue): IUtcDateRange
```
**Parameters**
- `dayCount` `number`: The number of days, a positive safe integer; 1 gives a range of one day.
- `value?` `IDateValue`: No description.
**Returns** `IUtcDateRange`: The first and last UTC dates, as `YYYY-MM-DD`.
**Throws**
- `INVALID_DAY_COUNT`: The day count must be a positive safe integer whose range starts in the year 0000 or later.
- `INVALID_DATE`: The value is not a valid date in the years 0000 to 9999.
**Example**
```ts
import { getTrailingUtcDateRange } from 'jg-utilities';
getTrailingUtcDateRange(7, '2026-10-17T08:30:00.000Z'); // => { fromDate: '2026-10-11', toDate: '2026-10-17' }
getTrailingUtcDateRange(30); // the last 30 days, today included
```
#### `hasNavigator`
*Function*
Checks whether a global navigator object exists. It reports presence only and doesn't identify a browser: Node.js 21 and later define a global navigator too.
```ts
hasNavigator(): boolean
```
**Returns** `boolean`: True when the navigator global is defined.
**Example**
```ts
import { hasNavigator } from 'jg-utilities';
hasNavigator(); // true in browsers and in Node.js 21 or later
```
#### `hasWindow`
*Function*
Checks whether a global window object exists. It reports presence only and doesn't identify a browser.
```ts
hasWindow(): boolean
```
**Returns** `boolean`: True when the window global is defined.
**Example**
```ts
import { hasWindow } from 'jg-utilities';
hasWindow(); // true in a browser, false in Node.js
```
#### `isArrayValid`
*Function*
Verifies if a value is an array. It also validates if it has elements inside (optional).
```ts
isArrayValid(value: unknown, allowEmpty?: boolean): value is any[]
```
**Parameters**
- `value` `unknown`: The value to be validated.
- `allowEmpty?` `boolean`: No description.
**Returns** `value is any[]`: A boolean indicating whether the value is a valid array and meets the element requirements (if provided).
**Example**
```ts
import { isArrayValid } from 'jg-utilities';
isArrayValid([1]); // => true
isArrayValid([]); // => false
isArrayValid([], true); // => true
```
#### `isAuthorizationHeaderValid`
*Function*
Verifies if a value has a valid Authorization Header format based on the RFC6750. Example: Authorization: Bearer eyJhbGciOiJIUzI1NiIXVCJ9TJV...r7E20RMHrHDcEfxjoYZgeFONFh7HgQ More info: - https://stackoverflow.com/questions/33265812/best-http-authorization-header-type-for-jwt - https://www.rfc-editor.org/rfc/rfc6750
```ts
isAuthorizationHeaderValid(value: unknown): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
**Returns** `value is string`: A boolean indicating whether the value is a valid Authorization header.
**Example**
```ts
import { isAuthorizationHeaderValid } from 'jg-utilities';
isAuthorizationHeaderValid('Bearer eyJhbGciOi.payload.signature'); // => true
isAuthorizationHeaderValid('Basic dXNlcg=='); // => false
```
#### `isBigIntStringValid`
*Function*
Verifies that a value is an integer written in plain decimal digits, as PostgreSQL returns a BIGINT, and that it fits a range: a signed 64-bit integer by default. Leading zeros, a plus sign, `-0`, decimals, exponents, and padding are rejected, so one number has one spelling.
```ts
isBigIntStringValid(value: unknown, min?: bigint, max?: bigint): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
- `min?` `bigint`: No description. Defaults to `INT64_MIN`.
- `max?` `bigint`: No description. Defaults to `INT64_MAX`.
**Returns** `value is string`: A boolean indicating whether the value is such a string, within the range.
**Example**
```ts
import { isBigIntStringValid } from 'jg-utilities';
isBigIntStringValid('9223372036854775807'); // => true
isBigIntStringValid('9223372036854775808'); // => false
isBigIntStringValid('007'); // => false
isBigIntStringValid('5', 1n); // => true
```
#### `isEmailValid`
*Function*
Verifies if a value is a valid email address with a maximum length of 320 characters. Important: when providing forbiddenExtensions, ensure to include the dot (.) at the beginning. For example, to forbid .con, use ['.con'].
```ts
isEmailValid(value: unknown, forbiddenExtensions?: string[]): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
- `forbiddenExtensions?` `string[]`: No description. Defaults to `...`.
**Returns** `value is string`: A boolean indicating whether the value is a valid email address and does not have a forbidden extension.
**Example**
```ts
import { isEmailValid } from 'jg-utilities';
isEmailValid('ada@example.com'); // => true
isEmailValid('ada@example.con'); // => false
```
#### `isEqual`
*Function*
Compares two values. Primitives, `null`, and `undefined` are compared with `Object.is`, so `NaN` equals `NaN` and `0` doesn't equal `-0`. Two objects or arrays are compared deeply, ignoring key order, through their deterministic JSON. An object never equals a primitive.
```ts
isEqual(firstValue: unknown, secondValue: unknown): boolean
```
**Parameters**
- `firstValue` `unknown`: The first value.
- `secondValue` `unknown`: The second value.
**Returns** `boolean`: Whether the values are equal.
**Throws**
- `UNABLE_TO_SERIALIZE_JSON`: if either object contains data that cannot be serialized
**Example**
```ts
import { isEqual } from 'jg-utilities';
isEqual({ a: 1, b: [2] }, { b: [2], a: 1 }); // => true
isEqual('a', 'a'); // => true
isEqual({}, null); // => false
```
#### `isFileNameValid`
*Function*
Verifies if a value is a file name that is valid on Windows, macOS and Linux: one path component whose UTF-8 size is between 1 and maxBytes. It rejects ".", "..", control characters, the characters < > : " / \ | ? *, a trailing space or period, and Windows device names such as "con" or "lpt1.txt", in any letter case.
```ts
isFileNameValid(value: unknown, maxBytes?: number): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
- `maxBytes?` `number`: No description. Defaults to `MAX_FILE_NAME_BYTES`.
**Returns** `value is string`: A boolean indicating whether the value is a portable file name.
**Example**
```ts
import { isFileNameValid } from 'jg-utilities';
isFileNameValid('notes.md'); // => true
isFileNameValid('con.txt'); // => false
isFileNameValid('a/b.txt'); // => false
```
#### `isIndexedDbAvailable`
*Function*
Checks whether the IndexedDB API exists in the current environment.
```ts
isIndexedDbAvailable(): boolean
```
**Returns** `boolean`: True when the indexedDB global is defined.
**Example**
```ts
import { isIndexedDbAvailable } from 'jg-utilities';
isIndexedDbAvailable(); // true in browsers that support IndexedDB
```
#### `isIntegerValid`
*Function*
Verifies if a value is a valid integer and is within a range (optional). If a range is not provided, it will use the properties Number.MIN_SAFE_INTEGER & Number.MAX_SAFE_INTEGER.
```ts
isIntegerValid(value: unknown, min?: number, max?: number): value is number
```
**Parameters**
- `value` `unknown`: The value to be validated.
- `min?` `number`: No description.
- `max?` `number`: No description.
**Returns** `value is number`: A boolean indicating whether the value is a valid integer and within the specified range.
**Example**
```ts
import { isIntegerValid } from 'jg-utilities';
isIntegerValid(5); // => true
isIntegerValid(5.5); // => false
isIntegerValid(5, 1, 3); // => false
```
#### `isJwtValid`
*Function*
Verifies if a value has a correct JWT Format: [Base64-URL Encoded Header].[Base64-URL Encoded Payload].[Signature]
```ts
isJwtValid(value: unknown): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
**Returns** `value is string`: A boolean indicating whether the value is a valid JWT.
**Example**
```ts
import { isJwtValid } from 'jg-utilities';
isJwtValid('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl'); // => true
isJwtValid('not-a-token'); // => false
```
#### `isNumberValid`
*Function*
Verifies if a value is a valid number and is within a range (optional). The minimum value defaults to Number.MIN_SAFE_INTEGER (-9007199254740991) while the maximum value defaults to Number.MAX_SAFE_INTEGER (9007199254740991).
```ts
isNumberValid(value: unknown, min?: number, max?: number): value is number
```
**Parameters**
- `value` `unknown`: The value to be validated.
- `min?` `number`: No description. Defaults to `Number.MIN_SAFE_INTEGER`.
- `max?` `number`: No description. Defaults to `Number.MAX_SAFE_INTEGER`.
**Returns** `value is number`: A boolean indicating whether the value is a valid number and within the specified range.
**Example**
```ts
import { isNumberValid } from 'jg-utilities';
isNumberValid(12.5); // => true
isNumberValid(Number.NaN); // => false
isNumberValid(5, 10); // => false
```
#### `isNumeric`
*Function*
Verifies if a value is a valid numeric string.
```ts
isNumeric(value: string): boolean
```
**Parameters**
- `value` `string`: The value to be validated.
**Returns** `boolean`: A boolean indicating whether the value is a valid numeric string.
**Example**
```ts
import { isNumeric } from 'jg-utilities';
isNumeric('-12.5'); // => true
isNumeric('1,000'); // => false
```
#### `isObjectValid`
*Function*
Verifies if a value is an actual object. It also validates if it has keys (optional).
```ts
isObjectValid(
value: unknown,
allowEmpty?: boolean,
): value is Record
```
**Parameters**
- `value` `unknown`: The value to be validated.
- `allowEmpty?` `boolean`: No description.
**Returns** `value is Record`: A boolean indicating whether the value is a valid object and meets the key requirements (if provided).
**Example**
```ts
import { isObjectValid } from 'jg-utilities';
isObjectValid({ id: 1 }); // => true
isObjectValid({}); // => false
isObjectValid({}, true); // => true
isObjectValid([]); // => false
```
#### `isOTPSecretValid`
*Function*
Verifies if a value has the correct OTP Secret Format.
```ts
isOTPSecretValid(value: unknown): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
**Returns** `value is string`: A boolean indicating whether the value is a valid OTP secret.
**Example**
```ts
import { isOTPSecretValid } from 'jg-utilities';
isOTPSecretValid('JBSWY3DPEHPK3PXP'); // => true
```
#### `isOTPTokenValid`
*Function*
Verifies if a value has the correct OTP Token Format.
```ts
isOTPTokenValid(value: unknown): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
**Returns** `value is string`: A boolean indicating whether the value is a valid OTP token.
**Example**
```ts
import { isOTPTokenValid } from 'jg-utilities';
isOTPTokenValid('123456'); // => true
isOTPTokenValid('12345'); // => false
```
#### `isPasswordValid`
*Function*
Verifies if a password meets the following requirements: - Meets a length range (Defaults to 8 - 2048) - At least one uppercase letter - At least one lowercase letter - At least one number - At least one special character
```ts
isPasswordValid(
value: unknown,
minLength?: number,
maxLength?: number,
): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
- `minLength?` `number`: No description. Defaults to `8`.
- `maxLength?` `number`: No description. Defaults to `2048`.
**Returns** `value is string`: A boolean indicating whether the value is a valid password.
**Example**
```ts
import { isPasswordValid } from 'jg-utilities';
isPasswordValid('Sup3r$ecret'); // => true
isPasswordValid('password'); // => false
```
#### `isSemverValid`
*Function*
Verifies if a value complies with semantic versioning.
```ts
isSemverValid(value: unknown): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
**Returns** `value is string`: A boolean indicating whether the value is a valid semantic version.
**Example**
```ts
import { isSemverValid } from 'jg-utilities';
isSemverValid('1.2.0'); // => true
isSemverValid('1.2'); // => false
```
#### `isSlugValid`
*Function*
Verifies that a slug contains lowercase letters or digits separated by single hyphens and meets a length range.
```ts
isSlugValid(
value: unknown,
minLength?: number,
maxLength?: number,
): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
- `minLength?` `number`: No description. Defaults to `2`.
- `maxLength?` `number`: No description. Defaults to `16`.
**Returns** `value is string`: A boolean indicating whether the value is a valid slug.
**Example**
```ts
import { isSlugValid } from 'jg-utilities';
isSlugValid('jg-utilities'); // => true
isSlugValid('JG Utils'); // => false
```
#### `isStringValid`
*Function*
Verifies if a value is a valid string and its length is within a range (optional).
```ts
isStringValid(
value: unknown,
minLength?: number,
maxLength?: number,
disallowWhitespaceOnly?: boolean,
): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
- `minLength?` `number`: No description. Defaults to `1`.
- `maxLength?` `number`: No description.
- `disallowWhitespaceOnly?` `boolean`: No description. Defaults to `true`.
**Returns** `value is string`: A boolean indicating whether the value is a valid string and meets the length requirements (if provided).
**Example**
```ts
import { isStringValid } from 'jg-utilities';
isStringValid('hello'); // => true
isStringValid(' '); // => false
isStringValid('hello', 1, 3); // => false
```
#### `isTimestampValid`
*Function*
Verifies if a value is a valid unix timestamp in milliseconds. The smallest value is set for the beginning of the Unix epoch (January 1st, 1970 - 14400000) while the largest value is based on the numeric limit established by JavaScript (9007199254740991).
```ts
isTimestampValid(value: unknown): value is number
```
**Parameters**
- `value` `unknown`: The value to be validated.
**Returns** `value is number`: A boolean indicating whether the value is a valid unix timestamp in milliseconds.
**Example**
```ts
import { isTimestampValid } from 'jg-utilities';
isTimestampValid(Date.UTC(2026, 9, 17)); // => true
isTimestampValid(-1); // => false
```
#### `isUrlValid`
*Function*
Verifies if a value is a valid URL.
```ts
isUrlValid(value: unknown): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
**Returns** `value is string`: A boolean indicating whether the value is a valid URL.
**Example**
```ts
import { isUrlValid } from 'jg-utilities';
isUrlValid('https://jg-packages.jesusgraterol.dev'); // => true
isUrlValid('not a url'); // => false
```
#### `isUuidValid`
*Function*
Verifies if a value is a valid UUID and that it matches a specific version.
```ts
isUuidValid(value: unknown, version: IUuidVersion): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
- `version` `IUuidVersion`: The UUID version to be validated against.
**Returns** `value is string`: A boolean indicating whether the value is a valid UUID of the specified version.
**Example**
```ts
import { isUuidValid } from 'jg-utilities';
isUuidValid('0199f5e2-8f5a-7b3c-9d2e-4f1a6b8c0d7e', 7); // => true
isUuidValid('0199f5e2-8f5a-7b3c-9d2e-4f1a6b8c0d7e', 4); // => false
```
#### `isYouTubeVideoIdValid`
*Function*
Verifies if a value is a YouTube video id: 11 characters of letters, digits, `-`, and `_`.
```ts
isYouTubeVideoIdValid(value: unknown): value is string
```
**Parameters**
- `value` `unknown`: The value to be validated.
**Returns** `value is string`: A boolean indicating whether the value is a YouTube video id.
**Example**
```ts
import { isYouTubeVideoIdValid } from 'jg-utilities';
isYouTubeVideoIdValid('M7lc1UVf-VE'); // => true
isYouTubeVideoIdValid('M7lc1UVf-V'); // => false
```
#### `maskMiddle`
*Function*
Masks the middle of a string, keeping a specified number of visible characters at the start and end.
```ts
maskMiddle(text: string, visibleChars: number, mask?: string): string
```
**Parameters**
- `text` `string`: The string value to mask.
- `visibleChars` `number`: The number of characters to keep visible at the start and end of the string.
- `mask?` `string`: No description. Defaults to `'...'`.
**Returns** `string`: A string with the middle masked, keeping the specified number of visible characters at the start.
**Example**
```ts
import { maskMiddle } from 'jg-utilities';
maskMiddle('0x1234567890abcdef', 4); // => '0x12...cdef'
```
#### `normalizeQuery`
*Function*
Normalizes a query string by removing control characters, zero-width characters, and extra spaces, then converts it to lowercase. Optionally, it can truncate the string to a specified maximum length.
```ts
normalizeQuery(query: string, maxLength?: number): string
```
**Parameters**
- `query` `string`: The query string to normalize.
- `maxLength?` `number`: No description.
**Returns** `string`: The normalized query string.
**Example**
```ts
import { normalizeQuery } from 'jg-utilities';
normalizeQuery(' Héllo WORLD '); // => 'héllo world'
```
#### `omitProps`
*Function*
Omits a list of properties from an object and returns a new object (shallow) with only those keys that weren't omitted
```ts
omitProps(
input: T,
propKeys: K[],
): Omit
```
**Parameters**
- `input` `T`: The object from which to omit properties.
- `propKeys` `K[]`: The keys of the properties to omit.
**Returns** `Omit`: A new object containing only the properties that were not omitted.
**Throws**
- `INVALID_OR_EMPTY_OBJECT`: if the input is not a valid object or it is empty
- `INVALID_OR_EMPTY_ARRAY`: if the keys to be omitted are not a valid array or it is empty
**Example**
```ts
import { omitProps } from 'jg-utilities';
omitProps({ id: 1, name: 'Ada', email: 'ada@example.com' }, ['email']); // => { id: 1, name: 'Ada' }
```
#### `parseJson`
*Function*
Deserializes a JSON string with the JSON.parse method.
```ts
parseJson(value: string): T
```
**Parameters**
- `value` `string`: The JSON string to be deserialized.
**Returns** `T`: The deserialized object or array.
**Throws**
- `UNSUPPORTED_DATA_TYPE`: if the provided value is not a non-empty string
- `UNABLE_TO_DESERIALIZE_JSON`: if the result of JSON.parse is not a valid object or array
- `UNABLE_TO_DESERIALIZE_JSON`: if an error is thrown during parsing
**Example**
```ts
import { parseJson } from 'jg-utilities';
const user = parseJson<{ id: number }>('{"id":1}'); // { id: 1 }
```
#### `parseYouTubeVideoId`
*Function*
Reads the id of the single YouTube video a URL points at, such as one a user pasted. It accepts https URLs without credentials or a port, on these hosts and paths, and ignores the query, apart from `v`, and the hash: - `youtu.be/` - `youtube.com/watch?v=`, `/shorts/`, `/embed/`, and `/live/` - `m.youtube.com/watch?v=` - `youtube-nocookie.com/embed/`
The `www.` form of each host works too. Anything else, such as a playlist, a channel, or a value that isn't a URL, returns null rather than throwing.
```ts
parseYouTubeVideoId(url: unknown): string | null
```
**Parameters**
- `url` `unknown`: The value to read, trimmed when it's a string.
**Returns** `string | null`: The 11-character video id, or null.
**Example**
```ts
import { parseYouTubeVideoId } from 'jg-utilities';
parseYouTubeVideoId('https://youtu.be/M7lc1UVf-VE'); // => 'M7lc1UVf-VE'
parseYouTubeVideoId('https://www.youtube.com/watch?v=M7lc1UVf-VE&t=30'); // => 'M7lc1UVf-VE'
parseYouTubeVideoId('https://www.youtube.com/playlist?list=PL123'); // => null
```
#### `pickProps`
*Function*
Picks a list of properties from an object and returns a new object (shallow) with the provided keys.
```ts
pickProps(
input: T,
propKeys: K[],
): Pick
```
**Parameters**
- `input` `T`: The object from which to pick properties.
- `propKeys` `K[]`: The keys of the properties to pick.
**Returns** `Pick`: A new object containing only the picked properties.
**Throws**
- `INVALID_OR_EMPTY_OBJECT`: if the input is not a valid object or it is empty
- `INVALID_OR_EMPTY_ARRAY`: if the keys to be picked are not a valid array or it is empty
**Example**
```ts
import { pickProps } from 'jg-utilities';
pickProps({ id: 1, name: 'Ada', email: 'ada@example.com' }, ['id', 'name']); // => { id: 1, name: 'Ada' }
```
#### `pluralize`
*Function*
Picks the singular or plural form of a word for a count, such as "1 file" and "3 files".
```ts
pluralize(count: number, singular: string, plural?: string): string
```
**Parameters**
- `count` `number`: The count.
- `singular` `string`: The word for exactly one.
- `plural?` `string`: The word for any other count. Defaults to the singular with an "s". Defaults to `...`.
**Returns** `string`: The word alone, so other words can sit between it and the count.
**Example**
```ts
import { pluralize } from 'jg-utilities';
pluralize(1, 'file'); // => 'file'
pluralize(0, 'file'); // => 'files'
pluralize(2, 'entry', 'entries'); // => 'entries'
```
#### `prefersReducedMotion`
*Function*
Checks whether the user asked the system to reduce motion. The view defaults to the global window when one exists; pass another one, such as an element's ownerDocument.defaultView, to check a different window.
```ts
prefersReducedMotion(view?: IMediaQueryView | null): boolean
```
**Parameters**
- `view?` `IMediaQueryView | null`: No description. Defaults to `...`.
**Returns** `boolean`: True only when the view reports a reduced-motion preference.
**Example**
```ts
import { prefersReducedMotion } from 'jg-utilities';
prefersReducedMotion(); // true when the user asked the system to reduce motion
```
#### `prettifyBadgeCount`
*Function*
Formats the number that will be inserted in a badge so it doesn't take too much space. If the current count is 0, it returns undefined as the badge shouldn't be displayed.
```ts
prettifyBadgeCount(value: number, maxValue?: number): string | undefined
```
**Parameters**
- `value` `number`: The current count to be displayed in the badge.
- `maxValue?` `number`: The maximum value to display before showing a "+" sign. Defaults to 9. Defaults to `9`.
**Returns** `string | undefined`: A string representing the formatted badge count or undefined if the count is 0.
**Example**
```ts
import { prettifyBadgeCount } from 'jg-utilities';
prettifyBadgeCount(3); // => '3'
prettifyBadgeCount(42); // => '9+'
prettifyBadgeCount(0); // => undefined
```
#### `prettifyDate`
*Function*
Formats a date instance based on a template. The output is always en-US, whatever the runtime's locale, and hours have no leading zero. - date-short: 12/05/2024 (Default) - date-medium: December 5, 2024 - date-long: Thursday, December 5, 2024 - time-short: 9:05 PM - time-medium: 9:05:20 PM - datetime-short: 12/5/2024, 9:05 PM - datetime-medium: December 5, 2024 at 9:05 PM - datetime-long: Thursday, December 5, 2024 at 9:05:20 PM
Without a time zone, the runtime's local zone is used, so a server and a browser can format the same instant differently. Pass 'UTC' for calendar dates and for data defined in UTC periods, and an explicit time zone for server-rendered output. Templates with a time end with the zone's short name when a time zone is given, for example "December 5, 2024 at 11:30 PM UTC". Prefer date-medium, datetime-medium and time-medium in UI text over the numeric templates.
```ts
prettifyDate(
value: IDateValue,
template?: IDateTemplate,
timeZone?: string,
): string
```
**Parameters**
- `value` `IDateValue`: The date value to be formatted. It can be a Date object, a timestamp, or a date string.
- `template?` `IDateTemplate`: The template to use for formatting the date. Defaults to date-short. Defaults to `'date-short'`.
- `timeZone?` `string`: No description.
**Returns** `string`: A string representing the formatted date.
**Throws**
- A RangeError if the time zone is unknown.
**Example**
```ts
import { prettifyDate } from 'jg-utilities';
prettifyDate('2026-10-17T08:30:00.000Z', 'date-medium', 'UTC'); // => 'October 17, 2026'
prettifyDate('2026-10-17T08:30:00.000Z', 'datetime-short', 'UTC'); // => '10/17/2026, 8:30 AM UTC'
```
#### `prettifyFileSize`
*Function*
Formats a bytes value into a human readable format. Bigints, such as BIGINT database values, are accepted too; values beyond Number.MAX_SAFE_INTEGER are formatted without losing precision.
```ts
prettifyFileSize(value: number | bigint, decimalPlaces?: number): string
```
**Parameters**
- `value` `number | bigint`: The bytes value to be formatted, as a number or a bigint.
- `decimalPlaces?` `number`: The number of decimal places to include. Defaults to 2. Defaults to `2`.
**Returns** `string`: A string representing the formatted file size.
**Example**
```ts
import { prettifyFileSize } from 'jg-utilities';
prettifyFileSize(1536); // => '1.50 kB'
prettifyFileSize(3_250_000_000, 1); // => '3.0 GB'
```
#### `prettifyNumber`
*Function*
Formats a numeric value based on the user's default language. A negative value's sign goes before the prefix, as in `-$5`.
```ts
prettifyNumber(
value: number,
configuration?: Partial,
): string
```
**Parameters**
- `value` `number`: The number to be formatted.
- `configuration?` `Partial`: No description.
**Returns** `string`: A string representing the formatted number.
**Example**
```ts
import { prettifyNumber } from 'jg-utilities';
prettifyNumber(1234567.891); // => '1,234,567.89'
prettifyNumber(19.5, { minimumFractionDigits: 2, prefix: '$' }); // => '$19.50'
prettifyNumber(-5, { prefix: '$' }); // => '-$5'
```
#### `prettifyPercentage`
*Function*
Formats a numeric value as a percentage based on the user's default language.
```ts
prettifyPercentage(
value: number,
configuration?: Partial,
): string
```
**Parameters**
- `value` `number`: The number to be formatted as a percentage - whole value (e.g., 50 for 50%)
- `configuration?` `Partial`: No description.
**Returns** `string`: A string representing the formatted percentage.
**Example**
```ts
import { prettifyPercentage } from 'jg-utilities';
prettifyPercentage(12.345); // => '12.35%'
prettifyPercentage(-3); // => '-3%'
```
#### `prettifyTime`
*Function*
Formats a duration in milliseconds into a human-readable string.
```ts
prettifyTime(milliseconds: number): string
```
**Parameters**
- `milliseconds` `number`: The duration in milliseconds.
**Returns** `string`: A string representing the formatted duration.
**Example**
```ts
import { prettifyTime } from 'jg-utilities';
prettifyTime(3_725_000); // => '1h 2m'
prettifyTime(45_000); // => '45s'
```
#### `pruneJson`
*Function*
Removes null, undefined, empty objects, and empty arrays from the given data recursively.
```ts
pruneJson(value: T): T | null
```
**Parameters**
- `value` `T`: The value to be pruned. It should be an object, array, or primitive value.
**Returns** `T | null`: The pruned value, or null if the value is empty or invalid.
**Example**
```ts
import { pruneJson } from 'jg-utilities';
pruneJson({ a: 1, b: null, c: {}, d: [], e: { f: [] } }); // => { a: 1 }
```
#### `redactSecrets`
*Function*
Replaces the secrets in free text, such as a log line or a file headed for an AI model: complete PEM private key blocks first, then every match of the token patterns, which default to `SECRET_TOKEN_PATTERNS`. It runs in linear time over the key blocks, matches each pattern globally whatever its flags, and never throws for any text, so a logger can call it safely.
With `preserveLineBreaks`, a private key becomes the replacement followed by its line breaks, or preceded by them when it ends the text, so line numbers in the text stay the same.
```ts
redactSecrets(
text: string,
options?: IRedactSecretsOptions,
): IRedactSecretsResult
```
**Parameters**
- `text` `string`: The text to redact.
- `options?` `IRedactSecretsOptions`: The patterns, the replacement, and whether key blocks keep their line breaks. Defaults to `{}`.
**Returns** `IRedactSecretsResult`: The redacted text, and how many secrets were replaced.
**Example**
```ts
import { redactSecrets } from 'jg-utilities';
redactSecrets('auth: ghp_abcdefghijklmnopqrstuvwxyz'); // => { text: 'auth: [redacted]', count: 1 }
redactSecrets('port: 3000'); // => { text: 'port: 3000', count: 0 }
```
#### `redactUrlQueryParams`
*Function*
Removes a URL's query and fragment when the query holds any of the named parameters, such as a token, so the URL can be logged. A name matches exactly, after decoding, or as the start of an array parameter, such as `token[]`. A name that can't be decoded could hide one of them, so the whole URL becomes `[redacted]`, as does a URL that is only a query.
```ts
redactUrlQueryParams(url: string, names: readonly string[]): string
```
**Parameters**
- `url` `string`: The request target or absolute URL.
- `names` `readonly string[]`: The names of the parameters that make the query unsafe to log.
**Returns** `string`: The URL when it holds none of them, otherwise the part before its query.
**Example**
```ts
import { redactUrlQueryParams } from 'jg-utilities';
redactUrlQueryParams('/reset?token=abc#form', ['token']); // => '/reset'
redactUrlQueryParams('/reset?tok%65n=abc', ['token']); // => '/reset'
redactUrlQueryParams('/search?q=shoes', ['token']); // => '/search?q=shoes'
```
#### `resolveSafeInternalPath`
*Function*
Checks an untrusted path, such as a `returnTo` parameter after sign-in, and returns it only when it stays on the same site and outside the excluded paths, so a link can't send someone to another site or back into a sign-in loop. It must start with one `/`; a protocol-relative URL, an absolute URL, a backslash, or a path that can't be decoded is rejected. An excluded path matches itself and everything under it, by whole segments, case-insensitively, and after decoding, as a router matches routes.
```ts
resolveSafeInternalPath(
candidate: unknown,
excludedPaths?: readonly string[],
): string | null
```
**Parameters**
- `candidate` `unknown`: The untrusted value.
- `excludedPaths?` `readonly string[]`: Paths it must not lead to, such as `['/sign-in']`. Defaults to `[]`.
**Returns** `string | null`: The path, normalized, with its query and fragment, or null when it isn't safe.
**Example**
```ts
import { resolveSafeInternalPath } from 'jg-utilities';
resolveSafeInternalPath('/projects/a?tab=files', ['/sign-in']); // => '/projects/a?tab=files'
resolveSafeInternalPath('//evil.example', ['/sign-in']); // => null
resolveSafeInternalPath('/sign-in/step', ['/sign-in']); // => null
resolveSafeInternalPath('/sign-in-help', ['/sign-in']); // => '/sign-in-help'
```
#### `retryAsyncFunction`
*Function*
Executes an asynchronous function persistently, retrying on error with incremental delays defined in retryScheduleDuration (seconds). When the signal aborts, the returned promise rejects immediately, even while an attempt is pending. The attempt's own work isn't cancelled; pass the signal into it as well when it supports one.
```ts
retryAsyncFunction(
fn: () => Promise,
retryScheduleDuration?: number[],
options?: IRetryAsyncFunctionOptions,
): Promise
```
**Parameters**
- `fn` `() => Promise`: The asynchronous function to be executed persistently.
- `retryScheduleDuration?` `number[]`: No description. Defaults to `...`.
- `options?` `IRetryAsyncFunctionOptions`: No description.
**Returns** `Promise`: A promise that resolves with the result of the asynchronous function or rejects with the last encountered error.
**Throws**
- The last error thrown by the function, or the first one shouldRetry declines to retry.
- The signal's reason when the signal aborts.
**Example**
```ts
import { retryAsyncFunction } from 'jg-utilities';
let attempts = 0;
const readRate = async (): Promise => {
attempts += 1;
if (attempts < 2) {
throw new Error('The service is busy.');
}
return 1.08;
};
// up to 3 attempts, 0.1 and 0.5 seconds apart
await retryAsyncFunction(readRate, [0.1, 0.5]); // => 1.08
attempts; // => 2
```
#### `shuffleArray`
*Function*
Creates a shallow copy of the input array and shuffles it with the Fisher-Yates algorithm, drawing each position with `crypto.getRandomValues`, so every order is equally likely.
```ts
shuffleArray(input: T[]): T[]
```
**Parameters**
- `input` `T[]`: The array to be shuffled.
**Returns** `T[]`: A new array with the values of the input array in a random order.
**Throws**
- `INVALID_OR_EMPTY_ARRAY`: if the input is not array or it is empty
**Example**
```ts
import { shuffleArray } from 'jg-utilities';
shuffleArray([1, 2, 3, 4]); // a new array, such as [3, 1, 4, 2]
```
#### `sortPrimitives`
*Function*
Sorts a list of primitive values based on their type and a sort direction.
```ts
sortPrimitives(
direction: ISortDirection,
): (firstValue: T, secondValue: T) => number
```
**Parameters**
- `direction` `ISortDirection`: The direction to sort the values.
**Returns** `(firstValue: T, secondValue: T) => number`: A number indicating the sort order based on the primitive type.
**Throws**
- `MIXED_OR_UNSUPPORTED_DATA_TYPES`: if the values are mixed or are different to string | number | bigint
**Example**
```ts
import { sortPrimitives } from 'jg-utilities';
[3, 1, 2].sort(sortPrimitives('asc')); // => [1, 2, 3]
['b', 'c', 'a'].sort(sortPrimitives('desc')); // => ['c', 'b', 'a']
```
#### `sortRecords`
*Function*
Sorts a list of record values by key based on their type and a sort direction.
```ts
sortRecords(
key: string,
direction: ISortDirection,
): (firstRecord: T, secondRecord: T) => number
```
**Parameters**
- `key` `string`: The key of the record to sort by.
- `direction` `ISortDirection`: The direction to sort the values.
**Returns** `(firstRecord: T, secondRecord: T) => number`: A number indicating the sort order based on the primitive type.
**Throws**
- `MIXED_OR_UNSUPPORTED_DATA_TYPES`: if the values are mixed or are different to string | number | bigint
**Example**
```ts
import { sortRecords } from 'jg-utilities';
[{ age: 40 }, { age: 25 }].sort(sortRecords('age', 'asc')); // => [{ age: 25 }, { age: 40 }]
```
#### `sortRecordsWithBigIntString`
*Function*
Sorts a list of record values by key, treating stringified bigints as actual bigints, based on a sort direction.
```ts
sortRecordsWithBigIntString>(
key: keyof T,
direction: ISortDirection,
): (firstRecord: T, secondRecord: T) => number
```
**Parameters**
- `key` `keyof T`: The key of the record to sort by.
- `direction` `ISortDirection`: The direction to sort the values.
**Returns** `(firstRecord: T, secondRecord: T) => number`: A number indicating the sort order based on the primitive type.
**Throws**
- `MIXED_OR_UNSUPPORTED_DATA_TYPES`: if the values are mixed or are different to string | number | bigint
**Example**
```ts
import { sortRecordsWithBigIntString } from 'jg-utilities';
[{ amount: '900000000000000000001' }, { amount: '5' }].sort(sortRecordsWithBigIntString('amount', 'asc')); // => [{ amount: '5' }, { amount: '900000000000000000001' }]
```
#### `sortRecordsWithDateValue`
*Function*
Sorts a list of record values by key, treating date values as actual Date objects, based on a sort direction.
```ts
sortRecordsWithDateValue>(
key: keyof T,
direction: ISortDirection,
): (firstRecord: T, secondRecord: T) => number
```
**Parameters**
- `key` `keyof T`: The key of the record to sort by.
- `direction` `ISortDirection`: The direction to sort the values.
**Returns** `(firstRecord: T, secondRecord: T) => number`: A number indicating the sort order based on the date values.
**Throws**
- `MIXED_OR_UNSUPPORTED_DATA_TYPES`: if either record has a missing, null, or invalid date value
**Example**
```ts
import { sortRecordsWithDateValue } from 'jg-utilities';
[{ at: '2026-10-17' }, { at: '2025-01-01' }].sort(sortRecordsWithDateValue('at', 'asc')); // => [{ at: '2025-01-01' }, { at: '2026-10-17' }]
```
#### `splitArrayIntoBatches`
*Function*
Splits an array into smaller arrays (batches) of a given size.
```ts
splitArrayIntoBatches(items: T[], batchSize: number): T[][]
```
**Parameters**
- `items` `T[]`: The array to be split into batches.
- `batchSize` `number`: The size of each batch.
**Returns** `T[][]`: An array of arrays, where each inner array is a batch of the specified size.
**Throws**
- `INVALID_BATCH_SIZE`: if the batch size is not a valid integer greater than 0
**Example**
```ts
import { splitArrayIntoBatches } from 'jg-utilities';
splitArrayIntoBatches([1, 2, 3, 4, 5], 2); // => [[1, 2], [3, 4], [5]]
```
#### `stringifyJson`
*Function*
Serializes a JSON object with the JSON.stringify method.
```ts
stringifyJson(value: T): string
```
**Parameters**
- `value` `T`: The value to be serialized. It should be an object or an array.
**Returns** `string`: A string representing the serialized JSON object.
**Throws**
- `UNSUPPORTED_DATA_TYPE`: if the provided value is not an object or an array
- `UNABLE_TO_SERIALIZE_JSON`: if the result of JSON.stringify is not a valid string
- `UNABLE_TO_SERIALIZE_JSON`: if an error is thrown during stringification
**Example**
```ts
import { stringifyJson } from 'jg-utilities';
stringifyJson({ id: 1, tags: ['a'] }); // => '{"id":1,"tags":["a"]}'
```
#### `stringifyJsonDeterministically`
*Function*
Stringifies a JSON object in a deterministic way, ensuring that the keys are sorted and the output is consistent.
```ts
stringifyJsonDeterministically(value: T): string
```
**Parameters**
- `value` `T`: The value to be serialized. It should be an object or an array.
**Returns** `string`: A string representing the serialized JSON object with sorted keys.
**Throws**
- `UNSUPPORTED_DATA_TYPE`: if the provided value is not an object or an array
- `UNABLE_TO_SERIALIZE_JSON`: if the result of JSON.stringify is not a valid string
- `UNABLE_TO_SERIALIZE_JSON`: if an error is thrown during stringification
**Example**
```ts
import { stringifyJsonDeterministically } from 'jg-utilities';
stringifyJsonDeterministically({ b: 1, a: { d: 2, c: 3 } }); // => '{"a":{"c":3,"d":2},"b":1}'
```
#### `stringifyValue`
*Function*
Converts any value into a string. If the value is an object or an array, it will be stringified with JSON.stringify.
```ts
stringifyValue(value: unknown, jsonIndent?: number): string
```
**Parameters**
- `value` `unknown`: The value to convert into a string.
- `jsonIndent?` `number`: No description. Defaults to `0`.
**Returns** `string`: A string representation of the value.
**Example**
```ts
import { stringifyValue } from 'jg-utilities';
stringifyValue({ id: 1 }); // => '{"id":1}'
stringifyValue(42); // => '42'
stringifyValue(null); // => 'null'
```
#### `toDate`
*Function*
Creates an instance of Date based on a value.
```ts
toDate(value: IDateValue): Date
```
**Parameters**
- `value` `IDateValue`: The value to create a Date instance from. It can be a Date object, a timestamp, or a date string.
**Returns** `Date`: A Date instance.
**Example**
```ts
import { toDate } from 'jg-utilities';
toDate('2026-10-17T08:30:00.000Z'); // a Date for that instant
toDate(1_792_217_505_000); // a Date from a millisecond epoch
```
#### `toMS`
*Function*
Converts a time string into milliseconds. The time string should be in the format of "{value} {unit}", where the value is a number and the unit can be milliseconds, seconds, minutes, hours, days, weeks, months, or years. For example: "2 days", "5 minutes", "2 hours".
```ts
toMS(
str: `${number} years` | `${number} year` | `${number} months` | `${number} month` | `${number} weeks` | `${number} week` | `${number} days` | `${number} day` | `${number} hours` | `${number} hour` | `${number} minutes` | `${number} minute` | `${number} seconds` | `${number} second` | `${number} milliseconds` | `${number} millisecond`,
): number
```
**Parameters**
- `str` `` `${number} years` | `${number} year` | `${number} months` | `${number} month` | `${number} weeks` | `${number} week` | `${number} days` | `${number} day` | `${number} hours` | `${number} hour` | `${number} minutes` | `${number} minute` | `${number} seconds` | `${number} second` | `${number} milliseconds` | `${number} millisecond` ``: The time string to convert into milliseconds.
**Returns** `number`: The equivalent time in milliseconds.
**Throws**
- `INVALID_TIME_STRING`: if the provided time string is not a valid string.
- `INVALID_TIME_STRING`: if the chunks do not match the expected format.
- `INVALID_TIME_STRING`: if the value is not a valid positive integer.
- `INVALID_TIME_STRING`: if the unit is not one of the accepted values.
**Example**
```ts
import { toMS } from 'jg-utilities';
toMS('2 days'); // => 172800000
toMS('30 minutes'); // => 1800000
```
#### `toSlug`
*Function*
Converts a string value into a slug, meeting the following requirements: - lowercase - removes accents/diacritics - replaces spaces and separators with "-" - removes invalid URL characters - collapses repeated "-" - trims leading/trailing "-"
```ts
toSlug(value: string): string
```
**Parameters**
- `value` `string`: The string value to convert.
**Returns** `string`: A string converted to a slug.
**Throws**
- `UNABLE_TO_SLUGIFY_STRING`: if the resulting slug is empty after processing the input string
**Example**
```ts
import { toSlug } from 'jg-utilities';
toSlug('Héllo, World! 2026'); // => 'hello-world-2026'
```
#### `toTitleCase`
*Function*
Converts a string value into Title Case.
```ts
toTitleCase(value: string): string
```
**Parameters**
- `value` `string`: The string value to convert.
**Returns** `string`: A string converted to Title Case.
**Example**
```ts
import { toTitleCase } from 'jg-utilities';
toTitleCase('the QUICK brown fox'); // => 'The Quick Brown Fox'
```
#### `toUtcDate`
*Function*
Gives the UTC calendar date of an instant, as `YYYY-MM-DD`, such as the day a UTC report or analytics period counts it in. The instant must fall in the years 0000 to 9999.
```ts
toUtcDate(value?: IDateValue): string
```
**Parameters**
- `value?` `IDateValue`: No description.
**Returns** `string`: The UTC date, such as `2026-10-17`.
**Throws**
- `INVALID_DATE`: The value is not a valid date in the years 0000 to 9999.
**Example**
```ts
import { toUtcDate } from 'jg-utilities';
toUtcDate('2026-10-17T23:30:00.000-05:00'); // => '2026-10-18'
toUtcDate(0); // => '1970-01-01'
toUtcDate(); // today's UTC date
```
#### `toYouTubeEmbedUrl`
*Function*
Builds the URL that embeds a YouTube video in an iframe, on YouTube's privacy-enhanced domain, which sets no cookies until the video plays.
```ts
toYouTubeEmbedUrl(videoId: string): string
```
**Parameters**
- `videoId` `string`: The video id, such as one parseYouTubeVideoId read.
**Returns** `string`: The URL, `https://www.youtube-nocookie.com/embed/`.
**Throws**
- `INVALID_YOUTUBE_VIDEO_ID`: The YouTube video id must be 11 letters, digits, '-', or '_'.
**Example**
```ts
import { toYouTubeEmbedUrl } from 'jg-utilities';
toYouTubeEmbedUrl('M7lc1UVf-VE'); // => 'https://www.youtube-nocookie.com/embed/M7lc1UVf-VE'
```
#### `toYouTubeWatchUrl`
*Function*
Builds the canonical watch page URL of a YouTube video, such as for a link.
```ts
toYouTubeWatchUrl(videoId: string): string
```
**Parameters**
- `videoId` `string`: The video id, such as one parseYouTubeVideoId read.
**Returns** `string`: The URL, `https://www.youtube.com/watch?v=`.
**Throws**
- `INVALID_YOUTUBE_VIDEO_ID`: The YouTube video id must be 11 letters, digits, '-', or '_'.
**Example**
```ts
import { toYouTubeWatchUrl } from 'jg-utilities';
toYouTubeWatchUrl('M7lc1UVf-VE'); // => 'https://www.youtube.com/watch?v=M7lc1UVf-VE'
```
#### `truncateText`
*Function*
Truncates a string to a specified length and appends an ellipsis if it exceeds that length.
```ts
truncateText(text: string, maxLength: number): string
```
**Parameters**
- `text` `string`: The string value to truncate.
- `maxLength` `number`: The maximum length of the string before truncation.
**Returns** `string`: A string truncated to the specified length with an ellipsis if necessary.
**Example**
```ts
import { truncateText } from 'jg-utilities';
truncateText('The quick brown fox jumps', 12); // => 'The quick...'
```
#### `truncateToUtf8Bytes`
*Function*
Shortens a string to the longest prefix of whole code points that fits a number of UTF-8 bytes, such as a push notification's body under the provider's payload limit. A character made of several code points, such as an emoji with a skin tone, can lose its last ones, but a code point is never split.
```ts
truncateToUtf8Bytes(text: string, maxBytes: number): string
```
**Parameters**
- `text` `string`: The string.
- `maxBytes` `number`: The most bytes the result may take in UTF-8, a non-negative safe integer.
**Returns** `string`: The string itself when it fits, otherwise its longest prefix that does.
**Throws**
- `INVALID_BYTE_LIMIT`: The byte limit must be a non-negative safe integer.
**Example**
```ts
import { truncateToUtf8Bytes } from 'jg-utilities';
truncateToUtf8Bytes('Hello, world', 5); // => 'Hello'
truncateToUtf8Bytes('café au lait', 4); // => 'caf'
truncateToUtf8Bytes('café au lait', 5); // => 'café'
```
#### `waitUntil`
*Function*
Probes a condition until it returns a value other than null, undefined or false, and resolves with that value. It probes immediately, then after every interval, and one final time at the deadline. The timeout bounds the whole call, so it rejects even while a probe is still pending. A running probe isn't cancelled when the call stops waiting; pass your own signal into it when it supports one.
```ts
waitUntil(
probe: () => T | Promise,
options: IWaitUntilOptions,
): Promise>
```
**Parameters**
- `probe` `() => T | Promise`: The function that checks the condition.
- `options` `IWaitUntilOptions`: The timeout, the interval between probes and the signal that stops the wait.
**Returns** `Promise>`: A promise that resolves with the first ready value.
**Throws**
- `WAIT_UNTIL_TIMED_OUT`: if the condition isn't met before the timeout.
- The probe's error when a probe throws or rejects.
- The signal's reason when the signal aborts.
**Example**
```ts
import { waitUntil } from 'jg-utilities';
const startedAt = Date.now();
const readStatus = (): string => (Date.now() - startedAt > 200 ? 'ready' : 'starting');
// the probe runs every 0.1 seconds until it returns something other than undefined
await waitUntil(() => (readStatus() === 'ready' ? readStatus() : undefined), {
timeoutSeconds: 5,
intervalSeconds: 0.1,
}); // => 'ready'
```
### Constants
#### `ERRORS`
*Constant*
the codes for all the errors thrown by the library
```ts
const ERRORS: { MIXED_OR_UNSUPPORTED_DATA_TYPES: "MIXED_OR_UNSUPPORTED_DATA_TYPES"; UNSUPPORTED_DATA_TYPE: "UNSUPPORTED_DATA_TYPE"; INVALID_OR_EMPTY_ARRAY: "INVALID_OR_EMPTY_ARRAY"; INVALID_OR_EMPTY_OBJECT: "INVALID_OR_EMPTY_OBJECT"; UNABLE_TO_SERIALIZE_JSON: "UNABLE_TO_SERIALIZE_JSON"; UNABLE_TO_DESERIALIZE_JSON: "UNABLE_TO_DESERIALIZE_JSON"; INVALID_BATCH_SIZE: "INVALID_BATCH_SIZE"; UNABLE_TO_SLUGIFY_STRING: "UNABLE_TO_SLUGIFY_STRING"; INVALID_TIME_STRING: "INVALID_TIME_STRING"; INVALID_AUTHORIZATION_HEADER: "INVALID_AUTHORIZATION_HEADER"; INVALID_EMAIL_ADDRESS: "INVALID_EMAIL_ADDRESS"; INVALID_BASE64URL: "INVALID_BASE64URL"; WAIT_UNTIL_TIMED_OUT: "WAIT_UNTIL_TIMED_OUT"; INVALID_CURSOR: "INVALID_CURSOR"; INVALID_DATE: "INVALID_DATE"; INVALID_DAY_COUNT: "INVALID_DAY_COUNT"; INVALID_DATE_RANGE: "INVALID_DATE_RANGE"; INVALID_BYTE_LIMIT: "INVALID_BYTE_LIMIT"; INVALID_YOUTUBE_VIDEO_ID: "INVALID_YOUTUBE_VIDEO_ID"; INVALID_CONCURRENCY_LIMIT: "INVALID_CONCURRENCY_LIMIT"; INVALID_QUEUE_LIMIT: "INVALID_QUEUE_LIMIT"; REQUEST_QUEUE_FULL: "REQUEST_QUEUE_FULL"; REQUEST_COOLDOWN_ACTIVE: "REQUEST_COOLDOWN_ACTIVE"; REQUEST_COORDINATOR_CLOSED: "REQUEST_COORDINATOR_CLOSED"; CURSOR_TOO_LONG: "CURSOR_TOO_LONG" }
```
#### `MAX_EMAIL_LENGTH`
*Constant*
the maximum length for an email address according to the standard (RFC 5321)
```ts
const MAX_EMAIL_LENGTH: 320
```
#### `ONE_DAY_IN_MILLISECONDS`
*Constant*
one day in milliseconds
```ts
const ONE_DAY_IN_MILLISECONDS: number
```
#### `ONE_HOUR_IN_MILLISECONDS`
*Constant*
one hour in milliseconds
```ts
const ONE_HOUR_IN_MILLISECONDS: number
```
#### `ONE_MINUTE_IN_MILLISECONDS`
*Constant*
one minute in milliseconds
```ts
const ONE_MINUTE_IN_MILLISECONDS: number
```
#### `ONE_MONTH_IN_MILLISECONDS`
*Constant*
one average month, a twelfth of an average year, in milliseconds
```ts
const ONE_MONTH_IN_MILLISECONDS: number
```
#### `ONE_SECOND_IN_MILLISECONDS`
*Constant*
one second in milliseconds
```ts
const ONE_SECOND_IN_MILLISECONDS: 1000
```
#### `ONE_WEEK_IN_MILLISECONDS`
*Constant*
one week in milliseconds
```ts
const ONE_WEEK_IN_MILLISECONDS: number
```
#### `ONE_YEAR_IN_MILLISECONDS`
*Constant*
one average year, of 365.25 days, in milliseconds
```ts
const ONE_YEAR_IN_MILLISECONDS: number
```
#### `SECRET_TOKEN_PATTERNS`
*Constant*
The single-line secrets redactSecrets finds by default: GitHub's personal, OAuth, user, server, and refresh tokens, its fine-grained personal access tokens, and `sk-` API keys, such as OpenAI's, each with at least 20 characters after its prefix.
```ts
const SECRET_TOKEN_PATTERNS: readonly RegExp[]
```
### Types
#### `ICursorCodec`
*Type*
encodes a list's continuation as a cursor, and decodes one only for the list it came from
```ts
type ICursorCodec = {
encode: (payload: TPayload) => string;
decode: (cursor: string | null | undefined, scope: TScope) => TPayload | undefined;
};
```
**Members**
- `encode: (payload: TPayload) => string` (property)
- `decode: (cursor: string | null | undefined, scope: TScope) => TPayload | undefined` (property)
#### `ICursorCodecOptions`
*Type*
the options of createCursorCodec
```ts
type ICursorCodecOptions = {
schema: ISchema;
maxLength?: number;
matchesScope?: (payload: TPayload, scope: TScope) => boolean;
};
```
**Members**
- `schema: ISchema` (property): validates the payload when it's encoded and when a cursor is decoded
- `maxLength: number` (property): the longest cursor, in characters, that's encoded or decoded. Defaults to 2048
- `matchesScope: (payload: TPayload, scope: TScope) => boolean` (property): Whether a decoded payload belongs to the list being read. Without it, every key of the scope must equal the payload's, with null and undefined counting as the same value.
#### `ICursorValue`
*Type*
the JSON a cursor can hold: an object or an array, as pagination continuations are
```ts
type ICursorValue = {
[key: string]: IJsonValue;
} | IJsonValue[];
```
#### `IDateTemplate`
*Type*
Date Template A date can be prettified by choosing a template that meets the user's requirements. - date-short: 12/05/2024 - date-medium: December 5, 2024 - date-long: Thursday, December 5, 2024 - time-short: 9:05 PM - time-medium: 9:05:20 PM - datetime-short: 12/5/2024, 9:05 PM - datetime-medium: December 5, 2024 at 9:05 PM - datetime-long: Thursday, December 5, 2024 at 9:05:20 PM
```ts
type IDateTemplate = 'date-short' | 'date-medium' | 'date-long' | 'time-short' | 'time-medium' | 'datetime-short' | 'datetime-medium' | 'datetime-long';
```
#### `IDateValue`
*Type*
the value that can be used to create a Date instance
```ts
type IDateValue = Date | number | string;
```
#### `IDelayOptions`
*Type*
the options that can be passed to the delay function
```ts
type IDelayOptions = {
signal?: AbortSignal | undefined;
};
```
**Members**
- `signal: AbortSignal` (property): aborts the wait and rejects with the signal's reason
#### `IEventBus`
*Type*
an in-process event bus, whose events and their payloads are the keys and values of TEvents
```ts
type IEventBus> = {
emit: (name: TName, event: TEvents[TName]) => void;
subscribe: (name: TName, handler: (event: TEvents[TName]) => unknown) => () => void;
close: () => void;
};
```
**Members**
- `emit: (name: TName, event: TEvents[TName]) => void` (property): delivers the event to its handlers later, and returns at once
- `subscribe: (name: TName, handler: (event: TEvents[TName]) => unknown) => () => void` (property): registers a handler for an event, and returns the function that unsubscribes it
- `close: () => void` (property): drops the pending deliveries and every handler, and ignores later emits and subscriptions
#### `IEventBusOptions`
*Type*
what an event bus calls with each error a handler throws or rejects with
```ts
type IEventBusOptions> = {
onError: (error: unknown, context: {
name: keyof TEvents;
event: TEvents[keyof TEvents];
}) => void;
};
```
**Members**
- `onError: (error: unknown, context: { name: keyof TEvents; event: TEvents[keyof TEvents] }) => void` (property): receives the error and the event the handler was handling; anything it throws is swallowed
#### `IFilterByQueryOptions`
*Type*
the options that can be used to filter items by a query
```ts
type IFilterByQueryOptions = {
queryProp?: keyof T;
limit?: number;
};
```
**Members**
- `queryProp: keyof T` (property): the property to filter by, if not provided, the whole item will be used
- `limit: number` (property): the maximum number of items to return
#### `IJsonValue`
*Type*
A value that survives a trip through JSON. An object's values may also be `undefined`, because `JSON.stringify` drops them, so an object with optional fields fits. Interfaces and classes don't fit, because they have no index signature: pass an interface-typed value as `{ ...value }`, or declare its type with `type`.
```ts
type IJsonValue = string | number | boolean | null | IJsonValue[] | {
[key: string]: IJsonValue | undefined;
};
```
#### `INumberFormatConfig`
*Type*
Number Format Configuration The configuration that will be used to prettify a number.
```ts
type INumberFormatConfig = {
minimumFractionDigits: number;
maximumFractionDigits: number;
prefix: string;
suffix: string;
};
```
**Members**
- `minimumFractionDigits: number` (property)
- `maximumFractionDigits: number` (property)
- `prefix: string` (property)
- `suffix: string` (property)
#### `IRedactSecretsOptions`
*Type*
the options accepted by redactSecrets
```ts
type IRedactSecretsOptions = {
patterns?: readonly RegExp[];
replacement?: string;
preserveLineBreaks?: boolean;
};
```
**Members**
- `patterns: readonly RegExp[]` (property): the secrets to find, after the private keys. Defaults to SECRET_TOKEN_PATTERNS
- `replacement: string` (property): what each secret becomes. Defaults to '[redacted]'
- `preserveLineBreaks: boolean` (property): whether a private key keeps its line breaks, so the text's line numbers stay the same
#### `IRedactSecretsResult`
*Type*
the text redactSecrets returns, and how many secrets it replaced
```ts
type IRedactSecretsResult = {
text: string;
count: number;
};
```
**Members**
- `text: string` (property)
- `count: number` (property)
#### `IRequestCoordinator`
*Type*
runs tasks within a concurrency limit and a bounded queue, and pauses them during a cooldown
```ts
type IRequestCoordinator = {
run: (task: () => Promise, options?: IRunRequestOptions) => Promise;
deferUntil: (deadline: Date | number) => void;
isIdle: () => boolean;
close: () => void;
};
```
**Members**
- `run: (task: () => Promise, options?: IRunRequestOptions) => Promise` (property): runs the task once a slot is free and no cooldown is active, and settles as it does
- `deferUntil: (deadline: Date | number) => void` (property): pauses queued tasks until the deadline, a Date or epoch milliseconds, keeping a later one
- `isIdle: () => boolean` (property): whether no task runs or waits and no cooldown is active, such as before discarding it
- `close: () => void` (property): rejects the waiting tasks and every later run, and lets the running ones finish
#### `IRequestCoordinatorOptions`
*Type*
the limits of createRequestCoordinator
```ts
type IRequestCoordinatorOptions = {
maxConcurrent: number;
maxQueued: number;
};
```
**Members**
- `maxConcurrent: number` (property): how many tasks run at once, at least 1
- `maxQueued: number` (property): how many tasks can wait for a slot, across every caller, at least 0
#### `IRetryAsyncFunctionOptions`
*Type*
the options that can be passed to the retryAsyncFunction function
```ts
type IRetryAsyncFunctionOptions = {
signal?: AbortSignal;
shouldRetry?: (error: unknown) => boolean;
};
```
**Members**
- `signal: AbortSignal` (property): stops retrying and rejects with the signal's reason, even while an attempt is pending
- `shouldRetry: (error: unknown) => boolean` (property): decides whether a failed attempt is retried; returning false rethrows the error immediately
#### `IRunRequestOptions`
*Type*
the options of one task a request coordinator runs
```ts
type IRunRequestOptions = {
signal?: AbortSignal;
caller?: unknown;
maxQueuedForCaller?: number;
rejectDuringCooldown?: boolean;
};
```
**Members**
- `signal: AbortSignal` (property): removes the task from the queue and rejects with the signal's reason when it aborts first
- `caller: unknown` (property): who the task runs for, compared with ===, so maxQueuedForCaller can limit each caller
- `maxQueuedForCaller: number` (property): how many of this caller's tasks can wait at once, at least 0
- `rejectDuringCooldown: boolean` (property): rejects with REQUEST_COOLDOWN_ACTIVE during a cooldown, instead of waiting it out
#### `ISchema`
*Type*
a validator for data from outside the program, such as a Zod schema: parse returns the data, or throws when it doesn't fit
```ts
type ISchema = {
parse(value: unknown): T;
};
```
**Members**
- `parse(value: unknown): T` (method)
#### `ISortDirection`
*Type*
the sort direction that can be applied to a list
```ts
type ISortDirection = 'asc' | 'desc';
```
#### `ISubstitutionOptions`
*Type*
the options that can be passed to the applySubstitutions function
```ts
type ISubstitutionOptions = {
jsonIndent: number;
};
```
**Members**
- `jsonIndent: number` (property)
#### `ITimeString`
*Type*
a duration such as '2 days' or '30 minutes', which toMS converts to milliseconds
```ts
type ITimeString = `${number} ${IUnit}`;
```
#### `IUtcDateRange`
*Type*
an inclusive range of UTC calendar dates, each as `YYYY-MM-DD`
```ts
type IUtcDateRange = {
fromDate: string;
toDate: string;
};
```
**Members**
- `fromDate: string` (property)
- `toDate: string` (property)
#### `IUuidVersion`
*Type*
UUID Version The UUID versions supported by this library.
```ts
type IUuidVersion = 4 | 7;
```
#### `IWaitUntilOptions`
*Type*
the options that can be passed to the waitUntil function
```ts
type IWaitUntilOptions = {
timeoutSeconds: number;
intervalSeconds?: number;
signal?: AbortSignal;
};
```
**Members**
- `timeoutSeconds: number` (property): the maximum number of seconds the whole call can take
- `intervalSeconds: number` (property): the number of seconds to wait between probes. Defaults to 1
- `signal: AbortSignal` (property): stops waiting and rejects with the signal's reason, even while a probe is pending
---
# jg-utilities recipes
Patterns for apps that page through lists, cache results by request, check what clients send, and share a rate-limited API, with jg-utilities' cursor codecs, JSON helpers, validations, and request coordinator.
## Paginating with cursors
For a list that grows while people page through it, such as a feed or an order history, page by position instead of by offset. Each page ends with a cursor that holds the sort keys of its last row, and the next request reads the rows after it. Rows added in between don't shift the pages, and the database seeks to the position instead of counting past an offset.
```ts
import { createCursorCodec, isStringValid } from 'jg-utilities';
// an order in the list, which is sorted newest first
type IOrder = { id: string; createdAt: string; total: number };
// the list's filter, and where a page ended: the sort keys of its last row
type IOrderCursor = { status: string | null; createdAt: string; id: string };
// a page, and the cursor for the next one, or null after the last
type IOrderPage = { items: IOrder[]; nextCursor: string | null };
// reads the orders with a status after a position, or from the start for null, such as with
// WHERE status = $1 AND (created_at, id) < ($2, $3) ORDER BY created_at DESC, id DESC LIMIT $4
declare function findOrders(
status: string | null,
after: IOrderCursor | null,
limit: number,
): Promise;
const PAGE_SIZE = 20;
// a client can encode any JSON itself, so the codec checks each cursor with this schema, which
// could be a Zod schema instead, and accepts it only for the status it was made for
const orderCursors = createCursorCodec({
schema: {
parse: (value) => {
const { status, createdAt, id } = value as Partial;
if (
(status !== null && !isStringValid(status)) ||
!isStringValid(createdAt) ||
!isStringValid(id)
) {
throw new TypeError('The value is not an order cursor.');
}
return { status, createdAt, id };
},
},
});
// a plain handler: it takes the request's query string and returns the page
const listOrders = async (searchParams: URLSearchParams): Promise => {
const status = searchParams.get('status');
const after = orderCursors.decode(searchParams.get('cursor'), { status }) ?? null;
// one row more than a page holds says whether another page follows
const rows = await findOrders(status, after, PAGE_SIZE + 1);
const items = rows.slice(0, PAGE_SIZE);
const last = items.at(-1);
return {
items,
nextCursor:
rows.length > PAGE_SIZE && last !== undefined
? orderCursors.encode({ status, createdAt: last.createdAt, id: last.id })
: null,
};
};
```
Without a cursor, `decode` returns `undefined`, the first page. A cursor it can't accept throws `INVALID_CURSOR`, whether its encoding, its shape, or its status is wrong, which `data.reason` says, so your error handler answers it with a `400`. The codec also refuses a cursor longer than 2048 characters, unless you pass another `maxLength`, and won't encode one that long either.
A cursor is opaque, but not secret or signed: it's base64url JSON, which a client can decode and change. The status check stops a cursor made for one filter from being replayed with another, but a client could still write one by hand, so this is only harmless while a cursor picks a position in rows the user can already read. Never put anything in a cursor that the client mustn't see.
When an API pages by the last row's id instead, such as `?after=42`, `getNextPageParam` reads the next id from a page: the last row's when the page is full, or `undefined` after a short page, which is the last.
```ts
import { getNextPageParam } from 'jg-utilities';
// a chat message
type IMessage = { id: number; text: string };
// reads up to limit messages after an id, oldest first, or from the start without one
declare function fetchMessages(after: number | undefined, limit: number): Promise;
const PAGE_SIZE = 50;
// reads every page, until a short one says there are no more
const messages: IMessage[] = [];
let after: number | undefined;
do {
const page = await fetchMessages(after, PAGE_SIZE);
messages.push(...page);
after = getNextPageParam('id', page, PAGE_SIZE);
} while (after !== undefined);
```
When the last page happens to be full, this costs one more request, which comes back empty. A server that sends a `nextCursor`, as the handler above does, saves it.
## Stable cache keys from JSON
To cache a result by the request that produced it, such as a search, build the key from the request's parameters with `stringifyJsonDeterministically`, which sorts object keys at every depth. `{ q, sort }` and `{ sort, q }` then share one entry, where `JSON.stringify` would give two.
```ts
import { createHash } from 'node:crypto';
import {
normalizeQuery,
parseJson,
pruneJson,
stringifyJson,
stringifyJsonDeterministically,
} from 'jg-utilities';
// the filters of a product search, as the request sends them
type ISearchFilters = {
q?: string;
tags?: string[];
maxPrice?: number | null;
sort?: 'price' | 'newest';
};
// a product a search finds
type IProduct = { id: string; name: string; price: number };
// a key-value store with expiry, such as Redis
declare const cache: {
get(key: string): Promise;
set(key: string, value: string, ttlSeconds: number): Promise;
};
// runs the search on the database
declare function searchProducts(filters: ISearchFilters): Promise;
// the same search gives the same key, however the client ordered or spaced it
const toSearchCacheKey = (filters: ISearchFilters): string => {
// pruneJson drops null, undefined, and empty arrays and objects, so { tags: [] } matches {}
const normalized = pruneJson({
...filters,
// text that differs only in case or spacing finds the same products, and blank text is none
q: normalizeQuery(filters.q ?? '') || undefined,
// the tags' order doesn't change the result, so it doesn't change the key
tags: filters.tags?.toSorted(),
});
const json = stringifyJsonDeterministically(normalized ?? {});
// a hash keeps the key short whatever the filters hold; the prefix says what's cached, and the
// version of its shape
return `products:search:v1:${createHash('sha256').update(json).digest('base64url')}`;
};
const searchProductsCached = async (filters: ISearchFilters): Promise => {
const key = toSearchCacheKey(filters);
const cached = await cache.get(key);
if (cached !== null) {
return parseJson(cached);
}
const products = await searchProducts(filters);
await cache.set(key, stringifyJson(products), 60);
return products;
};
```
Arrays keep their order, because it often matters, so sort the ones where it doesn't, as the tags are here. Change the version in the prefix when the cached value's shape changes, so old entries are never read as new ones.
The same key works in memory without a hash, such as to share one request between the parts of a page that ask for the same search at once:
```ts
import { stringifyJsonDeterministically } from 'jg-utilities';
// the filters of a product search
type ISearchFilters = { q?: string; tags?: string[]; sort?: 'price' | 'newest' };
// a product a search finds
type IProduct = { id: string; name: string; price: number };
// sends the search to the API
declare function fetchProducts(filters: ISearchFilters): Promise;
// the searches in flight, by key
const inFlight = new Map>();
const searchProductsOnce = (filters: ISearchFilters): Promise => {
const key = stringifyJsonDeterministically(filters);
const pending = inFlight.get(key);
if (pending !== undefined) {
return pending;
}
// the entry leaves once the request settles, so a later search asks again
const search = fetchProducts(filters).finally(() => inFlight.delete(key));
inFlight.set(key, search);
return search;
};
```
## Validating input at the edge
Check what a client sends where it enters your app, then pass typed values inward. jg-utilities has no schema builder: its validations are type guards, which fit a handful of flat fields. For nested or larger input, use a schema, such as a Zod one.
```ts
import { Exception } from 'jg-exception';
import { isEmailValid, isObjectValid, isPasswordValid, isStringValid } from 'jg-utilities';
// the sign-up form, once it's checked
type ISignUpInput = { email: string; password: string; name: string };
// one code for every rejected field, with the field's name in data, so the client can mark it
const invalidInput = (field: string): Exception =>
new Exception(`The ${field} is invalid.`, { code: 'INVALID_INPUT', data: { field } });
// checks the parsed JSON body one field at a time
const readSignUpInput = (body: unknown): ISignUpInput => {
if (!isObjectValid(body)) {
throw invalidInput('body');
}
const { email, password, name } = body;
if (!isEmailValid(email)) {
throw invalidInput('email');
}
if (!isPasswordValid(password)) {
throw invalidInput('password');
}
if (!isStringValid(name, 1, 100)) {
throw invalidInput('name');
}
return { email: email.toLowerCase(), password, name: name.trim() };
};
```
Each guard narrows its field, so the returned object needs no casts, and building a new object drops whatever else the client sent, such as an `isAdmin` flag. The error names the field in `data`, never its value, which here could be a password.
Query strings and headers arrive as strings or `null`. Check each string before you convert it, and map each code to a status in one place:
```ts
import { buildResponse } from 'jg-api-response';
import { Exception, hasErrorCode } from 'jg-exception';
import {
extractTokenFromAuthorizationHeader,
isAuthorizationHeaderValid,
isIntegerValid,
isNumeric,
normalizeQuery,
} from 'jg-utilities';
// an order a search finds
type IOrder = { id: string; total: number };
// reads the user a token belongs to, such as by verifying a JWT
declare function verifyToken(token: string): Promise<{ userId: string }>;
// searches a user's orders
declare function searchOrders(userId: string, query: string, limit: number): Promise;
// reads ?limit=, from 1 to 100, or 20 without one
const readLimit = (value: string | null): number => {
if (value === null) {
return 20;
}
// isNumeric rejects '', ' 12', and '0x10', which Number reads as 0, 12, and 16
const limit = Number(value);
if (!isNumeric(value) || !isIntegerValid(limit, 1, 100)) {
throw new Exception('The limit is invalid.', {
code: 'INVALID_INPUT',
data: { field: 'limit' },
});
}
return limit;
};
// a plain handler: the framework passes the query string and the Authorization header, and
// sends back the status and the body
const listOrders = async (searchParams: URLSearchParams, authorization: string | null) => {
try {
if (!isAuthorizationHeaderValid(authorization)) {
throw new Exception('The request has no valid bearer token.', { code: 'UNAUTHENTICATED' });
}
const { userId } = await verifyToken(extractTokenFromAuthorizationHeader(authorization));
const limit = readLimit(searchParams.get('limit'));
// searches that differ only in case or spacing run as one, cut to 100 characters
const query = normalizeQuery(searchParams.get('q') ?? '', 100);
return { status: 200, body: buildResponse(await searchOrders(userId, query, limit)) };
} catch (error) {
if (hasErrorCode(error, 'UNAUTHENTICATED')) {
return { status: 401, body: buildResponse(null, error) };
}
if (hasErrorCode(error, 'INVALID_INPUT')) {
return { status: 400, body: buildResponse(null, error) };
}
// anything else is the app's own failure, for the error handler every route shares
throw error;
}
};
```
Checking the header with `isAuthorizationHeaderValid` before `extractTokenFromAuthorizationHeader` turns a missing or malformed header into the handler's own `UNAUTHENTICATED`, answered with a 401, so it never reaches the shared error handler as jg-utilities' `INVALID_AUTHORIZATION_HEADER`, which that handler doesn't know.
## Waiting out a rate limit across requests
Use this when many requests share one rate-limited API, such as GitHub's for an app installation. A coordinator runs at most a few at a time, queues the rest, and, once a response says to wait with `Retry-After`, holds every queued request until then, without retrying them one by one. jg-fetch's retries handle one request; the coordinator keeps a whole app's requests in line.
```ts
import { hasErrorCode } from 'jg-exception';
import { createRequestCoordinator, ERRORS } from 'jg-utilities';
// GitHub's requests for every installation, four at a time, with up to 256 waiting
const github = createRequestCoordinator({ maxConcurrent: 4, maxQueued: 256 });
// the time a Retry-After header names, as seconds or as an HTTP date, in epoch milliseconds
const readRetryAt = (response: Response): number | undefined => {
const header = response.headers.get('retry-after');
if (header === null) {
return undefined;
}
const seconds = Number(header);
const retryAt = Number.isFinite(seconds) ? Date.now() + seconds * 1000 : Date.parse(header);
return Number.isNaN(retryAt) ? undefined : retryAt;
};
// one installation's request, which waits behind at most 32 of its own
export const requestGitHub = (installationId: number, url: string, signal: AbortSignal) =>
github.run(
async () => {
const response = await fetch(url, { signal });
const retryAt =
response.status === 403 || response.status === 429 ? readRetryAt(response) : undefined;
if (retryAt !== undefined) {
github.deferUntil(retryAt);
}
return response;
},
{ signal, caller: installationId, maxQueuedForCaller: 32 },
);
// a webhook that answers at once instead of waiting: GitHub redelivers it after a 503
export const handleWebhook = async (installationId: number, url: string, signal: AbortSignal) => {
try {
await github.run(() => fetch(url, { signal }), {
signal,
caller: installationId,
rejectDuringCooldown: true,
});
return { status: 202 };
} catch (error) {
if (
hasErrorCode(error, ERRORS.REQUEST_COOLDOWN_ACTIVE) ||
hasErrorCode(error, ERRORS.REQUEST_QUEUE_FULL)
) {
return { status: 503 };
}
throw error;
}
};
```
A request that waits for a slot holds nothing: the cooldown blocks no slot, and an aborted signal takes its request out of the queue. `maxQueuedForCaller` keeps one busy installation from filling the queue for the rest. Discard the coordinator only when `isIdle()` is true, or call `close()`, which rejects what's still waiting.
---
# jg-bignumber
> Exact decimal math on bignumber.js: rounding modes, formatting, and financial calculations such as percentage changes.
- **Version:** `1.4.0`
- **Runtimes:** Universal
- **Install:** `npm install jg-bignumber`
- **On npm:** [jg-bignumber](https://www.npmjs.com/package/jg-bignumber)
- **Source:** [packages/jg-bignumber](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-bignumber)
- **Recipes:** [jg-bignumber recipes](https://jg-packages.jesusgraterol.dev/packages/jg-bignumber/recipes/index.md)
## Usage
Every calculation is exact, then rounded to 2 decimal places with `ROUND_HALF_UP` and returned as a number, unless the configuration asks otherwise:
```ts
import { calculateSum, processValue, prettifyValue } from 'jg-bignumber';
calculateSum([0.1, '0.2', 0.3]); // 0.6, not 0.6000000000000001
processValue(512.155); // 512.16
processValue(19.991, { roundingMode: 'ROUND_CEIL' }); // 20
processValue('0.000001', { decimalPlaces: 6, type: 'string' }); // '0.000001'
prettifyValue(1234.5, { format: { prefix: '$' } }); // '$1,234.5'
prettifyValue(1234.5, { format: { prefix: '$', minimumDecimalPlaces: 2 } }); // '$1,234.50'
prettifyValue(-5, { format: { prefix: '$' } }); // '-$5'
```
Percentages and money:
```ts
import {
adjustByPercentage,
calculateExchange,
calculatePercentageChange,
calculateWeightedEntry,
} from 'jg-bignumber';
calculatePercentageChange(100, 125); // 25
adjustByPercentage(100, -10); // 90
calculateExchange(100, 65000, { decimalPlaces: 8 }); // 0.00153846
calculateWeightedEntry([
[100, 2],
[130, 1],
]); // 110, the average price weighted by amount
```
Store money as an integer count of its smallest unit, such as microcents in a PostgreSQL `BIGINT`, and convert it exactly, never rounding:
```ts
import { applyBasisPoints, fromScaledInteger, toScaledInteger } from 'jg-bignumber';
toScaledInteger('1.23', 8); // 123000000n
toScaledInteger('0.123456789', 8); // throws INVALID_VALUE: the 9th decimal would be lost
toScaledInteger(1e-8, 8); // throws INVALID_VALUE: JavaScript writes it as '1e-8'
fromScaledInteger(123000000n, 8); // '1.23000000'
applyBasisPoints(100000000n, 500); // 5000000n: a 5% commission, rounded down to a whole unit
```
`BigNumber` is the package's own bignumber.js constructor, so creating values with it never changes the global bignumber.js configuration.
## jg-bignumber API reference
Every export of `jg-bignumber@1.4.0`, 33 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc.
- Functions: [`adjustByPercentage`](#api-adjustByPercentage), [`applyBasisPoints`](#api-applyBasisPoints), [`calculateExchange`](#api-calculateExchange), [`calculateExchangeFee`](#api-calculateExchangeFee), [`calculateMax`](#api-calculateMax), [`calculateMean`](#api-calculateMean), [`calculateMedian`](#api-calculateMedian), [`calculateMin`](#api-calculateMin), [`calculatePercentageChange`](#api-calculatePercentageChange), [`calculatePercentageRepresentation`](#api-calculatePercentageRepresentation), [`calculateSum`](#api-calculateSum), [`calculateWeightedEntry`](#api-calculateWeightedEntry), [`fromScaledInteger`](#api-fromScaledInteger), [`getBigNumber`](#api-getBigNumber), [`isBigNumber`](#api-isBigNumber), [`isFloat`](#api-isFloat), [`isInteger`](#api-isInteger), [`isNumber`](#api-isNumber), [`prettifyValue`](#api-prettifyValue), [`processValue`](#api-processValue), [`toScaledInteger`](#api-toScaledInteger)
- Constants: [`BigNumber`](#api-BigNumber), [`ERRORS`](#api-ERRORS)
- Types: [`IBigNumber`](#api-IBigNumber), [`IBigNumberFormat`](#api-IBigNumberFormat), [`IBigNumberRoundingMode`](#api-IBigNumberRoundingMode), [`IBigNumberRoundingModeName`](#api-IBigNumberRoundingModeName), [`IBigNumberToType`](#api-IBigNumberToType), [`IBigNumberValue`](#api-IBigNumberValue), [`IConfig`](#api-IConfig), [`IOutput`](#api-IOutput), [`IPrettifyFormat`](#api-IPrettifyFormat), [`IType`](#api-IType)
### Functions
#### `adjustByPercentage`
*Function*
Changes a value by a percentage: a positive percentage increases it, and a negative one decreases it, to 0 at most. A value of 0 stays 0.
```ts
adjustByPercentage>(
value: Value,
percentage: Value,
config?: T,
): IOutput
```
**Parameters**
- `value` `Value`: The value to change.
- `percentage` `Value`: The percentage to change it by.
- `config?` `T`: How the result is processed.
**Returns** `IOutput`: The changed value.
**Throws**
- `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error
- `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason
- `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported
- `INVALID_TYPE`: if the processing type is not supported
- `NEGATIVE_VALUE_NOT_ALLOWED`: if the value is negative
**Example**
```ts
import { adjustByPercentage } from 'jg-bignumber';
adjustByPercentage(100, 25); // => 125
adjustByPercentage(100, -10); // => 90
```
#### `applyBasisPoints`
*Function*
Applies a rate in basis points to an integer count of an amount's smallest unit, such as a 5% commission on a payment in microcents, rounding down to a whole unit, so the result never exceeds what the rate gives. 100 basis points are 1%, and 10,000 are 100%.
```ts
applyBasisPoints(value: bigint, basisPoints: number): bigint
```
**Parameters**
- `value` `bigint`: The amount in the smallest unit, 0 or more.
- `basisPoints` `number`: The rate, a non-negative safe integer; it may exceed 10,000 for more than 100%.
**Returns** `bigint`: `value × basisPoints / 10,000`, rounded down.
**Throws**
- `INVALID_VALUE`: The value must be a bigint.
- `NEGATIVE_VALUE_NOT_ALLOWED`: The value must be greater than or equal to 0.
- `INVALID_VALUE`: The basis points must be a non-negative safe integer.
**Example**
```ts
import { applyBasisPoints } from 'jg-bignumber';
applyBasisPoints(100_000_000n, 500); // => 5000000n
applyBasisPoints(123_000_000n, 1500); // => 18450000n
applyBasisPoints(10_001n, 1); // => 1n
```
#### `calculateExchange`
*Function*
Calculates the amount of an asset an exchange gives for a value at a rate, such as how much BTC 100 USDT buys at 65,000 USDT per BTC.
```ts
calculateExchange>(
value: Value,
rate: Value,
config?: T,
): IOutput
```
**Parameters**
- `value` `Value`: The amount being exchanged.
- `rate` `Value`: The price of one unit of the asset received.
- `config?` `T`: How the result is processed.
**Returns** `IOutput`: The amount received.
**Throws**
- `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error
- `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason
- `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported
- `INVALID_TYPE`: if the processing type is not supported
- `NEGATIVE_VALUE_NOT_ALLOWED`: if the value or rate are not positive
**Example**
```ts
import { calculateExchange } from 'jg-bignumber';
calculateExchange(100, 65000, { decimalPlaces: 8 }); // => 0.00153846
```
#### `calculateExchangeFee`
*Function*
Calculates the fee charged for an exchange, as a percentage of the value exchanged.
```ts
calculateExchangeFee>(
value: Value,
feePercentage: Value,
config?: T,
): IOutput
```
**Parameters**
- `value` `Value`: The amount being exchanged.
- `feePercentage` `Value`: The fee, as a percentage.
- `config?` `T`: How the result is processed.
**Returns** `IOutput`: The fee.
**Throws**
- `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error
- `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason
- `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported
- `INVALID_TYPE`: if the processing type is not supported
- `NEGATIVE_VALUE_NOT_ALLOWED`: if the value or feePercentage are not positive
**Example**
```ts
import { calculateExchangeFee } from 'jg-bignumber';
calculateExchangeFee(1000, 0.1); // => 1
```
#### `calculateMax`
*Function*
Finds the largest value in a list. An empty list returns 0.
```ts
calculateMax>(
values: Value[],
config?: T,
): IOutput
```
**Parameters**
- `values` `Value[]`: The values.
- `config?` `T`: How the result is processed.
**Returns** `IOutput`: The largest value.
**Throws**
- `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error
- `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason
- `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported
- `INVALID_TYPE`: if the processing type is not supported
- `INVALID_VALUES_ARRAY`: if the provided values arg is not a valid array
**Example**
```ts
import { calculateMax } from 'jg-bignumber';
calculateMax([3, '1.5', 2]); // => 3
```
#### `calculateMean`
*Function*
Calculates the mean of a list of values. An empty list returns 0.
```ts
calculateMean>(
values: Value[],
config?: T,
): IOutput
```
**Parameters**
- `values` `Value[]`: The values.
- `config?` `T`: How the result is processed.
**Returns** `IOutput`: The mean.
**Throws**
- `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error
- `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason
- `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported
- `INVALID_TYPE`: if the processing type is not supported
- `INVALID_VALUES_ARRAY`: if the provided values arg is not a valid array
**Example**
```ts
import { calculateMean } from 'jg-bignumber';
calculateMean([1, 2, 4]); // => 2.33
```
#### `calculateMedian`
*Function*
Calculates the median of a list of values: the middle value, or the mean of the two middle values when the list has an even length. An empty list returns 0.
```ts
calculateMedian>(
values: Value[],
config?: T,
): IOutput
```
**Parameters**
- `values` `Value[]`: The values.
- `config?` `T`: How the result is processed.
**Returns** `IOutput`: The median.
**Throws**
- `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error
- `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason
- `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported
- `INVALID_TYPE`: if the processing type is not supported
- `INVALID_VALUES_ARRAY`: if the provided values arg is not a valid array
**Example**
```ts
import { calculateMedian } from 'jg-bignumber';
calculateMedian([5, 1, 3]); // => 3
calculateMedian([4, 1, 3, 2]); // => 2.5
```
#### `calculateMin`
*Function*
Finds the smallest value in a list. An empty list returns 0.
```ts
calculateMin>(
values: Value[],
config?: T,
): IOutput
```
**Parameters**
- `values` `Value[]`: The values.
- `config?` `T`: How the result is processed.
**Returns** `IOutput`: The smallest value.
**Throws**
- `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error
- `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason
- `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported
- `INVALID_TYPE`: if the processing type is not supported
- `INVALID_VALUES_ARRAY`: if the provided values arg is not a valid array
**Example**
```ts
import { calculateMin } from 'jg-bignumber';
calculateMin([3, '1.5', 2]); // => 1.5
```
#### `calculatePercentageChange`
*Function*
Calculates the percentage a value changed by: positive when it increased, negative when it decreased, and 0 when it didn't change. The largest decrease is -100%, returned whenever the new value is 0 or less.
```ts
calculatePercentageChange>(
oldValue: Value,
newValue: Value,
config?: T,
): IOutput
```
**Parameters**
- `oldValue` `Value`: The value before the change.
- `newValue` `Value`: The value after the change.
- `config?` `T`: How the result is processed.
**Returns** `IOutput`: The percentage change.
**Throws**
- `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error
- `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason
- `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported
- `INVALID_TYPE`: if the processing type is not supported
- `NEGATIVE_VALUE_NOT_ALLOWED`: if the oldValue is not positive
**Example**
```ts
import { calculatePercentageChange } from 'jg-bignumber';
calculatePercentageChange(100, 125); // => 25
calculatePercentageChange(100, 80); // => -20
calculatePercentageChange(100, 0); // => -100
```
#### `calculatePercentageRepresentation`
*Function*
Calculates what percentage of a total a value represents.
```ts
calculatePercentageRepresentation>(
value: Value,
total: Value,
config?: T,
): IOutput
```
**Parameters**
- `value` `Value`: The part.
- `total` `Value`: The total.
- `config?` `T`: How the result is processed.
**Returns** `IOutput`: The percentage.
**Throws**
- `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error
- `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason
- `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported
- `INVALID_TYPE`: if the processing type is not supported
- `NEGATIVE_VALUE_NOT_ALLOWED`: if the value or total are not positive
**Example**
```ts
import { calculatePercentageRepresentation } from 'jg-bignumber';
calculatePercentageRepresentation(25, 200); // => 12.5
```
#### `calculateSum`
*Function*
Adds up a list of values. An empty list sums to 0.
```ts
calculateSum>(
values: Value[],
config?: T,
): IOutput
```
**Parameters**
- `values` `Value[]`: The values.
- `config?` `T`: How the result is processed.
**Returns** `IOutput`: The sum.
**Throws**
- `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error
- `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason
- `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported
- `INVALID_TYPE`: if the processing type is not supported
- `INVALID_VALUES_ARRAY`: if the provided values arg is not a valid array
**Example**
```ts
import { calculateSum } from 'jg-bignumber';
calculateSum([0.1, '0.2', 0.3]); // => 0.6
calculateSum([0.1, 0.2], { type: 'string' }); // => '0.3'
```
#### `calculateWeightedEntry`
*Function*
Calculates the average entry price of a position bought in several trades, weighting each price by the amount bought. An empty list, or one whose amounts add up to 0, returns 0.
```ts
calculateWeightedEntry>(
trades: [Value, Value][],
config?: T,
): IOutput
```
**Parameters**
- `trades` `[Value, Value][]`: The trades, each as a `[price, amount]` tuple.
- `config?` `T`: How the result is processed.
**Returns** `IOutput`: The weighted average price.
**Throws**
- `INVALID_VALUE`: if any of the given values isn't a finite number or BigNumber throws an error
- `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason
- `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported
- `INVALID_TYPE`: if the processing type is not supported
- `INVALID_VALUES_ARRAY`: if the provided values arg is not a valid array
**Example**
```ts
import { calculateWeightedEntry } from 'jg-bignumber';
calculateWeightedEntry([[100, 2], [130, 1]]); // => 110
calculateWeightedEntry([]); // => 0
```
#### `fromScaledInteger`
*Function*
Converts an integer count of an amount's smallest unit, such as microcents, back to the amount as an exact decimal string, with all `decimals` digits and no symbol, separators, or rounding. Pass it to `prettifyValue` to show it to people.
```ts
fromScaledInteger(value: string | bigint, decimals: number): string
```
**Parameters**
- `value` `string | bigint`: The amount in the smallest unit, as a bigint or an integer string, such as a PostgreSQL BIGINT. A string may have surrounding spaces and a sign.
- `decimals` `number`: How many decimals the smallest unit has, such as 8 for microcents.
**Returns** `string`: The amount.
**Throws**
- `INVALID_VALUE`: if the value isn't a bigint or an integer string
- `INVALID_DECIMAL_PLACES`: if decimals isn't an integer from 0 to 100
**Example**
```ts
import { fromScaledInteger } from 'jg-bignumber';
fromScaledInteger(123000000n, 8); // => '1.23000000'
fromScaledInteger('-1', 8); // => '-0.00000001'
fromScaledInteger(1250n, 0); // => '1250'
```
#### `getBigNumber`
*Function*
Creates a BigNumber from a number, a numeric string, or a BigNumber, which is returned as is. Only finite values are accepted: `NaN` and `±Infinity` are rejected.
```ts
getBigNumber(value: Value): BigNumber
```
**Parameters**
- `value` `Value`: The value.
**Returns** `BigNumber`: The BigNumber.
**Throws**
- `INVALID_VALUE`: if the value isn't a finite number or BigNumber throws an error
**Example**
```ts
import { getBigNumber } from 'jg-bignumber';
getBigNumber('0.1').plus('0.2').toString(); // => '0.3'
```
#### `isBigNumber`
*Function*
Checks whether a value is a BigNumber instance, from this package or any other copy of bignumber.js.
```ts
isBigNumber(value: unknown): value is BigNumber
```
**Parameters**
- `value` `unknown`: The value.
**Returns** `value is BigNumber`: Whether it's a BigNumber.
**Example**
```ts
import { getBigNumber, isBigNumber } from 'jg-bignumber';
isBigNumber(getBigNumber(1)); // => true
isBigNumber(1); // => false
```
#### `isFloat`
*Function*
Checks whether a value is a finite number with a fractional part, in any of the supported types.
```ts
isFloat(value: unknown): boolean
```
**Parameters**
- `value` `unknown`: The value.
**Returns** `boolean`: Whether it's a float.
**Example**
```ts
import { isFloat } from 'jg-bignumber';
isFloat(12.5); // => true
isFloat('12'); // => false
```
#### `isInteger`
*Function*
Checks whether a value is a finite integer, in any of the supported types.
```ts
isInteger(value: unknown): boolean
```
**Parameters**
- `value` `unknown`: The value.
**Returns** `boolean`: Whether it's an integer.
**Example**
```ts
import { isInteger } from 'jg-bignumber';
isInteger('12'); // => true
isInteger(12.5); // => false
```
#### `isNumber`
*Function*
Checks whether a value is a finite number, numeric string, or BigNumber.
```ts
isNumber(value: unknown): boolean
```
**Parameters**
- `value` `unknown`: The value.
**Returns** `boolean`: Whether it's a finite number.
**Example**
```ts
import { isNumber } from 'jg-bignumber';
isNumber('12.5'); // => true
isNumber(Infinity); // => false
isNumber('abc'); // => false
```
#### `prettifyValue`
*Function*
Processes a value and formats it for people, with group separators, a prefix, and a suffix. Trailing zeros are dropped, unless `format.minimumDecimalPlaces` keeps them. The sign goes before the prefix, as in `-$5`.
```ts
prettifyValue(
value: Value,
config?: { processing?: Partial; format?: IPrettifyFormat },
): string
```
**Parameters**
- `value` `Value`: The value.
- `config?` `{ processing?: Partial; format?: IPrettifyFormat }`: How the value is processed and formatted. Defaults to `{}`.
**Returns** `string`: The formatted value.
**Throws**
- `INVALID_VALUE`: if the value isn't a finite number or BigNumber throws an error
- `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason
- `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported
- `INVALID_TYPE`: if the processing type is not supported
- `INVALID_BIGNUMBER_FORMAT`: if any of the format properties are invalid, or minimumDecimalPlaces
- exceeds decimalPlaces
**Example**
```ts
import { prettifyValue } from 'jg-bignumber';
prettifyValue(1234567.891); // => '1,234,567.89'
prettifyValue(1234.5, { format: { prefix: '$' } }); // => '$1,234.5'
prettifyValue(1234.5, { format: { prefix: '$', minimumDecimalPlaces: 2 } }); // => '$1,234.50'
prettifyValue(-5, { format: { prefix: '$' } }); // => '-$5'
```
#### `processValue`
*Function*
Rounds a value and returns it as the type the configuration asks for: a number by default, or a string or a BigNumber.
```ts
processValue>(
value: Value,
configuration?: T,
): IOutput
```
**Parameters**
- `value` `Value`: The value.
- `configuration?` `T`: The decimal places, rounding mode, and output type.
**Returns** `IOutput`: The processed value.
**Throws**
- `INVALID_VALUE`: if the value isn't a finite number or BigNumber throws an error
- `INVALID_DECIMAL_PLACES`: if the number of decimal places is invalid for any reason
- `INVALID_ROUNDING_MODE`: if the rounding mode name is not supported
- `INVALID_TYPE`: if the processing type is not supported
**Example**
```ts
import { processValue } from 'jg-bignumber';
processValue(512.155); // => 512.16
processValue(512.155, { decimalPlaces: 1, roundingMode: 'ROUND_FLOOR' }); // => 512.1
processValue('0.000001', { decimalPlaces: 6, type: 'string' }); // => '0.000001'
```
#### `toScaledInteger`
*Function*
Converts an amount, such as dollars, to an integer count of its smallest unit, such as microcents with 8 decimals, so it can be stored and added up exactly, as in a PostgreSQL BIGINT. It takes a number or a plain decimal string, with surrounding spaces and a leading `+` allowed, and never rounds: scientific notation, including a number JavaScript writes that way, such as `0.00000001`, hex, malformed values, and digits beyond `decimals` other than trailing zeros are rejected. Only the text is read, so `0.1 + 0.2`, which is `0.30000000000000004`, fails rather than becoming a slightly wrong amount.
```ts
toScaledInteger(value: string | number, decimals: number): bigint
```
**Parameters**
- `value` `string | number`: The amount.
- `decimals` `number`: How many decimals the smallest unit has, such as 8 for microcents.
**Returns** `bigint`: The amount in the smallest unit.
**Throws**
- `INVALID_VALUE`: if the value isn't a plain decimal, or has more decimals than `decimals`
- `INVALID_DECIMAL_PLACES`: if decimals isn't an integer from 0 to 100
**Example**
```ts
import { toScaledInteger } from 'jg-bignumber';
toScaledInteger('1.23', 8); // => 123000000n
toScaledInteger(' +0.00000001 ', 8); // => 1n
toScaledInteger(-12.5, 2); // => -1250n
```
### Constants
#### `BigNumber`
*Constant*
The BigNumber constructor every function uses: a clone of bignumber.js's, so the package never changes the global bignumber.js configuration other code relies on. It never switches to exponential notation, and with STRICT off an invalid value becomes NaN instead of throwing, which the package then reports as INVALID_VALUE.
```ts
const BigNumber: typeof BigNumber
```
#### `ERRORS`
*Constant*
the codes of every error the package throws
```ts
const ERRORS: { INVALID_VALUE: "INVALID_VALUE"; INVALID_TYPE: "INVALID_TYPE"; INVALID_ROUNDING_MODE: "INVALID_ROUNDING_MODE"; INVALID_DECIMAL_PLACES: "INVALID_DECIMAL_PLACES"; INVALID_BIGNUMBER_FORMAT: "INVALID_BIGNUMBER_FORMAT"; INVALID_VALUES_ARRAY: "INVALID_VALUES_ARRAY"; NEGATIVE_VALUE_NOT_ALLOWED: "NEGATIVE_VALUE_NOT_ALLOWED" }
```
### Types
#### `IBigNumber`
*Type*
A BigNumber instance, created with `getBigNumber(value)` or the exported `BigNumber` constructor. Every bignumber.js method is available on it.
```ts
type IBigNumber = BigNumber;
```
#### `IBigNumberFormat`
*Type*
How prettifyValue formats a value, with every setting of bignumber.js's toFormat, defaults in parentheses: prefix (''), negativeSign ('-'), positiveSign (''), decimalSeparator ('.'), groupSeparator (','), groupSize (3), secondaryGroupSize (0), fractionGroupSeparator (' '), fractionGroupSize (0), and suffix ('').
```ts
type IBigNumberFormat = Required;
```
#### `IBigNumberRoundingMode`
*Type*
the numeric rounding mode bignumber.js uses for each IBigNumberRoundingModeName
```ts
type IBigNumberRoundingMode = BigNumber.RoundingMode;
```
#### `IBigNumberRoundingModeName`
*Type*
How a value is rounded when it has more decimals than allowed: - ROUND_UP: away from zero - ROUND_DOWN: towards zero - ROUND_CEIL: towards Infinity - ROUND_FLOOR: towards -Infinity - ROUND_HALF_UP (default): to the nearest neighbour, away from zero when equidistant - ROUND_HALF_DOWN: to the nearest neighbour, towards zero when equidistant - ROUND_HALF_EVEN: to the nearest neighbour, towards the even one when equidistant - ROUND_HALF_CEIL: to the nearest neighbour, towards Infinity when equidistant - ROUND_HALF_FLOOR: to the nearest neighbour, towards -Infinity when equidistant
```ts
type IBigNumberRoundingModeName = 'ROUND_UP' | 'ROUND_DOWN' | 'ROUND_CEIL' | 'ROUND_FLOOR' | 'ROUND_HALF_UP' | 'ROUND_HALF_DOWN' | 'ROUND_HALF_EVEN' | 'ROUND_HALF_CEIL' | 'ROUND_HALF_FLOOR';
```
#### `IBigNumberToType`
*Type*
the TypeScript type of a value converted to an IType
```ts
type IBigNumberToType = T extends 'string' ? string : T extends 'number' ? number : T extends 'bignumber' ? IBigNumber : never;
```
#### `IBigNumberValue`
*Type*
the values a BigNumber can be created from: numbers, numeric strings, and BigNumbers
```ts
type IBigNumberValue = BigNumber.Value;
```
#### `IConfig`
*Type*
how a value is processed
```ts
type IConfig = {
decimalPlaces: number;
roundingMode: IBigNumberRoundingModeName;
type: IType;
};
```
**Members**
- `decimalPlaces: number` (property): the maximum number of decimals in the output. Defaults to 2
- `roundingMode: IBigNumberRoundingModeName` (property): how the value is rounded when it has more decimals. Defaults to 'ROUND_HALF_UP'
- `type: IType` (property): the output's type. Defaults to 'number'
#### `IOutput`
*Type*
the return type of a function that processes a value, picked from its configuration's type
```ts
type IOutput = T extends {
type: 'string';
} ? string : T extends {
type: 'number';
} ? number : T extends {
type: 'bignumber';
} ? IBigNumber : number;
```
#### `IPrettifyFormat`
*Type*
The format prettifyValue takes: any of bignumber.js's toFormat settings, and the fewest decimals it shows, padded with trailing zeros, such as 2 for '$1,234.50'. minimumDecimalPlaces defaults to 0, and can't exceed the processing's decimalPlaces.
```ts
type IPrettifyFormat = Partial & {
minimumDecimalPlaces?: number;
};
```
#### `IType`
*Type*
the type a processed value is returned as
```ts
type IType = 'string' | 'number' | 'bignumber';
```
---
# jg-bignumber recipes
Patterns for apps that add up money and report how a metric changed, with exact decimal math and rounding where you choose.
## Summing money without rounding errors
Use this for a cart, an invoice, or any total of prices. JavaScript numbers are binary fractions, so `0.1 + 0.2` is `0.30000000000000004`, and each addition can add an error. Keep amounts as decimal strings from the API or the database, do the arithmetic with jg-bignumber, and round where the invoice rounds:
```ts
import {
calculateSum,
getBigNumber,
prettifyValue,
processValue,
type IBigNumber,
} from 'jg-bignumber';
// a line of an invoice, with its price as a decimal string, as an API or a PostgreSQL numeric
// column sends it, so it never passes through a float
type IInvoiceLine = { unitPrice: string; quantity: number };
type IInvoiceTotals = { subtotal: IBigNumber; tax: IBigNumber; total: IBigNumber };
// the reader's invoice, with its tax rate as a percentage, such as '8.25'
declare const invoice: { lines: IInvoiceLine[]; taxRate: string };
// the cell that shows the invoice's total
declare const totalCell: HTMLElement;
const calculateInvoiceTotals = (lines: IInvoiceLine[], taxRate: string): IInvoiceTotals => {
// each line's total stays exact, so nothing is rounded before the sum
const lineTotals = lines.map(({ unitPrice, quantity }) =>
getBigNumber(unitPrice).times(quantity),
);
// the subtotal and the tax are each rounded to cents, as the invoice prints them, so the
// printed amounts add up to the printed total
const subtotal = calculateSum(lineTotals, { type: 'bignumber' });
const tax = processValue(subtotal.times(taxRate).dividedBy(100), { type: 'bignumber' });
return { subtotal, tax, total: calculateSum([subtotal, tax], { type: 'bignumber' }) };
};
const { total } = calculateInvoiceTotals(invoice.lines, invoice.taxRate);
// toFixed keeps the cents, such as '1234.50', where a number drops the trailing zero, and
// minimumDecimalPlaces does the same for the formatted total, such as '$1,234.50'
const totalForApi = total.toFixed(2);
totalCell.textContent = prettifyValue(total, { format: { prefix: '$', minimumDecimalPlaces: 2 } });
```
Every jg-bignumber calculation, and `processValue`, rounds its result, so call one where the invoice rounds, and do the steps in between with BigNumber methods, such as `times` and `dividedBy`, which don't. `{ type: 'bignumber' }` returns a BigNumber instead of a number, so the next step starts from the decimal value. Results round to 2 decimal places with `ROUND_HALF_UP`: pass `roundingMode: 'ROUND_HALF_EVEN'` where an accounting rule asks for banker's rounding, or `decimalPlaces: 0` for a currency without cents. A string that isn't a number, such as `''` from an empty form field, throws `INVALID_VALUE`, so check input with `isNumber` before it reaches the math.
## Storing money as integers
Use this when amounts are stored or added up as integers, such as microcents in a PostgreSQL `BIGINT`, which sums exactly and sorts fast. Convert at the edges: an amount that reaches the API becomes an integer before it's stored, and an integer read back becomes a decimal string before it's shown. The conversions never round, so an amount with more decimals than the unit holds is rejected instead of losing a fraction of a cent:
```ts
import { fromScaledInteger, prettifyValue, toScaledInteger } from 'jg-bignumber';
// microcents: a dollar is 100,000,000 of them
const MICROCENTS = 8;
// adds a credit to an account's balance, such as with a SQL query on a BIGINT column, which
// node-postgres returns as a string
declare function addToBalance(accountId: string, microcents: bigint): Promise;
export const creditAccount = async (accountId: string, dollars: string): Promise => {
// throws INVALID_VALUE for '1e-8', '0x10', or '0.123456789', so the request fails instead of
// storing a different amount
const balance = await addToBalance(accountId, toScaledInteger(dollars, MICROCENTS));
// the exact amount, such as '12.50000000', formatted as '$12.50' for people
return prettifyValue(fromScaledInteger(balance, MICROCENTS), {
format: { prefix: '$', minimumDecimalPlaces: 2 },
});
};
```
Pass amounts as strings, as a form or an API sends them. A number works while JavaScript writes it as a plain decimal, but `0.00000001` is written `1e-8`, and `0.1 + 0.2` is `0.30000000000000004`, so both are rejected. `fromScaledInteger` keeps every decimal, so its result can go back through `toScaledInteger` unchanged, and `prettifyValue` rounds it only for display.
## A percentage change for a dashboard
Use this for a metric tile that compares a period with the one before, such as this month's revenue against last month's. `calculatePercentageChange` returns a signed percentage, and `prettifyValue` formats it with a sign and a suffix:
```ts
import {
calculatePercentageChange,
getBigNumber,
prettifyValue,
type IBigNumberValue,
} from 'jg-bignumber';
// how a metric moved, which picks the tile's arrow and color
type ITrend = 'up' | 'down' | 'flat';
type IChange = { label: string; trend: ITrend };
// the reader's revenue for the last two months, and the tile's change label
declare const revenue: { previous: string; current: string };
declare const changeLabel: HTMLElement;
// describes how a metric changed, or returns null when the previous value can't be the base
const describeChange = (previous: IBigNumberValue, current: IBigNumberValue): IChange | null => {
// a change from 0, or from a negative value, has no meaningful percentage, and
// calculatePercentageChange throws NEGATIVE_VALUE_NOT_ALLOWED for one
if (!getBigNumber(previous).isGreaterThan(0)) {
return null;
}
// rounded to one decimal before the trend is picked, so the arrow always agrees with the label
const change = calculatePercentageChange(previous, current, { decimalPlaces: 1 });
let trend: ITrend = 'flat';
if (change > 0) {
trend = 'up';
} else if (change < 0) {
trend = 'down';
}
// only an increase gets a plus sign, so an unchanged metric reads 0%, not +0%
const label = prettifyValue(change, {
format: { positiveSign: trend === 'up' ? '+' : '', suffix: '%' },
});
return { label, trend };
};
const change = describeChange(revenue.previous, revenue.current);
// revenue that starts from 0 has no percentage, so the tile says it's new
changeLabel.textContent = change?.label ?? 'New';
changeLabel.dataset['trend'] = change?.trend ?? 'flat';
```
A change too small to show, such as 0.04%, rounds to 0 and reads as flat, never as an arrow next to 0%. The largest decrease is -100%, which `calculatePercentageChange` returns whenever the current value is 0 or less. `prettifyValue` groups thousands, as in `+1,234.6%`, and drops trailing zeros, so 12% and 12.5% can sit side by side.
---
# jg-fetch
> A fetch wrapper that never throws: timeouts, retries that honour Retry-After, response size caps, and API envelopes.
- **Version:** `0.3.0`
- **Runtimes:** Universal
- **Install:** `npm install jg-fetch`
- **On npm:** [jg-fetch](https://www.npmjs.com/package/jg-fetch)
- **Source:** [packages/jg-fetch](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-fetch)
- **Recipes:** [jg-fetch recipes](https://jg-packages.jesusgraterol.dev/packages/jg-fetch/recipes/index.md)
## Usage
`request` resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, and never throws:
```ts
import { request } from 'jg-fetch';
const { data, error, meta } = await request('https://api.example.com/users/1');
if (error) {
error.kind; // what failed, such as 'TIMEOUT', 'NETWORK_ERROR', or 'HTTP_ERROR'
error.status; // 404, or null when no response arrived
} else {
data; // IUser
}
meta.attempts; // 1
```
Read a jg-api-response envelope, validate its data, and send a fresh token on every attempt:
```ts
import { request } from 'jg-fetch';
import { z } from 'zod';
const User = z.object({ id: z.number(), name: z.string() });
const { data, error } = await request('/users/me', {
baseUrl: 'https://api.example.com',
parseAs: 'envelope', // resolves to the envelope's data
schema: User, // data is { id: number; name: string }
headers: async () => ({ authorization: `Bearer ${await getIdToken()}` }),
});
if (error?.kind === 'API_ERROR') {
error.code; // the envelope error's code, such as 'USER_NOT_FOUND'
error.message; // the envelope error's message
error.data; // the envelope error's data, or null without any
error.body; // the whole envelope
}
```
When a credential can expire, such as an ID token, refresh it and repeat the attempt at once, without using up a retry:
```ts
await request('/users/me', {
baseUrl,
headers: async () => ({ authorization: `Bearer ${await getIdToken()}` }),
// returning true repeats the attempt; once per call unless maxCredentialRefreshes says more
refreshCredentials: async ({ status, signal }) => {
if (status !== 401) {
return false;
}
await refreshIdToken(signal);
return true;
},
});
```
Send JSON, query parameters, or any other body:
```ts
await request('/orders', {
baseUrl,
method: 'POST',
body: { items: [1, 2] }, // sent as JSON, with Content-Type: application/json
query: { notify: true, tags: ['a', 'b'] }, // ?notify=true&tags=a&tags=b
});
await request('/avatar', { baseUrl, method: 'PUT', body: formData }); // the browser sets the multipart type
```
A string, `Blob`, `ArrayBuffer` or view of one, `FormData`, `URLSearchParams`, or `ReadableStream` is sent as it is. Any other body, such as a `Map`, a `Date`, or a number, fails with `INVALID_BODY` before the request is sent, because `fetch` would turn it into text such as `[object Map]`.
Read other kinds of responses with `parseAs`: `'json'` (the default), `'envelope'`, `'text'`, `'bytes'`, `'blob'`, `'response'` for the unread `Response`, or `'none'`. The data's type follows it.
### Timeouts and retries
| Option | Default | What it does |
| ------------------------ | ---------------------------------------- | ---------------------------------------------------------------------------------- |
| `timeoutSeconds` | `15` | Limits the whole call, retries and waits included. |
| `attemptTimeoutSeconds` | none | Limits each attempt; an attempt that runs out retries. |
| `retryDelaysSeconds` | `[3, 5]` | The waits before each retry. `[]` turns retries off. |
| `retryMethods` | `GET`, `HEAD`, `OPTIONS` | The methods that may retry. Add `POST` only for requests that are safe to repeat. |
| `retryStatusCodes` | `408`, `429`, `500`, `502`, `503`, `504` | The statuses that may retry. `400`–`405`, `409`, `410`, `413`, and `422` never do. |
| `nonRetryableErrorCodes` | none | API error codes that never retry. |
| `maxRetryAfterSeconds` | `30` | The longest `Retry-After` a retry waits for. |
| `maxResponseBytes` | 10 MiB | The largest body the buffered modes read. |
Network errors, attempt timeouts, and retryable statuses retry. A `429` retries only with `Retry-After`, and every retry waits the longer of its delay and `Retry-After`. When `Retry-After` asks for more than `maxRetryAfterSeconds`, or more than the time left, the call stops and the error carries `retryAfterMs`. A stream body never retries.
Pass `signal` to cancel: its abort reports `ABORTED`, or `TIMEOUT` when its reason is a `TimeoutError`, such as `AbortSignal.timeout(5000)`'s.
### Errors
The error is a `FetchException`, an `Exception` from `jg-exception`:
| Field | Holds |
| ----------------------------------------- | -------------------------------------------------------------------------------------------- |
| `kind` | What failed, one of `ERRORS` |
| `code`, `message` | The envelope error's code and message for an `API_ERROR`, otherwise the kind and its message |
| `status`, `headers`, `body` | The response's status, headers, and body, parsed as JSON when it is JSON |
| `data` | The envelope error's data for an `API_ERROR`, `null` when it has none; otherwise undefined |
| `retryAfterMs` | The wait `Retry-After` asked for |
| `method`, `url`, `attempts`, `durationMs` | The request, without its query string or fragment |
| `cause` | The original error |
Messages never include the URL or the body, and `url` and `meta.url` drop the query string and fragment, where tokens such as `?key=` sit.
### Reading headers across origins
A browser lets the page read only a few response headers from another origin. To read `Retry-After`, a `cf-ray`, or any custom header, the API must list them in `Access-Control-Expose-Headers`. Otherwise `headers.get()` returns `null`, and a `429` never retries, because its `Retry-After` can't be read.
## jg-fetch API reference
Every export of `jg-fetch@0.3.0`, 26 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc.
- Functions: [`parseRetryAfterMs`](#api-parseRetryAfterMs), [`request`](#api-request)
- Classes: [`FetchException`](#api-FetchException)
- Constants: [`DEFAULT_MAX_RESPONSE_BYTES`](#api-DEFAULT_MAX_RESPONSE_BYTES), [`DEFAULT_MAX_RETRY_AFTER_SECONDS`](#api-DEFAULT_MAX_RETRY_AFTER_SECONDS), [`DEFAULT_RETRY_DELAYS_SECONDS`](#api-DEFAULT_RETRY_DELAYS_SECONDS), [`DEFAULT_RETRY_METHODS`](#api-DEFAULT_RETRY_METHODS), [`DEFAULT_RETRY_STATUS_CODES`](#api-DEFAULT_RETRY_STATUS_CODES), [`DEFAULT_TIMEOUT_SECONDS`](#api-DEFAULT_TIMEOUT_SECONDS), [`ERRORS`](#api-ERRORS), [`MAX_TIMEOUT_SECONDS`](#api-MAX_TIMEOUT_SECONDS), [`NON_RETRYABLE_STATUS_CODES`](#api-NON_RETRYABLE_STATUS_CODES)
- Types: [`IAttemptContext`](#api-IAttemptContext), [`ICredentialFailure`](#api-ICredentialFailure), [`IFetchErrorKind`](#api-IFetchErrorKind), [`IFetchExceptionInit`](#api-IFetchExceptionInit), [`IHeadersInit`](#api-IHeadersInit), [`IHttpMethod`](#api-IHttpMethod), [`IParseAs`](#api-IParseAs), [`IQueryParams`](#api-IQueryParams), [`IQueryScalar`](#api-IQueryScalar), [`IRequestOptions`](#api-IRequestOptions), [`IRequestResult`](#api-IRequestResult), [`IResponseMeta`](#api-IResponseMeta), [`IRetryContext`](#api-IRetryContext), [`ISchema`](#api-ISchema)
### Functions
#### `parseRetryAfterMs`
*Function*
Parses a Retry-After header into the milliseconds to wait. It accepts a delay in seconds, such as `120`, or an HTTP date, such as `Wed, 21 Oct 2015 07:28:00 GMT`, whose wait is counted from nowMs. A date in the past waits 0, and a wait longer than MAX_TIMEOUT_SECONDS is clamped to it. For a header with several values, such as a WebSocket handshake's, the first one counts.
```ts
parseRetryAfterMs(
value: string | readonly string[] | null | undefined,
nowMs?: number,
): number | null
```
**Parameters**
- `value` `string | readonly string[] | null | undefined`: The header's value, or its values.
- `nowMs?` `number`: The current time, in milliseconds since the epoch. Defaults to Date.now(). Defaults to `...`.
**Returns** `number | null`: The milliseconds to wait, or null when the value is missing or invalid.
**Example**
```ts
import { parseRetryAfterMs } from 'jg-fetch';
parseRetryAfterMs('120'); // 120000
parseRetryAfterMs('Wed, 21 Oct 2015 07:28:30 GMT', Date.parse('Wed, 21 Oct 2015 07:28:00 GMT')); // 30000
parseRetryAfterMs('soon'); // null
```
#### `request`
*Function*
Sends an HTTP request and resolves to its data or its error, never throwing. It retries network errors, attempt timeouts, and retryable statuses for the methods that may retry, waiting retryDelaysSeconds between attempts, or longer when the server sends Retry-After, all within timeoutSeconds. Each attempt sends a fresh Request. With parseAs 'envelope', it reads a jg-api-response envelope and resolves to its data, or to API_ERROR with the API's own code. With refreshCredentials, an attempt that fails with a response, such as a 401, can refresh the credentials and repeat at once, up to maxCredentialRefreshes times, without using up a retry.
```ts
request(
url: string | URL,
options: IRequestOptions & { parseAs: "text" },
): Promise>
```
**Parameters**
- `url` `string | URL`: The URL, absolute or relative to baseUrl.
- `options` `IRequestOptions & { parseAs: "text" }`: The method, query, headers, body, how to read the response, and the timeouts and retries.
**Returns** `Promise>`: A promise that resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, where error is a FetchException.
```ts
request(
url: string | URL,
options: IRequestOptions> & { parseAs: "bytes" },
): Promise>>
```
**Parameters**
- `url` `string | URL`: The URL, absolute or relative to baseUrl.
- `options` `IRequestOptions> & { parseAs: "bytes" }`: The method, query, headers, body, how to read the response, and the timeouts and retries.
**Returns** `Promise>>`: A promise that resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, where error is a FetchException.
```ts
request(
url: string | URL,
options: IRequestOptions & { parseAs: "blob" },
): Promise>
```
**Parameters**
- `url` `string | URL`: The URL, absolute or relative to baseUrl.
- `options` `IRequestOptions & { parseAs: "blob" }`: The method, query, headers, body, how to read the response, and the timeouts and retries.
**Returns** `Promise>`: A promise that resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, where error is a FetchException.
```ts
request(
url: string | URL,
options: IRequestOptions & { parseAs: "response" },
): Promise>
```
**Parameters**
- `url` `string | URL`: The URL, absolute or relative to baseUrl.
- `options` `IRequestOptions & { parseAs: "response" }`: The method, query, headers, body, how to read the response, and the timeouts and retries.
**Returns** `Promise>`: A promise that resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, where error is a FetchException.
```ts
request(
url: string | URL,
options: IRequestOptions & { parseAs: "none" },
): Promise>
```
**Parameters**
- `url` `string | URL`: The URL, absolute or relative to baseUrl.
- `options` `IRequestOptions & { parseAs: "none" }`: The method, query, headers, body, how to read the response, and the timeouts and retries.
**Returns** `Promise>`: A promise that resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, where error is a FetchException.
```ts
request(
url: string | URL,
options?: IRequestOptions,
): Promise>
```
**Parameters**
- `url` `string | URL`: The URL, absolute or relative to baseUrl.
- `options?` `IRequestOptions`: The method, query, headers, body, how to read the response, and the timeouts and retries.
**Returns** `Promise>`: A promise that resolves to `{ data, error: null, meta }` or `{ data: null, error, meta }`, where error is a FetchException.
**Example 1**
```ts
import { request } from 'jg-fetch';
const { data, error } = await request('/users/1', {
baseUrl: 'https://api.example.com',
parseAs: 'envelope',
headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
});
if (error) {
error.kind; // such as 'API_ERROR' or 'TIMEOUT'
}
```
**Example 2**
```ts
import { request } from 'jg-fetch';
const { data: html } = await request('https://example.com', { parseAs: 'text' }); // string | null
```
### Classes
#### `FetchException`
*Class*
The error request returns when a call fails, never throws. kind says what failed, one of the ERRORS codes. For an API error, code, message, and data are the envelope error's; otherwise code is the kind. Its message never includes the URL or the body.
**Members**
- `code: string` (property)
- `data: IJsonValue | undefined` (property)
- `severity: "expected" | "critical"` (property)
- `shouldLog: boolean` (property)
- `toJSON(): IExceptionJson` (method): Converts the exception to a plain object, which `JSON.stringify` calls.
- `toString(): string` (method): Describes the exception in one line, with its code.
- `kind: "INVALID_URL" | "INVALID_OPTIONS" | "INVALID_HEADERS" | "INVALID_BODY" | "NETWORK_ERROR" | "TIMEOUT" | "ABORTED" | "HTTP_ERROR" | "API_ERROR" | "PARSE_ERROR" | "ENVELOPE_INVALID" | "RESPONSE_INVALID" | "RESPONSE_TOO_LARGE" | "CREDENTIALS_REFRESH_FAILED"` (property): what failed, one of the ERRORS codes
- `status: number | null` (property): the HTTP status, or null when no response arrived
- `headers: Headers | null` (property): the response's headers, or null when no response arrived
- `body: unknown` (property): the response body: parsed JSON when it is JSON, otherwise its text
- `retryAfterMs: number | null` (property): the wait the server asked for with Retry-After, in milliseconds, or null without one
- `method: IHttpMethod` (property): the request's method
- `url: string` (property): the request's URL, without its query string or fragment
- `attempts: number` (property): the number of attempts made
- `durationMs: number` (property): the milliseconds the whole call took
- `new FetchException(kind: "INVALID_URL" | "INVALID_OPTIONS" | "INVALID_HEADERS" | "INVALID_BODY" | "NETWORK_ERROR" | "TIMEOUT" | "ABORTED" | "HTTP_ERROR" | "API_ERROR" | "PARSE_ERROR" | "ENVELOPE_INVALID" | "RESPONSE_INVALID" | "RESPONSE_TOO_LARGE" | "CREDENTIALS_REFRESH_FAILED", init: IFetchExceptionInit)` (constructor)
**Example**
```ts
import { request } from 'jg-fetch';
const { data, error } = await request('/users/1', { baseUrl, parseAs: 'envelope' });
if (error) {
error.kind; // 'API_ERROR'
error.code; // 'USER_NOT_FOUND', the envelope error's code
error.data; // { id: 1 }, the envelope error's data
error.status; // 404
}
```
### Constants
#### `DEFAULT_MAX_RESPONSE_BYTES`
*Constant*
the largest response body the buffered parseAs modes read, in bytes: 10 MiB
```ts
const DEFAULT_MAX_RESPONSE_BYTES: 10485760
```
#### `DEFAULT_MAX_RETRY_AFTER_SECONDS`
*Constant*
the longest Retry-After wait a retry accepts, in seconds, unless maxRetryAfterSeconds says otherwise
```ts
const DEFAULT_MAX_RETRY_AFTER_SECONDS: 30
```
#### `DEFAULT_RETRY_DELAYS_SECONDS`
*Constant*
the waits before each retry, in seconds: two retries, after 3 and then 5 seconds
```ts
const DEFAULT_RETRY_DELAYS_SECONDS: readonly number[]
```
#### `DEFAULT_RETRY_METHODS`
*Constant*
the methods that retry by default, which are safe to repeat
```ts
const DEFAULT_RETRY_METHODS: readonly IHttpMethod[]
```
#### `DEFAULT_RETRY_STATUS_CODES`
*Constant*
the statuses that retry by default, because a later attempt can succeed
```ts
const DEFAULT_RETRY_STATUS_CODES: readonly number[]
```
#### `DEFAULT_TIMEOUT_SECONDS`
*Constant*
the seconds a whole call may take, retries and waits included, unless timeoutSeconds says otherwise
```ts
const DEFAULT_TIMEOUT_SECONDS: 15
```
#### `ERRORS`
*Constant*
the codes for every error request reports, which FetchException's kind holds
```ts
const ERRORS: { INVALID_URL: "INVALID_URL"; INVALID_OPTIONS: "INVALID_OPTIONS"; INVALID_HEADERS: "INVALID_HEADERS"; INVALID_BODY: "INVALID_BODY"; NETWORK_ERROR: "NETWORK_ERROR"; TIMEOUT: "TIMEOUT"; ABORTED: "ABORTED"; HTTP_ERROR: "HTTP_ERROR"; API_ERROR: "API_ERROR"; PARSE_ERROR: "PARSE_ERROR"; ENVELOPE_INVALID: "ENVELOPE_INVALID"; RESPONSE_INVALID: "RESPONSE_INVALID"; RESPONSE_TOO_LARGE: "RESPONSE_TOO_LARGE"; CREDENTIALS_REFRESH_FAILED: "CREDENTIALS_REFRESH_FAILED" }
```
#### `MAX_TIMEOUT_SECONDS`
*Constant*
the longest timeout a timer can hold, in seconds, which is 2^31 - 1 milliseconds
```ts
const MAX_TIMEOUT_SECONDS: 2147483
```
#### `NON_RETRYABLE_STATUS_CODES`
*Constant*
the statuses that never retry, even when retryStatusCodes lists them
```ts
const NON_RETRYABLE_STATUS_CODES: readonly number[]
```
### Types
#### `IAttemptContext`
*Type*
what a headers function receives on each attempt
```ts
type IAttemptContext = {
attempt: number;
signal: AbortSignal;
};
```
**Members**
- `attempt: number` (property): the attempt's number, starting at 1
- `signal: AbortSignal` (property): aborts when the attempt is cancelled, for work such as refreshing a token
#### `ICredentialFailure`
*Type*
what refreshCredentials receives when an attempt fails with a response
```ts
type ICredentialFailure = {
status: number;
code: string;
refreshes: number;
signal: AbortSignal;
};
```
**Members**
- `status: number` (property): the response's HTTP status, such as 401
- `code: string` (property): the error's code: the envelope error's own code, or HTTP_ERROR
- `refreshes: number` (property): the refreshes this call already made
- `signal: AbortSignal` (property): aborts when the call is cancelled, by its timeout or your signal, for the refresh's own work
#### `IFetchErrorKind`
*Type*
the kind of a failed request, one of the ERRORS codes
```ts
type IFetchErrorKind = keyof typeof ERRORS;
```
#### `IFetchExceptionInit`
*Type*
what a FetchException carries besides its kind
```ts
type IFetchExceptionInit = {
message?: string;
code?: string;
data?: IJsonValue;
status?: number | null;
headers?: Headers | null;
body?: unknown;
retryAfterMs?: number | null;
method: IHttpMethod;
url: string;
attempts: number;
durationMs: number;
cause?: unknown;
};
```
**Members**
- `message: string` (property): the message. Defaults to the kind's message
- `code: string` (property): the code. Defaults to the kind; an API error's own code takes its place
- `data: IJsonValue` (property): the envelope error's data, for an API error
- `status: number | null` (property): the HTTP status, or null when no response arrived
- `headers: Headers | null` (property): the response's headers, or null when no response arrived
- `body: unknown` (property): the response body: parsed JSON when it is JSON, otherwise its text
- `retryAfterMs: number | null` (property): the wait the server asked for with Retry-After, in milliseconds
- `method: IHttpMethod` (property): the request's method
- `url: string` (property): the request's URL, without its query string or fragment
- `attempts: number` (property): the number of attempts made
- `durationMs: number` (property): the milliseconds the whole call took
- `cause: unknown` (property): the original error, such as a TypeError from fetch or a schema's error
#### `IHeadersInit`
*Type*
the headers the Headers constructor accepts: Headers, [name, value] pairs, or a record
```ts
type IHeadersInit = ConstructorParameters[0];
```
#### `IHttpMethod`
*Type*
the HTTP methods request sends
```ts
type IHttpMethod = 'GET' | 'HEAD' | 'OPTIONS' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
```
#### `IParseAs`
*Type*
how request reads a successful response: - envelope: a jg-api-response envelope, resolving to its data - json: parsed JSON - text: a string - bytes: a Uint8Array - blob: a Blob - response: the Response itself, unread - none: nothing, the body is discarded
```ts
type IParseAs = 'envelope' | 'json' | 'text' | 'bytes' | 'blob' | 'response' | 'none';
```
#### `IQueryParams`
*Type*
the query parameters appended to the URL. An array repeats the key once per item
```ts
type IQueryParams = Record;
```
#### `IQueryScalar`
*Type*
a query parameter's value. null, undefined, and empty strings are left out
```ts
type IQueryScalar = string | number | boolean | bigint | null | undefined;
```
#### `IRequestOptions`
*Type*
the options request accepts
```ts
type IRequestOptions = {
method?: IHttpMethod;
baseUrl?: string | URL;
query?: IQueryParams;
headers?: IHeadersInit | ((context: IAttemptContext) => IHeadersInit | Promise);
body?: RequestInit['body'] | Record | readonly unknown[];
parseAs?: IParseAs;
schema?: ISchema;
signal?: AbortSignal;
timeoutSeconds?: number;
attemptTimeoutSeconds?: number;
retryDelaysSeconds?: readonly number[];
retryMethods?: readonly IHttpMethod[];
retryStatusCodes?: readonly number[];
nonRetryableErrorCodes?: readonly string[];
maxRetryAfterSeconds?: number;
maxResponseBytes?: number;
init?: Omit;
onRetry?: (context: IRetryContext) => void;
refreshCredentials?: (failure: ICredentialFailure) => boolean | Promise;
maxCredentialRefreshes?: number;
};
```
**Members**
- `method: IHttpMethod` (property): the HTTP method. Defaults to 'GET'
- `baseUrl: string | URL` (property): the URL a relative url resolves against. A URL that resolves to another origin fails with INVALID_URL
- `query: IQueryParams` (property): the query parameters appended to the URL
- `headers: IHeadersInit | ((context: IAttemptContext) => IHeadersInit | Promise)` (property): the headers, or a function that builds them for each attempt, such as to add a fresh token
- `body: RequestInit["body"] | Record | readonly unknown[]` (property): the body. Plain objects and arrays are sent as JSON, with Content-Type: application/json unless the headers set one. A string, Blob, ArrayBuffer or view of one, FormData, URLSearchParams, or ReadableStream is sent as it is. Any other value, such as a Map, a Date, or a number, is an INVALID_BODY error, sent nowhere
- `parseAs: IParseAs` (property): how a successful response is read. Defaults to 'json'
- `schema: ISchema` (property): validates the parsed data, or the envelope's data, such as a Zod schema
- `signal: AbortSignal` (property): aborts the call. An abort whose reason is a TimeoutError, such as AbortSignal.timeout's, reports TIMEOUT, and any other ABORTED
- `timeoutSeconds: number` (property): the seconds the whole call may take, retries and waits included. Defaults to DEFAULT_TIMEOUT_SECONDS
- `attemptTimeoutSeconds: number` (property): the seconds each attempt may take. An attempt that runs out retries. No limit by default
- `retryDelaysSeconds: readonly number[]` (property): the waits before each retry, in seconds; one retry per item. Defaults to DEFAULT_RETRY_DELAYS_SECONDS, and [] turns retries off
- `retryMethods: readonly IHttpMethod[]` (property): the methods that may retry. Defaults to DEFAULT_RETRY_METHODS
- `retryStatusCodes: readonly number[]` (property): the statuses that may retry. Defaults to DEFAULT_RETRY_STATUS_CODES, and NON_RETRYABLE_STATUS_CODES never retry
- `nonRetryableErrorCodes: readonly string[]` (property): the API error codes, from an envelope's error, that never retry
- `maxRetryAfterSeconds: number` (property): the longest Retry-After wait a retry accepts, in seconds. Defaults to DEFAULT_MAX_RETRY_AFTER_SECONDS
- `maxResponseBytes: number` (property): the largest body the buffered parseAs modes read, counted as it streams. Defaults to DEFAULT_MAX_RESPONSE_BYTES
- `init: Omit` (property): the other fetch options, such as credentials, cache, or redirect
- `onRetry: (context: IRetryContext) => void` (property): called before each retry. Whatever it throws is ignored, so request still never throws
- `refreshCredentials: (failure: ICredentialFailure) => boolean | Promise` (property): called when an attempt fails with a response, such as a 401, to refresh the credentials the headers function reads. Returning true repeats the attempt at once, for any method; false leaves the failure to the retry rules. Return true only for a credential failure, because the server didn't process the request then. A function that throws ends the call with CREDENTIALS_REFRESH_FAILED. Never called for a streamed body, which can be sent once
- `maxCredentialRefreshes: number` (property): the most refreshes a call makes, such as 2 for two kinds of credential. Defaults to 1
#### `IRequestResult`
*Type*
what request resolves to: the data and no error, or no data and the error
```ts
type IRequestResult = {
data: T;
error: null;
meta: IResponseMeta;
} | {
data: null;
error: FetchException;
meta: IResponseMeta;
};
```
#### `IResponseMeta`
*Type*
what request knows about the last response, successful or not
```ts
type IResponseMeta = {
status: number | null;
headers: Headers | null;
url: string;
attempts: number;
durationMs: number;
};
```
**Members**
- `status: number | null` (property): the HTTP status, or null when no response arrived
- `headers: Headers | null` (property): the response's headers, or null when no response arrived
- `url: string` (property): the URL, without its query string or fragment, which can hold tokens
- `attempts: number` (property): the number of attempts made
- `durationMs: number` (property): the milliseconds the whole call took, retries and waits included
#### `IRetryContext`
*Type*
what onRetry receives before each retry
```ts
type IRetryContext = {
attempt: number;
error: FetchException;
delayMs: number;
};
```
**Members**
- `attempt: number` (property): the attempt that failed, starting at 1
- `error: FetchException` (property): why it failed
- `delayMs: number` (property): the milliseconds request waits before the next attempt
#### `ISchema`
*Type*
validates parsed data and returns it typed, which a Zod schema does
```ts
type ISchema = {
parse(value: unknown): T;
};
```
**Members**
- `parse(value: unknown): T` (method)
---
# jg-fetch recipes
Patterns for apps that call HTTP APIs with jg-fetch: a typed client for an envelope API, auth headers that stay fresh, and retries that never repeat an unsafe request.
## A typed client for an envelope API
When your app calls an API that answers with jg-api-response envelopes, keep the base URL, the envelope parsing, and the auth header in one function, and give each endpoint its own schema. Every call then resolves to data its schema checked, or to a `FetchException` whose `code` is the API's own.
```ts
import { request, type IRequestOptions, type IRequestResult, type ISchema } from 'jg-fetch';
// a Zod schema, or any validator with parse
declare const UserSchema: { parse(value: unknown): IUser };
// what an endpoint may set; the client sets the rest
type ICallOptions = Omit, 'baseUrl' | 'parseAs' | 'schema' | 'headers'>;
// every call reads an envelope, checks its data with the endpoint's schema, and sends a token
// read before each attempt
const callApi = (
path: string,
schema: ISchema,
options: ICallOptions = {},
): Promise> =>
request(path, {
...options,
baseUrl,
parseAs: 'envelope',
schema,
headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
});
export const getUser = (userId: number): Promise> =>
callApi(`/users/${userId}`, UserSchema);
export const renameUser = (userId: number, name: string): Promise> =>
callApi(`/users/${userId}`, UserSchema, { method: 'PATCH', body: { name } });
```
The schema runs on the envelope's `data`. When the API's answer doesn't match it, the call fails with `RESPONSE_INVALID` and the schema's error as `cause`, so a changed API shows up as an error here instead of as data of the wrong shape further on.
Handle failures where you know what each one means. A `FetchException`'s `kind` says what failed, one of `ERRORS`. For an `API_ERROR`, its `code` is the one in the envelope, such as `USER_NOT_FOUND`, which `hasErrorCode` checks; for every other kind, the code is the kind itself.
```ts
import { hasErrorCode } from 'jg-exception';
import { ERRORS, type IRequestResult } from 'jg-fetch';
// the client's getUser, from the block above
declare function getUser(userId: number): Promise>;
// sends an error to your error tracker
declare function trackError(error: unknown): void;
// what the profile page shows
type IProfileState =
| { status: 'ready'; user: IUser }
| { status: 'not-found' }
| { status: 'unreachable' }
| { status: 'failed' };
const loadProfile = async (userId: number): Promise => {
const { data, error } = await getUser(userId);
if (error === null) {
return { status: 'ready', user: data };
}
// the code the API sent in the envelope
if (hasErrorCode(error, 'USER_NOT_FOUND')) {
return { status: 'not-found' };
}
// the API couldn't be reached or didn't answer in time, even after the retries, so trying
// again later may work
if (error.kind === ERRORS.NETWORK_ERROR || error.kind === ERRORS.TIMEOUT) {
return { status: 'unreachable' };
}
// anything else is a bug on one side, such as RESPONSE_INVALID when the API's data stopped
// matching the schema
trackError(error);
return { status: 'failed' };
};
```
When the caller has to throw instead, such as on a server, pass the `FetchException` as the `cause` of your own error: `new Exception("Couldn't load the profile.", { code: 'PROFILE_UNAVAILABLE', cause: error })`. Your message says what failed at your step, and the cause keeps the request's kind, status, and attempts. A `FetchException`'s message never includes the URL or the body.
## A fresh auth header on every request
Pass `headers` as a function when a header holds a token that expires. `request` calls it before every attempt, retries included, so each attempt sends the token as it is when that attempt starts.
```ts
import { request } from 'jg-fetch';
// the token in an object is read once, before the call: a retry seconds later sends the same
// token, even if it expired in between
await request('/reports', {
baseUrl,
headers: { authorization: `Bearer ${await getToken()}` },
});
// a function runs before every attempt
await request('/reports', {
baseUrl,
headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
});
```
The function receives an `IAttemptContext`: the attempt's number, from 1, and a `signal` that aborts when the attempt is cancelled, by its own timeout, the call's, or your `signal`. `request` stops waiting for the function when that signal aborts, so a token call that hangs ends the attempt like a slow response: as an attempt timeout, which can retry, when `attemptTimeoutSeconds` runs out, or as the call's `TIMEOUT` when `timeoutSeconds` does. A function that throws or rejects ends the call with `INVALID_HEADERS` and its error as `cause`, without a retry, which usually means the user has to sign in again.
A token can also stop working before it expires, such as when it's revoked. `request` never retries a `401` on its own, because the same token would fail again. Pass `refreshCredentials`, which `request` calls when an attempt fails with a response: refresh the token and return `true`, and the attempt is repeated at once, with `headers` called again. Return `false` for any other failure, which the retry rules then handle:
```ts
import { request, type ICredentialFailure } from 'jg-fetch';
// gets a new access token from the auth server, which getToken returns from then on; the signal
// aborts when the call is cancelled
declare function refreshToken(signal: AbortSignal): Promise;
// refreshes the token only for a credential failure: the server didn't process the request then,
// so repeating it is safe, even for a POST
const refreshOnUnauthorized = async ({ status, signal }: ICredentialFailure): Promise => {
if (status !== 401) {
return false;
}
await refreshToken(signal);
return true;
};
const { data, error } = await request('/reports', {
baseUrl,
method: 'POST',
body: { range: 'last-30-days' },
headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
refreshCredentials: refreshOnUnauthorized,
});
```
A call refreshes once by default, so a second `401` means the session is over: send the user to sign in. Set `maxCredentialRefreshes` when more than one credential can expire, such as an App Check token and an ID token, and tell them apart by `code`, the envelope error's code with `parseAs: 'envelope'`. A refresh that throws ends the call with `CREDENTIALS_REFRESH_FAILED`, its error as `cause`, and the response's status. A refresh doesn't use up a retry, and the call's `timeoutSeconds` covers it, so a refresh that hangs ends as `TIMEOUT`. A streamed body is never repeated, so its failure goes straight to the retry rules. When several calls can get a `401` at once, have `refreshToken` share one refresh between them, and don't let one call's signal cancel a refresh the others are waiting on.
## Retrying only what's safe to retry
A retry sends the request again, so it's safe only when a second copy can't do something twice. `request` retries `GET`, `HEAD`, and `OPTIONS` by default, after network errors, attempt timeouts, and the statuses in `DEFAULT_RETRY_STATUS_CODES`. Tune what a read retries on:
```ts
import { request } from 'jg-fetch';
// a GET retries network errors, attempts that time out, and 408, 429 with Retry-After, 500,
// 502, 503, and 504, waiting 3 and then 5 seconds, within timeoutSeconds
const users = await request('/users', {
baseUrl,
parseAs: 'envelope',
// a hung attempt ends after 4 seconds and retries, instead of using up the call's 15
attemptTimeoutSeconds: 4,
// the API answers 503 with this code during maintenance, which lasts longer than any retry waits
nonRetryableErrorCodes: ['MAINTENANCE'],
// a call that succeeds on a retry still leaves a trace of the failures before it
onRetry: ({ attempt, error, delayMs }) => {
console.warn(`Attempt ${attempt} failed with ${error.code}; retrying in ${delayMs} ms.`);
},
});
```
Some failures never retry, whatever the options say: the statuses in `NON_RETRYABLE_STATUS_CODES` (400 to 405, 409, 410, 413, and 422), an abort, the whole call's timeout, and a stream body, which can only be sent once. When a `Retry-After` asks for longer than `maxRetryAfterSeconds`, or than the time left, the call stops, and the error's `retryAfterMs` says how long the server asked you to wait.
A `POST` doesn't retry by default. When a network error cuts the connection, there's no telling whether the server created the order, and a retry could create a second one. Make it safe with an idempotency key, created once per order and sent on every attempt, then opt the request in:
```ts
import { request } from 'jg-fetch';
// what the checkout form sends
declare const order: { items: { productId: number; quantity: number }[] };
// one key per order, created before the first attempt, so every retry sends the same one
const idempotencyKey = crypto.randomUUID();
const { data, error } = await request<{ orderId: number }>('/orders', {
baseUrl,
method: 'POST',
body: order,
parseAs: 'envelope',
headers: async () => ({
authorization: `Bearer ${await getToken()}`,
'idempotency-key': idempotencyKey,
}),
// safe only because the API stores the key and answers a repeat with the first result
retryMethods: ['POST'],
});
```
The key protects you only if the API stores it and answers a repeated key with the first result instead of acting again; without that, leave `POST` out of `retryMethods`. Create the key when the checkout starts rather than on each submit, and a second click on the button is safe too. `PUT`, `PATCH`, and `DELETE` are also off the default list, because only your API knows whether repeating them is harmless.
---
# jg-storage
> Typed key-value stores over localStorage, sessionStorage, IndexedDB, and memory, for pages and workers alike.
- **Version:** `1.1.1`
- **Runtimes:** Browser
- **Install:** `npm install jg-storage`
- **On npm:** [jg-storage](https://www.npmjs.com/package/jg-storage)
- **Source:** [packages/jg-storage](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-storage)
- **Recipes:** [jg-storage recipes](https://jg-packages.jesusgraterol.dev/packages/jg-storage/recipes/index.md)
## Usage
Keep settings in localStorage, with a root record and records by ID:
```ts
import { createLocalStore } from 'jg-storage';
const settings = createLocalStore<{ theme: 'light' | 'dark' }>('settings');
settings.set(undefined, { theme: 'dark' }); // stored under the key 'jg-storage:settings'
settings.get(); // { theme: 'dark' }
settings.set('draft', { theme: 'light' }); // stored under the key 'jg-storage:settings:draft'
settings.del('draft');
```
Store ids hold only letters, digits, underscores, and hyphens; any other id throws `INVALID_STORE_ID`. Record ids become strings, so `1` and `'1'` address the same record.
Keep records in IndexedDB, from a page or a service worker. They live in the `records` object store of the `jg-storage` database:
```ts
import { createIndexedDbStore } from 'jg-storage';
const devices = createIndexedDbStore<{ token: string }>('devices');
await devices.set('phone', { token: 'abc' });
await devices.get('phone'); // { token: 'abc' }
```
Validate what you read, and fall back to memory where storage is blocked:
```ts
import { createSessionStore, ERRORS } from 'jg-storage';
import { hasErrorCode } from 'jg-exception';
import { z } from 'zod';
const Step = z.number().int().min(1);
const wizard = createSessionStore('wizard-step', {
fallback: 'memory', // without it, set and del throw STORE_UNAVAILABLE
schema: Step, // validates every stored value get reads; its errors propagate unchanged
});
wizard.set(undefined, 2);
wizard.get(); // 2
try {
createSessionStore('other').set(undefined, 1);
} catch (error) {
if (hasErrorCode(error, ERRORS.STORE_UNAVAILABLE)) {
// sessionStorage is unavailable in this context
}
}
```
Every store probes its mechanism on its first call, never when it's created, so creating stores at module scope is safe during server-side rendering.
## jg-storage API reference
Every export of `jg-storage@1.1.1`, 11 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc.
- Functions: [`createIndexedDbStore`](#api-createIndexedDbStore), [`createLocalStore`](#api-createLocalStore), [`createMemoryStore`](#api-createMemoryStore), [`createSessionStore`](#api-createSessionStore)
- Constants: [`ERRORS`](#api-ERRORS)
- Types: [`IAsyncStore`](#api-IAsyncStore), [`IRecordId`](#api-IRecordId), [`ISchema`](#api-ISchema), [`IStoreMechanism`](#api-IStoreMechanism), [`IStoreOptions`](#api-IStoreOptions), [`ISyncStore`](#api-ISyncStore)
### Functions
#### `createIndexedDbStore`
*Function*
Creates a store over IndexedDB, which works in pages and workers alike. Records live in the `records` object store of the `jg-storage` database, under `[id]` for the root record and `[id, recordId]` for the others. Values are stored with the structured clone algorithm, so dates, maps, and binary data keep their types. IndexedDB is read and probed on the first call, never when the store is created.
```ts
createIndexedDbStore(id: string, options?: IStoreOptions): IAsyncStore
```
**Parameters**
- `id` `string`: The store's identifier: letters, digits, underscores, and hyphens.
- `options?` `IStoreOptions`: fallback: 'memory' keeps records in memory when IndexedDB is unavailable, and schema validates every stored value get reads. Defaults to `{}`.
**Returns** `IAsyncStore`: The store.
**Throws**
- `INVALID_STORE_ID`: The store id must hold only letters, digits, underscores, and hyphens.
- `STORE_UNAVAILABLE`: set or del ran while IndexedDB is unavailable, without a fallback.
- Errors from IndexedDB itself, such as a value it can't clone, propagate.
**Example**
```ts
import { createIndexedDbStore } from 'jg-storage';
const devices = createIndexedDbStore<{ token: string }>('devices');
await devices.set('phone', { token: 'abc' }); // stored under the key ['devices', 'phone']
await devices.get('phone'); // { token: 'abc' }
await devices.del('phone');
```
#### `createLocalStore`
*Function*
Creates a store over localStorage, whose records outlive the page. Values are stored as JSON, under `jg-storage:` for the root record and `jg-storage::` for the others. localStorage is read and probed on the first call, never when the store is created.
```ts
createLocalStore(id: string, options?: IStoreOptions): ISyncStore
```
**Parameters**
- `id` `string`: The store's identifier: letters, digits, underscores, and hyphens.
- `options?` `IStoreOptions`: fallback: 'memory' keeps records in memory when localStorage is unavailable, and schema validates every stored value get reads. Defaults to `{}`.
**Returns** `ISyncStore`: The store.
**Throws**
- `INVALID_STORE_ID`: The store id must hold only letters, digits, underscores, and hyphens.
- `FAILED_TO_STRINGIFY_JSON`: set received a value JSON can't represent.
- `FAILED_TO_PARSE_JSON`: get read a value that isn't JSON.
- `STORE_UNAVAILABLE`: set or del ran while localStorage is unavailable, without a fallback.
- Errors from localStorage itself, such as a full quota, propagate.
**Example**
```ts
import { createLocalStore } from 'jg-storage';
const settings = createLocalStore<{ theme: 'light' | 'dark' }>('settings');
settings.set(undefined, { theme: 'dark' }); // stored under the key 'jg-storage:settings'
settings.get(); // { theme: 'dark' }
settings.set('draft', { theme: 'light' }); // stored under the key 'jg-storage:settings:draft'
```
#### `createMemoryStore`
*Function*
Creates a store that keeps its records in memory for as long as the store exists. Values are kept as they are, without serializing them, and the store is always available.
```ts
createMemoryStore(id: string, options?: IStoreOptions): ISyncStore
```
**Parameters**
- `id` `string`: The store's identifier: letters, digits, underscores, and hyphens.
- `options?` `IStoreOptions`: schema validates every stored value get reads. fallback has no effect, because memory is always available. Defaults to `{}`.
**Returns** `ISyncStore`: The store.
**Throws**
- `INVALID_STORE_ID`: The store id must hold only letters, digits, underscores, and hyphens.
**Example**
```ts
import { createMemoryStore } from 'jg-storage';
const drafts = createMemoryStore<{ text: string }>('drafts');
drafts.set('post-1', { text: 'Hello' });
drafts.get('post-1'); // { text: 'Hello' }
drafts.del('post-1');
drafts.get('post-1'); // undefined
```
#### `createSessionStore`
*Function*
Creates a store over sessionStorage, whose records last as long as the browser tab. Values are stored as JSON, under `jg-storage:` for the root record and `jg-storage::` for the others. sessionStorage is read and probed on the first call, never when the store is created.
```ts
createSessionStore(id: string, options?: IStoreOptions): ISyncStore