Docs / Packages / @zudojs/transactions
Docs / Packages / @zudojs/types
v1.2.0

@zudojs/types

Small checks that tell TypeScript what a value really is, utility types that reshape existing types, safe converters, and injectable Clock and Random primitives.

TYPE GUARDS UTILITY TYPES CONVERTERS CLOCK & RANDOM ZERO DEPENDENCIES

OVERVIEW

TypeScript only knows the types you write down. The moment a value arrives from outside your program — a JSON body, an environment variable, a database row — its type is unknown, and TypeScript refuses to let you use it until you prove what it is.

@zudojs/types gives you three kinds of tool for that. Type guards are small functions that check a value at runtime and, when they pass, tell TypeScript the narrower type. Utility types reshape a type you already have, without you rewriting it. Converters turn a doubtful value into a string, number, boolean or array, falling back to a value you choose instead of guessing.

It also ships Clock and Random: two tiny interfaces that stand in for Date.now() and secure random generation, so a test can hand your code a fixed clock and a seeded generator instead of the real thing.

WHEN YOU NEED IT
  • → You hold an unknown value and need TypeScript to accept it as something concrete
  • → You want one shared definition of "is an email", "is a UUID", "is an ISO date" across packages
  • → You are reshaping a type: deep readonly, deep partial, make two keys required
  • → Your code calls Date.now() or generates tokens, and you want to test it deterministically
WHEN YOU DON'T
  • → You need to check a whole object shape at once with field-level error messages. Use @zudojs/validation or @zudojs/schema
  • → The value came from your own code and TypeScript already knows its type
  • → You need hashing, signing or encryption. Use @zudojs/crypto

INSTALLATION

Install the package. It has no dependencies of its own, so nothing else is pulled in.

$ npm install @zudojs/types
These docs follow the framework source. If an export shown here is missing from the version you installed, update to the latest @zudojs release.
Tip: the utility types are types only. Import them with import type { Maybe } from "@zudojs/types" so they disappear entirely from the compiled JavaScript.

QUICK START

This example takes a value TypeScript knows nothing about, proves it is an email, and converts two strings that came out of a query string.

import { isEmail, toNumber, camelToSnake } from "@zudojs/types"; // Pretend this arrived in a request body. TypeScript knows nothing about it. const input: unknown = "ada@example.com"; if (isEmail(input)) { // Inside this block, TypeScript now treats `input` as a string. console.log(input.toUpperCase()); } console.log(toNumber("25", 20)); console.log(toNumber("", 20)); console.log(camelToSnake("createdAt"));

What you should see:

ADA@EXAMPLE.COM 25 20 created_at

The second toNumber call is the interesting one. An empty query parameter is not the number zero, so the converter refuses to guess and returns the fallback you passed.

TYPE GUARDS

A type guard is a function that answers "is this value a X?" at runtime, and whose return type is written value is X instead of boolean. That last part is what makes it more than an ordinary check: when the function returns true, TypeScript narrows the value to X for the rest of that branch.

Without a guard you would write a check and then still have to cast, and a cast is just you telling the compiler to trust you. A guard does the check and earns the trust in one step.

This function turns an unknown request body into a typed object, or null if any field is wrong.

