castage
v2.1.0
Published
A type-safe library for dynamic object casting and ensuring type consistency in JavaScript/TypeScript.
Maintainers
Readme
castage

Castage is a small TypeScript runtime casting library. You describe the shape of unknown data with composable casters, run those casters at runtime, and get typed values back through resultage Result objects.
It is useful at boundaries where TypeScript cannot protect you by itself: JSON payloads, API responses, config files, request parameters, message queues, local storage, and other unknown inputs.
Installation
npm install castageMain Features
- Runtime type checks with static inference: a
Caster<T>validates unknown input and narrows the successful result toT. - Composable schemas: build object, array, tuple, record, union, and intersection casters from smaller casters.
- Refinements: use
.validate(...)to add domain constraints such as positive numbers, non-blank strings, ranges, or custom predicates. - Transforms: use
.map(...)and.chain(...)to convert successfully cast values into another representation. - Optional/default/null handling: derive
.optional,.nullable, and.default(...)casters from any caster. - Structured errors: failed casts return
CastingErrorvalues with an error code, path, expected type, and received value. - Single-error or multi-error parsing: call a caster directly for fail-fast casting, or call
.parse(...)to collect nested errors where supported.
Quick Start
import { array, int, string, struct } from 'castage';
import { nonBlank, positive } from 'castage/validators';
const User = struct({
id: int.validate(positive),
name: string.validate(nonBlank),
roles: array(string),
});
const result = User({
id: 1,
name: 'Ada',
roles: ['admin', 'editor'],
});
if (result.isOk) {
result.value.id; // number
result.value.name; // string
result.value.roles; // string[]
}
if (result.isErr) {
console.error(result.error.path);
console.error(result.error.extra);
}A caster is just a function:
const result = int(42); // Result<number, CastingError>
const failed = string(42); // Result<string, CastingError>Modeling Data
Primitives
import { boolean, int, nill, number, string, undef, unknown } from 'castage';
int(1); // ok(1)
number(1.5); // ok(1.5)
string('text'); // ok('text')
boolean(false); // ok(false)
nill(null); // ok(null)
undef(undefined); // ok(undefined)
unknown({ anything: true }); // ok(...)Literal Values
import { value, values } from 'castage';
const Enabled = value('enabled');
const Status = values('draft', 'published', 'archived');Objects
Use struct(...) for fixed object shapes. Missing required fields and invalid nested values include the failing path in the returned error.
import { int, string, struct } from 'castage';
const User = struct({
id: int,
name: string,
});
const result = User({ id: 1, name: 'Alice' });Optional fields are expressed by deriving optional casters:
const UserPatch = struct({
name: string.optional,
age: int.optional,
});Arrays, Tuples, and Records
import {
array,
boolean,
int,
number,
record,
string,
text,
tuple,
unknown,
values,
} from 'castage';
const IntList = array(int);
const Point = tuple(number, number);
const Scores = record(string, int);
const FeatureFlags = record(values('search', 'billing'), boolean);
const Metadata = record(string, unknown); // Record<string, unknown>
const UnknownResponses = record(text.int, unknown);Use nonEmptyArray(caster) when the array must contain at least one item.
Unions and Intersections
Use oneOf(...) when several shapes are accepted, and allOf(...) when multiple object casters should all apply and merge.
import { allOf, int, oneOf, string, struct, values } from 'castage';
const Id = oneOf(int, string);
const Entity = allOf(
struct({ id: Id }),
struct({ kind: values('user', 'team') }),
);JSON and Text Casters
Use json helpers to parse JSON strings before applying a caster, and text helpers to parse primitive values from strings.
import { json, string, struct, text } from 'castage';
const JsonUser = json.struct({
name: string,
});
const parsedUser = JsonUser('{"name":"Alice"}');
const parsedInt = text.int('42');Environment Variables
Use the dedicated castage/envs entrypoint for environment-specific constraints and explicit configuration loading. See src/envs/README.md for the full API and examples.
PKI Values
Use castage/pki for PEM-encoded RSA keys and X.509 certificates. See src/pki/README.md for the full API and examples.
Deriving TypeScript Types
Use CastedBy<typeof caster> to derive the TypeScript type produced by a caster. This keeps the runtime schema and static type in one place, so you do not have to maintain a separate interface that can drift from the actual validation rules.
import { array, int, string, struct, type CastedBy } from 'castage';
import { nonBlank, positive } from 'castage/validators';
const User = struct({
id: int.validate(positive),
name: string.validate(nonBlank),
email: string.optional,
roles: array(string),
});
type User = CastedBy<typeof User>;
// {
// id: number;
// name: string;
// email?: string | undefined;
// roles: string[];
// }This also works with composed casters:
import { oneOf, string, value, type CastedBy } from 'castage';
const Command = oneOf(value('start'), value('stop'), string);
type Command = CastedBy<typeof Command>; // "start" | "stop" | stringOkType<typeof caster> is also exported for code that works directly with CasterFn values.
Refinements With Validators
.validate(predicate, name?) adds an extra rule after the base caster succeeds. The dedicated castage/validators entrypoint provides common reusable predicates; these helpers are intentionally not exported from the main castage entrypoint.
import { int, string } from 'castage';
import { and, between, max, nonBlank, positive } from 'castage/validators';
const PositiveInt = int.validate(positive);
const SizedInt = int.validate(between(1, 10));
const NonBlankString = string.validate(nonBlank);
const PositiveSmallInt = int.validate(and(positive, max(100)));Predicate operators:
and(...predicates): Ensures every predicate returnstrue.or(...predicates): Ensures at least one predicate returnstrue.not(predicate): Ensures the predicate returnsfalse.
Number validators:
finite,safeIntegerpositive,negative,nonPositive,nonNegativeeven,oddmin(value),greaterThan(value),max(value),lessThan(value)between(min, max),multipleOf(divisor)
String validators:
nonBlankmatches(pattern)startsWith(value),endsWith(value),includes(value)email,url,uuid
Length validators for strings and arrays:
empty,nonEmptylengthOf(size),minLength(size),maxLength(size)lengthBetween(min, max)
Array validators:
contains(value)hasNoDuplicatesunique, an alias forhasNoDuplicates
Custom predicates work the same way:
type Email = string & { __brand: 'Email' };
const isEmail = (value: string): value is Email =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const Email = string.validate(isEmail, 'Email');Transforming Values
Use .map(...) for simple transformations and .chain(...) when the next step can fail with a Result.
import { string } from 'castage';
const Trimmed = string.map((value) => value.trim(), 'TrimmedString');For common text parsing, use the built-in text casters:
import { possibleText, text } from 'castage';
text.int('42'); // ok(42)
text.number('1.5'); // ok(1.5)
text.bool('true'); // ok(true)
possibleText.int(42); // ok(42)
possibleText.int('42'); // ok(42)Error Handling
Calling a caster directly returns one CastingError on failure:
import { isCastingError, string } from 'castage';
const result = string(42, ['name']);
if (result.isErr && isCastingError(result.error)) {
result.error.code; // "ERR_INVALID_VALUE_TYPE"
result.error.path; // ["name"]
result.error.extra.expected; // "string"
result.error.extra.received; // 42
}Use .parse(...) when you want a list of nested errors instead of the first failure:
const parsed = User.parse({ id: 'bad', name: 1 });
if (parsed.isErr) {
parsed.error; // CastingError[]
}Use .unpack when throwing on invalid data is preferable:
const id = int.unpack(42); // 42API Reference
Caster<T>
The main runtime type abstraction. A caster is callable and has helper methods for deriving related casters.
interface Caster<T> {
(value: unknown, path?: string[]): Result<T, CastingError>;
nullable: Caster<T | null>;
optional: Caster<T | undefined>;
default(value: T, name?: string): Caster<T>;
unpack: (value: unknown, path?: string[]) => T;
validate<S extends T>(
predicate: (value: T) => value is S,
name?: string,
error?: (value: T, path: string[]) => CastingError,
): Caster<S>;
validate(
predicate: (value: T) => boolean,
name?: string,
error?: (value: T, path: string[]) => CastingError,
): Caster<T>;
match<S, E>(
okMatcher: (data: T) => S,
errMatcher: (err: CastingError) => E,
): (value: unknown, path?: string[]) => S | E;
unpackOr<E>(
handleError: (err: CastingError) => E,
): (value: unknown, path?: string[]) => T | E;
map<S>(transform: (data: T) => S, name?: string): Caster<S>;
chain<S>(
casterFn: (data: T, path?: string[]) => Result<S, CastingError>,
name?: string,
): Caster<S>;
parse(value: unknown, path?: string[]): Result<T, CastingError[]>;
assert(value: unknown, path?: string[]): asserts value is T;
}Primitive Casters
intnumberstringbooleanobjectnillundefanyunknown
Composition Helpers
array(caster, name?)nonEmptyArray(caster, name?)tuple(...casters)struct(casters, name?)record(keyCaster, valueCaster, name?)oneOf(...casters)allOf(...casters)value(value)values(...values)
Date Casters
date: ParsesDate,string, ornumberinto a JavaScriptDate.date.iso: Parses ISO-like date strings.dateTimeStamp.unix: Parses a Unix timestamp in seconds.dateTimeStamp.js: Parses a JavaScript timestamp in milliseconds.
JSON Casters
json: Parses any valid JSON string.json.object: Parses JSON and validates that the result is an object.json.struct(casters, name?): Parses JSON and validates a structured object.json.array(caster, name?): Parses JSON and validates an array.
Text Casters
text.inttext.numbertext.boolpossibleText.intpossibleText.numberpossibleText.bool
Environment Casters
Exported from castage/envs. See src/envs/README.md.
PKI Casters
Exported from castage/pki. See src/pki/README.md.
CastingError
interface CastingError extends Error {
code: CastingErrorCode;
path: string[];
extra: {
expected: string;
received?: unknown;
causes?: CastingError[];
reason?: string;
};
}