diff --git a/apps/expo/app/(app)/(tabs)/(home)/index.tsx b/apps/expo/app/(app)/(tabs)/(home)/index.tsx index 3774c50b5e..af070fc4da 100644 --- a/apps/expo/app/(app)/(tabs)/(home)/index.tsx +++ b/apps/expo/app/(app)/(tabs)/(home)/index.tsx @@ -1,5 +1,6 @@ 'use client'; +import { assertIsString } from '@packrat/guards'; import type { LargeTitleSearchBarMethods, ListDataItem } from '@packrat/ui/nativewindui'; import { LargeTitleHeader, @@ -33,7 +34,6 @@ import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { asNonNullableRef } from 'expo-app/lib/utils/asNonNullableRef'; -import { assertIsString } from 'expo-app/utils/typeAssertions'; import { Link } from 'expo-router'; import { useMemo, useRef, useState } from 'react'; import { FlatList, Platform, Pressable, Text, View } from 'react-native'; diff --git a/apps/expo/app/(app)/gear-inventory.tsx b/apps/expo/app/(app)/gear-inventory.tsx index e4e8635937..a06c21ff42 100644 --- a/apps/expo/app/(app)/gear-inventory.tsx +++ b/apps/expo/app/(app)/gear-inventory.tsx @@ -1,10 +1,10 @@ +import { assertDefined } from '@packrat/guards'; import { LargeTitleHeader, Text } from '@packrat/ui/nativewindui'; import { PackItemCard } from 'expo-app/features/packs/components/PackItemCard'; import { useUserPackItems } from 'expo-app/features/packs/hooks/useUserPackItems'; import type { PackItem } from 'expo-app/features/packs/types'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { useState } from 'react'; import { Pressable, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; diff --git a/apps/expo/app/(app)/messages/chat.tsx b/apps/expo/app/(app)/messages/chat.tsx index 00f97c2bbd..34e862fd25 100644 --- a/apps/expo/app/(app)/messages/chat.tsx +++ b/apps/expo/app/(app)/messages/chat.tsx @@ -1,3 +1,4 @@ +import { assertDefined } from '@packrat/guards'; import type { ContextMenuMethods } from '@packrat/ui/nativewindui'; import { Avatar, @@ -11,7 +12,6 @@ import { Icon } from '@roninoss/icons'; import { FlashList } from '@shopify/flash-list'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { BlurView } from 'expo-blur'; import { router, Stack } from 'expo-router'; import * as React from 'react'; diff --git a/apps/expo/app/(app)/messages/conversations.android.tsx b/apps/expo/app/(app)/messages/conversations.android.tsx index 7850b20c27..b2ed4e6376 100644 --- a/apps/expo/app/(app)/messages/conversations.android.tsx +++ b/apps/expo/app/(app)/messages/conversations.android.tsx @@ -1,3 +1,4 @@ +import { assertDefined } from '@packrat/guards'; import { AdaptiveSearchHeader, Avatar, @@ -18,7 +19,6 @@ import { Portal } from '@rn-primitives/portal'; import { Icon } from '@roninoss/icons'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import * as Haptics from 'expo-haptics'; import { router } from 'expo-router'; import * as React from 'react'; diff --git a/apps/expo/app/(app)/messages/conversations.tsx b/apps/expo/app/(app)/messages/conversations.tsx index f308648154..e411c36786 100644 --- a/apps/expo/app/(app)/messages/conversations.tsx +++ b/apps/expo/app/(app)/messages/conversations.tsx @@ -1,3 +1,4 @@ +import { assertDefined } from '@packrat/guards'; import { Avatar, AvatarFallback, @@ -17,7 +18,6 @@ import { import { Icon } from '@roninoss/icons'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import * as Haptics from 'expo-haptics'; import { router } from 'expo-router'; import * as React from 'react'; diff --git a/apps/expo/app/(app)/season-suggestions.tsx b/apps/expo/app/(app)/season-suggestions.tsx index 999760c608..1a84b4261a 100644 --- a/apps/expo/app/(app)/season-suggestions.tsx +++ b/apps/expo/app/(app)/season-suggestions.tsx @@ -1,3 +1,4 @@ +import { assertDefined } from '@packrat/guards'; import { Button, LargeTitleHeader, Text, useColorScheme } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import { useCreatePackWithItems } from 'expo-app/features/packs/hooks/useCreatePackWithItems'; @@ -8,7 +9,6 @@ import { import { LocationPicker } from 'expo-app/features/weather/components'; import type { WeatherLocation } from 'expo-app/features/weather/types'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { useRouter } from 'expo-router'; import { useState } from 'react'; import { ActivityIndicator, ScrollView, View } from 'react-native'; diff --git a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx index 5179829118..3061689ce6 100644 --- a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx +++ b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx @@ -1,3 +1,4 @@ +import { assertDefined } from '@packrat/guards'; import { Button, Text } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import { useQueryClient } from '@tanstack/react-query'; @@ -7,7 +8,6 @@ import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { ErrorScreen } from 'expo-app/screens/ErrorScreen'; import type { WeightUnit } from 'expo-app/types'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; import { diff --git a/apps/expo/features/pack-templates/screens/ItemsScanScreen.tsx b/apps/expo/features/pack-templates/screens/ItemsScanScreen.tsx index 8f69c493d7..9c059f6d7a 100644 --- a/apps/expo/features/pack-templates/screens/ItemsScanScreen.tsx +++ b/apps/expo/features/pack-templates/screens/ItemsScanScreen.tsx @@ -1,4 +1,5 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; +import { assertNonNull } from '@packrat/guards'; import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import * as Burnt from 'burnt'; @@ -9,7 +10,6 @@ import { useImageDetection } from 'expo-app/features/packs/hooks/useImageDetecti import { type SelectedImage, useImagePicker } from 'expo-app/features/packs/hooks/useImagePicker'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { assertNonNull } from 'expo-app/utils/typeAssertions'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useState } from 'react'; import { Image, ScrollView, View } from 'react-native'; diff --git a/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx b/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx index 7e9ede4fd9..d10035418f 100644 --- a/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx +++ b/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx @@ -1,3 +1,4 @@ +import { assertDefined } from '@packrat/guards'; import { Button, Text, useColorScheme } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import { Chip } from 'expo-app/components/initial/Chip'; @@ -13,7 +14,6 @@ import { isWorn, shouldShowQuantity, } from 'expo-app/lib/utils/itemCalculations'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { router, useLocalSearchParams } from 'expo-router'; import { ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; diff --git a/apps/expo/features/pack-templates/utils/getPackTemplateItemDetailOptions.tsx b/apps/expo/features/pack-templates/utils/getPackTemplateItemDetailOptions.tsx index 2a42e3222b..2d54bacc15 100644 --- a/apps/expo/features/pack-templates/utils/getPackTemplateItemDetailOptions.tsx +++ b/apps/expo/features/pack-templates/utils/getPackTemplateItemDetailOptions.tsx @@ -1,8 +1,8 @@ +import { assertDefined } from '@packrat/guards'; import { Alert, Button, useColorScheme } from '@packrat-ai/nativewindui'; import { Icon } from '@roninoss/icons'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { t } from 'expo-app/lib/i18n'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { useRouter } from 'expo-router'; import { View } from 'react-native'; import { useDeletePackTemplateItem, usePackTemplateItem } from '../hooks'; diff --git a/apps/expo/features/packs/components/GapItemCatalogSuggestions.tsx b/apps/expo/features/packs/components/GapItemCatalogSuggestions.tsx index d7ac2a4781..57b51d1a91 100644 --- a/apps/expo/features/packs/components/GapItemCatalogSuggestions.tsx +++ b/apps/expo/features/packs/components/GapItemCatalogSuggestions.tsx @@ -1,8 +1,8 @@ +import { assertDefined } from '@packrat/guards'; import { ActivityIndicator, Button, cn, Text } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import type { CatalogItem } from 'expo-app/features/catalog/types'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { useState } from 'react'; import { Modal, ScrollView, TouchableOpacity, View } from 'react-native'; import type { GapAnalysisItem } from '../hooks/usePackGapAnalysis'; diff --git a/apps/expo/features/packs/components/PackItemCard.tsx b/apps/expo/features/packs/components/PackItemCard.tsx index 461d02e431..bf5d3e21e0 100644 --- a/apps/expo/features/packs/components/PackItemCard.tsx +++ b/apps/expo/features/packs/components/PackItemCard.tsx @@ -1,9 +1,9 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; +import { assertDefined } from '@packrat/guards'; import { Text } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { useRouter } from 'expo-router'; import { Alert, Pressable, TouchableWithoutFeedback, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; diff --git a/apps/expo/features/packs/hooks/useImagePicker.ts b/apps/expo/features/packs/hooks/useImagePicker.ts index fa04118222..4d640eab18 100644 --- a/apps/expo/features/packs/hooks/useImagePicker.ts +++ b/apps/expo/features/packs/hooks/useImagePicker.ts @@ -1,5 +1,5 @@ +import { assertDefined } from '@packrat/guards'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import * as ImagePicker from 'expo-image-picker'; import { nanoid } from 'nanoid/non-secure'; import { useState } from 'react'; diff --git a/apps/expo/features/packs/hooks/usePackWeightHistory.ts b/apps/expo/features/packs/hooks/usePackWeightHistory.ts index 2b281184aa..73e98ea715 100644 --- a/apps/expo/features/packs/hooks/usePackWeightHistory.ts +++ b/apps/expo/features/packs/hooks/usePackWeightHistory.ts @@ -1,5 +1,5 @@ import { use$ } from '@legendapp/state/react'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; +import { assertDefined } from '@packrat/guards'; import { packWeigthHistoryStore } from '../store/packWeightHistory'; import type { PackWeightHistoryEntry } from '../types'; diff --git a/apps/expo/features/packs/screens/EditPackScreen.tsx b/apps/expo/features/packs/screens/EditPackScreen.tsx index 81878e763d..544374b503 100644 --- a/apps/expo/features/packs/screens/EditPackScreen.tsx +++ b/apps/expo/features/packs/screens/EditPackScreen.tsx @@ -1,6 +1,6 @@ +import { assertDefined } from '@packrat/guards'; import { usePackDetailsFromStore } from 'expo-app/features/packs'; import { PackForm } from 'expo-app/features/packs/components/PackForm'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { useLocalSearchParams } from 'expo-router'; export function EditPackScreen() { diff --git a/apps/expo/features/packs/screens/ItemsScanScreen.tsx b/apps/expo/features/packs/screens/ItemsScanScreen.tsx index 77c6223805..6b61f6c103 100644 --- a/apps/expo/features/packs/screens/ItemsScanScreen.tsx +++ b/apps/expo/features/packs/screens/ItemsScanScreen.tsx @@ -1,4 +1,5 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; +import { assertNonNull } from '@packrat/guards'; import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import * as Burnt from 'burnt'; @@ -7,7 +8,6 @@ import { ErrorState } from 'expo-app/components/ErrorState'; import { type SelectedImage, useImagePicker } from 'expo-app/features/packs/hooks/useImagePicker'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { assertNonNull } from 'expo-app/utils/typeAssertions'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useState } from 'react'; import { Image, ScrollView, View } from 'react-native'; diff --git a/apps/expo/features/packs/utils/computeCategories.ts b/apps/expo/features/packs/utils/computeCategories.ts index 6361c5c157..7bda34999e 100644 --- a/apps/expo/features/packs/utils/computeCategories.ts +++ b/apps/expo/features/packs/utils/computeCategories.ts @@ -1,5 +1,5 @@ +import { assertDefined } from '@packrat/guards'; import { userStore } from 'expo-app/features/auth/store'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import type { Pack } from '../types'; import { convertFromGrams } from './convertFromGrams'; import { convertToGrams } from './convertToGrams'; diff --git a/apps/expo/features/packs/utils/getPackItemDetailOptions.tsx b/apps/expo/features/packs/utils/getPackItemDetailOptions.tsx index 2778508188..6816e25314 100644 --- a/apps/expo/features/packs/utils/getPackItemDetailOptions.tsx +++ b/apps/expo/features/packs/utils/getPackItemDetailOptions.tsx @@ -1,7 +1,7 @@ +import { assertDefined } from '@packrat/guards'; import { Alert, Button, useColorScheme } from '@packrat-ai/nativewindui'; import { Icon } from '@roninoss/icons'; import { t } from 'expo-app/lib/i18n'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { useRouter } from 'expo-router'; import { View } from 'react-native'; import { diff --git a/apps/expo/features/trips/components/TripForm.tsx b/apps/expo/features/trips/components/TripForm.tsx index f04f3231d8..d0169ec196 100644 --- a/apps/expo/features/trips/components/TripForm.tsx +++ b/apps/expo/features/trips/components/TripForm.tsx @@ -1,3 +1,4 @@ +import { assertDefined } from '@packrat/guards'; import { Form, FormItem, FormSection, TextField } from '@packrat/ui/nativewindui'; import DateTimePicker from '@react-native-community/datetimepicker'; import { Picker } from '@react-native-picker/picker'; @@ -7,7 +8,6 @@ import { usePacks } from 'expo-app/features/packs/hooks/usePacks'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { TestIds } from 'expo-app/lib/testIds'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { Stack, useRouter } from 'expo-router'; import { useMemo, useState } from 'react'; import { Alert, Modal, Pressable, Text, View } from 'react-native'; diff --git a/apps/expo/features/trips/screens/EditTripScreen.tsx b/apps/expo/features/trips/screens/EditTripScreen.tsx index fc9525adac..27f58b3910 100644 --- a/apps/expo/features/trips/screens/EditTripScreen.tsx +++ b/apps/expo/features/trips/screens/EditTripScreen.tsx @@ -1,5 +1,5 @@ +import { assertDefined } from '@packrat/guards'; import { useTripDetailsFromStore } from 'expo-app/features/trips/hooks/useTripDetailsFromStore'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { useLocalSearchParams } from 'expo-router'; import { TripForm } from '../components/TripForm'; diff --git a/apps/expo/features/trips/screens/TripDetailScreen.tsx b/apps/expo/features/trips/screens/TripDetailScreen.tsx index 85399d8ce1..2458168093 100644 --- a/apps/expo/features/trips/screens/TripDetailScreen.tsx +++ b/apps/expo/features/trips/screens/TripDetailScreen.tsx @@ -1,10 +1,10 @@ +import { assertDefined } from '@packrat/guards'; import { ActivityIndicator, Button, Card, Text } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import { appAlert } from 'expo-app/app/_layout'; import { useLocations } from 'expo-app/features/weather/hooks'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { ScrollView, Share, View } from 'react-native'; import MapView, { Marker, PROVIDER_GOOGLE } from 'react-native-maps'; diff --git a/apps/expo/features/weather/components/LocationPicker.tsx b/apps/expo/features/weather/components/LocationPicker.tsx index ecf1c79e0a..751e9ccbfb 100644 --- a/apps/expo/features/weather/components/LocationPicker.tsx +++ b/apps/expo/features/weather/components/LocationPicker.tsx @@ -1,9 +1,9 @@ +import { assertNonNull } from '@packrat/guards'; import { Button, Text } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { assertNonNull } from 'expo-app/utils/typeAssertions'; import { useRouter } from 'expo-router'; import { useState } from 'react'; import { Modal, Pressable, ScrollView, TouchableOpacity, View } from 'react-native'; diff --git a/apps/expo/features/weather/lib/weatherService.ts b/apps/expo/features/weather/lib/weatherService.ts index 4ae0ef942f..054c436e2f 100644 --- a/apps/expo/features/weather/lib/weatherService.ts +++ b/apps/expo/features/weather/lib/weatherService.ts @@ -1,9 +1,9 @@ +import { assertDefined } from '@packrat/guards'; import type { LocationSearchResult, WeatherApiForecastResponse, } from 'expo-app/features/weather/types'; import axiosInstance, { handleApiError } from 'expo-app/lib/api/client'; -import { assertDefined } from 'expo-app/utils/typeAssertions'; import { getWeatherIconName as getIconNameFromCode } from './weatherIcons'; /** diff --git a/apps/expo/package.json b/apps/expo/package.json index bb9f223cd5..cf4d162c06 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -44,6 +44,7 @@ "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "^5.1.2", "@legendapp/state": "^3.0.0-beta.30", + "@packrat/guards": "workspace:*", "@react-native-ai/apple": "~0.10.0", "@react-native-ai/llama": "~0.10.0", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/apps/expo/utils/__tests__/typeAssertions.test.ts b/apps/expo/utils/__tests__/typeAssertions.test.ts deleted file mode 100644 index 5eaa032222..0000000000 --- a/apps/expo/utils/__tests__/typeAssertions.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { assertDefined, assertIsString, assertNonNull } from '../typeAssertions'; - -describe('assertDefined', () => { - it('does not throw for a defined value', () => { - expect(() => assertDefined('hello')).not.toThrow(); - expect(() => assertDefined(0)).not.toThrow(); - expect(() => assertDefined(null)).not.toThrow(); - expect(() => assertDefined(false)).not.toThrow(); - }); - - it('throws for undefined', () => { - expect(() => assertDefined(undefined)).toThrow('Expects value to be defined'); - }); -}); - -describe('assertNonNull', () => { - it('does not throw for a non-null value', () => { - expect(() => assertNonNull('hello')).not.toThrow(); - expect(() => assertNonNull(0)).not.toThrow(); - expect(() => assertNonNull(false)).not.toThrow(); - expect(() => assertNonNull(undefined)).not.toThrow(); - }); - - it('throws for null', () => { - expect(() => assertNonNull(null)).toThrow('Expects value to be non-null'); - }); -}); - -describe('assertIsString', () => { - it('does not throw for string values', () => { - expect(() => assertIsString('hello')).not.toThrow(); - expect(() => assertIsString('')).not.toThrow(); - }); - - it('throws for non-string values', () => { - expect(() => assertIsString(123)).toThrow('Expected a string'); - expect(() => assertIsString(null)).toThrow('Expected a string'); - expect(() => assertIsString(undefined)).toThrow('Expected a string'); - expect(() => assertIsString({})).toThrow('Expected a string'); - expect(() => assertIsString([])).toThrow('Expected a string'); - }); -}); diff --git a/apps/expo/utils/typeAssertions.ts b/apps/expo/utils/typeAssertions.ts deleted file mode 100644 index 736010c85f..0000000000 --- a/apps/expo/utils/typeAssertions.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function assertDefined(val: T | undefined): asserts val is T { - if (val === undefined) throw new Error('Expects value to be defined'); -} - -export function assertNonNull(val: T | null): asserts val is T { - if (val === null) throw new Error('Expects value to be non-null'); -} - -export function assertIsString(value: unknown): asserts value is string { - if (typeof value !== 'string') { - throw new Error('Expected a string'); - } -} diff --git a/bun.lock b/bun.lock index 2ba294dfbb..3ba08867e2 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "packrat-monorepo", @@ -23,6 +24,7 @@ "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "^5.1.2", "@legendapp/state": "^3.0.0-beta.30", + "@packrat/guards": "workspace:*", "@react-native-ai/apple": "~0.10.0", "@react-native-ai/llama": "~0.10.0", "@react-native-async-storage/async-storage": "2.2.0", @@ -81,7 +83,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 +91,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", @@ -283,6 +286,7 @@ "@hono/zod-validator": "^0.4.3", "@mozilla/readability": "^0.6.0", "@neondatabase/serverless": "^1.0.0", + "@packrat/guards": "workspace:*", "@scalar/hono-api-reference": "^0.8.0", "@types/nodemailer": "^6.4.17", "ai": "^5.0.11", @@ -319,6 +323,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 +964,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 +1666,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 +2302,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 +2318,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 +2598,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 +2980,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 +3462,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 +3656,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 +3876,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 +3896,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 +3976,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 +4174,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/api/package.json b/packages/api/package.json index aa6138520d..6d25481653 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -25,6 +25,7 @@ "@hono/zod-validator": "^0.4.3", "@mozilla/readability": "^0.6.0", "@neondatabase/serverless": "^1.0.0", + "@packrat/guards": "workspace:*", "@scalar/hono-api-reference": "^0.8.0", "@types/nodemailer": "^6.4.17", "ai": "^5.0.11", diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts index 05c6894d4d..ab55557dad 100644 --- a/packages/api/src/routes/admin/index.ts +++ b/packages/api/src/routes/admin/index.ts @@ -5,7 +5,7 @@ import { ErrorResponseSchema } from '@packrat/api/schemas/catalog'; import { UserSearchQuerySchema } from '@packrat/api/schemas/users'; import type { Env } from '@packrat/api/types/env'; import { getEnv } from '@packrat/api/utils/env-validation'; -import { assertAllDefined } from '@packrat/api/utils/typeAssertions'; +import { assertAllDefined } from '@packrat/guards'; import { and, count, desc, eq, ilike, or, sql } from 'drizzle-orm'; import { basicAuth } from 'hono/basic-auth'; import { html, raw } from 'hono/html'; @@ -777,7 +777,7 @@ adminRoutes.openapi(getStatsRoute, async (c) => { .where(eq(packs.deleted, false)); const [itemCount] = await db.select({ count: count() }).from(catalogItems); - assertAllDefined(userCount, packCount, itemCount); + assertAllDefined([userCount, packCount, itemCount]); return c.json( { diff --git a/packages/api/src/routes/auth/index.ts b/packages/api/src/routes/auth/index.ts index 55b9aae39b..65e4b85b20 100644 --- a/packages/api/src/routes/auth/index.ts +++ b/packages/api/src/routes/auth/index.ts @@ -44,7 +44,7 @@ import { } from '@packrat/api/utils/auth'; import { sendPasswordResetEmail, sendVerificationCodeEmail } from '@packrat/api/utils/email'; import { getEnv } from '@packrat/api/utils/env-validation'; -import { assertDefined } from '@packrat/api/utils/typeAssertions'; +import { assertDefined } from '@packrat/guards'; import { and, eq, getTableColumns, gt, isNull } from 'drizzle-orm'; import { OAuth2Client } from 'google-auth-library'; diff --git a/packages/api/src/routes/guides/getGuidesRoute.ts b/packages/api/src/routes/guides/getGuidesRoute.ts index dd6570c47c..5ce760cb09 100644 --- a/packages/api/src/routes/guides/getGuidesRoute.ts +++ b/packages/api/src/routes/guides/getGuidesRoute.ts @@ -7,8 +7,8 @@ import { import { R2BucketService } from '@packrat/api/services/r2-bucket'; import type { RouteHandler } from '@packrat/api/types/routeHandler'; import { getEnv } from '@packrat/api/utils/env-validation'; +import { asNumber, asString, isArray } from '@packrat/guards'; import matter from 'gray-matter'; -import { isArray } from 'radash'; export const routeDefinition = createRoute({ method: 'get', @@ -92,15 +92,15 @@ export const handler: RouteHandler = async (c) => { id: obj.key.replace(/\.(mdx?|md)$/, ''), // Remove .mdx or .md extension key: obj.key, title: - (frontmatter.title as string) || + asString(frontmatter.title) || obj.customMetadata?.title || obj.key.replace(/\.(mdx?|md)$/, '').replace(/-/g, ' '), category: obj.customMetadata?.category || 'general', categories: (frontmatter.categories as string[]) || [], - description: (frontmatter.description as string) || obj.customMetadata?.description || '', - author: frontmatter.author as string, - readingTime: frontmatter.readingTime as number, - difficulty: frontmatter.difficulty as string, + description: asString(frontmatter.description) || obj.customMetadata?.description || '', + author: asString(frontmatter.author), + readingTime: asNumber(frontmatter.readingTime), + difficulty: asString(frontmatter.difficulty), createdAt: obj.uploaded.toISOString(), updatedAt: obj.uploaded.toISOString(), }; diff --git a/packages/api/src/routes/packTemplates/packTemplates.ts b/packages/api/src/routes/packTemplates/packTemplates.ts index 161995eefc..4945e15b76 100644 --- a/packages/api/src/routes/packTemplates/packTemplates.ts +++ b/packages/api/src/routes/packTemplates/packTemplates.ts @@ -10,7 +10,7 @@ import { } from '@packrat/api/schemas/packTemplates'; import type { Env } from '@packrat/api/types/env'; import type { Variables } from '@packrat/api/types/variables'; -import { assertDefined } from '@packrat/api/utils/typeAssertions'; +import { assertDefined } from '@packrat/guards'; import { and, eq, or } from 'drizzle-orm'; const packTemplateRoutes = new OpenAPIHono<{ diff --git a/packages/api/src/services/r2-bucket.ts b/packages/api/src/services/r2-bucket.ts index ac26cc9051..4c0500ed86 100644 --- a/packages/api/src/services/r2-bucket.ts +++ b/packages/api/src/services/r2-bucket.ts @@ -13,7 +13,7 @@ import { UploadPartCommand, } from '@aws-sdk/client-s3'; import type { Env } from '@packrat/api/types/env'; -import { isString } from 'radash'; +import { isString } from '@packrat/guards'; // Define our own types to avoid conflicts with Cloudflare Workers types interface R2HTTPMetadata { diff --git a/packages/api/src/utils/__tests__/typeAssertions.test.ts b/packages/api/src/utils/__tests__/typeAssertions.test.ts deleted file mode 100644 index 1265fe34da..0000000000 --- a/packages/api/src/utils/__tests__/typeAssertions.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { assertAllDefined, assertDefined } from '../typeAssertions'; - -describe('assertDefined', () => { - it('does not throw for a defined value', () => { - expect(() => assertDefined('hello')).not.toThrow(); - expect(() => assertDefined(0)).not.toThrow(); - expect(() => assertDefined(null)).not.toThrow(); - expect(() => assertDefined(false)).not.toThrow(); - }); - - it('throws for undefined', () => { - expect(() => assertDefined(undefined)).toThrow('Value must be defined'); - }); -}); - -describe('assertAllDefined', () => { - it('does not throw when all values are defined', () => { - expect(() => assertAllDefined('a', 1, false, null, 0)).not.toThrow(); - }); - - it('throws when any value is undefined', () => { - expect(() => assertAllDefined('a', undefined, 'b')).toThrow('Value at index 1 must be defined'); - }); - - it('throws with the correct index when the first value is undefined', () => { - expect(() => assertAllDefined(undefined, 'b')).toThrow('Value at index 0 must be defined'); - }); - - it('does not throw for an empty argument list', () => { - expect(() => assertAllDefined()).not.toThrow(); - }); -}); diff --git a/packages/api/src/utils/typeAssertions.ts b/packages/api/src/utils/typeAssertions.ts deleted file mode 100644 index 5c1c1bad5b..0000000000 --- a/packages/api/src/utils/typeAssertions.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function assertDefined(val: T | undefined, message?: string): asserts val is T { - if (val === undefined) throw new Error(message ?? 'Value must be defined'); -} - -export function assertAllDefined(...values: (unknown | undefined)[]): void { - values.forEach((val, i) => { - if (val === undefined) { - throw new Error(`Value at index ${i} must be defined`); - } - }); -} diff --git a/packages/api/test/utils/db-helpers.ts b/packages/api/test/utils/db-helpers.ts index 0a857fc5fd..955c3987e1 100644 --- a/packages/api/test/utils/db-helpers.ts +++ b/packages/api/test/utils/db-helpers.ts @@ -1,5 +1,5 @@ import { createDb } from '@packrat/api/db'; -import { assertDefined } from '@packrat/api/utils/typeAssertions'; +import { assertDefined } from '@packrat/guards'; import type { InferInsertModel } from 'drizzle-orm'; import type { Context } from 'hono'; import { 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/*"] }