@typemint/data
v0.13.1
Published
Type-safe data structures for TypeScript.
Maintainers
Readme
@typemint/data
Type-safe data structures for TypeScript.
This package ships complementary primitives for modelling closed sets of named values:
LiteralUnion— a runtime descriptor for a closed set of string literals. It names things: countries, statuses, roles, currencies, log levels. It provides type guards, exhaustive matching, and iteration.OrdinalUnion— aLiteralUnionwhose declaration order is a rank (career levels, severities, tiers). It adds comparison, bounding, stepping, and slicing by that rank.Dictionary— a frozen, read-only projection that maps each name to a fixed value (an HTTP status number, an ISO code, an emoji, a label). It encodes the names that aLiteralUniondeclares.
The guiding principle: names are strings, encodings are dictionaries. A
LiteralUnion holds the canonical identities of your domain; a Dictionary
holds whatever each identity projects to.
Installation
pnpm add @typemint/dataimport { LiteralUnion, OrdinalUnion, Dictionary } from '@typemint/data';Table of contents
LiteralUnionOrdinalUnionDictionary- How
LiteralUnionandDictionarywork together Invariant- Built-in invariants
Scalar- License
LiteralUnion
LiteralUnion turns a tuple of string literals into a runtime descriptor that
carries both the values themselves and a set of methods for working with them.
A LiteralUnion represents nominal states, not values
This is the most important thing to understand about LiteralUnion: it does
not represent values or their meaning — it represents nominal symbols,
the identities of the entities you want to work with.
When you write LiteralUnion(['germany', 'france', 'usa']), the member
'germany' is not a value that means anything in particular. It does not
carry Germany's calling code, its ISO alpha-2 code, its capital, its flag, or
its population. It is purely a name — a stable, comparable token that says
"this is the country we call Germany," nothing more.
The actual representations — the data each name maps to — live somewhere else:
- A
Dictionaryprojects each name to a fixed value ('germany' → 'DE'). - A codec encodes/decodes a name to and from some external wire format.
In other words: the point of a LiteralUnion is to give you a closed set of
nominal symbols to work with, and to make working with them safe and
exhaustive. The moment you need what a symbol means rather than which symbol
it is, you reach for a Dictionary or a codec instead. See
How they work together for the
full picture.
Creating a union
Call LiteralUnion with a tuple of strings. The argument must be a const
tuple so that TypeScript can derive the precise literal types of each member.
When you pass an array literal directly, TypeScript infers the tuple
automatically; when you pass a value declared elsewhere, add as const:
// Array literal passed directly — types are inferred.
const Country = LiteralUnion(['germany', 'france', 'usa']);
// A value declared separately must be marked `as const`.
const members = ['germany', 'france', 'usa'] as const;
const Country = LiteralUnion(members);Without as const, a separately-declared array widens to string[], and
TypeScript can no longer derive the literal union — you would lose the precise
'germany' | 'france' | 'usa' type and the exhaustiveness checks that depend
on it:
// ⚠️ widens to string[] — the literal types are lost.
const members = ['germany', 'france', 'usa'];
const Country = LiteralUnion(members); // members are typed as `string`To obtain the literal union type from the descriptor, use InferLiteralUnion:
type Country = InferLiteralUnion<typeof Country>;
// type Country = 'germany' | 'france' | 'usa'Some rules enforced at construction time:
- The tuple must contain at least one member, otherwise a
PanicExceptionis thrown. - Member names must not collide with the reserved descriptor keys (
isOfType,of,ofUnsafe,parse,parseUnsafe,parseOr,toArray,toSet,members,pick,omit,size,match,matchResult). A collision throws aPanicException. Members and methods share one namespace, so a member of that name would shadow the method it is named after — seemembersfor the access path that does not depend on the namespace, and for what the shared namespace means for versioning. - Only
stringmembers are allowed by design — see the rationale in How they work together.
Member access
Each declared member is exposed as a property on the descriptor whose value is the literal itself. This gives you a single, typo-proof source of truth instead of scattering raw string literals through your code:
Country.germany; // 'germany'
Country.france; // 'france'
if (value === Country.usa) {
// ...
}members
Every declared member, keyed by its own name. Country.members.germany and
Country.germany are the same string, and the direct property is the one to
reach for in ordinary code:
const Country = LiteralUnion(['germany', 'france', 'usa']);
Country.members.germany; // 'germany'
Country.members; // { germany: 'germany', france: 'france', usa: 'usa' }It exists for the case direct access cannot serve: generic code, which has
no member names to write. A helper typed against LiteralUnionLike<T> cannot
write union.germany, but it can read union.members:
function options<T extends string>(union: LiteralUnionLike<T>) {
return Object.values(union.members).map((m) => ({ value: m, label: m }));
}
options(Country); // works
options(OrdinalUnion(['low', 'high'])); // an ordinal inherits it tooThe map is frozen and has a null prototype, so it carries the members and
nothing else — members.toString is undefined, not
Object.prototype.toString, and a lookup of a name the union does not hold
cannot land on something inherited. On the descriptor the property is
non-enumerable, for the same reason size is: the members are the
union's serialized shape, and an enumerable members would repeat the whole map
inside JSON.stringify(Country).
The shared namespace, and what it means for versioning
Members are properties on the descriptor, beside the methods. One namespace
holds both, which is what makes Country.germany read the way it does — and it
has a price worth stating plainly:
- Every descriptor key is a name your members cannot use.
OrdinalUnioninherits these and adds its own (min,max,next,prev,range,clamp,compare, …), which are exactly the names an ordered scale might want:OrdinalUnion(['min', 'small', 'max'])is rejected, while the same members as a plainLiteralUnionare fine. - The method surface is frozen for the major version. Adding a method reserves a name some existing union may already hold, so it is a breaking change and is released as one — never slipped into a minor.
members is the access path that does not depend on the namespace, which is
also what would let the collision rule be relaxed later without a new API.
isOfType(value: unknown): value is T
Type guard that narrows an unknown value to a member of the union. Backed by
an O(1) Set lookup using strict string equality, so it is safe to use at
the trust boundary of your system (HTTP payloads, env vars, DB columns, …).
const Country = LiteralUnion(['germany', 'france', 'usa']);
function handleRequest(body: { country: unknown }) {
if (!Country.isOfType(body.country)) {
throw new Error(`Unknown country: ${String(body.country)}`);
}
// body.country is now typed as 'germany' | 'france' | 'usa'
return lookupCountry(body.country);
}It can also be used as a predicate to filter unknown arrays down to valid members:
const Status = LiteralUnion(['active', 'pending', 'archived']);
const raw: unknown[] = ['active', 42, 'pending', null, 'archived', 'bogus'];
const valid = raw.filter(Status.isOfType);
// valid: ('active' | 'pending' | 'archived')[] → ['active', 'pending', 'archived']Note: isOfType only proves membership in the union as a whole. To narrow to
a single member, compare directly afterwards (value === Country.germany).
of(value: string)
Validate that a string you already hold is a declared member, returning it —
narrowed to the union — on success, or a
LiteralUnionMismatchError on failure. Use it when
the value is statically a string but not yet known to be a member (an
environment variable, a VARCHAR column, a CLI argument); the "is it a string"
question is already answered by the type system, so of only checks membership.
It never throws and never mutates — the success value is the same string,
re-typed as the union member (a literal union is structural, so there is no
branding).
const LogLevel = LiteralUnion(['debug', 'info', 'warn', 'error']);
const level = LogLevel.of(process.env.LOG_LEVEL ?? 'info');
if (level.isOk()) {
level.value; // 'debug' | 'info' | 'warn' | 'error'
} else {
level.error.message; // 'Expected one of "debug", … but got "verbose"'
}For unknown input from a trust boundary, use parse,
which recognizes the string first.
ofUnsafe(value: string)
The throwing counterpart to of. It returns the value —
narrowed to the union — directly on success, and throws a
PanicException when the value is not a member, with the
LiteralUnionMismatchError attached as the exception's cause.
const country = Country.ofUnsafe(row.country); // 'germany' | 'france' | 'usa'
try {
Country.ofUnsafe('belgium');
} catch (err) {
// err instanceof PanicException
// err.cause.kind === 'LiteralUnionMismatchError'
}A thrown failure signals a bug: the value was asserted to be a member and
was not. Reach for it only for values from a trusted source (a persisted row, a
prior result whose narrowing was lost in transit, a test fixture). Prefer it
over an as cast — it re-checks membership, is easy to grep for, and accepts
only a string.
parse(value: unknown)
The entry point for untrusted input. It first recognizes the value as a
string (returning a TypeMismatchError if not), then checks membership
(returning a LiteralUnionMismatchError if the
string is not a member). The two failures are kept as separate error kinds so
you can tell them apart.
const result = Country.parse(JSON.parse(body).country);
// Result<
// 'germany' | 'france' | 'usa',
// TypeMismatchError<string, unknown> | LiteralUnionMismatchError<'germany' | 'france' | 'usa'>
// >
if (result.isErr()) {
if (Kind.isOf(result.error, 'TypeMismatchError')) {
// input was not a string at all
} else {
// input was a string, just not a declared member
}
}parseUnsafe(value: unknown)
The throwing counterpart to parse. It returns the
narrowed value on success, or throws a PanicException on failure with the
underlying error (TypeMismatchError or LiteralUnionMismatchError) attached as
cause.
const country = Country.parseUnsafe(config.country); // 'germany' | 'france' | 'usa'Use it only for input from a source you fully trust to be well-formed; for
untrusted input use parse and handle the Result.
parseOr(value: unknown, fallback)
Parse an unknown value, returning it — narrowed to the union — when it is a
member, or fallback otherwise. The result is always a member, so it never
fails and never throws. This is the ergonomic form of the read-a-value-with-a
default pattern; fallback is constrained to a union member, which is what
guarantees the return type is the union regardless of the input.
const LogLevel = LiteralUnion(['debug', 'info', 'warn', 'error']);
const level = LogLevel.parseOr(process.env.LOG_LEVEL, 'info');
// level: 'debug' | 'info' | 'warn' | 'error' — always validIt is equivalent to LogLevel.isOfType(x) ? x : 'info', spelled as one call.
When you need to distinguish "a default was used" from "the input was valid",
use parse and inspect the Result.
LiteralUnionMismatchError
The structured error returned (by of /
parse) or thrown as a cause (by
ofUnsafe /
parseUnsafe) when a string is not a declared
member. It is built from the same core mixins as TypeMismatchError — a kind
discriminant, a human-readable message, and structured details:
LiteralUnionMismatchError(['germany', 'france', 'usa'], 'belgium');
// {
// kind: 'LiteralUnionMismatchError',
// message: 'Expected one of "germany", "france", "usa" but got "belgium"',
// details: { expected: ['germany', 'france', 'usa'], received: 'belgium' },
// }The discriminant is deliberately scoped to this primitive (rather than a generic
name like 'NotAMemberError') so it never collides with a structurally similar
"value not in a closed set" error from a sibling primitive.
details.expected always holds the full member list even when the message
truncates it for readability. Narrow on kind to handle it:
if (Kind.isOf(error, 'LiteralUnionMismatchError')) {
error.details.expected; // the allowed members
}toArray(): NonEmptyReadonlyArray<T>
Returns the union's members as a non-empty readonly tuple, in declaration
order. The same reference is returned on every call (the array is cached
internally), so it is free to call repeatedly. Because the return type is a
non-empty tuple, destructuring the first element is safe without an
undefined check.
const Country = LiteralUnion(['germany', 'france', 'usa']);
for (const country of Country.toArray()) {
console.log(country); // 'germany', 'france', 'usa'
}
const [first] = Country.toArray(); // first: 'germany' | 'france' | 'usa'The returned array must not be mutated. If you need a mutable copy, spread it:
const sorted = [...Country.toArray()].sort();It pairs nicely with schema libraries, since the literal types are preserved:
const Role = LiteralUnion(['admin', 'editor', 'viewer']);
const RoleSchema = z.enum(Role.toArray());
// RoleSchema: z.ZodEnum<['admin', 'editor', 'viewer']>toSet(): ReadonlySet<T>
Returns the union's members as a Set, for membership semantics rather than
an ordered list — O(1) has lookups or set algebra (union / intersection /
difference) against another collection. Unlike
toArray (which returns a cached, frozen
array), a fresh, independent copy is returned on every call: a JavaScript
Set cannot be frozen, so toSet never exposes the descriptor's internal
membership store. Mutating the returned set is safe and never affects the union.
const Country = LiteralUnion(['germany', 'france', 'usa']);
const requested = new Set(userInput);
const known = Country.toSet();
const unknownOnes = [...requested].filter((c) => !known.has(c));Its type is ReadonlySet<T> to signal that the members are the union's, but
since the value is already your own copy you may freely build a mutable set from
it.
pick(keys)
Derive a new union from a subset of this union's members, selected by name.
The result is an independent descriptor over exactly the picked members. This is
"derive, don't redeclare" applied to the union itself: when a narrower context
needs a strict subset of a broader union, pick keeps the subset tied to its
source. Unlike hand-writing a second LiteralUnion([...]), the keys argument
is constrained to members of this union, so a typo or a member that never
existed is a compile error, not a silently divergent second union.
const PaymentMethod = LiteralUnion(['card', 'sepa', 'paypal', 'cash']);
const OnlineMethod = PaymentMethod.pick(['card', 'sepa', 'paypal']);
// OnlineMethod: LiteralUnionDescriptor<'card' | 'sepa' | 'paypal'>
OnlineMethod.isOfType('cash'); // false — 'cash' was not picked
// @ts-expect-error — 'crypto' is not a member of PaymentMethod
PaymentMethod.pick(['card', 'crypto']);Order follows the argument. The picked union's declaration order is the order
of keys (exactly as if you had written LiteralUnion(keys)), so pick doubles
as a reorder. Pass the keys in the parent's order when you want to preserve it.
The result is an independent snapshot. The returned descriptor has its own
member set and cached array/set, and no runtime back-reference to this union —
sub.isOfType(x) implying parent.isOfType(x) holds by value, but nothing
tracks the relationship. Passing an empty list (or, via a type bypass, a
non-member) throws a PanicException.
omit(keys)
Derive a new union containing every member except the named ones — the
complement of pick. Reach for omit when the subset is most
naturally described by what it excludes ("all statuses except the terminal
ones"). As with pick, keys is constrained to members of this union, so
removing a member that never existed is a compile error.
const Status = LiteralUnion(['draft', 'active', 'archived', 'deleted']);
const LiveStatus = Status.omit(['archived', 'deleted']);
// LiveStatus: LiteralUnionDescriptor<'draft' | 'active'>
LiveStatus.toArray(); // ['draft', 'active'] — source order preserved
// @ts-expect-error — 'suspended' is not a member of Status
Status.omit(['suspended']);Order follows this union. The remaining members keep this union's
declaration order (with the omitted ones removed), since omit describes a
removal rather than a re-selection; use pick when you also want to
reorder. Like pick, the result is an independent snapshot. At least one
member must survive — omitting every member throws a PanicException, since an
empty union is not representable.
size
The number of members in the union.
const Country = LiteralUnion(['germany', 'france', 'usa']);
Country.size; // 3Iteration (Symbol.iterator)
The descriptor is iterable, yielding each member in declaration order. This
means you can spread it or use it directly in a for...of loop without calling
toArray():
const Country = LiteralUnion(['germany', 'france', 'usa']);
[...Country]; // ['germany', 'france', 'usa']
for (const c of Country) { /* ... */ }The descriptor also reports Object.prototype.toString.call(Country) as
'[object LiteralUnion]' via Symbol.toStringTag.
match(value, handlers) / match(handlers)
Exhaustively dispatch on a member of the union and return the value produced by
the matching handler. match is the canonical way to run code per member —
the value-side counterpart to a Dictionary (which projects each member to a
fixed value).
Key properties:
- Exhaustiveness is enforced at compile time. Every member must have a handler; missing one is a TypeScript error. There is no default/fallthrough.
- Per-branch narrowing. Each handler receives the narrow literal for its
own key (the
germanyhandler seesvalue: 'germany', not the full union). - Synchronous only. Handlers may return Promises, in which case the
result type is
Promise<U>and the caller awaits — there is nomatchAsync.
Data-first — dispatch immediately:
const Country = LiteralUnion(['germany', 'france', 'usa']);
type Country = InferLiteralUnion<typeof Country>;
function alpha2(country: Country): string {
return Country.match(country, {
germany: () => 'DE',
france: () => 'FR',
usa: () => 'US',
});
}Data-last — pass only the handlers and get back a matcher function, ideal
for pipe chains and Array.prototype.map:
const Currency = LiteralUnion(['eur', 'usd', 'gbp']);
const symbol = Currency.match({
eur: () => '€',
usd: () => '$',
gbp: () => '£',
});
Currency.toArray().map(symbol); // ['€', '$', '£']Missing a case is a compile error:
// @ts-expect-error — Property 'usa' is missing in type ...
Country.match(country, {
germany: () => 'DE',
france: () => 'FR',
});If a handler is missing or not a function at runtime (only reachable by bypassing the type system, e.g. with
as anyor untyped JS),matchthrows aPanicException.
matchResult(result, handlers) / matchResult(handlers)
The Result-aware companion
to match. It lifts the dispatch into the Result monad so you can thread a
possibly-failed value through a literal-union dispatch without manually
unwrapping Ok/Err. It is implemented in terms of Result.andThen:
Errshort-circuits. If the input isErr(e), no handler runs and the exact sameErrinstance is propagated unchanged (reference identity and any attached metadata are preserved).Okdispatches. If the input isOk(v), the handler matchingvruns and its returnedResult<A, E2>becomes the output.- Errors compose. The output error type is
E1 | E2— the union of "the input may already be failed" and "any handler may fail."
Data-first:
import { Result } from '@typemint/result';
const Country = LiteralUnion(['germany', 'france', 'usa']);
type Country = InferLiteralUnion<typeof Country>;
declare function parseCountry(input: unknown): Result<Country, ParseError>;
declare function lookupCapital(c: Country): Result<string, NotFoundError>;
const capital = Country.matchResult(parseCountry(rawInput), {
germany: () => lookupCapital('germany'),
france: () => lookupCapital('france'),
usa: () => lookupCapital('usa'),
});
// capital: Result<string, ParseError | NotFoundError>Data-last — returns a matcher suitable for map/pipe. Handlers are
validated eagerly at matcher-creation time:
const toSymbol = Currency.matchResult({
eur: () => Result.Ok('€' as const),
usd: () => Result.Ok('$' as const),
gbp: () => Result.Ok('£' as const),
});
// toSymbol: <E>(r: Result<Currency, E>) => Result<'€' | '$' | '£', E>
const symbols = parsedInputs.map(toSymbol);The following two expressions are interchangeable — matchResult is just the
ergonomic form of andThen + match:
const a = Country.matchResult(parseCountry(input), {
germany: () => lookupCapital('germany'),
france: () => lookupCapital('france'),
usa: () => lookupCapital('usa'),
});
const b = parseCountry(input).andThen((country) =>
Country.match(country, {
germany: () => lookupCapital('germany'),
france: () => lookupCapital('france'),
usa: () => lookupCapital('usa'),
}),
);assertLiteralUnionMember
assertLiteralUnionMember(union, value, message?) is the assertion-function
companion to isOfType: it narrows value
to the union's member type in place (rather than returning a new binding
like parseUnsafe), throwing an AssertException
when value is not a member.
import { assertLiteralUnionMember } from '@typemint/data';
const Country = LiteralUnion(['germany', 'france', 'usa']);
function handle(input: unknown) {
assertLiteralUnionMember(Country, input);
// input is now 'germany' | 'france' | 'usa' — same variable, no re-binding
return Country.match(input, {
germany: () => 'DE',
france: () => 'FR',
usa: () => 'US',
});
}It is a standalone function, not a descriptor method, by necessity:
TypeScript only honours an assertion signature when every name in the call
target is explicitly typed (TS2775) — which a method reached through an inferred
const binding is not. A top-level function sidesteps that, matching the
convention of assertKind, assertWithCode, and assertOk/assertErr. The
optional message accepts a string or a lazy () => string; the default lists
the members.
LiteralUnion type helpers
| Type | Description |
| --- | --- |
| InferLiteralUnion<typeof U> | Extract the literal union type ('a' \| 'b') from a descriptor. |
| LiteralUnionFrom<T> | Derive a literal union from a const tuple type. |
| LiteralUnionLike<T> | The common supertype every literal-union-shaped descriptor satisfies — OrdinalUnion included. Write helpers against this. |
| LiteralUnionDescriptor<T> | The full descriptor type (members + methods), as returned by LiteralUnion itself. |
| LiteralUnionMembers<T> | The { [K in T]: K } member record. |
| LiteralUnionMethods<T> | The method portion of the descriptor. |
| LiteralUnionMatchHandlers<T, U> | Exhaustive handler map for match. |
| LiteralUnionResultHandlers<T, A, E> | Exhaustive handler map for matchResult. |
| LiteralUnionMismatchError<T> | The structured error for a non-member string (also the runtime constructor). |
| InferLiteralUnionMismatchError<T> | The LiteralUnionMismatchError type for a union — T is a descriptor or a bare member union. |
| LiteralUnionMemberBase | The base constraint for members (string). |
const Status = LiteralUnion(['active', 'pending', 'archived']);
type Status = InferLiteralUnion<typeof Status>;
// type Status = 'active' | 'pending' | 'archived'OrdinalUnion
OrdinalUnion is a LiteralUnion whose declaration order
is meaningful: members are ranked ascending, the first one lowest and the last
one highest. On top of everything a literal union does, it can compare, bound,
step through, and slice its members by that rank.
Reach for it when members are nominal identities that also rank against each
other: career levels, log severities, priorities, subscription tiers. When the
order is incidental (countries, payment methods), stay with a LiteralUnion.
import { OrdinalUnion, type InferOrdinalUnion } from '@typemint/data';
const Rank = OrdinalUnion([
'team_lead',
'manager',
'senior_manager',
'director',
'vp',
'c_suite',
]);
type Rank = InferOrdinalUnion<typeof Rank>;
// 'team_lead' | 'manager' | 'senior_manager' | 'director' | 'vp' | 'c_suite'
Rank.gte('director', Rank.manager); // true
Rank.max('manager', 'vp', 'team_lead'); // 'vp'
Rank.next('vp'); // 'c_suite'
Rank.atLeast('director').toArray(); // ['director', 'vp', 'c_suite']The rank is a position, not a value
An ordinal union still holds names, exactly as a LiteralUnion does (see
nominal states, not values).
The rank is derived from each member's position in the declaration and never
leaves the descriptor. Values are always the member strings: they serialize,
persist, and travel as 'director', never as 3.
This keeps the stored data stable when the scale changes. Inserting
'senior_director' between 'director' and 'vp' re-ranks every member above
it, but every stored 'vp' still means VP. If you need a numeric encoding for
an external system, project it with a Dictionary, just as you
would for a literal union.
Creating an ordinal union
Call OrdinalUnion with a tuple of strings, lowest first. The same as const
rule as LiteralUnion applies: an array literal passed
directly is inferred as a tuple, and a value declared elsewhere needs as const.
An ordinal is stricter about its members than a literal union, and it reports the problem at compile time, on the offending element:
Members must be distinct. A member cannot hold two ranks. A
LiteralUnionsilently deduplicates; anOrdinalUnionrejects the repeat:OrdinalUnion(['low', 'high', 'low']); // ~~~~~ // Type '"low"' is not assignable to type 'duplicate member "low": each // member of an ordinal union holds exactly one rank'.Members must not be descriptor keys. Members and methods share one namespace (see the shared namespace), and the ordinal adds its own methods to the reserved set:
indexOf,compare,lt,lte,gt,gte,between,lowest,highest,min,max,clamp,next,prev,range,atLeast,atMost. Several are names a scale might want, so check them before choosing member names:OrdinalUnion(['low', 'next']); // ~~~~~~ // Type '"next"' is not assignable to type 'reserved member "next": the // name is taken by a method of the ordinal union descriptor'.Each element must be a single literal. An element typed as a union (a variable of type
'a' | 'b') has no single rank, so it is rejected with anambiguous membermessage. Pass each member as its own literal.
When the compiler cannot see the literals (a widened string[], or a JavaScript
caller), the same rules are enforced at runtime: an empty tuple, a duplicate, or
a reserved name throws a PanicException.
Inherited LiteralUnion behavior
The ordinal is a literal union, extended in place. Member access, members,
isOfType, of, ofUnsafe, parse, parseUnsafe, parseOr, toArray,
toSet, size, iteration, match, and matchResult all behave exactly as
documented for LiteralUnion. toArray() and iteration yield
the members in rank order.
Two methods are overridden: pick and omit
return ordinals and never reorder. The descriptor reports
Object.prototype.toString.call(Rank) as '[object OrdinalUnion]', and panics
raised by inherited methods name OrdinalUnion.
Every ordering method is a closure over the descriptor, with no this, so it
can be passed around unbound: ranks.sort(Rank.compare) works.
Comparing members
| Method | Returns |
| --- | --- |
| compare(a, b) | -1 if a is lower, 0 if equal, 1 if higher. Shaped as a sort comparator. |
| lt(a, b) / lte(a, b) | true if a is lower than (or equal to) b. |
| gt(a, b) / gte(a, b) | true if a is higher than (or equal to) b. |
| between(value, lo, hi) | true if value lies in the inclusive range [lo, hi]. |
Rank.compare('manager', 'vp'); // -1
Rank.lt('vp', 'c_suite'); // true
ranks.sort(Rank.compare); // ascending
ranks.sort((a, b) => Rank.compare(b, a)); // descending
Rank.between('director', 'manager', 'vp'); // truebetween is the boolean form of Rank.range(lo, hi).isOfType(value). Use it
for a filter predicate or a guard clause. Use the derivation
when the branch needs value narrowed to the slice. An inverted range
(lo above hi) is a bug in the caller, so between throws a
PanicException rather than answering false.
Bounding and stepping
| Method | Returns | Typed as |
| --- | --- | --- |
| lowest() | The first declared member. | That exact literal. |
| highest() | The last declared member. | That exact literal. |
| min(...values) | The lowest of the arguments. | The union of the arguments. |
| max(...values) | The highest of the arguments. | The union of the arguments. |
| clamp(value, lo, hi) | value bounded to [lo, hi]. | The member union. |
| next(value) | The member directly above, or undefined at the top. | The member union | undefined. |
| prev(value) | The member directly below, or undefined at the bottom. | The member union | undefined. |
Rank.lowest(); // 'team_lead' (typed as 'team_lead')
Rank.highest(); // 'c_suite' (typed as 'c_suite')
Rank.min('vp', 'manager'); // 'manager' (typed as 'vp' | 'manager')
Rank.clamp('c_suite', 'manager', 'director'); // 'director'
Rank.next('vp'); // 'c_suite'
Rank.next('c_suite'); // undefined
Rank.prev('team_lead'); // undefinednext, prev, and clamp are deliberately typed as the whole member union:
Rank.next('vp') is Rank | undefined, not 'c_suite' | undefined. Naming the
exact successor would make the compiler walk the member tuple at every call
site, for a value that is almost always assigned back to a Rank anyway. If a
call site needs the literal, narrow it with a comparison or a derived ordinal's
isOfType. lowest and highest are exact because reading a fixed position
costs the compiler nothing.
min and max take at least one argument. On a tie, the earliest argument
wins. clamp, like between, throws a PanicException when lo is above
hi.
indexOf(value)
The zero-based position of value in this descriptor's toArray(), so
Rank.toArray()[Rank.indexOf(x)] === x. Unlike Array.prototype.indexOf, a
non-member does not return -1; it throws a PanicException (reachable only
by bypassing the type system).
Rank.indexOf('director'); // 3
Rank.atLeast('director').indexOf('director'); // 0: same member, new indexAn index is a position, not a property of the member: each derived ordinal
numbers its own members from 0, so an index means nothing without its
descriptor. To order members, use compare or lt/gte, which take member
strings and cannot be mixed up this way. Use indexOf only for positional work
on one descriptor, such as indexing toArray() or drawing a progress bar.
Never persist or transmit an index: store the member string.
Deriving ordinals
Five methods derive a new ordinal over part of the scale. The result is a
full OrdinalUnion descriptor, with its own guards, comparisons, and further
derivations, and its member type is computed exactly from the parent's tuple.
| Method | Keeps |
| --- | --- |
| range(from, to) | The inclusive slice [from, to]. |
| atLeast(value) | value and every member above it. |
| atMost(value) | value and every member below it. |
| pick(keys) | The named members, in this ordinal's order. |
| omit(keys) | Every member except the named ones, in this ordinal's order. |
const Management = Rank.range('manager', 'director');
// OrdinalUnionDescriptor<readonly ['manager', 'senior_manager', 'director']>
const Executive = Rank.atLeast('director');
if (Executive.isOfType(user.rank)) {
// user.rank: 'director' | 'vp' | 'c_suite'
}This is the ordinal counterpart to "derive, don't redeclare": a gate such as
"director and above" stays tied to the scale it came from, so inserting a new
rank above 'director' extends Executive automatically.
Bounds held in variables. With literal arguments the result type is exact.
With a union-typed argument, the type is the widest slice the call can return:
atLeast(r: Rank) starts at the union's lowest member and atMost(r: Rank)
runs through its highest. The type never claims fewer members than the runtime
returns.
Inverted ranges. range('vp', 'manager') throws a PanicException. With
literal bounds it is also typed as never, so the mistake shows up at the call
site.
pick and omit keep the order
Unlike LiteralUnion.pick, an ordinal's pick never
reorders. The picked members keep the parent's order whatever the order of
keys, because order is what an ordinal means: a pick that reordered would
produce a scale where vp < manager, contradicting its parent.
Rank.pick(['vp', 'manager']).toArray(); // ['manager', 'vp']
Rank.omit(['vp', 'c_suite']).toArray(); // ['team_lead', 'manager', 'senior_manager', 'director']As with the literal union, keys is constrained to members of this ordinal, so
a typo is a compile error. At runtime, pick throws a PanicException on a
non-member; omit is a set difference and ignores one. Any derivation that
would leave no members throws a PanicException.
Derived ordinals are shared
Derivations are memoized across the whole family of a root ordinal. A derived ordinal is identified by its members, not by the method or parent that produced it:
Rank.atLeast('vp') === Rank.range('vp', 'c_suite'); // true
Rank.atLeast('manager').atMost('vp') === Rank.range('manager', 'vp'); // true
Rank.omit(['team_lead']) === Rank.atLeast('manager'); // true
Rank.atLeast('team_lead') === Rank; // true: every member keptSo calling Rank.atLeast(x) in per-request code costs a lookup, not a
descriptor build, and the result is a stable React dependency or Map key.
- Contiguous slices (everything
range,atLeast, andatMostproduce) are kept for as long as the root lives. There are at most n(n+1)/2 of them. - Gapped subsets from
pickoromitare kept in a bounded least-recently-used cache, because there can be 2ⁿ of them and their keys often come from runtime data. Once an entry is evicted, the same members are rebuilt as an equal but new descriptor. Compare gapped subsets bytoArray(), not by reference.
Using an ordinal where a literal union is expected
An ordinal satisfies LiteralUnionLike<T>, the common supertype of both
descriptors, so every helper written against it accepts an ordinal unchanged:
assertLiteralUnionMember, Dictionary.fromLiteralUnion, InferLiteralUnion,
InferLiteralUnionMismatchError, and your own generic code.
const salaryBand = Dictionary.fromLiteralUnion(Rank, {
team_lead: 'B3',
manager: 'B4',
senior_manager: 'B5',
director: 'B6',
vp: 'B7',
c_suite: 'B8',
});
assertLiteralUnionMember(Rank, input); // input: RankTwo substitutions are intentionally rejected by the compiler:
- An ordinal is not a
LiteralUnionDescriptor<T>. Itspickandomitreturn ordinals, and its tag reads'OrdinalUnion'. Type helpers asLiteralUnionLike<T>when they should accept both. - A derived ordinal cannot stand in for its parent.
Rank.atLeast('director')cannot compare'manager', so it is not assignable to the parent's methods, not even to a partial shape likePick<OrdinalUnionMethods<…>, 'compare'>. Without this check, the call would panic at runtime.
OrdinalUnion type helpers
| Type | Description |
| --- | --- |
| InferOrdinalUnion<typeof U> | Extract the member union from an ordinal descriptor. A plain LiteralUnion yields never. |
| OrdinalUnionDescriptor<T> | The full descriptor type. Parameterized by the member tuple (not the union), which is what lets derivations compute exact slices. |
| OrdinalUnionMethods<T> | The ordering half of the descriptor, added on top of LiteralUnionLike. |
| OrdinalComparison | The result of compare: -1 \| 0 \| 1. |
type Rank = InferOrdinalUnion<typeof Rank>;
// `InferLiteralUnion` works too, since an ordinal is a literal union.
type SameRank = InferLiteralUnion<typeof Rank>;Dictionary
Dictionary turns a plain object into a frozen, read-only descriptor that maps
each key to a fixed value, and adds methods for iterating keys, values, and
entries. Where a LiteralUnion declares the names, a Dictionary declares
what each name projects to.
Creating a dictionary
const codes = Dictionary({ germany: 'DE', france: 'FR', usa: 'US' });As with LiteralUnion, the source must be a const value so that
TypeScript can derive the precise key and value literal types. An object
literal passed directly is inferred correctly, but a value declared elsewhere
must be marked as const:
// Object literal passed directly — keys and values are inferred as literals.
const codes = Dictionary({ germany: 'DE', france: 'FR', usa: 'US' });
// A value declared separately must be marked `as const`.
const source = { germany: 'DE', france: 'FR', usa: 'US' } as const;
const codes = Dictionary(source);Without as const, the values widen to string and you lose the precise
'DE' | 'FR' | 'US' value type that values() and isOfType depend on.
The source object is copied onto a null-prototype descriptor (no prototype
pollution), and keys/values/entries are memoized and frozen at construction.
Rules enforced at construction time:
- The source must have at least one key, otherwise a
PanicExceptionis thrown. - Keys must not collide with the reserved descriptor keys (
isOfType,keys,values,entries,size). A collision throws aPanicException.
Dictionary.fromLiteralUnion
Dictionary.fromLiteralUnion(union, source) builds a Dictionary whose keys
are pinned to the members of an existing LiteralUnion. Use it when the
union is the source of truth and every dictionary must cover it — the
inverse of the canonical pattern,
where keys flow out of the dictionary into the union.
const Country = LiteralUnion(['germany', 'france', 'usa']);
const alpha2 = Dictionary.fromLiteralUnion(Country, {
germany: 'DE',
france: 'FR',
usa: 'US',
});It gives you two guarantees that a plain Dictionary({ ... }) call does not:
1. Exhaustiveness is enforced at compile time. The source object must
provide an entry for every member of the union. Miss one and it is a type
error — you cannot forget a member:
// ❌ Argument of type '{ germany: string; france: string; }' is not
// assignable to parameter of type 'Record<"germany" | "france" | "usa", …>'
const partial = Dictionary.fromLiteralUnion(Country, {
germany: 'DE',
france: 'FR',
});The required keys come straight from the union you pass as the first argument, so the dictionary can never silently drift behind the union.
Note — extra keys are not rejected. Because
sourceis captured through a generic (const) type parameter, TypeScript's excess-property check does not apply, so a stray key beyond the union's members is not a compile error. The factory guarantees every member is present, not only members are present. If you also need to forbid extras, add an explicitsatisfies Record<InferLiteralUnion<typeof Country>, V>on the source.
2. Values are captured as literals — no as const needed. The factory uses
a const type parameter on the source, so value literals are preserved
without you having to annotate the object:
const alpha2 = Dictionary.fromLiteralUnion(Country, {
germany: 'DE',
france: 'FR',
usa: 'US',
});
alpha2.germany; // type: 'DE' (not widened to string)
alpha2.values(); // type: NonEmptyReadonlyArray<'DE' | 'FR' | 'US'>
alpha2.isOfType('DE'); // narrows to 'DE' | 'FR' | 'US'With a plain Dictionary({ germany: 'DE', ... }) the values would still be
inferred as literals, but the keys are unchecked against any union. With
fromLiteralUnion you get both: exhaustive keys and literal values, in a
single call.
The first argument is only used to drive the key type at compile time; the resulting descriptor is an ordinary
Dictionarybuilt fromsource. So all the members and methods below (keys,values,entries,isOfType, iteration, …) work exactly the same.
Member access
Each key is exposed as a read-only property whose value is the projected value:
const codes = Dictionary({ germany: 'DE', france: 'FR', usa: 'US' });
codes.germany; // 'DE'
codes.usa; // 'US'keys(): NonEmptyReadonlyArray
Returns the dictionary's keys as a non-empty readonly tuple, in insertion
order. The same memoized reference is returned on every call.
const codes = Dictionary({ germany: 'DE', france: 'FR', usa: 'US' });
codes.keys(); // ['germany', 'france', 'usa']This is the primary bridge into LiteralUnion:
const Country = LiteralUnion(codes.keys());values(): NonEmptyReadonlyArray
Returns the projected values as a non-empty readonly tuple, in key order.
Memoized and frozen.
const codes = Dictionary({ germany: 'DE', france: 'FR', usa: 'US' });
codes.values(); // ['DE', 'FR', 'US']entries(): NonEmptyReadonlyArray
Returns [key, value] pairs as a non-empty readonly tuple. Each entry is
itself a frozen readonly [K, T[K]] tuple, and the key type is preserved
per-entry.
const codes = Dictionary({ germany: 'DE', france: 'FR', usa: 'US' });
for (const [name, code] of codes.entries()) {
console.log(`${name} → ${code}`);
}
// germany → DE
// france → FR
// usa → USisOfType(value: unknown): value is value
Type guard that narrows an unknown value to one of the dictionary's
values (not its keys). It checks membership against the memoized value
list.
const codes = Dictionary({ germany: 'DE', france: 'FR', usa: 'US' });
function parseCode(input: unknown) {
if (!codes.isOfType(input)) {
throw new Error(`Unknown code: ${String(input)}`);
}
// input is now typed as 'DE' | 'FR' | 'US'
return input;
}size
The number of entries in the dictionary.
const codes = Dictionary({ germany: 'DE', france: 'FR', usa: 'US' });
codes.size; // 3Iteration (Symbol.iterator)
The descriptor is iterable, yielding [key, value] entries in key order — so
it can be passed straight to new Map(...) or used in a for...of loop:
const codes = Dictionary({ germany: 'DE', france: 'FR', usa: 'US' });
const map = new Map(codes); // Map { 'germany' => 'DE', ... }
for (const [name, code] of codes) { /* ... */ }The descriptor reports Object.prototype.toString.call(codes) as
'[object Dictionary]' via Symbol.toStringTag.
Dictionary type helpers
| Type | Description |
| --- | --- |
| DictionaryDescriptor<T> | The full descriptor type (members + methods). |
| DictionarySource<T> | The accepted source shape (Readonly<Record<string, T>>). |
| InferDictionaryKeys<T> | The union of key literals. |
| InferDictionaryValues<T> | The union of value types. |
| DictionaryEntry<T> | The per-key readonly [K, T[K]] entry type. |
| DictionaryMembers<T> | The member record type. |
| DictionaryMethods<T> | The method portion of the descriptor. |
| DictionaryKeyBase | The base constraint for keys (string). |
How LiteralUnion and Dictionary work together
The two primitives are designed to be used as a pair, and they divide the work along one clear seam:
The
LiteralUnionnames things. TheDictionaryencodes them.
A LiteralUnion models the closed set of nominal identities in your
domain — the names a domain uses to refer to its members (countries, statuses,
roles, currencies, payment methods). These are always strings, because a name
is text: it is self-documenting at every call site, survives every transport
(JSON, URLs, env vars, DB columns) unchanged, and compares safely across module
boundaries.
A Dictionary holds the projections of those names — the encodings that
external systems care about. Apparently-numeric domains almost always reduce to
"a name with a numeric projection":
| External numeric form | Nominal identity (LiteralUnion) | Numeric form lives in |
| ------------------------------ | --------------------------------- | ---------------------------------- |
| HTTP status (200, 404) | 'ok', 'notFound', … | Dictionary<HttpStatus, number> |
| ISO 3166 numeric-3 (276=DE) | 'germany', 'france', … | Dictionary<Country, number> |
| ISO 4217 numeric (978=EUR) | 'eur', 'usd', … | Dictionary<Currency, number> |
The canonical pattern: keys flow into the union
Build the Dictionary first (it holds the data), then derive the
LiteralUnion from its keys so both share a single source of truth:
const codes = Dictionary({ germany: 'DE', france: 'FR', usa: 'US' });
const Country = LiteralUnion(codes.keys());
type Country = InferLiteralUnion<typeof Country>;Now each primitive does what it is best at:
// Pure data lookup — the Dictionary projects a name to its value.
codes.germany; // 'DE'
// Pure dispatch — the LiteralUnion runs code per member, exhaustively.
Country.match(c, {
germany: () => '🇩🇪',
france: () => '🇫🇷',
usa: () => '🇺🇸',
});
// Dispatch that uses the projected value — match closes over the dictionary.
Country.match(c, {
germany: (k) => `${k}: ${codes[k]}`, // 'germany: DE'
france: (k) => `${k}: ${codes[k]}`,
usa: (k) => `${k}: ${codes[k]}`,
});The union as a linchpin between representations
The canonical pattern above flows keys out of one dictionary into the union.
But a domain usually has many representations of the same entity — an
alpha-2 code, an ISO numeric code, a flag emoji, a wire format, a DB column.
The union is the right home for the name; each representation is a separate
Dictionary. The union then becomes the linchpin they all pivot around.
Think of 'germany' as the hub of a wheel, with a spoke to each
representation. None of the spokes connect to each other directly — they all
connect through the name. Define the union first, then build each
representation with Dictionary.fromLiteralUnion, which forces every
dictionary to cover the union exhaustively:
const Country = LiteralUnion(['germany', 'france', 'usa']);
// Each representation must cover every member — forget one and it won't compile.
const alpha2 = Dictionary.fromLiteralUnion(Country, { germany: 'DE', france: 'FR', usa: 'US' });
const numeric = Dictionary.fromLiteralUnion(Country, { germany: 276, france: 250, usa: 840 });
const flag = Dictionary.fromLiteralUnion(Country, { germany: '🇩🇪', france: '🇫🇷', usa: '🇺🇸' });Because every dictionary is keyed by the same union, the representations never
drift apart: adding a member to Country makes all three fromLiteralUnion
calls fail to compile until you supply the missing entry. Converting between
any two representations is always a two-step pivot — encoding → name →
encoding — and the name is the one fixed point they all agree on.
Choosing between match and a Dictionary lookup
- If a name maps to a fixed value, reach for the
Dictionary(codes[name]). It is a pure data lookup, allocation-free, and serializable. - If a name needs to run logic (call a service, branch on more than the
value, build something), reach for
LiteralUnion.match. It guarantees exhaustiveness and gives per-branch narrowing.
End-to-end example: guard, then dispatch
A typical boundary flow combines both primitives — validate untrusted input with the union's guard, then project or dispatch:
const codes = Dictionary({ germany: 'DE', france: 'FR', usa: 'US' });
const Country = LiteralUnion(codes.keys());
function toAlpha2(input: unknown): string {
if (!Country.isOfType(input)) {
throw new Error(`Unknown country: ${String(input)}`);
}
// input: 'germany' | 'france' | 'usa'
return codes[input]; // 'DE' | 'FR' | 'US'
}Because the union is derived from the dictionary's keys, the two can never
drift out of sync: adding a country to codes automatically extends both the
union's members and the exhaustiveness check on every match.
Invariant
An Invariant is a validation rule for an already-typed value: a function
(value: TValue) => Result<void, TError> that returns Ok when the value
satisfies the rule and Err when it does not. Invariants are the primary way
to express domain constraints on a Scalar beyond what its decoder
can check (e.g. "this number must be positive", "this string must not be
empty").
Creating an invariant
Call Invariant with a predicate and an error factory. The error factory
receives the offending value so it can produce context-rich errors:
import { Invariant } from '@typemint/data';
const isPositive = Invariant(
(n: number) => n > 0,
(n) => `Expected positive number, got ${n}`,
);
isPositive(5); // Ok(void)
isPositive(-1); // Err('Expected positive number, got -1')Invariants compose freely via the static combinators below. A single
invariant can also be built from Result.liftPredicate when you already have
a predicate and an error factory available as separate values:
import { Result } from '@typemint/result';
const isNonEmpty = Result.liftPredicate(
(s: string) => s.length > 0,
() => 'EMPTY_STRING',
);
// (value: string) => Result<string, 'EMPTY_STRING'>
// assignable to Invariant<string, 'EMPTY_STRING'>Invariant.and(first, ...rest)
Fail-fast AND: runs each invariant left to right and returns the first Err
immediately, without evaluating the remaining invariants. Returns Ok only
when every invariant passes. The error type is the union of every individual
error type.
Use and when the first violation is enough to make the value unacceptable
and subsequent checks would be redundant or misleading.
const isInRange = Invariant.and(
Invariant((n: number) => n > 0, () => 'TOO_SMALL' as const),
Invariant((n: number) => n < 100, () => 'TOO_LARGE' as const),
);
isInRange(50); // Ok(void)
isInRange(-1); // Err('TOO_SMALL') — second invariant never runs
isInRange(200); // Err('TOO_LARGE')Invariant.andSettled(first, ...rest)
Accumulating AND: runs every invariant regardless of earlier failures and
collects all errors into an array. Returns Ok only when every invariant
passes; otherwise returns Err([...errors]) with one entry per failing
invariant. The error type is an array of the union of every individual error
type.
Use andSettled when you want to surface every violation at once — for
example, validating all fields of a form rather than stopping at the first
invalid one.
const validate = Invariant.andSettled(
Invariant((n: number) => n > 0, () => 'TOO_SMALL' as const),
Invariant((n: number) => n < 100, () => 'TOO_LARGE' as const),
Invariant((n: number) => n % 2 === 0, () => 'NOT_EVEN' as const),
);
validate(50); // Ok(void)
validate(-3); // Err(['TOO_SMALL', 'NOT_EVEN']) — both violations reported
validate(200); // Err(['TOO_LARGE', 'NOT_EVEN'])Invariant.or(first, ...rest)
Short-circuit OR: runs each invariant left to right and returns Ok
immediately on the first success, without evaluating the remaining invariants.
Returns the last Err only when every invariant fails. The error type is
the union of every individual error type.
Use or to express "at least one of these conditions must hold" — for
example, a value that is acceptable when it is either zero or a positive even
number.
const isNonZero = Invariant.or(
Invariant((n: number) => n > 0, () => 'NOT_POSITIVE' as const),
Invariant((n: number) => n < 0, () => 'NOT_NEGATIVE' as const),
);
isNonZero(5); // Ok(void) — first invariant passes, second never runs
isNonZero(-3); // Ok(void) — second invariant passes
isNonZero(0); // Err('NOT_NEGATIVE') — both fail, last error returnedCombinators compose freely, so complex rules can be expressed by nesting:
const isValidScore = Invariant.and(
Invariant((n: number) => Number.isInteger(n), () => 'NOT_INTEGER' as const),
Invariant.or(
Invariant((n: number) => n === 0, () => 'NOT_ZERO' as const),
Invariant((n: number) => n >= 10, () => 'BELOW_MIN' as const),
),
);Invariant type helpers
| Type | Description |
| ---- | ----------- |
| Invariant<TValue, TError> | The invariant function type: (value: TValue) => Result<void, TError>. |
| InferInvariantError<T> | Extracts the error type from an Invariant. |
const isPositive = Invariant(
(n: number) => n > 0,
() => 'NOT_POSITIVE' as const,
);
type Error = InferInvariantError<typeof isPositive>;
// type Error = 'NOT_POSITIVE'Built-in invariants
The examples above build invariants by hand, and their errors are bare string
literals ('NOT_EMAIL'). For the two primitives that most often need refining —
string and number — @typemint/data ships a set of ready-made invariant
factories. Each one takes an options object and returns an ordinary
Invariant, so it drops straight into a
Scalar's invariants array or composes with
Invariant.and/or/andSettled:
import {
Scalar,
NonEmptyStringInvariant,
StringMaxLengthInvariant,
} from '@typemint/data';
const Username = Scalar('Username', 'string', {
invariants: [
NonEmptyStringInvariant(),
StringMaxLengthInvariant({ maxLength: 32 }),
],
});Unlike the hand-rolled invariants above, the built-ins fail with a structured error object rather than a string. Every error carries:
kind— a discriminant string (e.g.'StringMinLengthInvariantError') you can switch on;message— a human-readable description (see Custom messages);details— the offendingreceivedvalue plus whatever bound was violated (minLength,maxLength,pattern,lowerBound).
const r = StringMinLengthInvariant({ minLength: 3 })('hi');
// Err({
// kind: 'StringMinLengthInvariantError',
// message: 'String must be at least 3 characters long. Got 2.',
// details: { minLength: 3, received: 'hi' },
// })Custom messages
Every built-in accepts an optional message in its options. It is a
MessageOption — either a fixed string, or a function computed from the
offending value — and it replaces only the default message text; the kind
and details are unaffected. When omitted, a sensible default message is used.
// Fixed string.
StringMinLengthInvariant({ minLength: 8, message: 'Too short!' });
// Computed from the offending value.
StringMinLengthInvariant({
minLength: 8,
message: (value) => `"${value}" is only ${value.length} characters.`,
});String invariants
| Factory | Options | Holds when | Error kind |
| --- | --- | --- | --- |
| NonEmptyStringInvariant | { message? } | value.length > 0 | NonEmptyStringInvariantError |
| StringMinLengthInvariant | { minLength, message? } | value.length >= minLength | StringMinLengthInvariantError |
| StringMaxLengthInvariant | { maxLength, message? } | value.length <= maxLength | StringMaxLengthInvariantError |
| StringPatternInvariant | { pattern, message? } | pattern.test(value) | StringPatternInvariantError |
NonEmptyStringInvariant()(''); // Err(NonEmptyStringInvariantError)
StringMinLengthInvariant({ minLength: 3 })('ok'); // Err — details: { minLength: 3, received: 'ok' }
StringMaxLengthInvariant({ maxLength: 5 })('hello'); // Ok(void)
StringPatternInvariant({ pattern: /^\d+$/ })('12a'); // Err — details: { pattern: /^\d+$/, received: '12a' }Two behaviours worth knowing:
- Length is measured in UTF-16 code units (
String.prototype.length), not Unicode code points or grapheme clusters — so an astral character such as'👍'counts as2. This isO(1)and matches the platform's native.length. minLength/maxLengthmust be non-negative integers. A non-integer, negative,NaN, or infinite bound is a programmer error in the schema, so it throws aRangeErrorat construction time rather than silently producing an invariant that accepts or rejects everything.StringPatternInvariantis stateless. Thegandyflags are stripped from the suppliedpattern(they mutatelastIndexand would make a reusable invariant return alternating results), and the normalized copy is stored — the caller'sRegExpinstance is never mutated.
Number invariants
| Factory | Options | Holds when | Error kind |
| --- | --- | --- | --- |
| IsIntegerInvariant | { message? } | Number.isInteger(value) | IsIntegerInvariantError |
| IsGreaterThanNumberInvariant | { lowerBound, message? } | value > lowerBound | IsGreaterThanNumberInvariantError |
| IsGreaterThenOrEqualNumberInvariant | { lowerBound, message? } | value >= lowerBound | IsGreaterThenOrEqualNumberInvariantError |
| IsLowerThanNumberInvariant | { lowerBound, message? } | value < lowerBound | IsLowerThanNumberInvariantError |
| IsLowerThenOrEqualNumberInvariant | { lowerBound, message? } | value <= lowerBound | IsLowerThenOrEqualNumberInvariantError |
Every comparison factory takes its bound under the lowerBound key (regardless
of the comparison direction), and reports both received and lowerBound in
its error details.
IsIntegerInvariant()(2.5); // Err(IsIntegerInvariantError)
IsGreaterThanNumberInvariant({ lowerBound: 0 })(-1); // Err — details: { received: -1, lowerBound: 0 }
IsLowerThenOrEqualNumberInvariant({ lowerBound: 100 })(100); // Ok(void)They compose naturally into range checks with Invariant.and, ready to hand to
a scalar:
const isPercentage = Invariant.and(
IsGreaterThenOrEqualNumberInvariant({ lowerBound: 0 }),
IsLowerThenOrEqualNumberInvariant({ lowerBound: 100 }),
);
const Percentage = Scalar('Percentage', 'number', { invariants: [isPercentage] });Scalar
A Scalar is a branded primitive: an ordinary string, number,
bigint, or boolean tagged with a phantom brand that makes it a distinct
nominal type. A Scalar<'Email', string> is a string at runtime — the
brand exists only in the type system — but the compiler treats it as its own
type that a plain string is not assignable to.
import { Scalar, InferScalarType } from '@typemint/data';
const isEmail = Invariant(
(value: string) => value.includes('@'),
() => 'NOT_EMAIL' as const,
);
const Email = Scalar('Email', 'string', { invariants: [isEmail] });
type Email = InferScalarType<typeof Email>; // Scalar<'Email', string>
const result = Email.parse('[email protected]'); // Result<Email, TypeMismatchError<…> | 'NOT_EMAIL'>