Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions packages/guards/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@packrat/guards",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"radash": "^12.1.0"
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

radash is declared as ^12.1.0 here while other workspaces already depend on ^12.1.1. Consider aligning the version range to reduce lockfile churn and avoid accidental multi-version installs.

Suggested change
"radash": "^12.1.0"
"radash": "^12.1.1"

Copilot uses AI. Check for mistakes.
}
}
60 changes: 60 additions & 0 deletions packages/guards/src/assertions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Assertion helpers. These throw on failure and narrow the type
* of the caller's variable via `asserts` clauses.
*
* Prefer these over non-null assertions (`!`) and `as` casts when
* you need to tell TypeScript that a value is present/valid.
*/

export function assertDefined<T>(
value: T | undefined,
message = 'Value must be defined',
): asserts value is T {
if (value === undefined) throw new Error(message);
}

export function assertNonNull<T>(
value: T | null,
message = 'Value must be non-null',
): asserts value is T {
if (value === null) throw new Error(message);
}

export function assertPresent<T>(
value: T | null | undefined,
message = 'Value must be present',
): asserts value is T {
if (value === null || value === undefined) throw new Error(message);
}

export function assertIsString(
value: unknown,
message = 'Expected a string',
): asserts value is string {
if (typeof value !== 'string') throw new Error(message);
}

export function assertIsNumber(
value: unknown,
message = 'Expected a number',
): asserts value is number {
if (typeof value !== 'number' || Number.isNaN(value)) throw new Error(message);
}

export function assertIsBoolean(
value: unknown,
message = 'Expected a boolean',
): asserts value is boolean {
if (typeof value !== 'boolean') throw new Error(message);
}

export function assertAllDefined(
values: readonly unknown[],
message = 'All values must be defined',
): void {
for (let i = 0; i < values.length; i++) {
if (values[i] === undefined) {
throw new Error(`${message} (index ${i})`);
}
}
}
37 changes: 37 additions & 0 deletions packages/guards/src/enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Helpers for validating string literal unions at runtime.
*
* Use these when mapping API responses (`string`) into internal
* string literal types like `type WeightUnit = 'g' | 'kg' | 'oz' | 'lb'`.
*/

/**
* Builds a type guard for a string literal union from its members.
*
* @example
* const WEIGHT_UNITS = ['g', 'kg', 'oz', 'lb'] as const;
* type WeightUnit = (typeof WEIGHT_UNITS)[number];
* const isWeightUnit = makeEnumGuard(WEIGHT_UNITS);
*
* if (isWeightUnit(raw)) {
* // raw is now narrowed to WeightUnit
* }
*/
export const makeEnumGuard =
<T extends string>(members: readonly T[]) =>
(value: unknown): value is T =>
typeof value === 'string' && (members as readonly string[]).includes(value);

/**
* Asserts a string belongs to a literal union, throwing otherwise.
* Narrows the caller's variable via an `asserts` clause.
*/
export function assertEnum<T extends string>(
value: unknown,
members: readonly T[],
name = 'value',
): asserts value is T {
if (typeof value !== 'string' || !(members as readonly string[]).includes(value)) {
throw new Error(`Invalid ${name}: expected one of ${members.join(', ')}, got ${String(value)}`);
}
}
28 changes: 28 additions & 0 deletions packages/guards/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @packrat/guards — runtime type guards and narrowing helpers.
*
* Re-exports radash's primitive guards so all narrowing goes through
* one canonical import path, and adds project-specific assertions
* on top. Import from `@packrat/guards` instead of reaching into
* `radash` or scattering per-app `typeAssertions.ts` copies.
*/

export {
isArray,
isDate,
isEmpty,
isEqual,
isFloat,
isFunction,
isInt,
isNumber,
isObject,
isPrimitive,
isPromise,
isString,
isSymbol,
} from 'radash';
Comment on lines +10 to +24
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description lists isBoolean as a radash re-export, but it is not currently exported from @packrat/guards. Either add it to this re-export list (if available in radash) or update the exported surface/docs so consumers don't hit missing import errors.

Copilot uses AI. Check for mistakes.

export * from './assertions';
export * from './enum';
export * from './narrow';
51 changes: 51 additions & 0 deletions packages/guards/src/narrow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Narrowing helpers that return `T | undefined` instead of throwing.
*
* Useful when mapping external data (API responses, unknown records)
* into strict internal types without `as` casts.
*/

/** Returns the value if it's a string, otherwise undefined. */
export const asString = (value: unknown): string | undefined =>
typeof value === 'string' ? value : undefined;

/** Returns the value if it's a finite number, otherwise undefined. */
export const asNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;

/** Returns the value if it's a boolean, otherwise undefined. */
export const asBoolean = (value: unknown): boolean | undefined =>
typeof value === 'boolean' ? value : undefined;

/**
* Coerces null → undefined for use with `exactOptionalPropertyTypes`
* stores that only accept `string | undefined`, not `string | null`.
*/
export const nullToUndefined = <T>(value: T | null): T | undefined =>
value === null ? undefined : value;

/**
* Returns the value if it's a Date, parses it if it's a string/number,
* otherwise undefined.
*/
export const asDate = (value: unknown): Date | undefined => {
if (value instanceof Date) return value;
if (typeof value === 'string' || typeof value === 'number') {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}
return undefined;
};

/**
* Returns a `Record<string, string>` from an unknown value, keeping only
* string-valued entries. Returns `{}` if the input isn't a plain object.
*/
export const asStringRecord = (value: unknown): Record<string, string> => {
if (value === null || typeof value !== 'object') return {};
const out: Record<string, string> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
Comment on lines +40 to +47
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asStringRecord claims it returns {} when the input isn't a plain object, but the current check only excludes null and non-objects—arrays (and other non-plain objects) will be treated as objects and may produce unexpected keys. Tighten the guard to reject arrays / non-plain objects before iterating entries.

Suggested change
/**
* Returns a `Record<string, string>` from an unknown value, keeping only
* string-valued entries. Returns `{}` if the input isn't a plain object.
*/
export const asStringRecord = (value: unknown): Record<string, string> => {
if (value === null || typeof value !== 'object') return {};
const out: Record<string, string> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};
/**
* Returns a `Record<string, string>` from an unknown value, keeping only
* string-valued entries. Returns `{}` if the input isn't a plain object.
*/
export const asStringRecord = (value: unknown): Record<string, string> => {
if (!isPlainObject(value)) return {};
const out: Record<string, string> = {};
for (const [key, val] of Object.entries(value)) {

Copilot uses AI. Check for mistakes.
if (typeof val === 'string') out[key] = val;
}
return out;
};
2 changes: 2 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"expo-app/*": ["./apps/expo/*"],
"app/*": ["./packages/app/*"],
"@packrat/api/*": ["./packages/api/src/*"],
"@packrat/guards": ["./packages/guards/src"],
"@packrat/guards/*": ["./packages/guards/src/*"],
"@packrat/ui/*": ["./packages/ui/*"],
"nativewindui/*": ["./apps/expo/components/ui/*"]
}
Expand Down
Loading