import { isPlainObject, isNonEmptyString, isEmail, isPositiveNumber, } from "@zudojs/types"; interface SignUp { name: string; email: string; age: number; } function readSignUp(body: unknown): SignUp | null { if (!isPlainObject(body)) return null; const { name, email, age } = body; if (!isNonEmptyString(name)) return null; if (!isEmail(email)) return null; if (!isPositiveNumber(age)) return null; // Every field is narrowed by now, so this object type-checks. return { name, email, age }; } console.log(readSignUp({ name: "Ada", email: "ada@example.com", age: 36 })); console.log(readSignUp({ name: "Ada", email: "ada@", age: 36 }));

What you should see:

{ name: 'Ada', email: 'ada@example.com', age: 36 } null

Checking an array, and filtering out nulls

isArrayOfType takes another guard and applies it to every element. isDefined is written so that passing it straight to Array.prototype.filter removes the null from the resulting type.

import { isArrayOfType, isDefined, isNonEmptyString } from "@zudojs/types"; const tags: unknown = ["auth", "logging"]; if (isArrayOfType(tags, isNonEmptyString)) { // tags is string[] here. console.log(tags.join(", ")); } const maybeIds: (string | null)[] = ["a", null, "b"]; const ids: string[] = maybeIds.filter(isDefined); console.log(ids);

Prints auth, logging and then [ 'a', 'b' ]. Without isDefined, the filtered array would still be typed (string | null)[].

Guards that come in pairs

Four of the guards have a stricter or looser sibling. Pick deliberately.

LooserStricterHow they differ
isUuidisUuidV4isUuid accepts versions 1–8 (UUIDv7 included) plus the nil and max UUIDs. isUuidV4 accepts v4 only.
isIsoDateStringisIsoDateTimeStringThe first accepts "2024-01-31" as well as a full timestamp. The second requires a time component.
isThenableisPromiseisThenable means "safe to await" and narrows to PromiseLike. isPromise means a real Promise, so .catch() and .finally() exist.
isNonNullObjectisPlainObjectThe first accepts arrays and class instances. The second accepts only object literals and null-prototype objects.
Watch out: isPositiveNumber and isFiniteNumber both reject Infinity and NaN. A count, a size or a price has no meaningful infinite value, so the guard treats it as bad input rather than a large number.
In plain words: isUrl is a guard, not a fetcher. It returns true only for a parseable http: or https: URL — "ftp://example.com" is false — and never checks whether the address exists.

UTILITY TYPES

A utility type is a type that takes another type as input and produces a new one. You write DeepReadonly<AppConfig> the way you would call a function, except it runs in the compiler and produces zero JavaScript.

They exist so one type stays the single source of truth. Instead of hand-writing a second "everything optional" copy of your config interface that drifts out of date, you derive it.

This example freezes a config type all the way down, and makes two optional keys required.

import type { DeepReadonly, RequireKeys, Maybe } from "@zudojs/types"; interface AppConfig { database: { host: string; port: number }; features: string[]; } const config: DeepReadonly<AppConfig> = { database: { host: "localhost", port: 5432 }, features: ["auth"], }; // config.database.port = 3306; // Error TS2540: Cannot assign to 'port' because it is a read-only property. interface Draft { id?: string; title?: string; body?: string; } // Same as Draft, but `id` and `title` must be present. type Publishable = RequireKeys<Draft, "id" | "title">; const post: Publishable = { id: "p1", title: "Hello" }; // Maybe<T> is shorthand for T | null | undefined. const nickname: Maybe<string> = null; console.log(config.database.host, post.title, nickname);

Prints localhost Hello null. The two commented lines are compile errors, not runtime ones — remove the comment and tsc refuses to build.

Dot-notation paths

NestedKeyOf lists every path through an object type as a string, and NestedValueOf looks up the type at one of those paths. This is how you type a get("app.debug") helper without any.

import type { NestedKeyOf, NestedValueOf } from "@zudojs/types"; interface Settings { app: { name: string; debug: boolean }; db: { host: string }; } type SettingsPath = NestedKeyOf<Settings>; // "app" | "app.name" | "app.debug" | "db" | "db.host" type DebugFlag = NestedValueOf<Settings, "app.debug">; // boolean const path: SettingsPath = "app.debug"; const flag: DebugFlag = true; console.log(path, flag);

Prints app.debug true. Typing "app.dbug" instead is a compile error, because that string is not in the union.

Tip: Prettify<T> changes nothing about a type except how your editor shows it. Wrap an intersection like A & B in it and the tooltip becomes one flat list of properties instead of A & B.

CONVERTERS

A converter takes a value of any type and returns the type you asked for. Where the value cannot honestly be converted, it returns a fallback you supply rather than producing a plausible-looking wrong answer.

That rule matters more than it sounds. Number("") is 0, and a missing page-size parameter silently becoming zero is a bug you find in production. These converters refuse instead.

This example parses an untrusted JSON body and reads two fields out of it.

import { safeJsonParse, toNumber, toBoolean, toString } from "@zudojs/types"; const body = '{"limit":"25","verbose":"yes"}'; const parsed = safeJsonParse<Record<string, unknown>>(body, {}); console.log(toNumber(parsed.limit, 20)); console.log(toBoolean(parsed.verbose)); console.log(toNumber("0x10", 20)); console.log(toString(Symbol("x"))); console.log(safeJsonParse("{not json", { limit: 20 }));

What you should see:

25 true 20 Symbol(x) { limit: 20 }

toBoolean accepts "true", "1", "yes" and "on" as true, and "false", "0", "no", "off" and "" as false. Anything else returns the fallback, which defaults to false.

Danger: "safe" in safeJsonParse means only that broken JSON returns your fallback instead of throwing. The type parameter is a cast, not a check — the parsed value is not validated against it. Validate anything from a trust boundary with @zudojs/validation or @zudojs/schema.

Maps and objects

mapToObject turns a Map into a plain object, and objectToMap goes the other way.

import { mapToObject, objectToMap, toArray } from "@zudojs/types"; const headers = new Map([ ["content-type", "application/json"], ["x-request-id", "abc"], ]); const asObject = mapToObject(headers); console.log(Object.keys(asObject)); console.log(Object.getPrototypeOf(asObject)); console.log(objectToMap({ a: 1 }).get("a")); console.log(toArray("one"), toArray(["one", "two"]));

What you should see:

[ 'content-type', 'x-request-id' ] null 1 [ 'one' ] [ 'one', 'two' ]
Watch out: the object mapToObject returns has a null prototype — that is the null on the second line above. Your keys are all there, but inherited methods are not, so asObject.hasOwnProperty(k) throws. Use Object.hasOwn(asObject, k) instead. The reason is that a __proto__ key coming from request data must not be able to replace the result's prototype.

Counting words

formatCount(count, singular, plural?) (new in 1.2.0) puts a number in front of the right form of a word: the singular for exactly 1 (or -1), the plural for everything else, including 0. It exists so messages never say "1 characters". When you leave out plural, it adds an s; pass it for irregular words.

import { formatCount } from "@zudojs/types"; console.log(formatCount(1, "file")); console.log(formatCount(3, "file")); console.log(formatCount(0, "item")); console.log(formatCount(2, "entry", "entries"));

What you should see:

1 file 3 files 0 items 2 entries

The @zudojs/schema and @zudojs/validation length and count messages are built with it ("at least 1 character", "at least 1 item").

CASE CONVERSION

Databases usually name columns created_at, JavaScript names properties createdAt, and CSS names classes primary-button. These four functions translate between those conventions.

Each call below converts one identifier.

import { camelToSnake, snakeToCamel, camelToKebab, kebabToCamel, } from "@zudojs/types"; console.log(camelToSnake("createdAt")); console.log(camelToSnake("HelloWorld")); console.log(camelToSnake("parseHTTPResponse")); console.log(snakeToCamel("user_name")); console.log(camelToKebab("primaryButton")); console.log(kebabToCamel("get-user-data"));

What you should see:

created_at hello_world parse_http_response userName primary-button getUserData

Note the second and third lines. A leading capital does not produce a leading underscore, and a run of capitals stays one word — parseHTTPResponse becomes parse_http_response, not parse_h_t_t_p_response.

CLOCK AND RANDOM

Code that calls Date.now() or generates a random token directly is hard to test: every run gives a different answer. The fix is to accept the capability as a parameter instead of reaching for it.

Clock has one method, now(). ClockSeconds has nowSeconds(). Random has uuid(), int(max), string(length) and custom(length, alphabet). The package ships a real implementation of each — systemClock, systemClockSeconds, systemRandom — plus test doubles.

Here a session-token function takes both as defaulted parameters, so production code calls it with no arguments and a test passes doubles.

import { systemClock, systemRandom, FixedClock, SeededRandom, } from "@zudojs/types"; import type { Clock, Random, PseudoRandom } from "@zudojs/types"; function makeSessionId( clock: Clock = systemClock, random: Random = systemRandom, ): string { return `session_${clock.now()}_${random.uuid()}`; } // Production: real time, cryptographically secure id. console.log(makeSessionId()); // session_1757376000000_9f1c8b0e-4d2a-4c1b-9a77-6b3f0c2e51da // A FixedClock starts where you put it and only moves when you say so. const clock = new FixedClock(1700000000000); clock.advance(5000); console.log(clock.now()); // The same seed always produces the same sequence. const seeded: PseudoRandom = new SeededRandom(42); console.log(seeded.string(8)); console.log(new SeededRandom(42).string(8));

What you should see (line 1 changes every run, the rest never do):

session_1757376000000_9f1c8b0e-4d2a-4c1b-9a77-6b3f0c2e51da 1700000005000 kesVo50h kesVo50h

FixedClock also has set(time) if you want to jump to an exact millisecond instead of advancing.

BRANDED TYPES

TypeScript matches types by shape, not by name. Two interfaces with the same methods are interchangeable, even if they mean completely different things. That is usually convenient and occasionally dangerous.

A branded (or nominal) type adds one impossible-to-forge property — here a unique symbol that is never exported — purely so shape-matching stops working. Now only code that goes through the package's own factory can produce the type.

Random is branded. PseudoRandom has exactly the same four methods but no brand, so SeededRandom cannot be handed to anything asking for a Random. Without that, a test double whose output is fully predictable from its seed could be injected where unpredictability is the whole requirement, and every token would be guessable.

This is the error you get if you try, and it is a good error to get.

import { SeededRandom } from "@zudojs/types"; import type { Random, PseudoRandom } from "@zudojs/types"; const ok: PseudoRandom = new SeededRandom(1); console.log(ok.deterministic); // true // const bad: Random = new SeededRandom(1); // Error TS2741: Property '[SecureRandomBrand]' is missing in type // 'SeededRandom' but required in type 'Random'.

To supply your own secure generator, build it with defineSecureRandom. That function is the only supported way to attach the brand, so the claim "this output is unpredictable" is something you opt into explicitly.

import { defineSecureRandom } from "@zudojs/types"; import type { Random } from "@zudojs/types"; import { randomInt, randomUUID } from "node:crypto"; const ALPHANUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; function draw(length: number, alphabet: string): string { let out = ""; for (let i = 0; i < length; i++) { out += alphabet.charAt(randomInt(alphabet.length)); } return out; } const nodeRandom: Random = defineSecureRandom({ uuid: (): string => randomUUID(), int: (max: number): number => randomInt(max), string: (length: number): string => draw(length, ALPHANUM), custom: (length: number, alphabet: string): string => draw(length, alphabet), }); console.log(nodeRandom.string(8).length); // 8
Danger: only call defineSecureRandom for a generator backed by node:crypto, Web Crypto or a hardware source. Wrapping Math.random() in it compiles perfectly and quietly makes every token it produces predictable.
In plain words: systemRandom.int(max) draws uniformly for every bound, not just powers of two — it re-draws rather than taking a remainder, so no value comes up more often than another. It throws a RangeError unless max is an integer from 1 to Number.MAX_SAFE_INTEGER (MAX_RANDOM_INT_BOUND); bounds above 232 draw 53 bits.

API REFERENCE

Everything below is exported from @zudojs/types.

Type guards

NameWhat it doesNotes
isPlainObject(value)True for an object literal or a null-prototype objectFalse for arrays, null, class instances, Date
isNonNullObject(value)True for any non-null objectArrays and class instances included
isNonEmptyString(value)True for a string of length ≥ 1" " passes; it is not trimmed
isFiniteNumber(value)True for a finite numberRejects NaN and both infinities
isPositiveNumber(value)True for a finite number greater than 00 and Infinity are false
isInteger(value)True for a whole numberNegatives and 0 pass
isDate(value)True for a valid Date instancenew Date("nope") is false
isUrl(value)True for a parseable http:/https: URL stringOther protocols are false
isEmail(value)True for an email address stringSame acceptance set as ValidationPattern.EMAIL in @zudojs/constants (max 254 chars). @zudojs/validation's email constraint uses the same pattern and bound.
isUuid(value)True for a UUID of versions 1–8Nil and max UUIDs accepted
isUuidV4(value)True for a v4 UUID onlyUse when the version matters
isIsoDateString(value)True for an ISO 8601 date, with or without a timeValidates the calendar date; accepts +02:00 offsets
isIsoDateTimeString(value)Same, but a time component is required"2024-01-01" is false
isArrayOfType(value, guard)True if every element passes guardAn empty array passes
isDefined(value)True unless the value is null or undefined0, "" and false pass. Pass it to .filter()
isFunction(value)True for any functionClasses included
isPromise(value)True for a native PromiseNarrows to Promise, so .catch() is safe
isThenable(value)True for anything with a then methodNarrows to PromiseLikeawait only

Converters

NameWhat it doesNotes
safeJsonParse<T>(json, fallback)Parses JSON, returning fallback on failureT is a cast, not a check. __proto__, constructor and prototype keys are dropped at every depth, by design
toString(value, fallback?)Converts to a stringAlways returns a string. Default fallback ""; functions use it
toNumber(value, fallback?)Converts to a finite numberDefault fallback NaN. Refuses "", "0x10", "1e999", Infinity
toBoolean(value, fallback?)Converts to a booleanDefault fallback false. NaN uses the fallback
toArray(value)Wraps a non-array in an arrayAn existing array is returned unchanged
mapToObject(map)Turns a Map into a plain objectResult has a null prototype; use Object.hasOwn
objectToMap(obj)Turns an object into a MapOwn enumerable keys only
formatCount(count, singular, plural?)Formats a count with the matching word form, e.g. "1 file", "3 files"Singular only for 1 and -1. plural defaults to singular + "s". See Counting words
snakeToCamel(str)user_nameuserName
camelToSnake(str)createdAtcreated_atHandles leading capitals and acronyms; Unicode-aware, keeps punctuation
kebabToCamel(str)get-user-datagetUserData
camelToKebab(str)primaryButtonprimary-buttonHandles leading capitals and acronyms; Unicode-aware, keeps punctuation

Runtime values and classes

NameWhat it doesNotes
systemClockA Clock backed by Date.now()Milliseconds
systemClockSecondsA ClockSeconds backed by Date.now()Whole seconds
systemRandomThe secure Random implementationUses Web Crypto, falling back to node:crypto
defineSecureRandom(impl)Brands your own implementation as a RandomThe only way to produce a Random
FixedClockTest Clock: new FixedClock(ms), now(), set(ms), advance(deltaMs)Defaults to 0
SeededRandomTest PseudoRandom: new SeededRandom(seed)Defaults to seed 1. Backed by mulberry32 (sequences differ from earlier releases). Not assignable to Random

Interfaces

NameWhat it doesNotes
Clocknow(): numberMilliseconds since the epoch
ClockSecondsnowSeconds(): numberSeconds since the epoch
Randomuuid(), int(max), string(length), custom(length, alphabet)Branded. Build one with defineSecureRandom
PseudoRandomSame four methods plus deterministic: trueFor test doubles only

Utility types

NameWhat it doesNotes
DeepReadonly<T>Makes every property readonly, at every depthHandles arrays, Map and Set
DeepPartial<T>Makes every property optional, at every depthGood for patch payloads
DeepRequired<T>Makes every property required, at every depthGood for a config after defaults are applied
Prettify<T>Flattens a type for displayNo effect on behaviour, only on tooltips
Nullable<T>T | null
Undefinable<T>T | undefined
Maybe<T>T | null | undefinedPairs with isDefined
MaybePromise<T>T | Promise<T>For a callback that may be sync or async
StringKeysOf<T> / NumberKeysOf<T>Only the string / number keys of T
RequireKeys<T, K>Makes the listed keys requiredEverything else is unchanged
OptionalKeys<T, K>Makes the listed keys optionalEverything else is unchanged
PartialExcept<T, K>Everything optional except the listed keys
RequiredExcept<T, K>Everything required except the listed keys
PickByValue<T, V> / OmitByValue<T, V>Keeps or drops properties by their value typee.g. all the string fields
NestedKeyOf<T>Every dot-notation path through T, as a string union
NestedValueOf<T, P>The type found at path Pnever if the path does not exist
AsyncReturnType<T>What an async function resolves toThe awaited type, not the Promise

COMMON MISTAKES

  • Trusting the type parameter of safeJsonParse. Nothing checks the parsed value against it, so a missing field is undefined at runtime while TypeScript says it is a string. Fix: use it for trusted data, and validate untrusted data with @zudojs/validation or @zudojs/schema.
  • Calling hasOwnProperty on a mapToObject result. The object has a null prototype, so the method does not exist and the call throws. Fix: Object.hasOwn(obj, key), or key in obj.
  • Expecting toNumber("") to be 0. A blank string is a missing value, not a zero, so you get your fallback — and NaN if you did not pass one. Fix: always pass a fallback: toNumber(query.limit, 20).
  • Using isPromise to check "can I await this?". A plain thenable fails the check even though awaiting it works fine. Fix: use isThenable for awaiting; keep isPromise for when you need .catch() or .finally().
  • Passing a SeededRandom where a Random is expected. It will not compile, and that is the point — its output is predictable from the seed. Fix: type the parameter PseudoRandom in tests, or wrap a real crypto source with defineSecureRandom.
  • Reaching for isUuidV4 by habit. UUIDv7 identifiers, which sort by creation time and are increasingly common, fail it. Fix: use isUuid unless the version genuinely matters.

COMPLETE EXPORT INDEX

Every name @zudojs/types exports from its package root at v1.2.0 — 62 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.

Show all 62 exports
Classes (2)
FixedClock SeededRandom
Functions (31)
camelToKebab camelToSnake defineSecureRandom formatCount isArrayOfType isDate isDefined isEmail isFiniteNumber isFunction isInteger isIsoDateString isIsoDateTimeString isNonEmptyString isNonNullObject isPlainObject isPositiveNumber isPromise isThenable isUrl isUuid isUuidV4 kebabToCamel mapToObject objectToMap safeJsonParse snakeToCamel toArray toBoolean toNumber toString
Interfaces (4)
Clock ClockSeconds PseudoRandom Random
Type aliases (20)
AsyncReturnType DeepPartial DeepReadonly DeepRequired Maybe MaybePromise NestedKeyOf NestedValueOf Nullable NumberKeysOf OmitByValue OptionalKeyNames PartialExcept PartialKeys PickByValue Prettify RequiredExcept RequireKeys StringKeysOf Undefinable
Constants (5)
MAX_EMAIL_LENGTH MAX_RANDOM_INT_BOUND systemClock systemClockSeconds systemRandom