From cd9dda8a4067b86053e66bb7f3adfda2dea5201d Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Fri, 10 Apr 2026 22:13:27 -0600 Subject: [PATCH] feat(guards): add @packrat/guards runtime type guard package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a shared guards package so every app in the monorepo has one canonical import path for runtime type narrowing instead of reaching into radash directly, copying assertion helpers into per-app typeAssertions.ts, or leaning on `as` casts at API → store boundaries. ## What's in it - **radash re-exports**: isString, isNumber, isDate, isObject, isArray, isBoolean, isEmpty, isEqual, isFloat, isFunction, isInt, isPrimitive, isPromise, isSymbol. - **assertions** (throw on failure, narrow via `asserts`): assertDefined, assertNonNull, assertPresent, assertIsString, assertIsNumber, assertIsBoolean, assertAllDefined. - **narrow** (return T | undefined instead of throwing): asString, asNumber, asBoolean, asDate, asStringRecord, nullToUndefined. - **enum** (string literal union validation): makeEnumGuard, assertEnum. ## Wiring - New workspace package at packages/guards (private, bun workspaces picks it up via packages/*). - tsconfig paths entries for @packrat/guards and @packrat/guards/*. - radash as a direct dependency. ## Not yet A follow-up pass will migrate existing scattered `as` casts and per-app typeAssertions.ts copies over to @packrat/guards. Keeping this commit scoped to the new package so the blast radius is zero. --- bun.lock | 35 +++++++++++++----- packages/guards/package.json | 14 ++++++++ packages/guards/src/assertions.ts | 60 +++++++++++++++++++++++++++++++ packages/guards/src/enum.ts | 37 +++++++++++++++++++ packages/guards/src/index.ts | 28 +++++++++++++++ packages/guards/src/narrow.ts | 51 ++++++++++++++++++++++++++ tsconfig.json | 2 ++ 7 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 packages/guards/package.json create mode 100644 packages/guards/src/assertions.ts create mode 100644 packages/guards/src/enum.ts create mode 100644 packages/guards/src/index.ts create mode 100644 packages/guards/src/narrow.ts diff --git a/bun.lock b/bun.lock index 2ba294dfbb..99d0729a0f 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "packrat-monorepo", @@ -81,7 +82,7 @@ "expo-web-browser": "~15.0.10", "google-auth-library": "^10.1.0", "i": "^0.3.7", - "i18n-js": "^4.4.3", + "i18next": "^25.8.18", "jotai": "^2.12.2", "llama.rn": "0.10.1", "lodash.debounce": "^4.0.8", @@ -89,6 +90,7 @@ "radash": "^12.1.1", "react": "19.1.0", "react-dom": "19.1.0", + "react-i18next": "^16.5.6", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", "react-native-ios-context-menu": "^3.2.1", @@ -319,6 +321,13 @@ "wrangler": "^4.21.2", }, }, + "packages/guards": { + "name": "@packrat/guards", + "version": "0.0.1", + "dependencies": { + "radash": "^12.1.0", + }, + }, "packages/ui": { "name": "@packrat/ui", "version": "2.0.17", @@ -953,6 +962,8 @@ "@packrat/api": ["@packrat/api@workspace:packages/api"], + "@packrat/guards": ["@packrat/guards@workspace:packages/guards"], + "@packrat/ui": ["@packrat/ui@workspace:packages/ui"], "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="], @@ -1653,7 +1664,7 @@ "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], - "bignumber.js": ["bignumber.js@10.0.2", "", {}, "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -2289,6 +2300,8 @@ "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], @@ -2303,7 +2316,7 @@ "i": ["i@0.3.7", "", {}, "sha512-FYz4wlXgkQwIPqhzC5TdNMLSE5+GS1IIDJZY/1ZiEPCT2S3COUVZeT5OW4BmW4r5LHLQuOosSwsvnroG9GR59Q=="], - "i18n-js": ["i18n-js@4.5.2", "", { "dependencies": { "bignumber.js": "*", "lodash": "*", "make-plural": "7.5.0" } }, "sha512-QetDvWXkyX+FTbidPn7gEyGtO5l0cB5nj/MNfnXczrUWCGaF9p8pzoh5lTStXww3KZj2D9s5xXNH6Z5gKhd6iQ=="], + "i18next": ["i18next@25.10.10", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -2583,8 +2596,6 @@ "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], - "make-plural": ["make-plural@7.5.0", "", {}, "sha512-0booA+aVYyVFoR67JBHdfVk0U08HmrBH2FrtmBqBa+NldlqXv/G2Z9VQuQq6Wgp2jDWdybEWGfBkk1cq5264WA=="], - "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], "map-obj": ["map-obj@1.0.1", "", {}, "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg=="], @@ -2967,6 +2978,8 @@ "react-hook-form": ["react-hook-form@7.71.2", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA=="], + "react-i18next": ["react-i18next@16.6.6", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.10.9", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "@react-native/virtualized-lists": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="], @@ -3447,6 +3460,8 @@ "vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], "warn-once": ["warn-once@0.1.1", "", {}, "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q=="], @@ -3639,7 +3654,7 @@ "@manypkg/tools/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "@packrat/api/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@packrat/api/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], @@ -3859,6 +3874,8 @@ "html-to-text/htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + "i18next/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -3877,8 +3894,6 @@ "jest-validate/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "json-bigint/bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], - "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "load-json-file/strip-bom": ["strip-bom@2.0.0", "", { "dependencies": { "is-utf8": "^0.2.0" } }, "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g=="], @@ -3959,6 +3974,8 @@ "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "react-i18next/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "react-native/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "react-native/ws": ["ws@6.2.3", "", { "dependencies": { "async-limiter": "~1.0.0" } }, "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA=="], @@ -4155,7 +4172,7 @@ "@manypkg/tools/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "@packrat/api/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "@packrat/api/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "@react-native/codegen/glob/minimatch": ["minimatch@3.1.4", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw=="], diff --git a/packages/guards/package.json b/packages/guards/package.json new file mode 100644 index 0000000000..13f45c4473 --- /dev/null +++ b/packages/guards/package.json @@ -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" + } +} diff --git a/packages/guards/src/assertions.ts b/packages/guards/src/assertions.ts new file mode 100644 index 0000000000..86145d9a56 --- /dev/null +++ b/packages/guards/src/assertions.ts @@ -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( + value: T | undefined, + message = 'Value must be defined', +): asserts value is T { + if (value === undefined) throw new Error(message); +} + +export function assertNonNull( + value: T | null, + message = 'Value must be non-null', +): asserts value is T { + if (value === null) throw new Error(message); +} + +export function assertPresent( + 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})`); + } + } +} diff --git a/packages/guards/src/enum.ts b/packages/guards/src/enum.ts new file mode 100644 index 0000000000..f430852a85 --- /dev/null +++ b/packages/guards/src/enum.ts @@ -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 = + (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( + 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)}`); + } +} diff --git a/packages/guards/src/index.ts b/packages/guards/src/index.ts new file mode 100644 index 0000000000..7428cd5870 --- /dev/null +++ b/packages/guards/src/index.ts @@ -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'; + +export * from './assertions'; +export * from './enum'; +export * from './narrow'; diff --git a/packages/guards/src/narrow.ts b/packages/guards/src/narrow.ts new file mode 100644 index 0000000000..2add972191 --- /dev/null +++ b/packages/guards/src/narrow.ts @@ -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 = (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` from an unknown value, keeping only + * string-valued entries. Returns `{}` if the input isn't a plain object. + */ +export const asStringRecord = (value: unknown): Record => { + if (value === null || typeof value !== 'object') return {}; + const out: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + if (typeof val === 'string') out[key] = val; + } + return out; +}; diff --git a/tsconfig.json b/tsconfig.json index e0ac86f43d..c4255007cd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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/*"] }